mandrel 2.23.0 → 2.24.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.
@@ -1,25 +1,80 @@
1
1
  import fs from 'node:fs';
2
2
  import escomplex from 'typhonjs-escomplex';
3
+ import { install as installAstCompat } from './escomplex-ast-compat.js';
3
4
  import { transpileIfNeeded } from './transpile.js';
4
5
 
5
6
  /**
6
7
  * Calculates the maintainability score of a JavaScript source file or string.
7
8
  * Uses `typhonjs-escomplex` internally, which provides a maintainability index
8
9
  * based on the Halstead Volume, Cyclomatic Complexity, and Lines of Code.
10
+ *
11
+ * The kernel's code generator predates the Babel AST its own parser emits, so
12
+ * ordinary modern syntax (`?.`, `await` in a loop head, a regex in a loop
13
+ * head, object spread in a default parameter) aborts the whole analysis.
14
+ * `escomplex-ast-compat` repairs that before any scoring runs — see that
15
+ * module for the defect and the upstream status.
16
+ */
17
+ installAstCompat();
18
+
19
+ /**
20
+ * Sentinel score for a file the kernel cannot analyse.
21
+ *
22
+ * A real maintainability index never reaches 0 for runnable code — the
23
+ * escomplex floor is ~10–20 — so 0 has long been used as an out-of-band
24
+ * "unscorable" marker. That overload is the bug: consumers drop `mi === 0`
25
+ * rows, so an unscorable file silently vanishes from the baseline instead of
26
+ * being reported, and no amount of re-seeding can ever give it a row.
27
+ *
28
+ * Deliberately module-private. The numeric return is kept for backwards
29
+ * compatibility, but the *value* is not something a caller should branch on —
30
+ * that is the overload this change exists to stop propagating. Callers that
31
+ * need to tell "unscorable" from "genuinely terrible" read the `unscorable`
32
+ * flag from {@link scoreSource} / {@link scoreFile}.
33
+ */
34
+ const UNSCORABLE = 0;
35
+
36
+ /**
37
+ * Score a raw string, distinguishing "the kernel could not analyse this" from
38
+ * "this scored badly".
39
+ *
40
+ * @param {string} sourceCode The JavaScript source code.
41
+ * @returns {{ score: number, unscorable: boolean, reason: string|null }}
42
+ * `score` is {@link UNSCORABLE} when `unscorable` is true; `reason` carries
43
+ * the kernel's own error message so a consumer can report *why* rather than
44
+ * just omitting the file.
45
+ */
46
+ export function scoreSource(sourceCode) {
47
+ try {
48
+ const score = escomplex.analyzeModule(sourceCode)?.maintainability;
49
+ return Number.isFinite(score)
50
+ ? { score, unscorable: false, reason: null }
51
+ : unscorable(`kernel returned a non-finite index (${String(score)})`);
52
+ } catch (err) {
53
+ return unscorable(
54
+ `${err?.constructor?.name ?? 'Error'}: ${err?.message ?? 'unknown kernel failure'}`,
55
+ );
56
+ }
57
+ }
58
+
59
+ /**
60
+ * @param {string} reason
61
+ * @returns {{ score: number, unscorable: boolean, reason: string }}
9
62
  */
63
+ function unscorable(reason) {
64
+ return { score: UNSCORABLE, unscorable: true, reason };
65
+ }
66
+
10
67
  /**
11
68
  * Calculate score for a raw string of source code.
69
+ *
70
+ * Returns 0 for unscorable input, which is ambiguous by construction — see
71
+ * {@link UNSCORABLE}. Prefer {@link scoreSource} in new code.
72
+ *
12
73
  * @param {string} sourceCode The JavaScript source code.
13
74
  * @returns {number} Score between 0 and 171. Higher is better.
14
75
  */
15
76
  export function calculateForSource(sourceCode) {
16
- try {
17
- const result = escomplex.analyzeModule(sourceCode);
18
- return result.maintainability;
19
- } catch (_err) {
20
- // Return 0 if the parser fails (e.g. invalid syntax)
21
- return 0;
22
- }
77
+ return scoreSource(sourceCode).score;
23
78
  }
24
79
 
25
80
  /**
@@ -34,17 +89,34 @@ export function calculateForSource(sourceCode) {
34
89
  * be parsed (escomplex parse error or TS transpile failure).
35
90
  */
