mandrel 2.16.0 → 2.17.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/docs/configuration.md +1 -0
- package/.agents/docs/quality-gates.md +137 -0
- package/.agents/schemas/agentrc.schema.json +6 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +4 -0
- package/.agents/scripts/acceptance-eval.js +52 -12
- package/.agents/scripts/audit-to-stories.js +92 -25
- package/.agents/scripts/boot-sweep.js +28 -6
- package/.agents/scripts/check-baseline-drift.js +138 -0
- package/.agents/scripts/coverage-capture.js +74 -25
- package/.agents/scripts/deliver-recover.js +45 -18
- package/.agents/scripts/drain-pending-cleanup.js +67 -23
- package/.agents/scripts/generate-lens-checklists.js +81 -30
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
- package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
- package/.agents/scripts/lib/baselines/envelope.js +7 -0
- package/.agents/scripts/lib/baselines/kernel.js +31 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
- package/.agents/scripts/lib/baselines/reader.js +12 -1
- package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
- package/.agents/scripts/lib/baselines/writer.js +10 -0
- package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
- package/.agents/scripts/lib/cli-utils.js +48 -13
- package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
- package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
- package/.agents/scripts/lib/close-validation/runner.js +68 -0
- package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
- package/.agents/scripts/lib/config/quality.js +40 -0
- package/.agents/scripts/lib/coverage-utils.js +92 -9
- package/.agents/scripts/lib/crap-engine.js +113 -23
- package/.agents/scripts/lib/crap-utils.js +159 -93
- package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
- package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
- package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +14 -0
- package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
- package/.agents/scripts/lib/stdio-flush.js +71 -0
- package/.agents/scripts/lib/transpile.js +133 -6
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
- package/.agents/scripts/lib/workers/crap-worker.js +49 -76
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
- package/.agents/scripts/nav-registry-diff.js +30 -8
- package/.agents/scripts/plan-run-epilogue.js +27 -11
- package/.agents/scripts/resolve-doc-tiers.js +18 -8
- package/.agents/scripts/single-story-close.js +9 -92
- package/.agents/scripts/update-crap-baseline.js +13 -0
- package/README.md +14 -6
- package/docs/CHANGELOG.md +24 -0
- package/lib/cli/version-helpers.js +7 -0
- package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
- package/package.json +5 -1
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
* skippedFileNoCoverage: boolean,
|
|
33
33
|
* crapRows: Array<{ method, startLine, cyclomatic, coverage, crap }> | null,
|
|
34
34
|
* skippedMethodsNoCoverage: number,
|
|
35
|
+
* hasCoverageEntry: boolean,
|
|
36
|
+
* resolvedMethods: number,
|
|
37
|
+
* totalMethods: number,
|
|
35
38
|
* } }
|
|
36
39
|
*
|
|
37
40
|
* A read/transpile/parse failure surfaces as `crapRows: null` so the host
|
|
@@ -40,10 +43,10 @@
|
|
|
40
43
|
* `miScore` is `null`; on a transpile/parse failure `miScore` is `0`.
|
|
41
44
|
*/
|
|
42
45
|
|
|
43
|
-
import fs from 'node:fs';
|
|
44
46
|
import { parentPort } from 'node:worker_threads';
|
|
47
|
+
import { finalizeMethodRows } from '../crap-engine.js';
|
|
45
48
|
import { analyzeOnce } from '../crap-utils.js';
|
|
46
|
-
import {
|
|
49
|
+
import { prepareSourceForScoring } from '../transpile.js';
|
|
47
50
|
|
|
48
51
|
/**
|
|
49
52
|
* Pure handler for a single inbound worker message. Exported so unit tests
|
|
@@ -58,8 +61,9 @@ import { transpileIfNeeded } from '../transpile.js';
|
|
|
58
61
|
* @param {unknown} msg
|
|
59
62
|
* @param {{
|
|
60
63
|
* readFile?: (abs: string) => string,
|
|
61
|
-
* transpile?: (abs: string, source: string) =>
|
|
62
|
-
*
|
|
64
|
+
* transpile?: (abs: string, source: string, opts?: object) => unknown,
|
|
65
|
+
* prepare?: (abs: string, deps: object) => object,
|
|
66
|
+
* analyze?: (source: string, entry: object|null, mapLine: Function|null) => {
|
|
63
67
|
* miScore: number,
|
|
64
68
|
* crapRows: Array<object>,
|
|
65
69
|
* parseError: boolean,
|
|
@@ -85,8 +89,7 @@ export function handleCombinedMiCrapWorkerMessage(msg, deps = {}) {
|
|
|
85
89
|
};
|
|
86
90
|
}
|
|
87
91
|
const { abs, relPath, requireCoverage } = item;
|
|
88
|
-
const
|
|
89
|
-
const transpile = deps.transpile ?? transpileIfNeeded;
|
|
92
|
+
const prepare = deps.prepare ?? prepareSourceForScoring;
|
|
90
93
|
const analyze = deps.analyze ?? analyzeOnce;
|
|
91
94
|
|
|
92
95
|
// Coverage entry is pre-resolved on the host and attached to the item.
|
|
@@ -94,47 +97,33 @@ export function handleCombinedMiCrapWorkerMessage(msg, deps = {}) {
|
|
|
94
97
|
// coverage, or `undefined` when the caller did not supply it (treat as null).
|
|
95
98
|
const entry = item.coverageEntry ?? null;
|
|
96
99
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
result: {
|
|
110
|
-
relPath,
|
|
111
|
-
miScore: null,
|
|
112
|
-
skippedFileNoCoverage: false,
|
|
113
|
-
crapRows: null,
|
|
114
|
-
skippedMethodsNoCoverage: 0,
|
|
115
|
-
},
|
|
100
|
+
const reply = (result) => ({
|
|
101
|
+
kind: 'reply',
|
|
102
|
+
message: {
|
|
103
|
+
ok: true,
|
|
104
|
+
result: {
|
|
105
|
+
relPath,
|
|
106
|
+
skippedFileNoCoverage: false,
|
|
107
|
+
skippedMethodsNoCoverage: 0,
|
|
108
|
+
hasCoverageEntry: entry !== null,
|
|
109
|
+
resolvedMethods: 0,
|
|
110
|
+
totalMethods: 0,
|
|
111
|
+
...result,
|
|
116
112
|
},
|
|
117
|
-
}
|
|
118
|
-
}
|
|
113
|
+
},
|
|
114
|
+
});
|
|
119
115
|
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
miScore: 0,
|
|
132
|
-
skippedFileNoCoverage: false,
|
|
133
|
-
crapRows: null,
|
|
134
|
-
skippedMethodsNoCoverage: 0,
|
|
135
|
-
},
|
|
136
|
-
},
|
|
137
|
-
};
|
|
116
|
+
// Read + transpile once, carrying the transpiled -> original-source line
|
|
117
|
+
// map the per-method coverage join needs (Story #4775). A read failure
|
|
118
|
+
// means neither MI nor CRAP can be computed (MI drops as null, matching
|
|
119
|
+
// `calculateAll`'s filter); a transpile failure scores MI 0 (matching
|
|
120
|
+
// `calculateForFile`). CRAP drops the file either way.
|
|
121
|
+
const prepared = prepare(abs, deps);
|
|
122
|
+
if (prepared.error) {
|
|
123
|
+
return reply({
|
|
124
|
+
miScore: prepared.error === 'read' ? null : 0,
|
|
125
|
+
crapRows: null,
|
|
126
|
+
});
|
|
138
127
|
}
|
|
139
128
|
|
|
140
129
|
// ONE parse: analyzeOnce derives both the module MI score and the raw
|
|
@@ -144,24 +133,12 @@ export function handleCombinedMiCrapWorkerMessage(msg, deps = {}) {
|
|
|
144
133
|
miScore,
|
|
145
134
|
crapRows: rawCrapRows,
|
|
146
135
|
parseError,
|
|
147
|
-
} = analyze(prepared, entry);
|
|
136
|
+
} = analyze(prepared.code, entry, prepared.mapLine);
|
|
148
137
|
if (parseError) {
|
|
149
|
-
// Parse error: MI scores 0 (parity with calculateForSource's catch
|
|
138
|
+
// Parse error: MI scores 0 (parity with calculateForSource's catch ->
|
|
150
139
|
// returns 0), CRAP drops the file (rows null, parity with the crap
|
|
151
140
|
// worker's calculateCrap-throw branch).
|
|
152
|
-
return {
|
|
153
|
-
kind: 'reply',
|
|
154
|
-
message: {
|
|
155
|
-
ok: true,
|
|
156
|
-
result: {
|
|
157
|
-
relPath,
|
|
158
|
-
miScore: 0,
|
|
159
|
-
skippedFileNoCoverage: false,
|
|
160
|
-
crapRows: null,
|
|
161
|
-
skippedMethodsNoCoverage: 0,
|
|
162
|
-
},
|
|
163
|
-
},
|
|
164
|
-
};
|
|
141
|
+
return reply({ miScore: 0, crapRows: null });
|
|
165
142
|
}
|
|
166
143
|
|
|
167
144
|
// CRAP coverage gate runs AFTER the parse so the MI score is always
|
|
@@ -169,49 +146,18 @@ export function handleCombinedMiCrapWorkerMessage(msg, deps = {}) {
|
|
|
169
146
|
// pass would have skipped it at the file level (no rows, counted) — but the
|
|
170
147
|
// MI pass would still have scored it, so miScore is returned regardless.
|
|
171
148
|
if (requireCoverage && entry === null) {
|
|
172
|
-
return {
|
|
173
|
-
kind: 'reply',
|
|
174
|
-
message: {
|
|
175
|
-
ok: true,
|
|
176
|
-
result: {
|
|
177
|
-
relPath,
|
|
178
|
-
miScore,
|
|
179
|
-
skippedFileNoCoverage: true,
|
|
180
|
-
crapRows: [],
|
|
181
|
-
skippedMethodsNoCoverage: 0,
|
|
182
|
-
},
|
|
183
|
-
},
|
|
184
|
-
};
|
|
149
|
+
return reply({ miScore, skippedFileNoCoverage: true, crapRows: [] });
|
|
185
150
|
}
|
|
186
151
|
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
startLine: mr.startLine,
|
|
197
|
-
cyclomatic: mr.cyclomatic,
|
|
198
|
-
coverage: mr.coverage,
|
|
199
|
-
crap: mr.crap,
|
|
200
|
-
});
|
|
201
|
-
}
|
|
202
|
-
return {
|
|
203
|
-
kind: 'reply',
|
|
204
|
-
message: {
|
|
205
|
-
ok: true,
|
|
206
|
-
result: {
|
|
207
|
-
relPath,
|
|
208
|
-
miScore,
|
|
209
|
-
skippedFileNoCoverage: false,
|
|
210
|
-
crapRows,
|
|
211
|
-
skippedMethodsNoCoverage,
|
|
212
|
-
},
|
|
213
|
-
},
|
|
214
|
-
};
|
|
152
|
+
const { rows, skippedMethodsNoCoverage, resolvedMethods, totalMethods } =
|
|
153
|
+
finalizeMethodRows(rawCrapRows, { requireCoverage });
|
|
154
|
+
return reply({
|
|
155
|
+
miScore,
|
|
156
|
+
crapRows: rows,
|
|
157
|
+
skippedMethodsNoCoverage,
|
|
158
|
+
resolvedMethods,
|
|
159
|
+
totalMethods,
|
|
160
|
+
});
|
|
215
161
|
}
|
|
216
162
|
|
|
217
163
|
if (parentPort) {
|
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
* skippedFileNoCoverage: boolean,
|
|
19
19
|
* rows: Array<{ method, startLine, cyclomatic, coverage, crap }>,
|
|
20
20
|
* skippedMethodsNoCoverage: number,
|
|
21
|
+
* hasCoverageEntry: boolean,
|
|
22
|
+
* resolvedMethods: number,
|
|
23
|
+
* totalMethods: number,
|
|
21
24
|
* } }
|
|
22
25
|
*
|
|
23
26
|
* A truly unrecoverable per-file failure (read error, transpile null)
|
|
@@ -26,10 +29,9 @@
|
|
|
26
29
|
* aborts the whole scan.
|
|
27
30
|
*/
|
|
28
31
|
|
|
29
|
-
import fs from 'node:fs';
|
|
30
32
|
import { parentPort } from 'node:worker_threads';
|
|
31
|
-
import { calculateCrapForSource } from '../crap-engine.js';
|
|
32
|
-
import {
|
|
33
|
+
import { calculateCrapForSource, finalizeMethodRows } from '../crap-engine.js';
|
|
34
|
+
import { prepareSourceForScoring } from '../transpile.js';
|
|
33
35
|
|
|
34
36
|
/**
|
|
35
37
|
* Pure handler for a single inbound worker message. Exported so unit
|
|
@@ -42,7 +44,10 @@ import { transpileIfNeeded } from '../transpile.js';
|
|
|
42
44
|
* - `{ kind: 'reply', message }` — caller should `postMessage(message)`.
|
|
43
45
|
*
|
|
44
46
|
* Side effects (fs, transpile, escomplex) are wired through `deps` so
|
|
45
|
-
* tests pass deterministic stubs.
|
|
47
|
+
* tests pass deterministic stubs. `readFile` / `transpile` are forwarded to
|
|
48
|
+
* `prepareSourceForScoring`, which also derives the transpiled →
|
|
49
|
+
* original-source line map the coverage join needs (Story #4775); a
|
|
50
|
+
* `transpile` stub that still returns a bare string is tolerated.
|
|
46
51
|
*
|
|
47
52
|
* Coverage is supplied via `item.coverageEntry` (pre-resolved on the host),
|
|
48
53
|
* not via a whole-map `coverage` argument. The second parameter is kept as
|
|
@@ -52,8 +57,9 @@ import { transpileIfNeeded } from '../transpile.js';
|
|
|
52
57
|
* @param {object|null} _coverage - Unused. Coverage is in `item.coverageEntry`.
|
|
53
58
|
* @param {{
|
|
54
59
|
* readFile?: (abs: string) => string,
|
|
55
|
-
* transpile?: (abs: string, source: string) =>
|
|
56
|
-
*
|
|
60
|
+
* transpile?: (abs: string, source: string, opts?: object) => unknown,
|
|
61
|
+
* prepare?: (abs: string, deps: object) => object,
|
|
62
|
+
* calculateCrap?: (source: string, entry: object|null, mapLine: Function|null) => Array<object>,
|
|
57
63
|
* }} [deps]
|
|
58
64
|
* @returns {{kind: 'exit'} | {kind: 'reply', message: object}}
|
|
59
65
|
*/
|
|
@@ -75,9 +81,6 @@ export function handleCrapWorkerMessage(msg, _coverage, deps = {}) {
|
|
|
75
81
|
};
|
|
76
82
|
}
|
|
77
83
|
const { abs, relPath, requireCoverage } = item;
|
|
78
|
-
const readFile = deps.readFile ?? ((p) => fs.readFileSync(p, 'utf-8'));
|
|
79
|
-
const transpile = deps.transpile ?? transpileIfNeeded;
|
|
80
|
-
const calculateCrap = deps.calculateCrap ?? calculateCrapForSource;
|
|
81
84
|
|
|
82
85
|
// Coverage entry is pre-resolved on the host and attached to the item.
|
|
83
86
|
// `item.coverageEntry` may be explicitly `null` when the file has no
|
|
@@ -93,84 +96,54 @@ export function handleCrapWorkerMessage(msg, _coverage, deps = {}) {
|
|
|
93
96
|
skippedFileNoCoverage: true,
|
|
94
97
|
rows: [],
|
|
95
98
|
skippedMethodsNoCoverage: 0,
|
|
99
|
+
hasCoverageEntry: false,
|
|
100
|
+
resolvedMethods: 0,
|
|
101
|
+
totalMethods: 0,
|
|
96
102
|
},
|
|
97
103
|
},
|
|
98
104
|
};
|
|
99
105
|
}
|
|
100
106
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
},
|
|
107
|
+
const dropped = (error) => ({
|
|
108
|
+
kind: 'reply',
|
|
109
|
+
message: {
|
|
110
|
+
ok: true,
|
|
111
|
+
result: {
|
|
112
|
+
relPath,
|
|
113
|
+
skippedFileNoCoverage: false,
|
|
114
|
+
rows: null,
|
|
115
|
+
skippedMethodsNoCoverage: 0,
|
|
116
|
+
hasCoverageEntry: entry !== null,
|
|
117
|
+
resolvedMethods: 0,
|
|
118
|
+
totalMethods: 0,
|
|
119
|
+
...(error ? { error } : {}),
|
|
115
120
|
},
|
|
116
|
-
}
|
|
117
|
-
}
|
|
121
|
+
},
|
|
122
|
+
});
|
|
118
123
|
|
|
119
|
-
// TS/TSX
|
|
120
|
-
// source path (vitest's coverage-final.json
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
message: {
|
|
128
|
-
ok: true,
|
|
129
|
-
result: {
|
|
130
|
-
relPath,
|
|
131
|
-
skippedFileNoCoverage: false,
|
|
132
|
-
rows: null,
|
|
133
|
-
skippedMethodsNoCoverage: 0,
|
|
134
|
-
},
|
|
135
|
-
},
|
|
136
|
-
};
|
|
137
|
-
}
|
|
124
|
+
// TS/TSX -> transpile-then-analyze, carrying the source map. The coverage
|
|
125
|
+
// lookup above used the ORIGINAL source path (vitest's coverage-final.json
|
|
126
|
+
// keys on the .ts file, not transpiled output) and the per-method join
|
|
127
|
+
// below uses ORIGINAL source *lines*, remapped from escomplex's transpiled
|
|
128
|
+
// coordinates via `prepared.mapLine` (Story #4775).
|
|
129
|
+
const prepare = deps.prepare ?? prepareSourceForScoring;
|
|
130
|
+
const prepared = prepare(abs, deps);
|
|
131
|
+
if (prepared.error) return dropped(null);
|
|
138
132
|
|
|
139
133
|
let methodRows;
|
|
140
134
|
try {
|
|
141
|
-
methodRows = calculateCrap
|
|
135
|
+
methodRows = (deps.calculateCrap ?? calculateCrapForSource)(
|
|
136
|
+
prepared.code,
|
|
137
|
+
entry,
|
|
138
|
+
prepared.mapLine,
|
|
139
|
+
);
|
|
142
140
|
} catch (err) {
|
|
143
|
-
return
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
ok: true,
|
|
147
|
-
result: {
|
|
148
|
-
relPath,
|
|
149
|
-
skippedFileNoCoverage: false,
|
|
150
|
-
rows: null,
|
|
151
|
-
skippedMethodsNoCoverage: 0,
|
|
152
|
-
error:
|
|
153
|
-
err && typeof err.message === 'string' ? err.message : String(err),
|
|
154
|
-
},
|
|
155
|
-
},
|
|
156
|
-
};
|
|
141
|
+
return dropped(
|
|
142
|
+
err && typeof err.message === 'string' ? err.message : String(err),
|
|
143
|
+
);
|
|
157
144
|
}
|
|
158
145
|
|
|
159
|
-
const
|
|
160
|
-
let skippedMethodsNoCoverage = 0;
|
|
161
|
-
for (const mr of methodRows) {
|
|
162
|
-
if (mr.crap === null || mr.coverage === null) {
|
|
163
|
-
skippedMethodsNoCoverage += 1;
|
|
164
|
-
continue;
|
|
165
|
-
}
|
|
166
|
-
rows.push({
|
|
167
|
-
method: mr.method,
|
|
168
|
-
startLine: mr.startLine,
|
|
169
|
-
cyclomatic: mr.cyclomatic,
|
|
170
|
-
coverage: mr.coverage,
|
|
171
|
-
crap: mr.crap,
|
|
172
|
-
});
|
|
173
|
-
}
|
|
146
|
+
const finalized = finalizeMethodRows(methodRows, { requireCoverage });
|
|
174
147
|
return {
|
|
175
148
|
kind: 'reply',
|
|
176
149
|
message: {
|
|
@@ -178,8 +151,8 @@ export function handleCrapWorkerMessage(msg, _coverage, deps = {}) {
|
|
|
178
151
|
result: {
|
|
179
152
|
relPath,
|
|
180
153
|
skippedFileNoCoverage: false,
|
|
181
|
-
|
|
182
|
-
|
|
154
|
+
hasCoverageEntry: entry !== null,
|
|
155
|
+
...finalized,
|
|
183
156
|
},
|
|
184
157
|
},
|
|
185
158
|
};
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import fs from 'node:fs';
|
|
17
17
|
import { rm as fsPromisesRm } from 'node:fs/promises';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
18
19
|
import { isInsideWorktree, samePath } from '../inspector.js';
|
|
19
20
|
import { sleepSync } from '../node-modules-strategy.js';
|
|
20
21
|
import { checkMergeReachability } from './merge-reachability.js';
|
|
@@ -493,20 +494,92 @@ async function deleteBranchAfterReap(ctx, { branch, push }) {
|
|
|
493
494
|
return { branchDeleted, remoteBranchDeleted };
|
|
494
495
|
}
|
|
495
496
|
|
|
497
|
+
/**
|
|
498
|
+
* The on-disk paths of the code currently executing: the entry script and
|
|
499
|
+
* this module itself.
|
|
500
|
+
*
|
|
501
|
+
* @returns {string[]}
|
|
502
|
+
*/
|
|
503
|
+
function runningCodePaths() {
|
|
504
|
+
const paths = [];
|
|
505
|
+
const entry = process.argv[1];
|
|
506
|
+
if (typeof entry === 'string' && entry !== '') paths.push(entry);
|
|
507
|
+
try {
|
|
508
|
+
paths.push(fileURLToPath(import.meta.url));
|
|
509
|
+
} catch {
|
|
510
|
+
// A non-file module URL cannot be inside a worktree; nothing to add.
|
|
511
|
+
}
|
|
512
|
+
return paths;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Return the running-code path that lives inside `wtPath`, or `null`.
|
|
517
|
+
*
|
|
518
|
+
* `escapeWorktreeCwd` already handles the *cwd* being inside the doomed
|
|
519
|
+
* tree; this is the other half — the **code** being inside it. An agent that
|
|
520
|
+
* invokes `node .agents/scripts/single-story-close.js` with its shell cwd set
|
|
521
|
+
* to the Story worktree runs the WORKTREE's copy of the script, and reaping
|
|
522
|
+
* the tree then deletes the running program out from under itself. Node has
|
|
523
|
+
* already loaded the static module graph, so the process does not die on the
|
|
524
|
+
* spot — it dies later, at the first lazy read or dynamic `import()`, in
|
|
525
|
+
* whatever phase happens to need one. That is how a close whose PR merged,
|
|
526
|
+
* whose Story flipped to `agent::done`, and whose post-land tail was green
|
|
527
|
+
* still exited non-zero with no terminal envelope.
|
|
528
|
+
*
|
|
529
|
+
* Refusing the reap is cheap: the tree survives one extra cycle and the next
|
|
530
|
+
* boot sweep takes it. Reaping it costs the run's return contract.
|
|
531
|
+
*
|
|
532
|
+
* @param {object} ctx
|
|
533
|
+
* @param {string} wtPath
|
|
534
|
+
* @returns {string|null}
|
|
535
|
+
*/
|
|
536
|
+
function findRunningCodeInside(ctx, wtPath) {
|
|
537
|
+
return (
|
|
538
|
+
runningCodePaths().find((p) => isInsideWorktree(p, wtPath, ctx.platform)) ??
|
|
539
|
+
null
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* The refusals that disqualify a tree before any git work happens, in the
|
|
545
|
+
* order they are checked. Returns the refusal envelope, or `null` when the
|
|
546
|
+
* tree is eligible for the safety checks that follow.
|
|
547
|
+
*
|
|
548
|
+
* @param {object} ctx
|
|
549
|
+
* @param {object} opts
|
|
550
|
+
* @param {string} wtPath
|
|
551
|
+
* @returns {object|null}
|
|
552
|
+
*/
|
|
553
|
+
function firstReapRefusal(ctx, opts, wtPath) {
|
|
554
|
+
const known = opts.worktrees
|
|
555
|
+
? opts.worktrees.some((r) => samePath(r.path, wtPath, ctx.platform))
|
|
556
|
+
: findByPath(ctx, wtPath) !== null;
|
|
557
|
+
if (!known) return { removed: false, reason: 'not-a-worktree', path: wtPath };
|
|
558
|
+
|
|
559
|
+
const selfPath = findRunningCodeInside(ctx, wtPath);
|
|
560
|
+
if (!selfPath) return null;
|
|
561
|
+
ctx.logger.warn(
|
|
562
|
+
`reap-skipped reason=running-from-target-tree path=${wtPath} selfPath=${selfPath} — ` +
|
|
563
|
+
`the running process was loaded from this worktree; removing it would break every ` +
|
|
564
|
+
`later lazy read and dynamic import in this run. Left for the next sweep. ` +
|
|
565
|
+
`Invoke the script by its MAIN-checkout path to reap in-run.`,
|
|
566
|
+
);
|
|
567
|
+
return {
|
|
568
|
+
removed: false,
|
|
569
|
+
reason: 'running-from-target-tree',
|
|
570
|
+
path: wtPath,
|
|
571
|
+
selfPath,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
496
575
|
function checkReapPreconditions(ctx, _storyId, opts, wtPath) {
|
|
497
576
|
if (opts.force) {
|
|
498
577
|
throw new Error(
|
|
499
578
|
'WorktreeManager.reap: --force is not permitted by the framework',
|
|
500
579
|
);
|
|
501
580
|
}
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
: findByPath(ctx, wtPath) !== null;
|
|
505
|
-
if (!known)
|
|
506
|
-
return {
|
|
507
|
-
ok: false,
|
|
508
|
-
result: { removed: false, reason: 'not-a-worktree', path: wtPath },
|
|
509
|
-
};
|
|
581
|
+
const refusal = firstReapRefusal(ctx, opts, wtPath);
|
|
582
|
+
if (refusal) return { ok: false, result: refusal };
|
|
510
583
|
// Story #4539 removed an `epic-branch-required` gate here: a
|
|
511
584
|
// `story-<id>` worktree used to be unreapable unless the caller supplied
|
|
512
585
|
// an Epic integration branch. v2 has no Epic branch, and the only v2
|
|
@@ -340,12 +340,13 @@ export function computeNavDiff({ routes = [], nav = [], refs = [] } = {}) {
|
|
|
340
340
|
*
|
|
341
341
|
* @param {string} label human-readable role for the error message
|
|
342
342
|
* @param {string} file
|
|
343
|
+
* @param {typeof fs} [fsImpl] filesystem seam; defaults to the real `node:fs`.
|
|
343
344
|
* @returns {unknown[]}
|
|
344
345
|
*/
|
|
345
|
-
function readJsonArray(label, file) {
|
|
346
|
+
function readJsonArray(label, file, fsImpl = fs) {
|
|
346
347
|
let raw;
|
|
347
348
|
try {
|
|
348
|
-
raw =
|
|
349
|
+
raw = fsImpl.readFileSync(file, 'utf8');
|
|
349
350
|
} catch (err) {
|
|
350
351
|
throw new Error(
|
|
351
352
|
`nav-registry-diff: cannot read ${label} file '${file}': ${err.message}`,
|
|
@@ -400,10 +401,23 @@ export function formatDiffText(diff) {
|
|
|
400
401
|
}
|
|
401
402
|
|
|
402
403
|
/**
|
|
403
|
-
*
|
|
404
|
+
* The reporter core, extracted from the CLI shell so the argv → read → diff →
|
|
405
|
+
* render → exit-code path is reachable without touching the real filesystem or
|
|
406
|
+
* the real stdout.
|
|
407
|
+
*
|
|
408
|
+
* Both seams on the optional final `deps` parameter default to the real
|
|
409
|
+
* implementation (`.agents/rules/test-seams.md` rules 1-2, 4 — `readJsonArray`
|
|
410
|
+
* forwards `fsImpl` rather than re-acquiring `fs`), so `main` and every
|
|
411
|
+
* production invocation are unchanged.
|
|
412
|
+
*
|
|
413
|
+
* @param {string[]} [argv]
|
|
414
|
+
* @param {{ fsImpl?: typeof fs, stdout?: { write: (s: string) => void } }} [deps]
|
|
404
415
|
* @returns {Promise<number>} process exit code
|
|
405
416
|
*/
|
|
406
|
-
async function
|
|
417
|
+
export async function runNavRegistryDiff(
|
|
418
|
+
argv = process.argv.slice(2),
|
|
419
|
+
{ fsImpl = fs, stdout = process.stdout } = {},
|
|
420
|
+
) {
|
|
407
421
|
const { values } = parseArgs({
|
|
408
422
|
args: argv,
|
|
409
423
|
options: {
|
|
@@ -423,9 +437,9 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
423
437
|
);
|
|
424
438
|
}
|
|
425
439
|
|
|
426
|
-
const routes = readJsonArray('routes', values.routes);
|
|
427
|
-
const nav = readJsonArray('nav', values.nav);
|
|
428
|
-
const refs = values.refs ? readJsonArray('refs', values.refs) : [];
|
|
440
|
+
const routes = readJsonArray('routes', values.routes, fsImpl);
|
|
441
|
+
const nav = readJsonArray('nav', values.nav, fsImpl);
|
|
442
|
+
const refs = values.refs ? readJsonArray('refs', values.refs, fsImpl) : [];
|
|
429
443
|
|
|
430
444
|
const diff = computeNavDiff({ routes, nav, refs });
|
|
431
445
|
|
|
@@ -434,13 +448,21 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
434
448
|
const rendered = values.json
|
|
435
449
|
? JSON.stringify(diff, null, 2)
|
|
436
450
|
: formatDiffText(diff);
|
|
437
|
-
|
|
451
|
+
stdout.write(`${rendered}\n`);
|
|
438
452
|
|
|
439
453
|
const hasFindings =
|
|
440
454
|
diff.orphanedRoutes.length > 0 || diff.deadHrefs.length > 0;
|
|
441
455
|
return values.strict && hasFindings ? 1 : 0;
|
|
442
456
|
}
|
|
443
457
|
|
|
458
|
+
/**
|
|
459
|
+
* @param {string[]} [argv]
|
|
460
|
+
* @returns {Promise<number>} process exit code
|
|
461
|
+
*/
|
|
462
|
+
async function main(argv = process.argv.slice(2)) {
|
|
463
|
+
return runNavRegistryDiff(argv);
|
|
464
|
+
}
|
|
465
|
+
|
|
444
466
|
export { main };
|
|
445
467
|
|
|
446
468
|
runAsCli(import.meta.url, main, {
|
|
@@ -27,9 +27,23 @@ const CLI_OPTIONS = {
|
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* @param {string[]} [argv]
|
|
30
|
+
* @param {{
|
|
31
|
+
* resolveConfigImpl?: typeof resolveConfig,
|
|
32
|
+
* createProviderImpl?: typeof createProvider,
|
|
33
|
+
* runPlanRunEpilogueImpl?: typeof runPlanRunEpilogue,
|
|
34
|
+
* logger?: { info: Function, warn: Function },
|
|
35
|
+
* }} [deps] Injectable seams; every entry defaults to the real
|
|
36
|
+
* implementation (`.agents/rules/test-seams.md` rules 1-2), so the CLI path
|
|
37
|
+
* and every production caller are unchanged.
|
|
30
38
|
* @returns {Promise<object>}
|
|
31
39
|
*/
|
|
32
|
-
export async function main(argv = process.argv.slice(2)) {
|
|
40
|
+
export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
41
|
+
const {
|
|
42
|
+
resolveConfigImpl = resolveConfig,
|
|
43
|
+
createProviderImpl = createProvider,
|
|
44
|
+
runPlanRunEpilogueImpl = runPlanRunEpilogue,
|
|
45
|
+
logger = Logger,
|
|
46
|
+
} = deps;
|
|
33
47
|
const { values } = parseArgs({
|
|
34
48
|
args: argv,
|
|
35
49
|
options: CLI_OPTIONS,
|
|
@@ -44,8 +58,8 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
44
58
|
typeof values.cwd === 'string' && values.cwd.trim()
|
|
45
59
|
? values.cwd.trim()
|
|
46
60
|
: process.cwd();
|
|
47
|
-
const config =
|
|
48
|
-
const provider =
|
|
61
|
+
const config = resolveConfigImpl({ cwd });
|
|
62
|
+
const provider = createProviderImpl(config);
|
|
49
63
|
|
|
50
64
|
// Story #4540 retired the `--run <planRunId>` label-resolution branch
|
|
51
65
|
// along with the label itself. The epilogue is keyed on the delivered id
|
|
@@ -58,16 +72,16 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
58
72
|
|
|
59
73
|
const planRunId = `adhoc-${[...stories].sort((a, b) => a - b).join('-')}`;
|
|
60
74
|
|
|
61
|
-
const result = await
|
|
75
|
+
const result = await runPlanRunEpilogueImpl({
|
|
62
76
|
planRunId,
|
|
63
77
|
stories,
|
|
64
78
|
provider,
|
|
65
79
|
config,
|
|
66
80
|
cwd,
|
|
67
81
|
});
|
|
68
|
-
warnOnUnresolvedBase(result);
|
|
69
|
-
warnOnEmptyRollup(result);
|
|
70
|
-
|
|
82
|
+
warnOnUnresolvedBase(result, logger);
|
|
83
|
+
warnOnEmptyRollup(result, logger);
|
|
84
|
+
logger.info(JSON.stringify(result));
|
|
71
85
|
if (result.errors?.length) {
|
|
72
86
|
process.exitCode = 1;
|
|
73
87
|
}
|
|
@@ -84,15 +98,16 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
84
98
|
* roster is still useful.
|
|
85
99
|
*
|
|
86
100
|
* @param {object} result - `runPlanRunEpilogue` envelope.
|
|
101
|
+
* @param {{ warn: Function }} [logger]
|
|
87
102
|
* @returns {void}
|
|
88
103
|
*/
|
|
89
|
-
function warnOnUnresolvedBase(result) {
|
|
104
|
+
function warnOnUnresolvedBase(result, logger = Logger) {
|
|
90
105
|
const roster = (result?.results ?? []).find(
|
|
91
106
|
(r) => r?.kind === 'audit-roster',
|
|
92
107
|
);
|
|
93
108
|
const base = roster?.baseResolution;
|
|
94
109
|
if (base?.resolved !== false) return;
|
|
95
|
-
|
|
110
|
+
logger.warn(
|
|
96
111
|
`⚠️ Combined landed diff unavailable — the pre-run base sha could not be ` +
|
|
97
112
|
`resolved against \`${base.baseRef}\`: ${base.reason}\n` +
|
|
98
113
|
` changedFiles is null (NOT an empty set). Determine the run diff by ` +
|
|
@@ -117,14 +132,15 @@ function warnOnUnresolvedBase(result) {
|
|
|
117
132
|
* rather than asserting either reading.
|
|
118
133
|
*
|
|
119
134
|
* @param {object} result - `runPlanRunEpilogue` envelope.
|
|
135
|
+
* @param {{ warn: Function }} [logger]
|
|
120
136
|
* @returns {void}
|
|
121
137
|
*/
|
|
122
|
-
function warnOnEmptyRollup(result) {
|
|
138
|
+
function warnOnEmptyRollup(result, logger = Logger) {
|
|
123
139
|
const rollup = (result?.results ?? []).find(
|
|
124
140
|
(r) => r?.kind === 'follow-up-rollup',
|
|
125
141
|
);
|
|
126
142
|
if (!rollup?.emptyRollupSuspect) return;
|
|
127
|
-
|
|
143
|
+
logger.warn(
|
|
128
144
|
`⚠️ 0 friction signals across ${rollup.storyCount} Stories — telemetry may not ` +
|
|
129
145
|
`have fired.\n` +
|
|
130
146
|
` An empty roll-up is NOT evidence of a clean run: it is the same output a ` +
|