peaks-loop 4.0.43 → 4.0.45
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/CHANGELOG.md +73 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/dist/cli/commands/codegraph-commands.d.ts +1 -0
- package/dist/cli/commands/codegraph-commands.js +239 -8
- package/dist/cli/commands/final-review-commands.d.ts +34 -10
- package/dist/cli/commands/final-review-commands.js +132 -34
- package/dist/cli/commands/share-commands.d.ts +49 -0
- package/dist/cli/commands/share-commands.js +114 -14
- package/dist/services/codegraph/codegraph-autorefresh.js +12 -0
- package/dist/services/codegraph/codegraph-exclude-integrity.d.ts +61 -0
- package/dist/services/codegraph/codegraph-exclude-integrity.js +98 -0
- package/dist/services/codegraph/codegraph-exclude-reconciler.d.ts +26 -0
- package/dist/services/codegraph/codegraph-exclude-reconciler.js +217 -0
- package/dist/services/codegraph/codegraph-exclude-repair.d.ts +102 -0
- package/dist/services/codegraph/codegraph-exclude-repair.js +266 -0
- package/dist/services/codegraph/codegraph-preflight-service.js +12 -0
- package/dist/services/codegraph/codegraph-service.d.ts +0 -1
- package/dist/services/codegraph/codegraph-service.js +5 -4
- package/dist/services/doctor/doctor-service/checks/codegraph-exclude-integrity.d.ts +29 -0
- package/dist/services/doctor/doctor-service/checks/codegraph-exclude-integrity.js +88 -0
- package/dist/services/doctor/doctor-service/plugin-registry.js +2 -0
- package/dist/services/doctor/doctor-service/types.d.ts +27 -0
- package/dist/services/final-review/final-review-service.d.ts +335 -1
- package/dist/services/final-review/final-review-service.js +1457 -6
- package/dist/services/final-review/index.d.ts +2 -1
- package/dist/services/final-review/index.js +2 -1
- package/dist/services/final-review/pre-post-diff.d.ts +137 -0
- package/dist/services/final-review/pre-post-diff.js +657 -0
- package/dist/services/prd/handoff-auto-regen.js +0 -1
- package/dist/services/prd/handoff-service.d.ts +9 -1
- package/dist/services/prd/handoff-service.js +48 -6
- package/package.json +7 -5
- package/skills/peaks-final-review/SKILL.md +79 -35
- 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
|
+
}
|