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.
- package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
- package/.agents/scripts/deliver-light.js +23 -45
- package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +10 -25
- package/.agents/scripts/lib/baselines/kinds/maintainability.js +20 -32
- package/.agents/scripts/lib/escomplex-ast-compat.js +360 -0
- package/.agents/scripts/lib/maintainability-engine.js +83 -11
- package/.agents/scripts/lib/maintainability-unscorable.js +60 -0
- package/.agents/scripts/lib/maintainability-utils.js +14 -5
- package/.agents/scripts/lib/observability/runtime-friction.js +8 -0
- package/.agents/scripts/lib/orchestration/diff-magnitude.js +283 -0
- package/.agents/scripts/lib/orchestration/light-backstop.js +107 -0
- package/.agents/scripts/lib/orchestration/light-escalation.js +169 -0
- package/.agents/scripts/lib/orchestration/light-suitability.js +151 -46
- package/.agents/scripts/lib/orchestration/plan-context.js +12 -13
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +70 -2
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +23 -5
- package/.agents/scripts/lib/workers/maintainability-worker.js +14 -9
- package/.agents/workflows/helpers/deliver-light.md +21 -4
- package/docs/CHANGELOG.md +9 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
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,
|
|
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
|
+
}
|