mandrel 2.19.0 → 2.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/docs/agentrc-reference.json +0 -21
- package/.agents/docs/configuration.md +11 -14
- package/.agents/docs/execution-reference.md +8 -5
- package/.agents/schemas/agentrc.schema.json +0 -31
- package/.agents/scripts/check-test-temp-hygiene.js +153 -14
- package/.agents/scripts/lib/baselines/kinds/maintainability.js +3 -4
- package/.agents/scripts/lib/bdd-scenario-scanner.js +3 -2
- package/.agents/scripts/lib/config/explain.js +0 -8
- package/.agents/scripts/lib/config/temp-paths.js +12 -1
- package/.agents/scripts/lib/config-settings-schema.js +8 -24
- package/.agents/scripts/lib/orchestration/file-assumptions.js +4 -2
- package/.agents/scripts/lib/orchestration/plan-context.js +13 -14
- package/.agents/scripts/lib/orchestration/planning/authoring-context.js +12 -66
- package/.agents/scripts/lib/test-env.js +15 -3
- package/.agents/scripts/lib/test-temp.js +311 -0
- package/.agents/workflows/helpers/plan-reference.md +19 -4
- package/.agents/workflows/plan.md +7 -6
- package/docs/CHANGELOG.md +8 -0
- package/lib/migrations/index.js +2 -0
- package/lib/migrations/steps/2.20.0-retire-codebase-snapshot.js +113 -0
- package/package.json +1 -1
- package/.agents/scripts/lib/codebase-snapshot.js +0 -513
- package/.agents/scripts/lib/orchestration/planning/spec-authoring-grounding.js +0 -147
- package/.agents/scripts/lib/orchestration/spec-freshness.js +0 -129
package/package.json
CHANGED
|
@@ -1,513 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* codebase-snapshot.js — Bounded structural view of the consumer repo.
|
|
3
|
-
*
|
|
4
|
-
* Story #2634 (sibling to #2635 spec-freshness). `/plan` Phase 7
|
|
5
|
-
* authors the Tech Spec from documentation alone — `architecture.md`,
|
|
6
|
-
* `data-dictionary.md`, `decisions.md`, `patterns.md`. When those docs
|
|
7
|
-
* drift from the real source tree, the Architect persona cites modules
|
|
8
|
-
* and paths that no longer exist, and the mismatch only surfaces at
|
|
9
|
-
* delivery time.
|
|
10
|
-
*
|
|
11
|
-
* `buildCodebaseSnapshot` produces a deterministic JSON view of the repo
|
|
12
|
-
* that gets threaded into the spec-author's authoring context alongside
|
|
13
|
-
* `docsContext`, so the Architect can prefer real module names over
|
|
14
|
-
* doc-only ones.
|
|
15
|
-
*
|
|
16
|
-
* Two tiers, picked from `planning.codebaseSnapshot.tier`:
|
|
17
|
-
*
|
|
18
|
-
* - `skinny` (default) — file tree (paths only) of the configured
|
|
19
|
-
* include globs, `package.json` exports + bin entries, the detected
|
|
20
|
-
* test runner, the BDD feature root, and the list of directories
|
|
21
|
-
* touched in the most recent `recentCommitWindow` commits.
|
|
22
|
-
*
|
|
23
|
-
* - `medium` — skinny + a single-line export signature per public
|
|
24
|
-
* `.js` / `.ts` / `.mjs` file in the include set. Signatures are
|
|
25
|
-
* extracted from a regex pass over the file body — deliberately not
|
|
26
|
-
* a full AST so the snapshot stays cheap to generate on every plan
|
|
27
|
-
* run. The regex catches the common shapes (`export function foo`,
|
|
28
|
-
* `export class Bar`, `export const baz`, `export default`).
|
|
29
|
-
*
|
|
30
|
-
* The snapshot never throws on a probe failure. A missing git ref,
|
|
31
|
-
* unreadable directory, or absent BDD root all degrade to an empty /
|
|
32
|
-
* sentinel value in the corresponding field so Phase 7 stays
|
|
33
|
-
* non-blocking.
|
|
34
|
-
*/
|
|
35
|
-
|
|
36
|
-
import fs from 'node:fs';
|
|
37
|
-
import path from 'node:path';
|
|
38
|
-
|
|
39
|
-
import { gitSpawn } from './git-utils.js';
|
|
40
|
-
import { Logger } from './Logger.js';
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Allowed tier values for `planning.codebaseSnapshot.tier`. Mirrored
|
|
44
|
-
* in `agentrc.schema.json` so a typo lands as a schema-validation
|
|
45
|
-
* error before the runner is reached.
|
|
46
|
-
*/
|
|
47
|
-
export const CODEBASE_SNAPSHOT_TIERS = Object.freeze(['skinny', 'medium']);
|
|
48
|
-
|
|
49
|
-
const DEFAULT_INCLUDE = Object.freeze([
|
|
50
|
-
'.agents/scripts/**',
|
|
51
|
-
'src/**',
|
|
52
|
-
'lib/**',
|
|
53
|
-
'app/**',
|
|
54
|
-
'packages/**',
|
|
55
|
-
]);
|
|
56
|
-
|
|
57
|
-
const DEFAULT_EXCLUDE = Object.freeze([
|
|
58
|
-
'**/node_modules/**',
|
|
59
|
-
'**/dist/**',
|
|
60
|
-
'**/build/**',
|
|
61
|
-
'**/.next/**',
|
|
62
|
-
'**/.turbo/**',
|
|
63
|
-
'**/coverage/**',
|
|
64
|
-
'**/*.test.*',
|
|
65
|
-
'**/*.spec.*',
|
|
66
|
-
]);
|
|
67
|
-
|
|
68
|
-
const DEFAULT_RECENT_COMMIT_WINDOW = 30;
|
|
69
|
-
|
|
70
|
-
const SIGNATURE_FILE_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx']);
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Hard cap on the file list in the skinny tier. When the include set
|
|
74
|
-
* matches more than `MAX_FILES_SKINNY` tracked files, the result is
|
|
75
|
-
* truncated and `truncated: true` is set on the envelope so the
|
|
76
|
-
* Architect (and the Phase 7 authoring-context warning) sees that the
|
|
77
|
-
* snapshot is incomplete and can ask for `medium` (or narrow the
|
|
78
|
-
* include globs). 250 entries is roughly 4–6k tokens of paths on
|
|
79
|
-
* typical repos — the upper edge of the Phase 7 budget.
|
|
80
|
-
*/
|
|
81
|
-
const MAX_FILES_SKINNY = 250;
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Group a sorted file list by its top-level directory. The top-level
|
|
85
|
-
* segment is the first path component (`.agents/scripts/lib/foo.js` →
|
|
86
|
-
* `.agents`, `src/index.ts` → `src`). Returns an insertion-ordered Map
|
|
87
|
-
* keyed by top-level dir whose values are the files under it, preserving
|
|
88
|
-
* the input order within each group. Because the input is pre-sorted
|
|
89
|
-
* lexicographically and we never reorder, group keys appear in sorted
|
|
90
|
-
* order and per-group file order is sorted too — the whole operation
|
|
91
|
-
* is deterministic for a given tree.
|
|
92
|
-
*
|
|
93
|
-
* @param {string[]} files - Lexicographically sorted relative paths.
|
|
94
|
-
* @returns {Map<string, string[]>}
|
|
95
|
-
*/
|
|
96
|
-
function groupByTopLevel(files) {
|
|
97
|
-
const groups = new Map();
|
|
98
|
-
for (const file of files) {
|
|
99
|
-
const slash = file.indexOf('/');
|
|
100
|
-
const top = slash === -1 ? file : file.slice(0, slash);
|
|
101
|
-
let bucket = groups.get(top);
|
|
102
|
-
if (!bucket) {
|
|
103
|
-
bucket = [];
|
|
104
|
-
groups.set(top, bucket);
|
|
105
|
-
}
|
|
106
|
-
bucket.push(file);
|
|
107
|
-
}
|
|
108
|
-
return groups;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Apply the skinny-tier cap with **per-top-level-dir proportional
|
|
113
|
-
* budgeting** so a large, dot-prefixed tree (e.g. `.agents/scripts/**`)
|
|
114
|
-
* can no longer monopolise the budget and truncate away the consumer's
|
|
115
|
-
* own source. The flat `filtered.slice(0, cap)` it replaces kept only
|
|
116
|
-
* the lexicographically-first `cap` paths, and dot-prefixed paths sort
|
|
117
|
-
* ahead of every consumer path — so in any consumer repo whose include
|
|
118
|
-
* set exceeds the cap, the snapshot devolved to mostly `.agents/**`.
|
|
119
|
-
*
|
|
120
|
-
* Algorithm (deterministic, no time/randomness):
|
|
121
|
-
* 1. Group the sorted file list by top-level directory.
|
|
122
|
-
* 2. Round-robin across the groups (in sorted key order), taking one
|
|
123
|
-
* file from each group per pass, until the cap is filled or every
|
|
124
|
-
* group is exhausted. Round-robin gives each top-level tree a fair,
|
|
125
|
-
* proportional share of the budget regardless of its absolute size.
|
|
126
|
-
* 3. Re-sort the selected subset so the emitted `files` array stays in
|
|
127
|
-
* stable lexicographic order (same shape the rest of the pipeline
|
|
128
|
-
* and the golden tests expect).
|
|
129
|
-
*
|
|
130
|
-
* When only one top-level group matches (the Mandrel-repo dogfood case,
|
|
131
|
-
* where `.agents/scripts/**` is the only matching tree), round-robin
|
|
132
|
-
* degenerates to "take the first `cap` from that one group" — identical
|
|
133
|
-
* to the old behaviour, so the Mandrel snapshot stays useful.
|
|
134
|
-
*
|
|
135
|
-
* @param {string[]} sortedFiles - Lexicographically sorted relative paths.
|
|
136
|
-
* @param {number} cap - Maximum number of files to keep.
|
|
137
|
-
* @returns {string[]} The kept subset, lexicographically sorted, length ≤ cap.
|
|
138
|
-
*/
|
|
139
|
-
function proportionalCap(sortedFiles, cap) {
|
|
140
|
-
if (sortedFiles.length <= cap) return sortedFiles;
|
|
141
|
-
const groups = [...groupByTopLevel(sortedFiles).values()];
|
|
142
|
-
const kept = [];
|
|
143
|
-
let exhausted = false;
|
|
144
|
-
for (let pass = 0; !exhausted && kept.length < cap; pass += 1) {
|
|
145
|
-
exhausted = true;
|
|
146
|
-
for (const bucket of groups) {
|
|
147
|
-
if (pass >= bucket.length) continue;
|
|
148
|
-
exhausted = false;
|
|
149
|
-
kept.push(bucket[pass]);
|
|
150
|
-
if (kept.length >= cap) break;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
kept.sort();
|
|
154
|
-
return kept;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Resolve the snapshot configuration from a raw `.agentrc.json` block.
|
|
159
|
-
* Defaults fill in every absent field so callers can hand the result
|
|
160
|
-
* straight to `buildCodebaseSnapshot` without further normalisation.
|
|
161
|
-
*
|
|
162
|
-
* @param {object} [raw] - `planning.codebaseSnapshot` block (may be undefined).
|
|
163
|
-
* @returns {{ tier: string, include: string[], exclude: string[], recentCommitWindow: number }}
|
|
164
|
-
*/
|
|
165
|
-
export function resolveSnapshotConfig(raw) {
|
|
166
|
-
const cfg = raw && typeof raw === 'object' ? raw : {};
|
|
167
|
-
const tier = CODEBASE_SNAPSHOT_TIERS.includes(cfg.tier) ? cfg.tier : 'skinny';
|
|
168
|
-
const include =
|
|
169
|
-
Array.isArray(cfg.include) && cfg.include.length > 0
|
|
170
|
-
? cfg.include.map(String)
|
|
171
|
-
: [...DEFAULT_INCLUDE];
|
|
172
|
-
const exclude =
|
|
173
|
-
Array.isArray(cfg.exclude) && cfg.exclude.length > 0
|
|
174
|
-
? cfg.exclude.map(String)
|
|
175
|
-
: [...DEFAULT_EXCLUDE];
|
|
176
|
-
const recentCommitWindow =
|
|
177
|
-
Number.isInteger(cfg.recentCommitWindow) && cfg.recentCommitWindow > 0
|
|
178
|
-
? cfg.recentCommitWindow
|
|
179
|
-
: DEFAULT_RECENT_COMMIT_WINDOW;
|
|
180
|
-
return { tier, include, exclude, recentCommitWindow };
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Compile one glob entry into a per-segment regex. Supports `**`,
|
|
185
|
-
* `*`, and literal segments. Deliberately small — we don't pull
|
|
186
|
-
* `micromatch` so the snapshot stays self-contained.
|
|
187
|
-
*
|
|
188
|
-
* @param {string} glob
|
|
189
|
-
* @returns {RegExp}
|
|
190
|
-
*/
|
|
191
|
-
function compileGlob(glob) {
|
|
192
|
-
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
193
|
-
// `**` becomes `.*` (cross-segment), `*` becomes `[^/]*` (single segment).
|
|
194
|
-
// We replace `**` first via a placeholder so the single-`*` rule doesn't
|
|
195
|
-
// chew on its inner stars.
|
|
196
|
-
const pattern = escaped
|
|
197
|
-
.replace(/\*\*/g, 'DOUBLESTAR')
|
|
198
|
-
.replace(/\*/g, '[^/]*')
|
|
199
|
-
.replace(/DOUBLESTAR/g, '.*');
|
|
200
|
-
return new RegExp(`^${pattern}$`);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Compile a list of glob entries to RegExp once. Callers hoist this out
|
|
205
|
-
* of the per-file loop so the snapshot pays the (cheap but non-zero)
|
|
206
|
-
* compilation cost a fixed number of times per `buildCodebaseSnapshot`
|
|
207
|
-
* call — ~13 globs total — rather than recompiling every glob for every
|
|
208
|
-
* tracked file (thousands of RegExp constructions on a real repo).
|
|
209
|
-
*
|
|
210
|
-
* @param {string[]} globs
|
|
211
|
-
* @returns {RegExp[]}
|
|
212
|
-
*/
|
|
213
|
-
function compileGlobs(globs) {
|
|
214
|
-
return globs.map(compileGlob);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/**
|
|
218
|
-
* Match `path` against any precompiled glob RegExp. Both the candidate
|
|
219
|
-
* and the globs are normalized to forward slashes (the globs at compile
|
|
220
|
-
* time, the candidate here) so Windows callsites see the same shape as
|
|
221
|
-
* POSIX ones.
|
|
222
|
-
*
|
|
223
|
-
* @param {string} candidate
|
|
224
|
-
* @param {RegExp[]} compiledGlobs
|
|
225
|
-
* @returns {boolean}
|
|
226
|
-
*/
|
|
227
|
-
function matchesAny(candidate, compiledGlobs) {
|
|
228
|
-
const normalised = candidate.replace(/\\/g, '/');
|
|
229
|
-
for (const re of compiledGlobs) {
|
|
230
|
-
if (re.test(normalised)) return true;
|
|
231
|
-
}
|
|
232
|
-
return false;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* List every tracked file under the repo via `git ls-files`. We avoid
|
|
237
|
-
* a node-level filesystem walk so `.gitignore` is naturally honoured
|
|
238
|
-
* and the snapshot is bounded by what's actually in the repo.
|
|
239
|
-
*
|
|
240
|
-
* Returns an empty array if git fails — non-blocking by design.
|
|
241
|
-
*
|
|
242
|
-
* @param {string} cwd
|
|
243
|
-
* @returns {string[]}
|
|
244
|
-
*/
|
|
245
|
-
function listTrackedFiles(cwd) {
|
|
246
|
-
const result = gitSpawn(cwd, 'ls-files');
|
|
247
|
-
if (result.status !== 0 || typeof result.stdout !== 'string') return [];
|
|
248
|
-
return result.stdout
|
|
249
|
-
.split(/\r?\n/)
|
|
250
|
-
.map((line) => line.trim())
|
|
251
|
-
.filter((line) => line.length > 0);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* List directories touched in the most recent `window` commits. Uses
|
|
256
|
-
* `git log -<N> --name-only` and reduces the result to a unique set
|
|
257
|
-
* of top-two-level directories (so `.agents/scripts/lib/foo.js` →
|
|
258
|
-
* `.agents/scripts/lib`). Caps the response at 25 entries so the
|
|
259
|
-
* field stays readable in the spec-author envelope.
|
|
260
|
-
*
|
|
261
|
-
* @param {string} cwd
|
|
262
|
-
* @param {number} window
|
|
263
|
-
* @returns {string[]}
|
|
264
|
-
*/
|
|
265
|
-
function listRecentlyTouchedDirs(cwd, window) {
|
|
266
|
-
const result = gitSpawn(
|
|
267
|
-
cwd,
|
|
268
|
-
'log',
|
|
269
|
-
`-${window}`,
|
|
270
|
-
'--name-only',
|
|
271
|
-
'--pretty=format:',
|
|
272
|
-
);
|
|
273
|
-
if (result.status !== 0 || typeof result.stdout !== 'string') return [];
|
|
274
|
-
const dirs = new Set();
|
|
275
|
-
for (const raw of result.stdout.split(/\r?\n/)) {
|
|
276
|
-
const file = raw.trim();
|
|
277
|
-
if (file.length === 0) continue;
|
|
278
|
-
const parts = file.replace(/\\/g, '/').split('/');
|
|
279
|
-
if (parts.length < 2) {
|
|
280
|
-
dirs.add(parts[0]);
|
|
281
|
-
continue;
|
|
282
|
-
}
|
|
283
|
-
// Top-two-level directory keeps the field useful for a planner
|
|
284
|
-
// ("activity recently concentrated in `.agents/scripts/lib/`")
|
|
285
|
-
// without devolving into a per-file list.
|
|
286
|
-
dirs.add(parts.slice(0, Math.min(3, parts.length - 1)).join('/'));
|
|
287
|
-
}
|
|
288
|
-
const out = [...dirs];
|
|
289
|
-
out.sort();
|
|
290
|
-
return out.slice(0, 25);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* Read `package.json` and pull the entries Phase 7 actually needs:
|
|
295
|
-
* declared `exports`, `bin` keys, the `main` entry, and `scripts`
|
|
296
|
-
* names. Returns `{}` when the file is missing or unparsable.
|
|
297
|
-
*
|
|
298
|
-
* @param {string} cwd
|
|
299
|
-
* @returns {object}
|
|
300
|
-
*/
|
|
301
|
-
function readPackageManifestSurface(cwd, logger) {
|
|
302
|
-
const pkgPath = path.join(cwd, 'package.json');
|
|
303
|
-
if (!fs.existsSync(pkgPath)) return {};
|
|
304
|
-
let parsed;
|
|
305
|
-
try {
|
|
306
|
-
parsed = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
307
|
-
} catch (err) {
|
|
308
|
-
logger.debug(
|
|
309
|
-
`[codebase-snapshot] package.json read/parse failed for ${pkgPath}: ${err?.message ?? err}`,
|
|
310
|
-
);
|
|
311
|
-
return {};
|
|
312
|
-
}
|
|
313
|
-
const exportsField = parsed.exports;
|
|
314
|
-
const exportPaths =
|
|
315
|
-
typeof exportsField === 'string'
|
|
316
|
-
? [exportsField]
|
|
317
|
-
: exportsField && typeof exportsField === 'object'
|
|
318
|
-
? Object.keys(exportsField)
|
|
319
|
-
: [];
|
|
320
|
-
const binField = parsed.bin;
|
|
321
|
-
const binNames =
|
|
322
|
-
typeof binField === 'string'
|
|
323
|
-
? [path.basename(binField)]
|
|
324
|
-
: binField && typeof binField === 'object'
|
|
325
|
-
? Object.keys(binField)
|
|
326
|
-
: [];
|
|
327
|
-
return {
|
|
328
|
-
name: typeof parsed.name === 'string' ? parsed.name : null,
|
|
329
|
-
main: typeof parsed.main === 'string' ? parsed.main : null,
|
|
330
|
-
exports: exportPaths,
|
|
331
|
-
bin: binNames,
|
|
332
|
-
scripts:
|
|
333
|
-
parsed.scripts && typeof parsed.scripts === 'object'
|
|
334
|
-
? Object.keys(parsed.scripts)
|
|
335
|
-
: [],
|
|
336
|
-
};
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
const EXPORT_SIGNATURE_RE =
|
|
340
|
-
/^\s*export\s+(?:default\s+(?:async\s+)?(?:function\s*\*?\s*([A-Za-z_$][\w$]*)|class\s+([A-Za-z_$][\w$]*))|(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)|class\s+([A-Za-z_$][\w$]*)|(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=)/gm;
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
* Extract a sorted, deduped list of exported identifiers from one file.
|
|
344
|
-
* Deliberately regex-based (not an AST parse) so the snapshot is cheap.
|
|
345
|
-
*
|
|
346
|
-
* @param {string} body
|
|
347
|
-
* @returns {string[]}
|
|
348
|
-
*/
|
|
349
|
-
function extractExportSignatures(body) {
|
|
350
|
-
const names = new Set();
|
|
351
|
-
EXPORT_SIGNATURE_RE.lastIndex = 0;
|
|
352
|
-
let match = EXPORT_SIGNATURE_RE.exec(body);
|
|
353
|
-
while (match !== null) {
|
|
354
|
-
for (let i = 1; i < match.length; i += 1) {
|
|
355
|
-
if (match[i]) names.add(match[i]);
|
|
356
|
-
}
|
|
357
|
-
match = EXPORT_SIGNATURE_RE.exec(body);
|
|
358
|
-
}
|
|
359
|
-
// Also catch `export { a, b }` patterns separately — the omnibus
|
|
360
|
-
// regex above is busy enough without trying to absorb braces.
|
|
361
|
-
const braceRe = /export\s*\{\s*([^}]+)\s*\}/g;
|
|
362
|
-
let braceMatch = braceRe.exec(body);
|
|
363
|
-
while (braceMatch !== null) {
|
|
364
|
-
for (const piece of braceMatch[1].split(',')) {
|
|
365
|
-
const name = piece.trim().split(/\s+as\s+/)[0];
|
|
366
|
-
if (name && /^[A-Za-z_$][\w$]*$/.test(name)) names.add(name);
|
|
367
|
-
}
|
|
368
|
-
braceMatch = braceRe.exec(body);
|
|
369
|
-
}
|
|
370
|
-
return [...names].sort();
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/**
|
|
374
|
-
* Build the medium-tier signature map. Walks every tracked file that
|
|
375
|
-
* matches the include/exclude globs and pulls export names from each
|
|
376
|
-
* `.js` / `.ts` body. Skips files larger than 64 KiB so an accidentally
|
|
377
|
-
* checked-in bundle can't blow the snapshot budget.
|
|
378
|
-
*
|
|
379
|
-
* @param {string} cwd
|
|
380
|
-
* @param {string[]} tracked
|
|
381
|
-
* @returns {Array<{ path: string, exports: string[] }>}
|
|
382
|
-
*/
|
|
383
|
-
function collectSignatures(cwd, tracked, logger) {
|
|
384
|
-
const out = [];
|
|
385
|
-
for (const rel of tracked) {
|
|
386
|
-
const ext = path.extname(rel).toLowerCase();
|
|
387
|
-
if (!SIGNATURE_FILE_EXTS.has(ext)) continue;
|
|
388
|
-
const abs = path.join(cwd, rel);
|
|
389
|
-
let stat;
|
|
390
|
-
try {
|
|
391
|
-
stat = fs.statSync(abs);
|
|
392
|
-
} catch (err) {
|
|
393
|
-
logger.debug(
|
|
394
|
-
`[codebase-snapshot] stat failed for ${abs}: ${err?.message ?? err}`,
|
|
395
|
-
);
|
|
396
|
-
continue;
|
|
397
|
-
}
|
|
398
|
-
if (stat.size > 64 * 1024) continue;
|
|
399
|
-
let body;
|
|
400
|
-
try {
|
|
401
|
-
body = fs.readFileSync(abs, 'utf8');
|
|
402
|
-
} catch (err) {
|
|
403
|
-
logger.debug(
|
|
404
|
-
`[codebase-snapshot] readFile failed for ${abs}: ${err?.message ?? err}`,
|
|
405
|
-
);
|
|
406
|
-
continue;
|
|
407
|
-
}
|
|
408
|
-
const exports = extractExportSignatures(body);
|
|
409
|
-
if (exports.length === 0) continue;
|
|
410
|
-
out.push({ path: rel.replace(/\\/g, '/'), exports });
|
|
411
|
-
}
|
|
412
|
-
return out;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
/**
|
|
416
|
-
* Detect the project's test framework + BDD feature root by reading
|
|
417
|
-
* `package.json` scripts and probing well-known directories. Returns
|
|
418
|
-
* a `{ runner, featureRoots }` envelope that the Architect can cite
|
|
419
|
-
* directly when authoring acceptance criteria.
|
|
420
|
-
*
|
|
421
|
-
* @param {string} cwd
|
|
422
|
-
* @param {{ scripts: string[] }} pkg
|
|
423
|
-
* @returns {{ runner: string|null, featureRoots: string[] }}
|
|
424
|
-
*/
|
|
425
|
-
function detectTestSurface(cwd, pkg) {
|
|
426
|
-
let runner = null;
|
|
427
|
-
if (Array.isArray(pkg.scripts)) {
|
|
428
|
-
if (pkg.scripts.includes('test')) runner = 'npm test';
|
|
429
|
-
}
|
|
430
|
-
// Probe canonical BDD feature locations. The actual scanner that
|
|
431
|
-
// Story #2637 will land does a more thorough scan; the snapshot
|
|
432
|
-
// just records the root so the Architect can cite it.
|
|
433
|
-
const featureRoots = [];
|
|
434
|
-
for (const candidate of ['tests/features', 'features']) {
|
|
435
|
-
if (fs.existsSync(path.join(cwd, candidate))) featureRoots.push(candidate);
|
|
436
|
-
}
|
|
437
|
-
return { runner, featureRoots };
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
/**
|
|
441
|
-
* Build the codebase snapshot. Pure with respect to the args (no
|
|
442
|
-
* environment reads beyond the explicit `cwd`); never throws.
|
|
443
|
-
*
|
|
444
|
-
* @param {object} opts
|
|
445
|
-
* @param {string} [opts.cwd] - Repo root (defaults to process.cwd()).
|
|
446
|
-
* @param {string} [opts.tier] - `skinny` | `medium`.
|
|
447
|
-
* @param {string[]} [opts.include] - Glob allowlist.
|
|
448
|
-
* @param {string[]} [opts.exclude] - Glob blocklist (applied after include).
|
|
449
|
-
* @param {number} [opts.recentCommitWindow] - How many recent commits to scan.
|
|
450
|
-
* @returns {{
|
|
451
|
-
* tier: string,
|
|
452
|
-
* generatedAt: string,
|
|
453
|
-
* pkg: object,
|
|
454
|
-
* files: string[],
|
|
455
|
-
* recentlyTouched: string[],
|
|
456
|
-
* testSurface: { runner: string|null, featureRoots: string[] },
|
|
457
|
-
* signatures: Array<{ path: string, exports: string[] }> | null,
|
|
458
|
-
* }}
|
|
459
|
-
*/
|
|
460
|
-
export function buildCodebaseSnapshot(opts = {}) {
|
|
461
|
-
const cwd = opts.cwd ?? process.cwd();
|
|
462
|
-
const logger = opts.logger ?? Logger;
|
|
463
|
-
const cfg = resolveSnapshotConfig({
|
|
464
|
-
tier: opts.tier,
|
|
465
|
-
include: opts.include,
|
|
466
|
-
exclude: opts.exclude,
|
|
467
|
-
recentCommitWindow: opts.recentCommitWindow,
|
|
468
|
-
});
|
|
469
|
-
|
|
470
|
-
const tracked = listTrackedFiles(cwd);
|
|
471
|
-
// Compile the include/exclude globs to RegExp exactly once per call
|
|
472
|
-
// (memoized here, hoisted out of the per-file loop) so a repo with
|
|
473
|
-
// thousands of tracked files doesn't trigger thousands of RegExp
|
|
474
|
-
// constructions. See `compileGlobs`.
|
|
475
|
-
const includeRes = compileGlobs(cfg.include);
|
|
476
|
-
const excludeRes = compileGlobs(cfg.exclude);
|
|
477
|
-
const filtered = tracked.filter(
|
|
478
|
-
(f) => matchesAny(f, includeRes) && !matchesAny(f, excludeRes),
|
|
479
|
-
);
|
|
480
|
-
filtered.sort();
|
|
481
|
-
|
|
482
|
-
const pkg = readPackageManifestSurface(cwd, logger);
|
|
483
|
-
const recentlyTouched = listRecentlyTouchedDirs(cwd, cfg.recentCommitWindow);
|
|
484
|
-
const testSurface = detectTestSurface(cwd, pkg);
|
|
485
|
-
|
|
486
|
-
let displayFiles = filtered;
|
|
487
|
-
let truncated = false;
|
|
488
|
-
if (cfg.tier === 'skinny' && filtered.length > MAX_FILES_SKINNY) {
|
|
489
|
-
// Proportional per-top-level-dir budgeting (not a flat lexicographic
|
|
490
|
-
// slice) so consumer source survives the cap even when a dot-prefixed
|
|
491
|
-
// tree like `.agents/scripts/**` sorts ahead of it. See `proportionalCap`.
|
|
492
|
-
displayFiles = proportionalCap(filtered, MAX_FILES_SKINNY);
|
|
493
|
-
truncated = true;
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
const snapshot = {
|
|
497
|
-
tier: cfg.tier,
|
|
498
|
-
generatedAt: new Date().toISOString(),
|
|
499
|
-
pkg,
|
|
500
|
-
files: displayFiles,
|
|
501
|
-
fileCount: filtered.length,
|
|
502
|
-
truncated,
|
|
503
|
-
recentlyTouched,
|
|
504
|
-
testSurface,
|
|
505
|
-
signatures: null,
|
|
506
|
-
};
|
|
507
|
-
|
|
508
|
-
if (cfg.tier === 'medium') {
|
|
509
|
-
snapshot.signatures = collectSignatures(cwd, filtered, logger);
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
return snapshot;
|
|
513
|
-
}
|
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* planning/spec-authoring-grounding.js — F10 Spec code-grounding.
|
|
3
|
-
*
|
|
4
|
-
* Story #4139 (Epic #4131). `/plan` Phase 7 authors the Tech Spec
|
|
5
|
-
* from the Epic body, the scraped project docs, and the
|
|
6
|
-
* `codebaseSnapshot` structural view of the consumer repo. Two failure
|
|
7
|
-
* modes made that grounding silently partial:
|
|
8
|
-
*
|
|
9
|
-
* 1. The skinny-tier snapshot caps its file list at `MAX_FILES_SKINNY`
|
|
10
|
-
* and sets `truncated: true` — but the only operator-visible signal
|
|
11
|
-
* was a stderr `Logger.warn` (Story #3959). The spec author consumes
|
|
12
|
-
* the JSON envelope, not stderr, so it never learned the snapshot
|
|
13
|
-
* was partial. A run that dropped "377 of 627 files" looked complete
|
|
14
|
-
* to the author.
|
|
15
|
-
*
|
|
16
|
-
* 2. When the Epic body cites a code path that is **absent** from the
|
|
17
|
-
* snapshot's file set, nothing surfaced the gap *during* authoring.
|
|
18
|
-
* The post-author spec-freshness gate (Story #2635) catches stale
|
|
19
|
-
* citations only after the Tech Spec is written — one phase too late
|
|
20
|
-
* to ground the author's choices.
|
|
21
|
-
*
|
|
22
|
-
* `buildAuthoringGrounding` derives a small, bounded `grounding` block that
|
|
23
|
-
* is attached to the `codebaseSnapshot` envelope so the author (and the
|
|
24
|
-
* operator inspecting the run) see both signals before the spec is written:
|
|
25
|
-
*
|
|
26
|
-
* - `truncation` — non-null when the snapshot dropped files. Carries the
|
|
27
|
-
* dropped count, the matched/shown totals, and the two remedies. This
|
|
28
|
-
* is the structured, in-envelope form of the Story #3959 stderr warning.
|
|
29
|
-
* - `citedButAbsent` — path-shaped references pulled from the authoring
|
|
30
|
-
* prose (the Epic body) that are **not** present in the snapshot's file
|
|
31
|
-
* set and are not phrased as net-new. Bounded to `MAX_CITED_ABSENT`
|
|
32
|
-
* entries so a pathological Epic body cannot blow the envelope budget.
|
|
33
|
-
*
|
|
34
|
-
* The grounding is targeted (the prose the author is grounding *from* and
|
|
35
|
-
* the files the snapshot already carries), not a whole-repo dump — the
|
|
36
|
-
* snapshot file set is the only source consulted, so this adds no new
|
|
37
|
-
* filesystem or git probes.
|
|
38
|
-
*/
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Hard cap on the `citedButAbsent` list so an Epic body that mentions a
|
|
42
|
-
* very large number of paths cannot inflate the authoring envelope. The
|
|
43
|
-
* cap is generous relative to a realistic Epic citation count; when it is
|
|
44
|
-
* hit, `citedButAbsentTruncated: true` flags the elision so the signal is
|
|
45
|
-
* not silently dropped (the very failure mode this Story fixes).
|
|
46
|
-
*/
|
|
47
|
-
export const MAX_CITED_ABSENT = 40;
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Build the operator-visible truncation signal from a snapshot envelope.
|
|
51
|
-
* Returns `null` when the snapshot is absent or was not truncated.
|
|
52
|
-
*
|
|
53
|
-
* @param {object|null} snapshot - The `codebaseSnapshot` envelope.
|
|
54
|
-
* @returns {{ dropped: number, matched: number, shown: number, tier: string, remedies: string[] } | null}
|
|
55
|
-
*/
|
|
56
|
-
export function buildTruncationSignal(snapshot) {
|
|
57
|
-
if (!snapshot || snapshot.truncated !== true) return null;
|
|
58
|
-
const matched = Number.isInteger(snapshot.fileCount) ? snapshot.fileCount : 0;
|
|
59
|
-
const shown = Array.isArray(snapshot.files) ? snapshot.files.length : 0;
|
|
60
|
-
const dropped = Math.max(0, matched - shown);
|
|
61
|
-
return {
|
|
62
|
-
dropped,
|
|
63
|
-
matched,
|
|
64
|
-
shown,
|
|
65
|
-
tier: typeof snapshot.tier === 'string' ? snapshot.tier : 'skinny',
|
|
66
|
-
remedies: [
|
|
67
|
-
'Set planning.codebaseSnapshot.tier: "medium" in .agentrc.json to restore full grounding.',
|
|
68
|
-
'Narrow planning.codebaseSnapshot.include in .agentrc.json so the cited surfaces survive the cap.',
|
|
69
|
-
],
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Surface path-shaped references from the authoring prose that are absent
|
|
75
|
-
* from the snapshot's file set. Reuses the spec-freshness path extractor so
|
|
76
|
-
* the citation shapes recognised here match the post-author freshness gate
|
|
77
|
-
* exactly — a path the author cites in prose is detected the same way before
|
|
78
|
-
* and after authoring.
|
|
79
|
-
*
|
|
80
|
-
* A reference is reported only when it is **not** present in `snapshotFiles`
|
|
81
|
-
* **and** the surrounding prose does not phrase it as net-new (the same
|
|
82
|
-
* cue heuristic the freshness gate uses to demote intentional new-file
|
|
83
|
-
* mentions). Results are deduped by path, sorted, and bounded to
|
|
84
|
-
* `MAX_CITED_ABSENT`.
|
|
85
|
-
*
|
|
86
|
-
* @param {string} prose - The authoring prose (typically the Epic body).
|
|
87
|
-
* @param {string[]} snapshotFiles - The snapshot's `files` array.
|
|
88
|
-
* @param {object} deps
|
|
89
|
-
* @param {Function} deps.collectReferences - (body) => Array<{ path, index, matchLength }>.
|
|
90
|
-
* @param {Function} deps.hasNewFileCue - (body, index, matchLength) => boolean.
|
|
91
|
-
* @returns {{ paths: string[], truncated: boolean }}
|
|
92
|
-
*/
|
|
93
|
-
export function findCitedButAbsent(prose, snapshotFiles, deps) {
|
|
94
|
-
const { collectReferences, hasNewFileCue } = deps;
|
|
95
|
-
if (typeof prose !== 'string' || prose.length === 0) {
|
|
96
|
-
return { paths: [], truncated: false };
|
|
97
|
-
}
|
|
98
|
-
const present = new Set(
|
|
99
|
-
(Array.isArray(snapshotFiles) ? snapshotFiles : []).map((f) =>
|
|
100
|
-
String(f).replace(/\\/g, '/'),
|
|
101
|
-
),
|
|
102
|
-
);
|
|
103
|
-
const absent = new Set();
|
|
104
|
-
for (const { path, index, matchLength } of collectReferences(prose)) {
|
|
105
|
-
const normalised = path.replace(/\\/g, '/');
|
|
106
|
-
if (present.has(normalised)) continue;
|
|
107
|
-
if (hasNewFileCue(prose, index, matchLength)) continue;
|
|
108
|
-
absent.add(normalised);
|
|
109
|
-
}
|
|
110
|
-
const sorted = [...absent].sort();
|
|
111
|
-
return {
|
|
112
|
-
paths: sorted.slice(0, MAX_CITED_ABSENT),
|
|
113
|
-
truncated: sorted.length > MAX_CITED_ABSENT,
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Build the full `grounding` block attached to the `codebaseSnapshot`
|
|
119
|
-
* envelope. Pure with respect to its inputs (no filesystem or git probes):
|
|
120
|
-
* the snapshot file set is the sole grounding source, keeping the context
|
|
121
|
-
* bounded for cost.
|
|
122
|
-
*
|
|
123
|
-
* @param {object} opts
|
|
124
|
-
* @param {object|null} opts.snapshot - The `codebaseSnapshot` envelope.
|
|
125
|
-
* @param {string} opts.prose - The authoring prose (Epic body) to scan.
|
|
126
|
-
* @param {Function} opts.collectReferences - spec-freshness path extractor.
|
|
127
|
-
* @param {Function} opts.hasNewFileCue - spec-freshness net-new cue check.
|
|
128
|
-
* @returns {{ truncation: object|null, citedButAbsent: string[], citedButAbsentTruncated: boolean }}
|
|
129
|
-
*/
|
|
130
|
-
export function buildAuthoringGrounding({
|
|
131
|
-
snapshot,
|
|
132
|
-
prose,
|
|
133
|
-
collectReferences,
|
|
134
|
-
hasNewFileCue,
|
|
135
|
-
}) {
|
|
136
|
-
const truncation = buildTruncationSignal(snapshot);
|
|
137
|
-
const { paths, truncated } = findCitedButAbsent(
|
|
138
|
-
prose,
|
|
139
|
-
snapshot?.files ?? [],
|
|
140
|
-
{ collectReferences, hasNewFileCue },
|
|
141
|
-
);
|
|
142
|
-
return {
|
|
143
|
-
truncation,
|
|
144
|
-
citedButAbsent: paths,
|
|
145
|
-
citedButAbsentTruncated: truncated,
|
|
146
|
-
};
|
|
147
|
-
}
|