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,
};