mandrel 2.23.0 → 2.25.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/agents/acceptance-critic.md +13 -19
- package/.agents/agents/auditor.md +16 -19
- package/.agents/agents/story-worker.md +41 -54
- package/.agents/audit-checklists/architecture.md +5 -1
- package/.agents/audit-checklists/quality.md +3 -0
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +2 -1
- package/.agents/docs/workflows.md +1 -1
- package/.agents/rules/ci-remediation.md +68 -3
- package/.agents/schemas/agentrc.schema.json +6 -1
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +5 -1
- package/.agents/schemas/crap-report.schema.json +37 -0
- package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
- package/.agents/scripts/acceptance-eval.js +35 -9
- package/.agents/scripts/audit-labels-bootstrap.js +16 -95
- package/.agents/scripts/audit-to-stories.js +33 -3
- package/.agents/scripts/check-context-budget.js +95 -14
- package/.agents/scripts/check-test-temp-hygiene.js +83 -0
- package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
- package/.agents/scripts/deliver-light.js +22 -45
- package/.agents/scripts/deliver-recover.js +13 -0
- package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +10 -25
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
- package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
- package/.agents/scripts/lib/baseline-loader.js +0 -0
- package/.agents/scripts/lib/baselines/git-base.js +0 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +396 -51
- package/.agents/scripts/lib/baselines/kinds/maintainability.js +20 -32
- package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
- package/.agents/scripts/lib/baselines/reader.js +10 -0
- package/.agents/scripts/lib/baselines/writer.js +7 -3
- package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
- package/.agents/scripts/lib/crap-engine.js +97 -19
- package/.agents/scripts/lib/crap-utils.js +73 -13
- package/.agents/scripts/lib/env-loader.js +46 -16
- package/.agents/scripts/lib/escomplex-ast-compat.js +360 -0
- package/.agents/scripts/lib/findings/route-finding.js +123 -17
- package/.agents/scripts/lib/findings/severity.js +80 -2
- package/.agents/scripts/lib/git/cached-fetch.js +0 -0
- package/.agents/scripts/lib/label-constants.js +17 -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/acceptance-eval-decision.js +110 -17
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
- package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
- package/.agents/scripts/lib/orchestration/diff-magnitude.js +283 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
- package/.agents/scripts/lib/orchestration/light-backstop.js +124 -0
- package/.agents/scripts/lib/orchestration/light-escalation.js +248 -0
- package/.agents/scripts/lib/orchestration/light-suitability.js +254 -51
- package/.agents/scripts/lib/orchestration/plan-context.js +12 -13
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +7 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +13 -1
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -34
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +70 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +73 -18
- package/.agents/scripts/lib/reserved-test-ids.js +77 -0
- package/.agents/scripts/lib/story-plan.js +137 -42
- package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
- package/.agents/scripts/lib/ticket-body-sections.js +1 -1
- package/.agents/scripts/lib/util/poll-loop.js +18 -2
- package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
- package/.agents/scripts/lib/wave-runner/ready-set.js +73 -9
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +6 -3
- package/.agents/scripts/lib/workers/crap-worker.js +6 -3
- package/.agents/scripts/lib/workers/maintainability-worker.js +14 -9
- package/.agents/scripts/pr-watch-with-update.js +568 -213
- package/.agents/scripts/quality-preview.js +25 -0
- package/.agents/scripts/run-tests.js +10 -0
- package/.agents/scripts/single-story-close.js +6 -0
- package/.agents/scripts/single-story-init.js +7 -0
- package/.agents/scripts/stories-wave-tick.js +95 -10
- package/.agents/scripts/story-plan.js +65 -9
- package/.agents/scripts/update-crap-baseline.js +42 -4
- package/.agents/scripts/update-maintainability-baseline.js +31 -4
- package/.agents/templates/single-story-body.md +16 -8
- package/.agents/workflows/audit-architecture.md +69 -13
- package/.agents/workflows/audit-quality.md +26 -0
- package/.agents/workflows/audit-to-stories.md +30 -4
- package/.agents/workflows/deliver.md +81 -95
- package/.agents/workflows/helpers/audit-lens-core.md +25 -4
- package/.agents/workflows/helpers/deliver-light.md +21 -4
- package/.agents/workflows/helpers/deliver-reference.md +18 -3
- package/.agents/workflows/helpers/deliver-story-reference.md +127 -46
- package/.agents/workflows/helpers/deliver-story.md +104 -119
- package/.agents/workflows/helpers/plan-reference.md +34 -0
- package/.agents/workflows/plan.md +60 -76
- package/docs/CHANGELOG.md +38 -0
- package/lib/cli/update.js +14 -11
- package/lib/cli/version-check.js +9 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -132,6 +132,29 @@ export function normalizeCheckState(raw) {
|
|
|
132
132
|
*/
|
|
133
133
|
export const extractPrNumber = parsePrNumberFromUrl;
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* The `gh --repo` flag pair for an optional `owner/repo` target, or an empty
|
|
137
|
+
* argv fragment when the repository is inferred from the cwd's remote.
|
|
138
|
+
*
|
|
139
|
+
* `gh` resolves a *cross-repository* PR reference only through this flag — it
|
|
140
|
+
* has no `<owner/repo>#<number>` argument form, and a caller that builds one
|
|
141
|
+
* gets it parsed as a **branch name** instead (every `--repo` invocation of the
|
|
142
|
+
* watch CLI failed on that, reported as a misleading `gh-checks-failed`). Pure
|
|
143
|
+
* — one place builds the fragment so no port can forget it.
|
|
144
|
+
*
|
|
145
|
+
* Module-private on purpose: the three ports below are the only callers, and
|
|
146
|
+
* the flag is asserted through them (a real-spawn argv probe), never by
|
|
147
|
+
* importing this helper — an export existing solely for a test is dead in the
|
|
148
|
+
* `--production` reachability ratchet.
|
|
149
|
+
*
|
|
150
|
+
* @param {string|null|undefined} repo `owner/repo`, or nullish to infer.
|
|
151
|
+
* @returns {string[]}
|
|
152
|
+
*/
|
|
153
|
+
function ghRepoFlag(repo) {
|
|
154
|
+
const trimmed = String(repo ?? '').trim();
|
|
155
|
+
return trimmed.length > 0 ? ['--repo', trimmed] : [];
|
|
156
|
+
}
|
|
157
|
+
|
|
135
158
|
/**
|
|
136
159
|
* Default `gh pr checks` spawn. Always invokes with `--required` so the
|
|
137
160
|
* returned set is authoritative for branch-protection gating. The
|
|
@@ -139,7 +162,7 @@ export const extractPrNumber = parsePrNumberFromUrl;
|
|
|
139
162
|
*
|
|
140
163
|
* Exported so tests can stub.
|
|
141
164
|
*/
|
|
142
|
-
function ghPrChecks({ prUrl, cwd, spawnFn = spawnSync }) {
|
|
165
|
+
function ghPrChecks({ prUrl, cwd, repo, spawnFn = spawnSync }) {
|
|
143
166
|
const result = spawnFn(
|
|
144
167
|
'gh',
|
|
145
168
|
[
|
|
@@ -149,6 +172,7 @@ function ghPrChecks({ prUrl, cwd, spawnFn = spawnSync }) {
|
|
|
149
172
|
'--required',
|
|
150
173
|
'--json',
|
|
151
174
|
'name,state,bucket,workflow',
|
|
175
|
+
...ghRepoFlag(repo),
|
|
152
176
|
],
|
|
153
177
|
{ cwd, encoding: 'utf-8', shell: false },
|
|
154
178
|
);
|
|
@@ -164,10 +188,10 @@ function ghPrChecks({ prUrl, cwd, spawnFn = spawnSync }) {
|
|
|
164
188
|
* can detect the BEHIND condition (PR head is behind its base branch)
|
|
165
189
|
* AFTER every required check is green. Exported so tests can stub.
|
|
166
190
|
*/
|
|
167
|
-
function ghPrView({ prUrl, cwd, spawnFn = spawnSync }) {
|
|
191
|
+
function ghPrView({ prUrl, cwd, repo, spawnFn = spawnSync }) {
|
|
168
192
|
const result = spawnFn(
|
|
169
193
|
'gh',
|
|
170
|
-
['pr', 'view', prUrl, '--json', 'mergeStateStatus'],
|
|
194
|
+
['pr', 'view', prUrl, '--json', 'mergeStateStatus', ...ghRepoFlag(repo)],
|
|
171
195
|
{ cwd, encoding: 'utf-8', shell: false },
|
|
172
196
|
);
|
|
173
197
|
return {
|
|
@@ -202,12 +226,16 @@ function parseMergeStateStatus(stdout) {
|
|
|
202
226
|
* loop to fast-forward the PR head with its base branch. Exported so
|
|
203
227
|
* tests can stub and assert call counts.
|
|
204
228
|
*/
|
|
205
|
-
function ghPrUpdateBranch({ prUrl, cwd, spawnFn = spawnSync }) {
|
|
206
|
-
const result = spawnFn(
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
229
|
+
function ghPrUpdateBranch({ prUrl, cwd, repo, spawnFn = spawnSync }) {
|
|
230
|
+
const result = spawnFn(
|
|
231
|
+
'gh',
|
|
232
|
+
['pr', 'update-branch', prUrl, ...ghRepoFlag(repo)],
|
|
233
|
+
{
|
|
234
|
+
cwd,
|
|
235
|
+
encoding: 'utf-8',
|
|
236
|
+
shell: false,
|
|
237
|
+
},
|
|
238
|
+
);
|
|
211
239
|
return {
|
|
212
240
|
status: result.status ?? 1,
|
|
213
241
|
stdout: result.stdout ?? '',
|
|
@@ -355,6 +383,7 @@ function defaultSleep(ms) {
|
|
|
355
383
|
* @param {object} opts
|
|
356
384
|
* @param {string} opts.prUrl
|
|
357
385
|
* @param {string} opts.cwd
|
|
386
|
+
* @param {string|null} [opts.repo] `owner/repo` passed to `gh` as `--repo`.
|
|
358
387
|
* @param {object} opts.outcomes Initial `{ checkName: outcome }` map.
|
|
359
388
|
* @param {number} opts.polls Current poll counter (mutated in-place by caller).
|
|
360
389
|
* @param {number} opts.maxPolls Hard cap on total poll iterations.
|
|
@@ -367,6 +396,7 @@ function defaultSleep(ms) {
|
|
|
367
396
|
export async function pollUntilTerminal({
|
|
368
397
|
prUrl,
|
|
369
398
|
cwd,
|
|
399
|
+
repo = null,
|
|
370
400
|
outcomes,
|
|
371
401
|
polls,
|
|
372
402
|
maxPolls,
|
|
@@ -380,7 +410,7 @@ export async function pollUntilTerminal({
|
|
|
380
410
|
while (!allTerminal(currentOutcomes) && currentPolls < maxPolls) {
|
|
381
411
|
await sleepFn(pollIntervalMs);
|
|
382
412
|
currentPolls += 1;
|
|
383
|
-
const probe = ghPrChecksFn({ prUrl, cwd });
|
|
413
|
+
const probe = ghPrChecksFn({ prUrl, cwd, repo });
|
|
384
414
|
const entries = parseGhPrChecks(probe.stdout);
|
|
385
415
|
if (entries.length === 0 && probe.status !== 0 && probe.status !== 8) {
|
|
386
416
|
// Transient `gh` failure — log and continue. The outer
|
|
@@ -411,6 +441,9 @@ export async function pollUntilTerminal({
|
|
|
411
441
|
* @param {object} opts
|
|
412
442
|
* @param {string} opts.prUrl PR URL or number (passed to `gh` verbatim).
|
|
413
443
|
* @param {string} opts.cwd
|
|
444
|
+
* @param {string|null} [opts.repo] `owner/repo` target, threaded to every
|
|
445
|
+
* `gh` port as a real `--repo` flag (Story #4890). Nullish infers the
|
|
446
|
+
* repository from the cwd's remote — the behaviour every in-repo caller wants.
|
|
414
447
|
* @param {number} opts.maxPolls Hard cap on total poll iterations per arm.
|
|
415
448
|
* @param {number} opts.maxUpdates Cap on `gh pr update-branch` recovery calls.
|
|
416
449
|
* @param {number} [opts.maxResumes] Story #4358: after the poll cap fires with
|
|
@@ -444,17 +477,19 @@ export async function pollUntilTerminal({
|
|
|
444
477
|
* terminal: boolean,
|
|
445
478
|
* green: boolean,
|
|
446
479
|
* stillRunning: boolean,
|
|
480
|
+
* requiredChecksEmpty?: boolean,
|
|
447
481
|
* error?: string,
|
|
448
482
|
* }>}
|
|
449
483
|
* `outcomes` is schema-valid (no `'pending'` — leftover pending is
|
|
450
484
|
* promoted to `'still-running'` when the cap and resume budget are both
|
|
451
485
|
* exhausted with no failed check). `stillRunning` is true in exactly
|
|
452
|
-
* that case (slow CI, not red). `error`
|
|
453
|
-
*
|
|
486
|
+
* that case (slow CI, not red). `requiredChecksEmpty` / `error` are set
|
|
487
|
+
* only when the first probe resolved NO required-check names.
|
|
454
488
|
*/
|
|
455
489
|
export async function watchPrToTerminal({
|
|
456
490
|
prUrl,
|
|
457
491
|
cwd,
|
|
492
|
+
repo = null,
|
|
458
493
|
maxPolls,
|
|
459
494
|
maxUpdates,
|
|
460
495
|
maxResumes = 0,
|
|
@@ -469,15 +504,25 @@ export async function watchPrToTerminal({
|
|
|
469
504
|
// First probe: resolve the required-check name set at runtime. Reuse a
|
|
470
505
|
// caller-supplied probe (the listener already issued one to resolve the
|
|
471
506
|
// required check names) so we never double-spend the first `gh` call.
|
|
472
|
-
const first = firstProbe ?? ghPrChecksFn({ prUrl, cwd });
|
|
507
|
+
const first = firstProbe ?? ghPrChecksFn({ prUrl, cwd, repo });
|
|
473
508
|
// `gh` exits 8 when checks are still pending; this is expected and
|
|
474
509
|
// does not indicate failure. Any other non-zero status with no
|
|
475
510
|
// parseable JSON body is a genuine failure.
|
|
476
511
|
const firstEntries = parseGhPrChecks(first.stdout);
|
|
477
|
-
if (firstEntries.length === 0
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
512
|
+
if (firstEntries.length === 0) {
|
|
513
|
+
// NO required-check name resolved. Never enter the poll loop on that:
|
|
514
|
+
// `allTerminal({})` is vacuously true, so the loop would exit on its
|
|
515
|
+
// first evaluation and report a terminal-but-not-green arm — a red
|
|
516
|
+
// verdict with no failing check in it (Story #4890). The name set is
|
|
517
|
+
// resolved exactly once per call, so converging on a context that
|
|
518
|
+
// attaches later means calling this function again; return the
|
|
519
|
+
// empty-set signal and let the caller's attach window re-resolve it.
|
|
520
|
+
const ghFaulted = first.status !== 0 && first.status !== 8;
|
|
521
|
+
if (ghFaulted) {
|
|
522
|
+
logger.warn?.(
|
|
523
|
+
`[Watcher] gh pr checks failed (status=${first.status}): ${first.stderr}`,
|
|
524
|
+
);
|
|
525
|
+
}
|
|
481
526
|
return {
|
|
482
527
|
outcomes: {},
|
|
483
528
|
requiredChecks: [],
|
|
@@ -487,7 +532,12 @@ export async function watchPrToTerminal({
|
|
|
487
532
|
terminal: false,
|
|
488
533
|
green: false,
|
|
489
534
|
stillRunning: false,
|
|
490
|
-
|
|
535
|
+
requiredChecksEmpty: true,
|
|
536
|
+
// `gh` overloads a non-zero exit for "no required check is attached
|
|
537
|
+
// right now" AND for a genuine fault, and its stderr prose is not a
|
|
538
|
+
// contract — so the status is reported and the *classification* is the
|
|
539
|
+
// caller's, made against a structural probe of the PR itself.
|
|
540
|
+
error: `gh-checks-${ghFaulted ? 'failed' : 'empty'}:status=${first.status}`,
|
|
491
541
|
};
|
|
492
542
|
}
|
|
493
543
|
|
|
@@ -512,6 +562,7 @@ export async function watchPrToTerminal({
|
|
|
512
562
|
({ outcomes, polls } = await pollUntilTerminal({
|
|
513
563
|
prUrl,
|
|
514
564
|
cwd,
|
|
565
|
+
repo,
|
|
515
566
|
outcomes,
|
|
516
567
|
polls,
|
|
517
568
|
maxPolls,
|
|
@@ -529,7 +580,7 @@ export async function watchPrToTerminal({
|
|
|
529
580
|
// indefinitely.
|
|
530
581
|
if (!allTerminal(outcomes) || !allGreen(outcomes)) break;
|
|
531
582
|
if (updatesApplied >= maxUpdates) break;
|
|
532
|
-
const view = ghPrViewFn({ prUrl, cwd });
|
|
583
|
+
const view = ghPrViewFn({ prUrl, cwd, repo });
|
|
533
584
|
if (view.status !== 0) {
|
|
534
585
|
logger.warn?.(
|
|
535
586
|
`[Watcher] gh pr view failed (status=${view.status}): ${view.stderr}`,
|
|
@@ -538,7 +589,7 @@ export async function watchPrToTerminal({
|
|
|
538
589
|
}
|
|
539
590
|
const mergeStateStatus = parseMergeStateStatus(view.stdout);
|
|
540
591
|
if (mergeStateStatus !== 'BEHIND') break;
|
|
541
|
-
const update = ghPrUpdateBranchFn({ prUrl, cwd });
|
|
592
|
+
const update = ghPrUpdateBranchFn({ prUrl, cwd, repo });
|
|
542
593
|
if (update.status !== 0) {
|
|
543
594
|
logger.warn?.(
|
|
544
595
|
`[Watcher] gh pr update-branch failed (status=${update.status}): ${update.stderr}`,
|
|
@@ -0,0 +1,124 @@
|
|
|
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 {
|
|
27
|
+
handleBlockedBackstop,
|
|
28
|
+
preserveRefusedWork,
|
|
29
|
+
} from './light-escalation.js';
|
|
30
|
+
import { checkLightDiffBackstop } from './light-suitability.js';
|
|
31
|
+
|
|
32
|
+
/** Exit code when the diff backstop blocked the land. */
|
|
33
|
+
const EXIT_BACKSTOP_BLOCKED = 3;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Run the diff backstop against a Story branch's actual change set.
|
|
37
|
+
*
|
|
38
|
+
* @param {{
|
|
39
|
+
* storyId: number,
|
|
40
|
+
* baseRef?: string,
|
|
41
|
+
* cwd?: string,
|
|
42
|
+
* computeFn?: typeof computeChangeSet,
|
|
43
|
+
* readRowsFn?: typeof readNumstatRows,
|
|
44
|
+
* injectedRules?: object,
|
|
45
|
+
* }} args
|
|
46
|
+
* @returns {ReturnType<typeof checkLightDiffBackstop>}
|
|
47
|
+
*/
|
|
48
|
+
function runDiffBackstop({
|
|
49
|
+
storyId,
|
|
50
|
+
baseRef = 'main',
|
|
51
|
+
cwd = process.cwd(),
|
|
52
|
+
computeFn = computeChangeSet,
|
|
53
|
+
readRowsFn = readNumstatRows,
|
|
54
|
+
injectedRules,
|
|
55
|
+
} = {}) {
|
|
56
|
+
const headRef = `story-${storyId}`;
|
|
57
|
+
const { files } = computeFn({ baseRef, headRef, cwd });
|
|
58
|
+
const rows = readRowsFn({ baseRef, headRef, cwd });
|
|
59
|
+
const magnitude = summarizeDiffMagnitude({ changedFiles: files, rows });
|
|
60
|
+
return checkLightDiffBackstop({
|
|
61
|
+
changedFiles: files,
|
|
62
|
+
magnitude,
|
|
63
|
+
injectedRules,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve the backstop pass into everything the CLI needs to print and exit
|
|
69
|
+
* with: the verdict, the recycle command on a refusal (`null` when clean), the
|
|
70
|
+
* exit code, and the log line.
|
|
71
|
+
*
|
|
72
|
+
* A refusal also **preserves** the work before it reports (Story #4875): the
|
|
73
|
+
* implementation is finished and the recycle command hands the receipt to
|
|
74
|
+
* `/plan`, so leaving it on an untracked local branch that routine cleanup may
|
|
75
|
+
* reap is not an acceptable end state. Preservation is best-effort and its
|
|
76
|
+
* outcome is reported either way — a failed push degrades the message, never
|
|
77
|
+
* the verdict or the exit code.
|
|
78
|
+
*
|
|
79
|
+
* @param {{
|
|
80
|
+
* storyId: number,
|
|
81
|
+
* runFn?: typeof runDiffBackstop,
|
|
82
|
+
* handleBlockedFn?: typeof handleBlockedBackstop,
|
|
83
|
+
* preserveFn?: typeof preserveRefusedWork,
|
|
84
|
+
* }} args Any further keys (`baseRef`, `cwd`, `computeFn`, `readRowsFn`,
|
|
85
|
+
* `injectedRules`) forward to the backstop run, so the git-surface join is
|
|
86
|
+
* drivable through this one entry point.
|
|
87
|
+
* @returns {Promise<{
|
|
88
|
+
* result: ReturnType<typeof checkLightDiffBackstop>,
|
|
89
|
+
* nextCommand: string|null,
|
|
90
|
+
* preservation: ReturnType<typeof preserveRefusedWork>|null,
|
|
91
|
+
* exitCode: number,
|
|
92
|
+
* message: string,
|
|
93
|
+
* }>}
|
|
94
|
+
*/
|
|
95
|
+
export async function resolveBackstopOutcome({
|
|
96
|
+
storyId,
|
|
97
|
+
runFn = runDiffBackstop,
|
|
98
|
+
handleBlockedFn = handleBlockedBackstop,
|
|
99
|
+
preserveFn = preserveRefusedWork,
|
|
100
|
+
...seams
|
|
101
|
+
} = {}) {
|
|
102
|
+
const result = runFn({ storyId, ...seams });
|
|
103
|
+
if (!result.blocked) {
|
|
104
|
+
return {
|
|
105
|
+
result,
|
|
106
|
+
nextCommand: null,
|
|
107
|
+
preservation: null,
|
|
108
|
+
exitCode: 0,
|
|
109
|
+
message: `[deliver-light] diff backstop clean for Story #${storyId}.`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const preservation = preserveFn({ storyId, cwd: seams.cwd });
|
|
113
|
+
const nextCommand = await handleBlockedFn({ storyId, result, preservation });
|
|
114
|
+
return {
|
|
115
|
+
result,
|
|
116
|
+
nextCommand,
|
|
117
|
+
preservation,
|
|
118
|
+
exitCode: EXIT_BACKSTOP_BLOCKED,
|
|
119
|
+
message:
|
|
120
|
+
`[deliver-light] diff backstop BLOCKED Story #${storyId}: ` +
|
|
121
|
+
`${result.reasons.join('; ')} — ${preservation.detail}; ` +
|
|
122
|
+
`recycle the receipt with "${nextCommand}"`,
|
|
123
|
+
};
|
|
124
|
+
}
|