peaks-loop 4.0.44 → 4.0.46

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.
Files changed (27) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/code-runtime-commands.js +30 -19
  5. package/dist/cli/commands/codegraph-commands.d.ts +1 -0
  6. package/dist/cli/commands/codegraph-commands.js +49 -3
  7. package/dist/cli/commands/final-review-commands.js +3 -1
  8. package/dist/services/code/auto-compact-lifecycle.d.ts +39 -3
  9. package/dist/services/code/auto-compact-lifecycle.js +41 -6
  10. package/dist/services/code/auto-compact-orchestrator.js +20 -11
  11. package/dist/services/compact-statusline/compact-lifecycle-store.d.ts +11 -2
  12. package/dist/services/compact-statusline/compact-lifecycle-store.js +19 -1
  13. package/dist/services/compact-statusline/compact-statusline-service.d.ts +1 -1
  14. package/dist/services/compact-statusline/compact-statusline-service.js +22 -0
  15. package/dist/services/final-review/final-review-service.d.ts +224 -44
  16. package/dist/services/final-review/final-review-service.js +934 -97
  17. package/dist/services/final-review/index.d.ts +2 -1
  18. package/dist/services/final-review/index.js +2 -1
  19. package/dist/services/final-review/pre-post-diff.d.ts +137 -0
  20. package/dist/services/final-review/pre-post-diff.js +657 -0
  21. package/dist/services/skills/skill-statusline-renderer.js +30 -5
  22. package/dist/services/skills/statusline-palette.d.ts +6 -0
  23. package/dist/services/skills/statusline-palette.js +4 -1
  24. package/package.json +5 -5
  25. package/skills/peaks-code/SKILL.md +2 -2
  26. package/skills/peaks-final-review/SKILL.md +51 -18
  27. package/skills/peaks-final-review/references/4-dimensions.md +42 -5