36
91
  export function calculateForFile(filePath) {
92
+ return scoreFile(filePath).score;
93
+ }
94
+
95
+ /**
96
+ * Score a file, distinguishing "unscorable" from "scored badly".
97
+ *
98
+ * The transpile-failure and kernel-failure cases are reported separately
99
+ * because they need different fixes: a transpile failure is usually the
100
+ * consumer's own syntax or `tsconfig`, whereas a kernel failure is the
101
+ * upstream generator gap described in `escomplex-ast-compat.js`.
102
+ *
103
+ * @param {string} filePath Path to the JS/TS source file.
104
+ * @returns {{ score: number, unscorable: boolean, reason: string|null }}
105
+ */
106
+ export function scoreFile(filePath) {
107
+ let sourceCode;
37
108
  try {
38
- const sourceCode = fs.readFileSync(filePath, 'utf-8');
39
- const prepared = transpileIfNeeded(filePath, sourceCode);
40
- if (prepared === null) return 0;
41
- return calculateForSource(prepared);
109
+ sourceCode = fs.readFileSync(filePath, 'utf-8');
42
110
  } catch (err) {
43
111
  if (err.code === 'ENOENT') {
44
112
  throw new Error(`File not found: ${filePath}`);
45
113
  }
46
114
  throw err;
47
115
  }
116
+
117
+ const prepared = transpileIfNeeded(filePath, sourceCode);
118
+ if (prepared === null) return unscorable('TypeScript transpile failed');
119
+ return scoreSource(prepared);
48
120
  }
49
121
 
50
122
  /**
@@ -0,0 +1,60 @@
1
+ /**
2
+ * maintainability-unscorable.js — reporting for files the MI kernel cannot
3
+ * analyse.
4
+ *
5
+ * A file the kernel throws on has no maintainability index, so it gets no
6
+ * baseline row — a phantom `mi: 0` would poison the `min`/p50 rollup and let
7
+ * real regressions hide behind it. Dropping the row is therefore correct; doing
8
+ * it *silently* is not. Without a report, an unscorable file is
9
+ * indistinguishable from a file nobody added yet: the scorer emits nothing, the
10
+ * scope gate sees an absence it cannot explain, and re-seeding the baseline can
11
+ * never produce the missing row no matter how many times it runs.
12
+ *
13
+ * Kept separate from `maintainability-utils.js` so the scoring path stays about
14
+ * scoring and this stays about explaining.
15
+ */
16
+
17
+ import { Logger } from './Logger.js';
18
+
19
+ /**
20
+ * Report every unscorable file, then summarise.
21
+ *
22
+ * @param {Array<{ relPath: string, unscorable?: boolean, reason?: string|null }>} perFile
23
+ * @returns {number} how many files were unscorable, for the caller's own use.
24
+ */
25
+ export function reportUnscorable(perFile) {
26
+ const unscorable = (perFile ?? []).filter((entry) => entry?.unscorable);
27
+ if (unscorable.length === 0) return 0;
28
+
29
+ for (const { relPath, reason } of unscorable) {
30
+ Logger.error(
31
+ `[Maintainability] UNSCORABLE ${relPath}: ${reason ?? 'unknown kernel failure'}`,
32
+ );
33
+ }
34
+ Logger.error(
35
+ `[Maintainability] ${unscorable.length} file(s) could not be scored and will have ` +
36
+ 'no baseline row, so the maintainability gate cannot see them. If the cause is a ' +
37
+ 'kernel AST gap, add a handler in lib/escomplex-ast-compat.js rather than an ' +
38
+ 'allowlist entry.',
39
+ );
40
+ return unscorable.length;
41
+ }
42
+
43
+ /**
44
+ * Whether a per-file entry carries a real maintainability index and so belongs
45
+ * in the baseline.
46
+ *
47
+ * An unscorable entry carries the sentinel score, not an index — letting it
48
+ * through would write an `mi: 0` phantom and drag the rollup floor down with it
49
+ * (Story #2467). `score === null` is the separate I/O-failure case.
50
+ *
51
+ * Tests for a *number* rather than `score !== null`, because the latter passes
52
+ * anything absent: `undefined !== null` is true, so a missing entry or one with
53
+ * no `score` key at all would have been treated as scored.
54
+ *
55
+ * @param {{ score?: number|null, unscorable?: boolean }} entry
56
+ * @returns {boolean}
57
+ */
58
+ export function isScored(entry) {
59
+ return typeof entry?.score === 'number' && !entry.unscorable;
60
+ }
@@ -4,7 +4,8 @@ import { minimatch } from 'minimatch';
4
4
  import { canonicalise as canonicalisePath } from './baselines/path-canon.js';
