Spaces:
Runtime error
Runtime error
File size: 14,375 Bytes
a6b96c2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 | /**
* Codebase Drift Detection (#2003)
*
* Detects structural drift between a committed codebase and the
* `.planning/codebase/STRUCTURE.md` map produced by `gsd-codebase-mapper`.
*
* Four categories of drift element:
* - new_dir β a newly-added file whose directory prefix does not appear
* in STRUCTURE.md
* - barrel β a newly-added barrel export at
* (packages|apps)/<name>/src/index.(ts|tsx|js|mjs|cjs)
* - migration β a newly-added migration file under one of the recognized
* migration directories (supabase, prisma, drizzle, src/migrations, β¦)
* - route β a newly-added route module under a `routes/` or `api/` dir
*
* Each file is counted at most once; when a file matches multiple categories
* the most specific category wins (migration > route > barrel > new_dir).
*
* Design decisions (see PR for full rubber-duck):
* - The library is pure. It takes parsed git diff output and returns a
* structured result. The CLI/workflow layer is responsible for running
* git and for spawning mappers.
* - `last_mapped_commit` is stored as YAML-style frontmatter at the top of
* each `.planning/codebase/*.md` file. This keeps the baseline attached
* to the file, survives git moves, and avoids a sidecar JSON.
* - The detector NEVER throws on malformed input β it returns a
* `{ skipped: true }` result. The phase workflow depends on this
* non-blocking guarantee.
*
* ADR-457 build-at-publish: the hand-written bin/lib/drift.cjs collapsed to
* a TypeScript source of truth. Behaviour is preserved byte-for-behaviour from
* the prior hand-written .cjs; only types are added.
*/
'use strict';
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
const node_fs_1 = __importDefault(require("node:fs"));
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
// βββ Constants βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const DRIFT_CATEGORIES = Object.freeze(['new_dir', 'barrel', 'migration', 'route']);
// Category priority when a single file matches multiple rules.
// Higher index = more specific = wins.
const CATEGORY_PRIORITY = { new_dir: 0, barrel: 1, route: 2, migration: 3 };
const BARREL_RE = /^(packages|apps)\/[^/]+\/src\/index\.(ts|tsx|js|mjs|cjs)$/;
const MIGRATION_RES = [
/^supabase\/migrations\/.+\.sql$/,
/^prisma\/migrations\/.+/,
/^drizzle\/meta\/.+/,
/^drizzle\/migrations\/.+/,
/^src\/migrations\/.+\.(ts|js|sql)$/,
/^db\/migrations\/.+\.(sql|ts|js)$/,
/^migrations\/.+\.(sql|ts|js)$/,
];
const ROUTE_RES = [
/^(apps|packages)\/[^/]+\/src\/routes\/.+\.(ts|tsx|js|jsx|mjs|cjs)$/,
/^src\/routes\/.+\.(ts|tsx|js|jsx|mjs|cjs)$/,
/^src\/api\/.+\.(ts|tsx|js|jsx|mjs|cjs)$/,
/^(apps|packages)\/[^/]+\/src\/api\/.+\.(ts|tsx|js|jsx|mjs|cjs)$/,
];
// A conservative allowlist for `--paths` arguments passed to the mapper:
// repo-relative path components separated by /, containing only
// alphanumerics, dash, underscore, and dot (no `..`, no `/..`).
const SAFE_PATH_RE = /^(?!.*\.\.)(?:[A-Za-z0-9_.][A-Za-z0-9_.\-]*)(?:\/[A-Za-z0-9_.][A-Za-z0-9_.\-]*)*$/;
/**
* Classify a single file path into a drift category or null.
*/
function classifyFile(file) {
if (typeof file !== 'string' || !file)
return null;
const norm = file.replace(/\\/g, '/');
if (MIGRATION_RES.some((r) => r.test(norm)))
return 'migration';
if (ROUTE_RES.some((r) => r.test(norm)))
return 'route';
if (BARREL_RE.test(norm))
return 'barrel';
return null;
}
/**
* True iff any prefix of `file` (dir1, dir1/dir2, β¦) appears as a substring
* of `structureMd`. Used to decide whether a file is in "mapped territory".
*
* Matching is deliberately substring-based β STRUCTURE.md is free-form
* markdown, not a structured manifest. If the map mentions `src/lib/` the
* check `structureMd.includes('src/lib')` holds.
*/
function isPathMapped(file, structureMd) {
const norm = file.replace(/\\/g, '/');
const parts = norm.split('/');
// Check prefixes from longest to shortest; any hit means "mapped".
for (let i = parts.length - 1; i >= 1; i--) {
const prefix = parts.slice(0, i).join('/');
if (structureMd.includes(prefix))
return true;
}
// Finally, if even the top-level dir is mentioned, count as mapped.
if (parts.length > 0 && structureMd.includes(parts[0] + '/'))
return true;
if (parts.length > 0 && structureMd.includes('`' + parts[0] + '`'))
return true;
return false;
}
// βββ Main detection ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
/**
* Detect codebase drift.
*/
function detectDrift(input) {
try {
if (!input || typeof input !== 'object') {
return skipped('invalid-input');
}
const inp = input;
const { addedFiles, modifiedFiles, deletedFiles, structureMd, } = inp;
const threshold = Number.isInteger(inp.threshold) && inp.threshold >= 1
? inp.threshold
: 3;
const action = inp.action === 'auto-remap' ? 'auto-remap' : 'warn';
if (structureMd === null || structureMd === undefined) {
return skipped('missing-structure-md');
}
if (typeof structureMd !== 'string') {
return skipped('invalid-structure-md');
}
const added = Array.isArray(addedFiles) ? addedFiles.filter((x) => typeof x === 'string') : [];
const modified = Array.isArray(modifiedFiles) ? modifiedFiles : [];
const deleted = Array.isArray(deletedFiles) ? deletedFiles : [];
// Build elements. One element per file, highest-priority category wins.
const elements = [];
const seen = new Map();
for (const rawFile of added) {
const file = rawFile.replace(/\\/g, '/');
const specific = classifyFile(file);
let category = specific;
if (!category) {
if (!isPathMapped(file, structureMd)) {
category = 'new_dir';
}
else {
continue; // mapped, known, ordinary file β not drift
}
}
// Dedup: if we've already counted this path at higher-or-equal priority, skip
const prior = seen.get(file);
if (prior && CATEGORY_PRIORITY[prior] >= CATEGORY_PRIORITY[category])
continue;
seen.set(file, category);
}
for (const [file, category] of seen.entries()) {
elements.push({ category, path: file });
}
// Sort for stable output.
elements.sort((a, b) => a.category === b.category
? a.path.localeCompare(b.path)
: a.category.localeCompare(b.category));
const actionRequired = elements.length >= threshold;
let directive = 'none';
let spawnMapper = false;
let affectedPaths = [];
let message = '';
if (actionRequired) {
directive = action;
affectedPaths = chooseAffectedPaths(elements.map((e) => e.path));
if (action === 'auto-remap') {
spawnMapper = true;
}
message = buildMessage(elements, affectedPaths, action, inp.runtime);
}
return {
skipped: false,
elements,
actionRequired,
directive,
spawnMapper,
affectedPaths,
threshold,
action,
message,
counts: {
added: added.length,
modified: modified.length,
deleted: deleted.length,
},
};
}
catch (err) {
// Non-blocking: never throw from this function.
const errMsg = err?.message ? err.message : String(err);
return skipped('exception:' + errMsg);
}
}
function skipped(reason) {
return {
skipped: true,
reason,
elements: [],
actionRequired: false,
directive: 'none',
spawnMapper: false,
affectedPaths: [],
message: '',
};
}
function buildMessage(elements, affectedPaths, action, runtime) {
const byCat = {};
for (const e of elements) {
if (!byCat[e.category])
byCat[e.category] = [];
byCat[e.category].push(e.path);
}
const lines = [
`Codebase drift detected: ${elements.length} structural element(s) since last mapping.`,
'',
];
const labels = {
new_dir: 'New directories',
barrel: 'New barrel exports',
migration: 'New migrations',
route: 'New route modules',
};
for (const cat of ['new_dir', 'barrel', 'migration', 'route']) {
if (byCat[cat]) {
lines.push(`${labels[cat]}:`);
for (const p of byCat[cat])
lines.push(` - ${p}`);
}
}
lines.push('');
if (action === 'auto-remap') {
lines.push(`Auto-remap scheduled for paths: ${affectedPaths.join(', ')}`);
}
else {
// drift.cts is a pure library β it must never read env/config. The
// caller (verify.cmdVerifyCodebaseDrift) resolves the runtime once and
// passes it in via input.runtime so emitted commands match the project
// the caller is targeting, not the current process directory.
const mapCmd = (0, runtime_slash_cjs_1.formatGsdSlash)('map-codebase', runtime || 'claude');
lines.push(`Run ${String(mapCmd)} --paths ${affectedPaths.join(',')} to refresh planning context.`);
}
return lines.join('\n');
}
// βββ Affected paths ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
/**
* Collapse a list of drifted file paths into a sorted, deduplicated list of
* the top-level directory prefixes (depth 2 when the repo uses an
* `<apps|packages>/<name>/β¦` layout; depth 1 otherwise).
*/
function chooseAffectedPaths(paths) {
const out = new Set();
for (const raw of paths || []) {
if (typeof raw !== 'string' || !raw)
continue;
const file = raw.replace(/\\/g, '/');
const parts = file.split('/');
if (parts.length === 0)
continue;
const top = parts[0];
if ((top === 'apps' || top === 'packages') && parts.length >= 2) {
out.add(`${top}/${parts[1]}`);
}
else {
out.add(top);
}
}
return [...out].sort();
}
/**
* Filter `paths` to only those that are safe to splice into a mapper prompt.
* Any path that is absolute, contains traversal, or includes shell
* metacharacters is dropped.
*/
function sanitizePaths(paths) {
if (!Array.isArray(paths))
return [];
const out = [];
for (const p of paths) {
if (typeof p !== 'string')
continue;
if (p.startsWith('/'))
continue;
if (!SAFE_PATH_RE.test(p))
continue;
out.push(p);
}
return out;
}
// βββ Frontmatter helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββ
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
function parseFrontmatter(content) {
if (typeof content !== 'string')
return { data: {}, body: '' };
const m = content.match(FRONTMATTER_RE);
if (!m)
return { data: {}, body: content };
const data = {};
for (const line of m[1].split(/\r?\n/)) {
const kv = line.match(/^([A-Za-z0-9_][A-Za-z0-9_-]*):\s*(.*)$/);
if (!kv)
continue;
data[kv[1]] = kv[2];
}
return { data, body: content.slice(m[0].length) };
}
function serializeFrontmatter(data, body) {
const keys = Object.keys(data);
if (keys.length === 0)
return body;
const lines = ['---'];
for (const k of keys)
lines.push(`${k}: ${data[k]}`);
lines.push('---');
return lines.join('\n') + '\n' + body;
}
/**
* Read `last_mapped_commit` from the frontmatter of a `.planning/codebase/*.md`
* file. Returns null if the file does not exist or has no frontmatter.
*/
function readMappedCommit(filePath) {
let content;
try {
content = node_fs_1.default.readFileSync(filePath, 'utf8');
}
catch {
return null;
}
const { data } = parseFrontmatter(content);
const sha = data['last_mapped_commit'];
return typeof sha === 'string' && sha.length > 0 ? sha : null;
}
/**
* Upsert `last_mapped_commit` and `last_mapped_at` into the frontmatter of
* the given file, preserving any other frontmatter keys and the body.
*/
function writeMappedCommit(filePath, commitSha, isoDate) {
// Symmetric with readMappedCommit (which returns null on missing files):
// tolerate a missing target by creating a minimal frontmatter-only file
// rather than throwing ENOENT. This matters when a mapper produces a new
// doc and the caller stamps it before any prior content existed.
let content = '';
try {
content = node_fs_1.default.readFileSync(filePath, 'utf8');
}
catch (err) {
if (err.code !== 'ENOENT')
throw err;
}
const { data, body } = parseFrontmatter(content);
data['last_mapped_commit'] = commitSha;
if (isoDate)
data['last_mapped_at'] = isoDate;
(0, shell_command_projection_cjs_1.platformWriteSync)(filePath, serializeFrontmatter(data, body));
}
module.exports = {
DRIFT_CATEGORIES,
classifyFile,
detectDrift,
chooseAffectedPaths,
sanitizePaths,
readMappedCommit,
writeMappedCommit,
// Exposed for the CLI layer to reuse the same parser.
parseFrontmatter,
};
|