@@ -0,0 +1,657 @@
1
+ /**
2
+ * `pre-post-diff` producer — the missing evidence source for the
3
+ * `existing-functionality-intact` dimension of `peaks prepare-final-review`.
4
+ *
5
+ * THE GAP THIS CLOSES. The dimension's own contract
6
+ * (`skills/peaks-final-review/references/4-dimensions.md`) asks for "a pre/post
7
+ * baseline diff [showing] no unintended drift in the test surface, public API,
8
+ * or key behavior" — an `EvidenceItem` of kind `pre-post-diff`. Nothing in
9
+ * peaks-loop produced one: the sources mapped to that dimension were
10
+ * `rd/tech-doc.md` (design intent) and `prd/handoff.md` (approved scope), and
11
+ * the reviewer LLM itself reported the mismatch ("the only FOUND source is a
12
+ * design-intent document, not a regression assessment"). `allPass === true`
13
+ * was therefore unreachable by construction, on every workflow.
14
+ *
15
+ * WHAT IT DOES. Compares a base ref against the working tree with git (no new
16
+ * dependency; `git` is already the repo's baseline tool) and records two
17
+ * structural surfaces:
18
+ * 1. test surface — the test FILE lists, and `it(` / `test(` case counts,
19
+ * before/after;
20
+ * 2. public API surface — the `.ts` / `.tsx` SOURCE FILE lists, the top-level
21
+ * `export` statement count, and the ADDED/REMOVED export NAMES over the
22
+ * changed files.
23
+ *
24
+ * WHAT IT DELIBERATELY DOES NOT DO. It never invents a baseline. When no base
25
+ * ref resolves — or the project is not a git work tree at all, or the base
26
+ * resolves to HEAD itself so the compared range is empty — it returns
27
+ * `unavailable` with a reason and writes NO artifact, so the dimension has no
28
+ * `pre-post-diff` evidence and cannot be reported `pass` on the strength of a
29
+ * fabricated (or empty) diff.
30
+ *
31
+ * HONESTY BOUNDARY (also written into the artifact itself). Export detection is
32
+ * a line-anchored REGEX, not a type checker. It detects a structural loss — an
33
+ * export that was removed or renamed — and it CANNOT detect a signature change,
34
+ * a narrowed type, or a behaviour change inside a function body. It is a drift
35
+ * detector, not proof that the API is unchanged. File-level deletion IS visible,
36
+ * because it is read from the file lists rather than inferred from the counts.
37
+ *
38
+ * THE VERDICT COMES FIRST. The artifact opens with its `VERDICT:` line, above
39
+ * the metadata and the counts. The consumer may legitimately receive only the
40
+ * head of this file (it is an evidence source under a per-file byte cap), and
41
+ * with the conclusion at the end every such truncation dropped exactly the part
42
+ * the dimension has to judge — so a partially delivered artifact was
43
+ * indistinguishable from a clean comparison. Conclusion-first makes "the
44
+ * reviewer got the answer" a property of the bytes received.
45
+ */
46
+ import { execFileSync } from 'node:child_process';
47
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
48
+ import { dirname, join } from 'node:path';
49
+ /** Path segments (under `.peaks/_runtime/<sessionId>/`) the artifact lands at. */
50
+ export const API_DIFF_ARTIFACT_SEGMENTS = ['final-review', 'api-diff.txt'];
51
+ /**
52
+ * The literal that opens the artifact's conclusion line.
53
+ *
54
+ * Exported because the consumer must be able to test for the CONCLUSION, not
55
+ * for a byte count (F-BLOCK-1BYTE): one byte of `api-diff.txt` satisfied
56
+ * "status: found" while conveying nothing, and the delivery gate it fed then let
57
+ * a `pass` stand on a comparison nobody saw. The producer writes the line this
58
+ * marker opens and `final-review-service.ts` requires it in the delivered
59
+ * bytes, so the two cannot drift apart into the same hole again.
60
+ */
61
+ export const PRE_POST_DIFF_VERDICT_MARKER = 'VERDICT: ';
62
+ /**
63
+ * The three conclusions the artifact may open with, exported as the same
64
+ * literals the writer interpolates below.
65
+ *
66
+ * They are constants because reading the conclusion is now part of the
67
+ * consumer's job (F2): the pre/post-diff source is the one source whose
68
+ * conclusion had a machine-readable form all along, and the envelope threw it
69
+ * away — a delivered `STRUCTURAL DRIFT DETECTED` produced `allPass: true` with
70
+ * an empty `needsAttention`. A consumer that must classify the conclusion may
71
+ * only do so against the producer's own literals, never against a re-typed
72
+ * copy of them.
73
+ */
74
+ export const PRE_POST_DIFF_NO_DRIFT = 'NO STRUCTURAL DRIFT';
75
+ export const PRE_POST_DIFF_ADDITIONS_ONLY = 'ADDITIONS ONLY';
76
+ export const PRE_POST_DIFF_DRIFT_DETECTED = 'STRUCTURAL DRIFT DETECTED';
77
+ /**
78
+ * Classify the delivered conclusion. Pure and total: a caller passes the bytes
79
+ * it received (possibly a head slice) and gets back what the artifact says.
80
+ *
81
+ * Only the FIRST `VERDICT:` line is read. The artifact's header promises the
82
+ * verdict "is repeated nowhere" and that it is the file's conclusion, so a
83
+ * later line cannot be the answer, and reading further would let a document's
84
+ * prose masquerade as its conclusion.
85
+ */
86
+ export function classifyPrePostDiffVerdict(content) {
87
+ const line = content.split(/\r?\n/).find(candidate => candidate.startsWith(PRE_POST_DIFF_VERDICT_MARKER));
88
+ if (line === undefined)
89
+ return 'indeterminate';
90
+ const conclusion = line.slice(PRE_POST_DIFF_VERDICT_MARKER.length);
91
+ if (conclusion.startsWith(PRE_POST_DIFF_DRIFT_DETECTED))
92
+ return 'drift-detected';
93
+ if (conclusion.startsWith(PRE_POST_DIFF_ADDITIONS_ONLY))
94
+ return 'additions-only';
95
+ if (conclusion.startsWith(PRE_POST_DIFF_NO_DRIFT))
96
+ return 'no-drift';
97
+ return 'indeterminate';
98
+ }
99
+ /** Test-file globs — the single definition both the file count and the case
100
+ * count use, so the two can never disagree about what a "test file" is. */
101
+ export const TEST_FILE_GLOBS = [
102
+ '*.test.ts',
103
+ '*.test.tsx',
104
+ '*.spec.ts',
105
+ '*.spec.tsx'
106
+ ];
107
+ /** Source globs for the public-API surface. */
108
+ export const API_SOURCE_GLOBS = ['*.ts', '*.tsx'];
109
+ const TEST_FILE_RE = /\.(test|spec)\.(ts|tsx)$/;
110
+ /** A `.ts` / `.tsx` file that is not a test file — the file-level source surface. */
111
+ function isSourcePath(path) {
112
+ return /\.(ts|tsx)$/.test(path) && !TEST_FILE_RE.test(path);
113
+ }
114
+ /**
115
+ * One `it(` / `test(` declaration per matching LINE — the same unit `git grep -c`
116
+ * counts on the base side. Counting raw matches instead would double-count a
117
+ * line holding two of them, and the two sides of the delta would then disagree
118
+ * about a file neither of them changed.
119
+ *
120
+ * Modifiers count (`it.skip(`, `it.only(`, `test.each(`, `test.concurrent(`…),
121
+ * and they count on BOTH sides — the JS regex and the POSIX ERE below are the
122
+ * same expression. Missing them made a plain rewrite (`it(` -> `test.each(`)
123
+ * read as two deleted cases; a class that is half-counted is worse than a class
124
+ * that is not counted at all.
125
+ *
126
+ * What is deliberately NOT modelled: an occurrence inside a comment or a string
127
+ * literal still matches. That is symmetric — the same expression is evaluated on
128
+ * both sides — so it cancels out of the DELTA, which is the only thing a removal
129
+ * is ever derived from.
130
+ */
131
+ const TEST_CASE_LINE_RE = /\b(it|test)(\.[A-Za-z]+)*\(/;
132
+ const TEST_CASE_ERE = '\\b(it|test)(\\.[A-Za-z]+)*\\(';
133
+ /**
134
+ * Column-0 `export` — an export STATEMENT, not a type-checked symbol.
135
+ *
136
+ * Deliberately a literal space rather than a character class: `[ \t]` is a tab
137
+ * in JS and "backslash or t" in POSIX ERE, so a pattern that spells the two
138
+ * differently would make the two sides of the count silently disagree.
139
+ */
140
+ const EXPORT_LINE_RE = /^export /;
141
+ const EXPORT_LINE_ERE = '^export ';
142
+ const EXPORT_DECL_RE = /^export\s+(?:declare\s+)?(?:async\s+)?(?:abstract\s+)?(?:function|class|interface|type|enum|const|let|var)\s+([A-Za-z_$][\w$]*)/;
143
+ /**
144
+ * `export type { T }` — a TYPE-ONLY re-export list.
145
+ *
146
+ * It has to be parsed on its own because `EXPORT_LIST_RE` below anchors `{`
147
+ * immediately after `export`, so `export type { T }` matched neither rule and
148
+ * bound NO name — while the inline spelling `export { type T }` was parsed and
149
+ * DID bind `T`. One construct, two spellings, opposite conclusions: the
150
+ * approximation has to pick a direction and hold it. Both spellings now bind
151
+ * `T`: a type-only export IS an export.
152
+ */
153
+ const EXPORT_TYPE_LIST_RE = /^export\s+type\s*\{([^}]*)\}/;
154
+ const EXPORT_LIST_RE = /^export\s*\{([^}]*)\}/;
155
+ const EXPORT_DEFAULT_RE = /^export\s+default\b/;
156
+ /**
157
+ * Run git read-only. Exit code 1 is `grep`-found-nothing and `merge-base`-no-
158
+ * common-ancestor, which are answers rather than failures — every caller
159
+ * therefore checks the CONTENT it needs (`ok` alone is never enough for a ref).
160
+ */
161
+ function runGit(cwd, args) {
162
+ try {
163
+ const stdout = execFileSync('git', [...args], {
164
+ cwd,
165
+ encoding: 'utf8',
166
+ stdio: ['ignore', 'pipe', 'pipe'],
167
+ maxBuffer: 32 * 1024 * 1024,
168
+ windowsHide: true
169
+ });
170
+ return { ok: true, stdout, detail: '' };
171
+ }
172
+ catch (error) {
173
+ const failure = error;
174
+ const stdout = failure.stdout === undefined ? '' : String(failure.stdout);
175
+ const stderr = failure.stderr === undefined ? '' : String(failure.stderr);
176
+ if (failure.status === 1)
177
+ return { ok: true, stdout, detail: stderr.trim() };
178
+ return { ok: false, stdout: '', detail: (stderr || failure.message || '').trim() };
179
+ }
180
+ }
181
+ function lines(text) {
182
+ return text
183
+ .split(/\r?\n/)
184
+ .map(line => line.trim())
185
+ .filter(line => line.length > 0);
186
+ }
187
+ function isTestPath(path) {
188
+ return TEST_FILE_RE.test(path);
189
+ }
190
+ /**
191
+ * The names bound by one `{ ... }` re-export list. The `type` marker is
192
+ * stripped, so `export { type T }` and `export type { T }` agree — see
193
+ * `EXPORT_TYPE_LIST_RE`.
194
+ */
195
+ function namesInExportList(body) {
196
+ return body
197
+ .split(',')
198
+ .map(part => part.replace(/^\s*type\s+/, '').trim())
199
+ .filter(part => part.length > 0)
200
+ .map(part => {
201
+ const alias = /\s+as\s+([A-Za-z_$][\w$]*)$/.exec(part);
202
+ return alias === null ? part : alias[1];
203
+ })
204
+ .filter(name => /^[A-Za-z_$][\w$]*$/.test(name));
205
+ }
206
+ /**
207
+ * The exported NAMES bound by one source line. Approximate by design: the
208
+ * column-0 anchor is what keeps it to top-level declarations, and
209
+ * `export { a as b }` binds `b` (the name importers get).
210
+ */
211
+ export function extractExportedNames(line) {
212
+ if (EXPORT_DEFAULT_RE.test(line))
213
+ return ['default'];
214
+ const typedList = EXPORT_TYPE_LIST_RE.exec(line);
215
+ if (typedList !== null)
216
+ return namesInExportList(typedList[1] ?? '');
217
+ const list = EXPORT_LIST_RE.exec(line);
218
+ if (list !== null)
219
+ return namesInExportList(list[1] ?? '');
220
+ const declaration = EXPORT_DECL_RE.exec(line);
221
+ return declaration === null ? [] : [declaration[1]];
222
+ }
223
+ /** Sum of the `N` in `git grep -c`'s `<rev>:<path>:N` / `<path>:N` lines. */
224
+ function sumGrepCounts(stdout) {
225
+ let total = 0;
226
+ for (const line of lines(stdout)) {
227
+ const count = Number.parseInt(line.slice(line.lastIndexOf(':') + 1), 10);
228
+ if (Number.isInteger(count))
229
+ total += count;
230
+ }
231
+ return total;
232
+ }
233
+ /** Lines of `source` matching `re` — never raw occurrences; see the regexes. */
234
+ function countMatchingLines(source, re) {
235
+ let total = 0;
236
+ for (const line of source.split(/\r?\n/))
237
+ if (re.test(line))
238
+ total += 1;
239
+ return total;
240
+ }
241
+ /**
242
+ * Count on the WORKING TREE by reading the files `afterFiles` already lists.
243
+ *
244
+ * This cannot be delegated to `git grep --untracked`: with that flag git greps
245
+ * through its directory walker, and a TRACKED file that also matches
246
+ * `.gitignore` is dropped from the walk without a word. Measured on this repo —
247
+ * `.gitignore` carries `best-practice/`, and `src/services/best-practice/*.ts`
248
+ * is tracked, so `git grep --untracked -- '*.ts'` silently omitted all three of
249
+ * its files while `git grep -- '*.ts'` (index-based) found them. A count that
250
+ * quietly loses files makes a delta that looks like drift.
251
+ */
252
+ function countFilesMatching(repoRoot, paths, re) {
253
+ let total = 0;
254
+ for (const path of paths) {
255
+ try {
256
+ total += countMatchingLines(readFileSync(join(repoRoot, path), 'utf8'), re);
257
+ }
258
+ catch {
259
+ // Unreadable working-tree file: it is already excluded from the file
260
+ // count by the existence filter, so counting it as 0 is consistent.
261
+ }
262
+ }
263
+ return total;
264
+ }
265
+ /**
266
+ * The baseline. An explicit `--base` wins; otherwise the merge-base with the
267
+ * upstream default branch (the point the current work forked from), and
268
+ * `HEAD~1` only as a last resort for a repo with no upstream.
269
+ *
270
+ * `origin/main` alone was not enough: a repository whose default branch is
271
+ * `master` (still the default `git init` name on many installs), or a
272
+ * `clone --depth 1` that never writes `origin/HEAD`, resolved NOTHING — and
273
+ * with the availability gate in place that means the dimension can never pass
274
+ * for reasons that have nothing to do with the work under review. `origin/master`
275
+ * is therefore tried too, and when every candidate fails the reason names the
276
+ * way out instead of leaving the operator with a silent dead end.
277
+ */
278
+ function resolveBaseRef(repoRoot, explicit) {
279
+ if (explicit !== undefined && explicit.trim() !== '') {
280
+ const requested = explicit.trim();
281
+ const resolved = runGit(repoRoot, ['rev-parse', '--verify', '--quiet', `${requested}^{commit}`]);
282
+ if (resolved.stdout.trim() !== '') {
283
+ return { ok: true, ref: resolved.stdout.trim(), label: requested, reason: '' };
284
+ }
285
+ return {
286
+ ok: false,
287
+ ref: '',
288
+ label: requested,
289
+ reason: `the requested --base ref "${requested}" does not resolve to a commit in this repository`
290
+ };
291
+ }
292
+ const tried = [];
293
+ for (const upstream of ['origin/HEAD', 'origin/main', 'origin/master']) {
294
+ tried.push(`${upstream} (merge-base)`);
295
+ const mergeBase = runGit(repoRoot, ['merge-base', 'HEAD', upstream]);
296
+ if (mergeBase.stdout.trim() !== '') {
297
+ return { ok: true, ref: mergeBase.stdout.trim(), label: upstream, reason: '' };
298
+ }
299
+ }
300
+ tried.push('HEAD~1');
301
+ const previous = runGit(repoRoot, ['rev-parse', '--verify', '--quiet', 'HEAD~1^{commit}']);
302
+ if (previous.stdout.trim() !== '') {
303
+ return { ok: true, ref: previous.stdout.trim(), label: 'HEAD~1', reason: '' };
304
+ }
305
+ return {
306
+ ok: false,
307
+ ref: '',
308
+ label: '',
309
+ reason: `no usable base ref: none of ${tried.join(', ')} resolved against HEAD. ` +
310
+ 'Pass an explicit base with --base <ref> (a branch, a tag, or a commit sha) ' +
311
+ 'to compare against the point this work started from'
312
+ };
313
+ }
314
+ /** ` + name (path)` lines, ordered and de-duplicated. */
315
+ function renderNameList(entries, sign) {
316
+ const seen = new Set();
317
+ const rendered = [];
318
+ for (const entry of entries) {
319
+ const line = ` ${sign} ${entry.name} (${entry.path})`;
320
+ if (seen.has(line))
321
+ continue;
322
+ seen.add(line);
323
+ rendered.push(line);
324
+ }
325
+ return rendered.join('\n');
326
+ }
327
+ /** `1→2 (delta +1)` — the shape a reviewer (and a test) can read at a glance. */
328
+ function delta(before, after) {
329
+ const change = after - before;
330
+ return `${String(before)} -> ${String(after)} (delta ${change >= 0 ? '+' : ''}${String(change)})`;
331
+ }
332
+ /**
333
+ * The per-path file lists, rendered by NET direction.
334
+ *
335
+ * A set of removed paths on its own is not a removal: `git mv` produces one
336
+ * removed path and one added path with no net change, and printing
337
+ * "Removed test files (1)" above a verdict that says nothing was removed is the
338
+ * exact contradiction this renderer must not create. The lists therefore follow
339
+ * the net delta, and an offsetting pair is reported for what it is — a path
340
+ * change, not a loss.
341
+ */
342
+ function renderFileList(label, added, removed, netDelta) {
343
+ const lines = [];
344
+ if (netDelta < 0 && removed.length > 0) {
345
+ lines.push(`Removed ${label} (${String(removed.length)}):`, ...removed.map(p => ` - ${p}`));
346
+ }
347
+ if (netDelta > 0 && added.length > 0) {
348
+ lines.push(`Added ${label} (${String(added.length)}):`, ...added.map(p => ` + ${p}`));
349
+ }
350
+ if (netDelta === 0 && (added.length > 0 || removed.length > 0)) {
351
+ lines.push(`${label}: ${String(removed.length)} path(s) gone / ${String(added.length)} new, with NO net change — so this is a path change (a move or a rename), not a removal.`);
352
+ }
353
+ return lines;
354
+ }
355
+ /**
356
+ * Produce the diff, or say why it could not be produced. Total: it never
357
+ * throws, because a final review must not die on a missing git binary — it
358
+ * must report a dimension it could not assess.
359
+ */
360
+ export function producePrePostDiff(opts) {
361
+ const absolutePath = join(opts.projectRoot, '.peaks', '_runtime', opts.sessionId, ...API_DIFF_ARTIFACT_SEGMENTS);
362
+ const relativePath = ['.peaks', '_runtime', opts.sessionId, ...API_DIFF_ARTIFACT_SEGMENTS].join('/');
363
+ // A stale artifact from an earlier run must never be read as this run's
364
+ // evidence, so it is dropped before anything else can go wrong.
365
+ try {
366
+ rmSync(absolutePath, { force: true });
367
+ }
368
+ catch {
369
+ // An undeletable stale file would be read as evidence, so refuse instead.
370
+ return {
371
+ status: 'unavailable',
372
+ reason: `a stale artifact at ${relativePath} could not be removed`,
373
+ inGitWorkTree: false
374
+ };
375
+ }
376
+ const insideWorkTree = runGit(opts.projectRoot, ['rev-parse', '--is-inside-work-tree']);
377
+ if (insideWorkTree.stdout.trim() !== 'true') {
378
+ return {
379
+ status: 'unavailable',
380
+ reason: `${opts.projectRoot} is not inside a git work tree, so no pre/post baseline can be computed`,
381
+ inGitWorkTree: false
382
+ };
383
+ }
384
+ const topLevel = runGit(opts.projectRoot, ['rev-parse', '--show-toplevel']);
385
+ const repoRoot = topLevel.stdout.trim() === '' ? opts.projectRoot : topLevel.stdout.trim();
386
+ const base = resolveBaseRef(repoRoot, opts.baseRef);
387
+ if (!base.ok) {
388
+ return { status: 'unavailable', reason: base.reason, inGitWorkTree: true };
389
+ }
390
+ const headSha = runGit(repoRoot, ['rev-parse', 'HEAD']).stdout.trim();
391
+ // F8 — "nothing was compared" must never be written up as "nothing drifted".
392
+ //
393
+ // Every count below is delta-shaped, so an EMPTY range makes them all
394
+ // trivially equal and the report proves nothing. That happens when the base
395
+ // ref resolves to HEAD itself and nothing on the compared surfaces moved: the
396
+ // comparison then covers only the uncommitted changes, and there are none. A
397
+ // shallow clone whose `merge-base` IS `HEAD` lands exactly here on its
398
+ // DEFAULT path, which is how a whole repository could get a clean bill of
399
+ // health for a slice nobody had compared yet.
400
+ //
401
+ // The check is on the SURFACES, not on the working tree at large: a project
402
+ // mid-slice is full of untracked artifacts that have nothing to do with the
403
+ // comparison, and a `git mv` of a test file is a surface change even though
404
+ // its net deltas are all zero.
405
+ const baseIsHead = headSha !== '' && base.ref === headSha;
406
+ const surfaceTouched = runGit(repoRoot, ['status', '--porcelain', '--', ...API_SOURCE_GLOBS]).stdout.trim() !== '';
407
+ if (baseIsHead && !surfaceTouched) {
408
+ return {
409
+ status: 'unavailable',
410
+ reason: `the resolved base ref "${base.label}" IS HEAD and nothing on the compared surfaces ` +
411
+ 'changed, so there is no comparable range — nothing was compared, and an empty range ' +
412
+ 'is not evidence that nothing drifted. Pass an explicit base with --base <ref> to ' +
413
+ 'compare against the point this work started from',
414
+ inGitWorkTree: true
415
+ };
416
+ }
417
+ // ---- file sets -----------------------------------------------------------
418
+ const baseFiles = lines(runGit(repoRoot, ['ls-tree', '-r', '--name-only', '--full-tree', base.ref]).stdout);
419
+ const trackedFiles = lines(runGit(repoRoot, ['ls-files', '-co', '--exclude-standard']).stdout);
420
+ const untrackedFiles = lines(runGit(repoRoot, ['ls-files', '--others', '--exclude-standard']).stdout);
421
+ // `ls-files` lists INDEX entries, and an entry can outlive its file on disk.
422
+ const afterFiles = trackedFiles.filter(path => existsSync(join(repoRoot, path)));
423
+ const testFilesBefore = baseFiles.filter(isTestPath);
424
+ const testFilesAfter = afterFiles.filter(isTestPath);
425
+ const addedTestFiles = testFilesAfter.filter(path => !baseFiles.includes(path));
426
+ const removedTestFiles = testFilesBefore.filter(path => !afterFiles.includes(path));
427
+ // F4 — the SOURCE FILE lists, so a deleted module is visible even when the
428
+ // counts it would have moved do not move at all: a `.ts` module with no
429
+ // top-level `export ` line leaves the export count untouched, and a module
430
+ // with no `it(` leaves the case count untouched, so a deleted file used to
431
+ // produce four identical counts and a "NO STRUCTURAL DRIFT" that was simply
432
+ // false. The file lists are the structural fact that does not depend on
433
+ // either approximation. Test files are excluded here because they have their
434
+ // own list above — the two surfaces stay disjoint, so one deleted file is
435
+ // never counted twice.
436
+ const sourceFilesBefore = baseFiles.filter(isSourcePath);
437
+ const sourceFilesAfter = afterFiles.filter(isSourcePath);
438
+ const addedSourceFiles = sourceFilesAfter.filter(path => !baseFiles.includes(path));
439
+ const removedSourceFiles = sourceFilesBefore.filter(path => !afterFiles.includes(path));
440
+ // ---- test surface --------------------------------------------------------
441
+ // Before: the base TREE (a tree grep cannot hit the ignore-walk trap above).
442
+ // After: the working tree, read from the files `afterFiles` lists.
443
+ const testCasesBefore = sumGrepCounts(runGit(repoRoot, ['grep', '-c', '-E', TEST_CASE_ERE, base.ref, '--', ...TEST_FILE_GLOBS])
444
+ .stdout);
445
+ const testCasesAfter = countFilesMatching(repoRoot, testFilesAfter, TEST_CASE_LINE_RE);
446
+ // ---- public API surface (changed .ts / .tsx files only) ------------------
447
+ // The COUNT keeps the `*.ts` / `*.tsx` glob semantics on both sides — the base
448
+ // side is a `git grep` over the same globs, so it cannot exclude test files
449
+ // and the working-tree side must not either, or the two sides would count
450
+ // different file sets. The FILE LISTS (`sourceFiles*`) are the disjoint
451
+ // non-test surface used for the artifact and the removal rule.
452
+ const countedSourceAfter = afterFiles.filter(path => /\.(ts|tsx)$/.test(path));
453
+ const exportsBefore = sumGrepCounts(runGit(repoRoot, ['grep', '-c', '-E', EXPORT_LINE_ERE, base.ref, '--', ...API_SOURCE_GLOBS])
454
+ .stdout);
455
+ const exportsAfter = countFilesMatching(repoRoot, countedSourceAfter, EXPORT_LINE_RE);
456
+ const addedExports = [];
457
+ const removedExports = [];
458
+ let currentPath = '';
459
+ const diffText = runGit(repoRoot, [
460
+ 'diff',
461
+ '--no-color',
462
+ '-U0',
463
+ base.ref,
464
+ '--',
465
+ ...API_SOURCE_GLOBS
466
+ ]).stdout;
467
+ const namesAddedByPath = new Map();
468
+ const namesRemovedByPath = new Map();
469
+ for (const line of diffText.split(/\r?\n/)) {
470
+ if (line.startsWith('+++ ')) {
471
+ currentPath = line.slice(4).replace(/^b\//, '');
472
+ continue;
473
+ }
474
+ if (line.startsWith('--- ') || line.length === 0)
475
+ continue;
476
+ if (!line.startsWith('-') && !line.startsWith('+'))
477
+ continue;
478
+ const names = extractExportedNames(line.slice(1));
479
+ if (names.length === 0)
480
+ continue;
481
+ const byPath = line.startsWith('-') ? namesRemovedByPath : namesAddedByPath;
482
+ const bucket = byPath.get(currentPath) ?? new Set();
483
+ for (const name of names)
484
+ bucket.add(name);
485
+ byPath.set(currentPath, bucket);
486
+ }
487
+ // F6 — a name that appears on BOTH sides of the same file was REWRITTEN or
488
+ // re-listed, not removed: `export { a }` -> `export { a, b }` diffs as
489
+ // `-export { a }` / `+export { a, b }`, and reading the minus side alone
490
+ // reported a removal of `a` that never happened. A false removal is the same
491
+ // class of defect as a false pass — it turns the gate red for a change that
492
+ // is not one — so the per-file set difference is what decides, and an
493
+ // unresolved name is simply not reported.
494
+ for (const [path, names] of namesRemovedByPath) {
495
+ const rewritten = namesAddedByPath.get(path) ?? new Set();
496
+ for (const name of names)
497
+ if (!rewritten.has(name))
498
+ removedExports.push({ name, path });
499
+ }
500
+ for (const [path, names] of namesAddedByPath) {
501
+ const preexisting = namesRemovedByPath.get(path) ?? new Set();
502
+ for (const name of names)
503
+ if (!preexisting.has(name))
504
+ addedExports.push({ name, path });
505
+ }
506
+ // Untracked files are invisible to `git diff`, and in this repo's workflow the
507
+ // slice's new files are exactly the ones still untracked.
508
+ for (const path of untrackedFiles.filter(path => /\.(ts|tsx)$/.test(path))) {
509
+ try {
510
+ for (const name of readFileSync(join(repoRoot, path), 'utf8')
511
+ .split(/\r?\n/)
512
+ .flatMap(line => extractExportedNames(line))) {
513
+ addedExports.push({ name, path });
514
+ }
515
+ }
516
+ catch {
517
+ // Unreadable new file: its exports simply do not appear in the added list.
518
+ }
519
+ }
520
+ // ---- verdict -------------------------------------------------------------
521
+ const caseDelta = testCasesAfter - testCasesBefore;
522
+ const testFileDelta = testFilesAfter.length - testFilesBefore.length;
523
+ const sourceFileDelta = sourceFilesAfter.length - sourceFilesBefore.length;
524
+ const exportStatementDelta = exportsAfter - exportsBefore;
525
+ // Every entry here is a NET LOSS, and the list is exhaustive over the counted
526
+ // quantities: a negative delta that has no entry would let the verdict say
527
+ // "nothing was removed" directly above a count that says otherwise. Two rules
528
+ // follow from that, and both favour UNDER-reporting:
529
+ // - a file that was removed while an equal number was added is a MOVE, not a
530
+ // deletion, so the net delta (not the per-path lists) decides;
531
+ // - an unresolved name is still a loss (`export {}` binds nothing to name),
532
+ // so it is reported as a count rather than dropped.
533
+ const removals = [];
534
+ if (removedExports.length > 0) {
535
+ removals.push(`${String(removedExports.length)} export name(s)`);
536
+ }
537
+ else if (exportStatementDelta < 0) {
538
+ removals.push(`${String(-exportStatementDelta)} export statement(s) (the removed names could not be resolved — an \`export {}\` list or a deleted module)`);
539
+ }
540
+ if (sourceFileDelta < 0)
541
+ removals.push(`${String(-sourceFileDelta)} source file(s)`);
542
+ if (testFileDelta < 0)
543
+ removals.push(`${String(-testFileDelta)} test file(s)`);
544
+ if (caseDelta < 0) {
545
+ removals.push(`${String(-caseDelta)} test case declaration line(s) (\`it(\` / \`test(\` — a data-driven rewrite such as \`it.each(\` lowers this line count without losing a case, so confirm it is a loss before treating it as one)`);
546
+ }
547
+ const changed = addedExports.length +
548
+ removedExports.length +
549
+ Math.abs(sourceFileDelta) +
550
+ Math.abs(testFileDelta) +
551
+ Math.abs(caseDelta) +
552
+ Math.abs(exportStatementDelta);
553
+ const verdict = changed === 0
554
+ ? `${PRE_POST_DIFF_VERDICT_MARKER}${PRE_POST_DIFF_NO_DRIFT} — the test file count, the test case count, the source file count, the top-level export statement count and the export name set are all IDENTICAL before and after.`
555
+ : removals.length === 0
556
+ ? `${PRE_POST_DIFF_VERDICT_MARKER}${PRE_POST_DIFF_ADDITIONS_ONLY} — the structural surface grew and nothing was removed; the deltas above are the whole change.`
557
+ : `${PRE_POST_DIFF_VERDICT_MARKER}${PRE_POST_DIFF_DRIFT_DETECTED} — the following structural removals were detected: ${removals.join('; ')}. A removal is not automatically wrong, but it is exactly the "unintended drift" this dimension exists to catch: judge whether each removal was authorized by the approved scope.`;
558
+ // The verdict is the FIRST thing in the file — above the metadata, the method
559
+ // notes and the counts — because a consumer may legitimately receive only the
560
+ // head of it (the per-file evidence cap) and, when it did, the head used to be
561
+ // a comment block: every truncation deterministically dropped the conclusion,
562
+ // so a truncated delivery could not be told from "nothing drifted". Putting
563
+ // the conclusion first makes "the reviewer received the verdict" a fact the
564
+ // bytes themselves can be asked about, and it costs nothing to read.
565
+ const content = [
566
+ '# Pre/post baseline diff — existing-functionality-intact',
567
+ '#',
568
+ '## Verdict',
569
+ verdict,
570
+ '',
571
+ '# The verdict above is repeated nowhere: it is the file\'s conclusion. The method',
572
+ '# and the raw counts it rests on follow, for a human who wants to check it.',
573
+ '#',
574
+ `# Base ref : ${base.label} (${base.ref})`,
575
+ `# After : working tree${headSha === '' ? ' (unborn HEAD)' : ` at HEAD ${headSha}`} — uncommitted changes included`,
576
+ `# Generated: ${new Date().toISOString()}`,
577
+ '#',
578
+ '# METHOD — test surface',
579
+ `# Test files : the file lists from \`git ls-tree --full-tree <base>\` and`,
580
+ `# \`git ls-files -co --exclude-standard\` (working tree, untracked`,
581
+ `# files included), matched against ${TEST_FILE_GLOBS.join(', ')}.`,
582
+ '# Test cases : declaration lines matching `it(` / `test(`, MODIFIERS INCLUDED',
583
+ '# (`it.skip(`, `test.each(`, `test.concurrent(`...) — counted',
584
+ '# with `git grep -c` against the base tree, and by reading the',
585
+ '# working-tree files with the same expression.',
586
+ '#',
587
+ '# METHOD — public API surface (changed .ts / .tsx files only)',
588
+ '# Top-level `export` statements (lines anchored at column 0) are counted with',
589
+ "# `git grep -c` on both sides. The added/removed NAMES come from the changed",
590
+ "# lines of `git diff -U0 <base> -- '*.ts' '*.tsx'`, plus any untracked file",
591
+ '# read from disk. The FILE lists below cover non-test `.ts` / `.tsx` files.',
592
+ '#',
593
+ '# HONEST BOUNDARY',
594
+ '# Export detection is APPROXIMATE — a line-anchored regex, NOT a type checker.',
595
+ '# It detects a structural loss (an export that was removed or renamed). It',
596
+ '# CANNOT detect a changed signature, a narrowed type, or a behaviour change',
597
+ '# inside a function body. `export { a, b }` counts as ONE export statement.',
598
+ '# Type-only exports ARE counted, both spellings: `export type { T }` and',
599
+ '# `export { type T }` both bind `T`. Read this as a drift DETECTOR, not as',
600
+ '# proof that the API is unchanged.',
601
+ '# File DELETIONS are visible: a removed test file or a removed non-test',
602
+ '# `.ts` / `.tsx` module is reported from the file LISTS, independently of the',
603
+ '# counts — which a deleted module can leave unchanged (a module with no',
604
+ '# top-level `export ` line has no exports to lose, and no `it(` to lose',
605
+ '# either, so all four counts would have stayed identical). A path that was',
606
+ '# removed while an equal number of files was added is reported as the NET',
607
+ '# change only, so a `git mv` is not read as a deletion.',
608
+ '# Test-case counting is a LINE count of `it(` / `test(` declaration lines.',
609
+ '# The same expression is evaluated on both sides, so an occurrence inside a',
610
+ '# comment or a string literal is counted on BOTH sides and cancels out of the',
611
+ '# delta instead of skewing it. A data-driven `*.each(...)` line stands for',
612
+ '# many cases: folding several `it(` lines into one `.each(` line lowers the',
613
+ '# count without losing a case, so a case-count drop is a signal to inspect,',
614
+ '# not proof of a lost test.',
615
+ '# IDEMPOTENCE: two runs over the same base differ on exactly one line — the',
616
+ '# `Generated:` timestamp above. A wall-clock timestamp is not drift.',
617
+ '',
618
+ '## Test surface',
619
+ `Test files: before ${String(testFilesBefore.length)}, after ${String(testFilesAfter.length)}, delta ${testFilesAfter.length - testFilesBefore.length >= 0 ? '+' : ''}${String(testFilesAfter.length - testFilesBefore.length)}`,
620
+ `Test cases: before ${String(testCasesBefore)}, after ${String(testCasesAfter)}, delta ${caseDelta >= 0 ? '+' : ''}${String(caseDelta)}`,
621
+ ...renderFileList('test files', addedTestFiles, removedTestFiles, testFileDelta),
622
+ '',
623
+ '## Public API surface (changed .ts / .tsx files only)',
624
+ `Source files (non-test): before ${String(sourceFilesBefore.length)}, after ${String(sourceFilesAfter.length)}, delta ${sourceFileDelta >= 0 ? '+' : ''}${String(sourceFileDelta)}`,
625
+ ...renderFileList('source files', addedSourceFiles, removedSourceFiles, sourceFileDelta),
626
+ `Top-level export statements: before ${String(exportsBefore)}, after ${String(exportsAfter)}, delta ${exportStatementDelta >= 0 ? '+' : ''}${String(exportStatementDelta)}`,
627
+ ...(addedExports.length === 0
628
+ ? ['Added exports: none']
629
+ : [`Added exports (${String(addedExports.length)}):`, renderNameList(addedExports, '+')]),
630
+ ...(removedExports.length === 0
631
+ ? ['Removed exports: none']
632
+ : [
633
+ `Removed exports (${String(removedExports.length)}):`,
634
+ renderNameList(removedExports, '-')
635
+ ]),
636
+ ''
637
+ ].join('\n');
638
+ try {
639
+ mkdirSync(dirname(absolutePath), { recursive: true });
640
+ writeFileSync(absolutePath, content, 'utf8');
641
+ }
642
+ catch (error) {
643
+ return {
644
+ status: 'unavailable',
645
+ reason: `the baseline diff was computed but could not be written to ${relativePath}: ${error instanceof Error ? error.message : String(error)}`,
646
+ inGitWorkTree: true
647
+ };
648
+ }
649
+ const summary = `Pre/post baseline diff ${base.label} (${base.ref.slice(0, 8)}) -> working tree: ` +
650
+ `test files ${delta(testFilesBefore.length, testFilesAfter.length)}, ` +
651
+ `test cases ${delta(testCasesBefore, testCasesAfter)}, ` +
652
+ `non-test source files ${delta(sourceFilesBefore.length, sourceFilesAfter.length)}, ` +
653
+ `top-level export statements ${delta(exportsBefore, exportsAfter)}, ` +
654
+ `+${String(addedExports.length)} / -${String(removedExports.length)} export names. ` +
655
+ (removals.length === 0 ? 'No structural removal detected.' : `Structural removals: ${removals.join('; ')}.`);
656
+ return { status: 'computed', summary, relativePath, absolutePath, content };
657
+ }