5
5
  import { POOL_SERIAL_THRESHOLD, runOnPool } from './cpu-pool.js';
6
6
  import { Logger } from './Logger.js';
7
- import { calculateForFile } from './maintainability-engine.js';
7
+ import { scoreFile } from './maintainability-engine.js';
8
+ import { isScored, reportUnscorable } from './maintainability-unscorable.js';
8
9
 
9
10
  const MAINTAINABILITY_WORKER_URL = new URL(
10
11
  './workers/maintainability-worker.js',
@@ -128,6 +129,13 @@ export function scanDirectory(dir, fileList = [], opts = {}) {
128
129
  * worker-side per-item failures surface as a `null` score that is
129
130
  * filtered out before assembly.
130
131
  *
132
+ * A file the kernel cannot analyse is also dropped — a phantom `mi: 0` row
133
+ * poisons the rollup — but it is **reported** on the way out, with the
134
+ * kernel's own error text, and the count is summarised at the end of the run.
135
+ * Silently omitting these is what let a file sit unmeasured indefinitely: the
136
+ * scorer emitted no row, so no amount of re-seeding could ever produce one,
137
+ * and nothing said so.
138
+ *
131
139
  * @param {string[]} paths
132
140
  * @returns {Promise<Record<string, number>>}
133
141
  */
@@ -142,7 +150,7 @@ export async function calculateAll(paths) {
142
150
  if (indexed.length < SERIAL_THRESHOLD) {
143
151
  perFile = indexed.map(({ abs, relPath }) => {
144
152
  try {
145
- return { relPath, score: calculateForFile(abs) };
153
+ return { relPath, ...scoreFile(abs) };
146
154
  } catch (err) {
147
155
  Logger.error(
148
156
  `[Maintainability] Failed to process ${abs}: ${err.message}`,
@@ -166,7 +174,7 @@ export async function calculateAll(paths) {
166
174
  if (r.score === null && r.error) {
167
175
  Logger.error(`[Maintainability] Failed to process ${abs}: ${r.error}`);
168
176
  }
169
- return { relPath, score: r.score };
177
+ return { relPath, ...r };
170
178
  });
171
179
  }
172
180
 
@@ -174,9 +182,10 @@ export async function calculateAll(paths) {
174
182
  a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0,
175
183
  );
176
184
 
185
+ reportUnscorable(perFile);
186
+
177
187
  const scores = {};
178
- for (const { relPath, score } of perFile) {
179
- if (score === null) continue;
188
+ for (const { relPath, score } of perFile.filter(isScored)) {
180
189
  scores[relPath] = score;
181
190
  }
182
191
  return scores;
@@ -75,6 +75,14 @@ export const RUNTIME_FRICTION_CATEGORIES = Object.freeze({
75
75
  * reflect code findings only.
76
76
  */
77
77
  TOOL_DEGRADED: 'tool-degraded',
78
+ /**
79
+ * The light delivery path refused a scope — a suitability-gate `ask-operator`
80
+ * or a blocked diff backstop. Story #4856 added it because neither rejection
81
+ * emitted anything, so an over-tight ceiling could only reach the framework
82
+ * as anecdote; the roll-up aggregating these by category is what makes the
83
+ * ceilings recalibratable from recorded evidence.
84
+ */
85
+ LIGHT_SCOPE_REJECTED: 'light-scope-rejected',
78
86
  });
79
87
 
80
88
  /** Cap on free-form reason text copied into a signal's `details`. */
@@ -0,0 +1,283 @@
1
+ /**
2
+ * lib/orchestration/diff-magnitude.js — the changed-line magnitude of a diff,
3
+ * split into implementation and mandated-companion halves (Story #4856).
4
+ *
5
+ * ## Why magnitude, and why the split
6
+ *
7
+ * The light path's diff backstop used to bound scope with a single
8
+ * `maxFiles: 4` ceiling. Measured against this repository's own history that
9
+ * ceiling was wrong in both directions:
10
+ *
11
+ * - **Too tight.** Of 33 real-work squash merges on `main` (excluding
12
+ * release-please and `chore(baselines)` automation) only 7 — 21% — touch
13
+ * four files or fewer; the median is 8. The framework's *own* narrow-diff
14
+ * scale, `DEFAULT_DIFF_WIDTH.softFiles` in `review-depth.js`, is 15.
15
+ * - **Blind.** A three-file, 323-line rewrite passed while a 190-file change
16
+ * was rejected 47× over — even though 186 of those files were tests and its
17
+ * implementation was 7 files.
18
+ *
19
+ * So the axis is **changed lines over implementation files**, and the companion
20
+ * classes the framework itself mandates are exempt from the count: obeying
21
+ * `rules/testing-standards.md` (test-first), the `delivery.docsFreshness` gate,
22
+ * and the baseline ratchets must not inflate the number that then rejects the
23
+ * change. Re-counting those same 33 merges on implementation files alone moves
24
+ * the four-file pass rate from 21% to 58%.
25
+ *
26
+ * ## Three contracts worth not rediscovering
27
+ *
28
+ * 1. **Additions plus deletions, never net.** A modified line counts twice
29
+ * (one `+`, one `-`), which is the intended weighting. Net is actively
30
+ * broken as a size signal: the merge retiring the planner snapshot is
31
+ * 1119 add+del but **−803** net, so a large deletion would measure as
32
+ * trivial.
33
+ * 2. **A pure rename is free.** `git diff --numstat` reports `0\t0` for one,
34
+ * and its path arrives in `old => new` form — normalized here to the
35
+ * destination so the file still counts toward the implementation tally.
36
+ * 3. **Exemption is from *counting*, never from *risk*.** Nothing here
37
+ * touches sensitive-path derivation, which every caller runs over the
38
+ * full changed set including companions.
39
+ *
40
+ * Companion matching runs through the audit suite's picomatch seam as a
41
+ * **positive** glob list negated by the caller. A `!`-prefixed picomatch
42
+ * pattern widens a match rather than narrowing it, so expressing the
43
+ * behavior-bearing exceptions as negations would silently exempt more than
44
+ * intended.
45
+ *
46
+ * Every export is total: no throws. {@link readNumstatRows} owns the one git
47
+ * read; everything else is pure.
48
+ *
49
+ * The public surface is deliberately just those two functions. The numstat
50
+ * parse, the rename normalization, and the companion classifier are internal:
51
+ * each is fully observable through them (a stubbed `gitSpawnFn` drives the
52
+ * parse, an `isCompanionFn` seam drives the classification), so exporting them
53
+ * would widen the module's contract for no caller.
54
+ *
55
+ * @module lib/orchestration/diff-magnitude
56
+ */
57
+
58
+ import { matchesAnyFilePattern } from '../audit-suite/selector.js';
59
+ import { gitSpawn } from '../git-utils.js';
60
+
61
+ /**
62
+ * Paths whose churn is a **mandated companion** of a change rather than the
63
+ * change itself, exempt from the implementation line and file counts.
64
+ *
65
+ * Deliberately absent, and load-bearing in their absence: `.agentrc.json`,
66
+ * `.agents/schemas/**`, `.github/workflows/**`, and `package.json`. Those are
67
+ * "config" by file type but behavior by effect, and `.agents/schemas/audit-rules.json`
68
+ * is the sensitive-path SSOT — exempting it would let a change widen the very
69
+ * allowlist that decides whether it is risky.
70
+ *
71
+ * Note the anchoring: `baselines/**` is the generated baseline **data** at the
72
+ * repository root. The baseline *schemas* under `.agents/schemas/baselines/`
73
+ * are unanchored by this pattern and therefore still count as implementation.
74
+ *
75
+ * Markdown is exempt wholesale, which includes `.agents/workflows/**` and
76
+ * `.agents/rules/**`. That is intended: rewriting a workflow contract is not
77
+ * *effort* the way a module rewrite is, and its real guards are the close-time
78
+ * context-budget, doc-link, and docs-reference-sync gates — none of which this
79
+ * ceiling replaces.
80
+ */
81
+ const COMPANION_PATH_GLOBS = Object.freeze([
82
+ // Tests — mandated by rules/testing-standards.md's test-first discipline.
83
+ '**/__tests__/**',
84
+ '**/*.test.js',
85
+ '**/*.test.mjs',
86
+ '**/*.test.cjs',
87
+ '**/*.test.ts',
88
+ '**/*.test.tsx',
89
+ 'tests/**',
90
+ 'features/**',
91
+ // Documentation — mandated by the delivery.docsFreshness gate.
92
+ 'docs/**',
93
+ '**/*.md',
94
+ // Generated baseline data — written by the ratchets, not hand-authored.
95
+ 'baselines/**',
96
+ // Lockfiles — regenerated wholesale; their line count means nothing.
97
+ 'package-lock.json',
98
+ 'pnpm-lock.yaml',
99
+ 'yarn.lock',
100
+ ]);
101
+
102
+ /**
103
+ * Normalize a `git diff --numstat` path field to the single file it names.
104
+ * Rename rows arrive as `old => new` or with a braced infix
105
+ * (`dir/{old => new}/file.js`); both resolve to the destination, so a renamed
106
+ * implementation file still counts as one implementation file.
107
+ *
108
+ * Pure and total.
109
+ *
110
+ * @param {string} raw
111
+ * @returns {string}
112
+ */
113
+ function normalizeNumstatPath(raw) {
114
+ const value = typeof raw === 'string' ? raw.trim() : '';
115
+ if (value === '') return '';
116
+ const braced = /^(.*)\{(.*) => (.*)\}(.*)$/.exec(value);
117
+ if (braced) {
118
+ const [, prefix, , to, suffix] = braced;
119
+ return `${prefix}${to}${suffix}`.replace(/\/{2,}/g, '/');
120
+ }
121
+ const arrow = value.split(' => ');
122
+ return (arrow.length > 1 ? arrow[arrow.length - 1] : value).trim();
123
+ }
124
+
125
+ /**
126
+ * Parse `git diff --numstat` output into per-file rows.
127
+ *
128
+ * Binary rows (`-\t-\tpath`) contribute zero text lines without poisoning the
129
+ * parse. Any line that does not match the numstat shape makes the whole result
130
+ * untrustworthy, so the function returns `null` — the "magnitude unknown"
131
+ * signal every caller fails closed (or fails open) on deliberately.
132
+ *
133
+ * Pure and total.
134
+ *
135
+ * @param {unknown} stdout
136
+ * @returns {Array<{ additions: number, deletions: number, path: string }>|null}
137
+ */
138
+ function parseNumstatRows(stdout) {
139
+ if (typeof stdout !== 'string') return null;
140
+ const rows = [];
141
+ for (const line of stdout.split('\n')) {
142
+ const trimmedEnd = line.replace(/\s+$/, '');
143
+ if (trimmedEnd.length === 0) continue;
144
+ const match = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(trimmedEnd);
145
+ if (!match) return null;
146
+ rows.push({
147
+ additions: match[1] === '-' ? 0 : Number(match[1]),
148
+ deletions: match[2] === '-' ? 0 : Number(match[2]),
149
+ path: normalizeNumstatPath(match[3]),
150
+ });
151
+ }
152
+ return rows;
153
+ }
154
+
155
+ /**
156
+ * True when `file` is a mandated companion rather than implementation.
157
+ *
158
+ * Pure and total — a throwing matcher resolves to `false`, which counts the
159
+ * file as implementation. That is the conservative direction: a
160
+ * classification failure must never shrink the measured magnitude.
161
+ *
162
+ * @param {unknown} file
163
+ * @param {{ matchFn?: typeof matchesAnyFilePattern }} [deps]
164
+ * @returns {boolean}
165
+ */
166
+ function isCompanionPath(file, { matchFn = matchesAnyFilePattern } = {}) {
167
+ if (typeof file !== 'string' || file.trim() === '') return false;
168
+ try {
169
+ return matchFn(COMPANION_PATH_GLOBS, [file.trim()]) === true;
170
+ } catch {
171
+ return false;
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Read the per-file numstat rows for the `baseRef...headRef` diff. The one
177
+ * side-effecting function in this module.
178
+ *
179
+ * Total — never throws; returns `null` on any git failure or unparseable
180
+ * output.
181
+ *
182
+ * @param {{
183
+ * baseRef?: string,
184
+ * headRef?: string,
185
+ * cwd?: string,
186
+ * gitSpawnFn?: typeof gitSpawn,
187
+ * }} [args]
188
+ * @returns {Array<{ additions: number, deletions: number, path: string }>|null}
189
+ */
190
+ export function readNumstatRows({
191
+ baseRef,
192
+ headRef,
193
+ cwd = process.cwd(),
194
+ gitSpawnFn = gitSpawn,
195
+ } = {}) {
196
+ if (typeof baseRef !== 'string' || baseRef.length === 0) return null;
197
+ if (typeof headRef !== 'string' || headRef.length === 0) return null;
198
+ try {
199
+ const result = gitSpawnFn(
200
+ cwd,
201
+ 'diff',
202
+ '--numstat',
203
+ `${baseRef}...${headRef}`,
204
+ );
205
+ if (!result || result.status !== 0) return null;
206
+ return parseNumstatRows(result.stdout);
207
+ } catch {
208
+ return null;
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Summarize a diff's magnitude, splitting implementation from mandated
214
+ * companions.
215
+ *
216
+ * `implFiles` is counted from `changedFiles` — the canonical
217
+ * `git diff --name-only` enumeration produced by `change-set.js` — rather than
218
+ * from the numstat rows, so file counting uses clean paths from the one
219
+ * enumerator every other consumer reads. `implLines` comes from the numstat
220
+ * rows, which is the only surface carrying line counts.
221
+ *
222
+ * Returns `null` when either input is unusable: the magnitude is then *unknown*,
223
+ * which is deliberately distinct from *zero* so a caller can fail closed on the
224
+ * absence of evidence.
225
+ *
226
+ * Pure and total.
227
+ *
228
+ * @param {{
229
+ * changedFiles?: unknown,
230
+ * rows?: unknown,
231
+ * isCompanionFn?: typeof isCompanionPath,
232
+ * }} [args]
233
+ * @returns {{
234
+ * implFiles: number,
235
+ * implLines: number,
236
+ * companionFiles: number,
237
+ * companionLines: number,
238
+ * totalFiles: number,
239
+ * }|null}
240
+ */
241
+ export function summarizeDiffMagnitude({
242
+ changedFiles,
243
+ rows,
244
+ isCompanionFn = isCompanionPath,
245
+ } = {}) {
246
+ if (!Array.isArray(changedFiles) || !Array.isArray(rows)) return null;
247
+ const files = changedFiles.filter(
248
+ (f) => typeof f === 'string' && f.trim() !== '',
249
+ );
250
+
251
+ // A classification failure resolves to "implementation" — the conservative
252
+ // direction, since counting a companion as implementation can only ever make
253
+ // the measured magnitude larger. Guarded here as well as inside the default
254
+ // classifier so an injected one cannot break this function's totality.
255
+ const isCompanion = (file) => {
256
+ try {
257
+ return isCompanionFn(file) === true;
258
+ } catch {
259
+ return false;
260
+ }
261
+ };
262
+
263
+ let implFiles = 0;
264
+ for (const file of files) {
265
+ if (!isCompanion(file)) implFiles += 1;
266
+ }
267
+
268
+ let implLines = 0;
269
+ let companionLines = 0;
270
+ for (const row of rows) {
271
+ const lines = (row?.additions ?? 0) + (row?.deletions ?? 0);
272
+ if (isCompanion(row?.path)) companionLines += lines;
273
+ else implLines += lines;
274
+ }
275
+
276
+ return {
277
+ implFiles,
278
+ implLines,
279
+ companionFiles: files.length - implFiles,
280
+ companionLines,
281
+ totalFiles: files.length,
282
+ };
283
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * lib/orchestration/light-backstop.js — the light path's diff-backstop pass
3
+ * (Story #4856).
4
+ *
5
+ * The backstop is invariant 3 of the light path: after implementation the
6
+ * **actual** change set is re-checked, because the diff — not the prompt — is
7
+ * the real scope signal. This module owns that pass end to end so
8
+ * `deliver-light.js` stays the thin CLI shell it claims to be: it reads the two
9
+ * git surfaces, applies
10
+ * {@link module:lib/orchestration/light-suitability.checkLightDiffBackstop},
11
+ * and resolves what a refusal means.
12
+ *
13
+ * ## Two git surfaces, each used for what it reports reliably
14
+ *
15
+ * - `--name-only`, via the one canonical `computeChangeSet` enumerator, gives
16
+ * the clean full file list. Sensitive-path derivation and
17
+ * implementation-file counting both read it, so the backstop and every
18
+ * other consumer are looking at the same change set.
19
+ * - `--numstat` gives per-file line counts, the only surface carrying them.
20
+ *
21
+ * @module lib/orchestration/light-backstop
22
+ */
23
+
24
+ import { computeChangeSet } from './change-set.js';
25
+ import { readNumstatRows, summarizeDiffMagnitude } from './diff-magnitude.js';
26
+ import { handleBlockedBackstop } from './light-escalation.js';
27
+ import { checkLightDiffBackstop } from './light-suitability.js';
28
+
29
+ /** Exit code when the diff backstop blocked the land. */
30
+ const EXIT_BACKSTOP_BLOCKED = 3;
31
+
32
+ /**
33
+ * Run the diff backstop against a Story branch's actual change set.
34
+ *
35
+ * @param {{
36
+ * storyId: number,
37
+ * baseRef?: string,
38
+ * cwd?: string,
39
+ * computeFn?: typeof computeChangeSet,
40
+ * readRowsFn?: typeof readNumstatRows,
41
+ * injectedRules?: object,
42
+ * }} args
43
+ * @returns {ReturnType<typeof checkLightDiffBackstop>}
44
+ */
45
+ function runDiffBackstop({
46
+ storyId,
47
+ baseRef = 'main',
48
+ cwd = process.cwd(),
49
+ computeFn = computeChangeSet,
50
+ readRowsFn = readNumstatRows,
51
+ injectedRules,
52
+ } = {}) {
53
+ const headRef = `story-${storyId}`;
54
+ const { files } = computeFn({ baseRef, headRef, cwd });
55
+ const rows = readRowsFn({ baseRef, headRef, cwd });
56
+ const magnitude = summarizeDiffMagnitude({ changedFiles: files, rows });
57
+ return checkLightDiffBackstop({
58
+ changedFiles: files,
59
+ magnitude,
60
+ injectedRules,
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Resolve the backstop pass into everything the CLI needs to print and exit
66
+ * with: the verdict, the recycle command on a refusal (`null` when clean), the
67
+ * exit code, and the log line.
68
+ *
69
+ * @param {{
70
+ * storyId: number,
71
+ * runFn?: typeof runDiffBackstop,
72
+ * handleBlockedFn?: typeof handleBlockedBackstop,
73
+ * }} args Any further keys (`baseRef`, `cwd`, `computeFn`, `readRowsFn`,
74
+ * `injectedRules`) forward to the backstop run, so the git-surface join is
75
+ * drivable through this one entry point.
76
+ * @returns {Promise<{
77
+ * result: ReturnType<typeof checkLightDiffBackstop>,
78
+ * nextCommand: string|null,
79
+ * exitCode: number,
80
+ * message: string,
81
+ * }>}
82
+ */
83
+ export async function resolveBackstopOutcome({
84
+ storyId,
85
+ runFn = runDiffBackstop,
86
+ handleBlockedFn = handleBlockedBackstop,
87
+ ...seams
88
+ } = {}) {
89
+ const result = runFn({ storyId, ...seams });
90
+ if (!result.blocked) {
91
+ return {
92
+ result,
93
+ nextCommand: null,
94
+ exitCode: 0,
95
+ message: `[deliver-light] diff backstop clean for Story #${storyId}.`,
96
+ };
97
+ }
98
+ const nextCommand = await handleBlockedFn({ storyId, result });
99
+ return {
100
+ result,
101
+ nextCommand,
102
+ exitCode: EXIT_BACKSTOP_BLOCKED,
103
+ message:
104
+ `[deliver-light] diff backstop BLOCKED Story #${storyId}: ` +
105
+ `${result.reasons.join('; ')} — recycle the receipt with "${nextCommand}"`,
106
+ };
107
+ }