mandrel 1.58.0 → 1.59.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/README.md +89 -87
- package/.agents/docs/SDLC.md +11 -7
- package/.agents/docs/workflows.md +2 -1
- package/.agents/schemas/audit-rules.json +20 -0
- package/.agents/scripts/acceptance-eval.js +20 -3
- package/.agents/scripts/assert-branch.js +1 -3
- package/.agents/scripts/bootstrap.js +1 -1
- package/.agents/scripts/check-arch-cycles.js +360 -0
- package/.agents/scripts/coverage-capture.js +24 -3
- package/.agents/scripts/epic-deliver-preflight.js +5 -3
- package/.agents/scripts/epic-deliver-prepare.js +12 -4
- package/.agents/scripts/epic-execute-record-wave.js +1 -1
- package/.agents/scripts/evidence-gate.js +1 -1
- package/.agents/scripts/git-rebase-and-resolve.js +1 -1
- package/.agents/scripts/hierarchy-gate.js +34 -14
- package/.agents/scripts/lib/baselines/kinds/coverage.js +33 -149
- package/.agents/scripts/lib/baselines/kinds/duplication.js +27 -116
- package/.agents/scripts/lib/baselines/kinds/kind-factory.js +192 -0
- package/.agents/scripts/lib/baselines/kinds/lighthouse.js +34 -133
- package/.agents/scripts/lib/baselines/kinds/maintainability.js +31 -124
- package/.agents/scripts/lib/baselines/kinds/mutation.js +25 -111
- package/.agents/scripts/lib/baselines/maintainability-baseline-io.js +59 -0
- package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +37 -0
- package/.agents/scripts/lib/baselines/writer.js +1 -1
- package/.agents/scripts/lib/close-validation/commands.js +188 -0
- package/.agents/scripts/lib/close-validation/gates.js +235 -0
- package/.agents/scripts/lib/close-validation/process.js +101 -0
- package/.agents/scripts/lib/close-validation/projections/maintainability.js +1 -1
- package/.agents/scripts/lib/close-validation/runner.js +325 -0
- package/.agents/scripts/lib/close-validation/telemetry.js +70 -0
- package/.agents/scripts/lib/config/quality.js +6 -6
- package/.agents/scripts/lib/config-resolver.js +2 -5
- package/.agents/scripts/lib/coverage-capture.js +147 -4
- package/.agents/scripts/lib/cpu-pool.js +14 -0
- package/.agents/scripts/lib/crap-utils.js +6 -11
- package/.agents/scripts/lib/dynamic-workflow/documentation-report-contract.js +87 -0
- package/.agents/scripts/lib/git-utils.js +24 -22
- package/.agents/scripts/lib/maintainability-engine.js +1 -1
- package/.agents/scripts/lib/maintainability-utils.js +4 -187
- package/.agents/scripts/lib/observability/perf-report-readers.js +32 -23
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +80 -6
- package/.agents/scripts/lib/orchestration/code-review.js +90 -77
- package/.agents/scripts/lib/orchestration/dispatch-pipeline.js +5 -12
- package/.agents/scripts/lib/orchestration/epic-deliver-lease-guard.js +14 -14
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/planning-artifacts.js +2 -2
- package/.agents/scripts/lib/orchestration/epic-plan-lease-guard.js +184 -49
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/drain.js +1 -1
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/plan-epic.js +26 -2
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +26 -6
- package/.agents/scripts/lib/orchestration/epic-runner/phases/build-wave-dag.js +7 -20
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +1 -2
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +0 -6
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +103 -0
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +22 -64
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +38 -76
- package/.agents/scripts/lib/orchestration/epic-runner/story-run-progress-writer.js +2 -2
- package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +4 -16
- package/.agents/scripts/lib/orchestration/file-assumptions.js +4 -3
- package/.agents/scripts/lib/orchestration/lease-guard-shared.js +144 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-story-heartbeat.js +2 -2
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +7 -7
- package/.agents/scripts/lib/orchestration/post-merge/phases/notification.js +3 -3
- package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +7 -7
- package/.agents/scripts/lib/orchestration/preflight-cache.js +35 -12
- package/.agents/scripts/lib/orchestration/review-providers/codex.js +5 -60
- package/.agents/scripts/lib/orchestration/review-providers/native.js +7 -6
- package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +105 -0
- package/.agents/scripts/lib/orchestration/review-providers/security-review.js +7 -59
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +2 -4
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +1 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -4
- package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +32 -35
- package/.agents/scripts/lib/orchestration/skill-capsule-loader.js +1 -2
- package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +451 -503
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/pre-merge-attribution.js +8 -2
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/refresh-commit.js +47 -2
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +2 -2
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +358 -54
- package/.agents/scripts/lib/orchestration/story-close/phases/close.js +1 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/gates.js +3 -2
- package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +30 -3
- package/.agents/scripts/lib/orchestration/story-close/post-merge-close.js +5 -18
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +3 -3
- package/.agents/scripts/lib/orchestration/story-close-recovery.js +33 -16
- package/.agents/scripts/lib/orchestration/story-reachability.js +47 -0
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +2 -33
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +42 -64
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +9 -0
- package/.agents/scripts/lib/orchestration/ticketing/state.js +50 -436
- package/.agents/scripts/lib/orchestration/ticketing/transition.js +471 -0
- package/.agents/scripts/lib/orchestration/ticketing.js +0 -1
- package/.agents/scripts/lib/orchestration/wave-record-notifications.js +1 -1
- package/.agents/scripts/lib/orchestration/wave-record-projection.js +1 -7
- package/.agents/scripts/lib/project-root.js +17 -0
- package/.agents/scripts/lib/story-adjacency.js +76 -0
- package/.agents/scripts/lib/story-lifecycle.js +1 -1
- package/.agents/scripts/lib/transpile.js +93 -0
- package/.agents/scripts/lib/wave-runner/tick.js +4 -153
- package/.agents/scripts/lib/workers/crap-worker.js +1 -1
- package/.agents/scripts/lib/workers/maintainability-report-worker.js +1 -1
- package/.agents/scripts/lib/worktree/lifecycle/creation.js +20 -2
- package/.agents/scripts/lib/worktree/lifecycle/force-drain.js +90 -0
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +26 -8
- package/.agents/scripts/lib/worktree/node-modules-strategy.js +74 -0
- package/.agents/scripts/providers/github/tickets.js +110 -6
- package/.agents/scripts/run-lint.js +9 -0
- package/.agents/scripts/run-tests.js +24 -4
- package/.agents/scripts/stories-wave-tick.js +8 -5
- package/.agents/scripts/story-init.js +149 -10
- package/.agents/scripts/sync-branch-from-base.js +1 -1
- package/.agents/skills/stack/qa/lighthouse-baseline/SKILL.md +1 -1
- package/.agents/workflows/audit-documentation.md +226 -0
- package/.agents/workflows/epic-deliver.md +16 -23
- package/.agents/workflows/epic-plan.md +1 -1
- package/.agents/workflows/helpers/epic-deliver-story.md +17 -28
- package/.agents/workflows/helpers/single-story-deliver.md +2 -1
- package/.agents/workflows/onboard.md +4 -3
- package/.agents/workflows/story-deliver.md +1 -1
- package/README.md +13 -8
- package/lib/cli/init.js +336 -0
- package/package.json +2 -1
- package/.agents/scripts/lib/auto-refresh-baselines.js +0 -308
- package/.agents/scripts/lib/close-validation.js +0 -897
- package/.agents/scripts/lib/orchestration/cascade-grouping.js +0 -275
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter.js +0 -69
- package/.agents/scripts/lib/orchestration/story-close/format-autofix-scoped.js +0 -221
- package/.agents/scripts/lib/orchestration/story-close/format-autofix-shared.js +0 -123
- package/.agents/scripts/lib/task-utils.js +0 -26
- package/.agents/scripts/story-deliver-prepare.js +0 -267
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* close-validation/runner.js — The `runCloseValidation` orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* Runs typecheck, lint, test, format check, and maintainability/coverage/
|
|
5
|
+
* CRAP regression checks before the story merge so drift is caught in the
|
|
6
|
+
* worktree rather than at pre-push time on the Epic branch. All gates
|
|
7
|
+
* inherit stdio so the operator sees the raw output; the returned summary
|
|
8
|
+
* surfaces actionable hints on failure.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
recordPass as defaultRecordPass,
|
|
13
|
+
shouldSkip as defaultShouldSkip,
|
|
14
|
+
hashCommandConfig,
|
|
15
|
+
} from '../validation-evidence.js';
|
|
16
|
+
import {
|
|
17
|
+
isFormatterEligible,
|
|
18
|
+
listChangedFilesForFormatGate,
|
|
19
|
+
} from './commands.js';
|
|
20
|
+
import { DEFAULT_GATES, partitionGates } from './gates.js';
|
|
21
|
+
import { defaultGateRunner } from './process.js';
|
|
22
|
+
import { defaultGetHeadSha } from './projections/head-sha.js';
|
|
23
|
+
|
|
24
|
+
/** @typedef {import('./gates.js').Gate} Gate */
|
|
25
|
+
|
|
26
|
+
function applyChangedFileScope({ gate, spawnCwd, log }) {
|
|
27
|
+
if (!gate.changedFileScope) {
|
|
28
|
+
return { gate, cmd: gate.cmd, args: gate.args, skip: false };
|
|
29
|
+
}
|
|
30
|
+
const changedFiles = listChangedFilesForFormatGate({
|
|
31
|
+
cwd: spawnCwd,
|
|
32
|
+
baseRef: gate.changedFileScope.baseRef,
|
|
33
|
+
});
|
|
34
|
+
// Filter to the formatter-eligible subset before deciding to skip. A
|
|
35
|
+
// non-empty diff that contains zero formatter-eligible files (e.g. a
|
|
36
|
+
// docs-only Story) must take the skip path, not invoke biome with only
|
|
37
|
+
// ineligible paths — biome reports "No files were processed" and exits 1
|
|
38
|
+
// in that case (Story #3410).
|
|
39
|
+
const eligibleFiles = changedFiles.filter(isFormatterEligible);
|
|
40
|
+
if (eligibleFiles.length === 0) {
|
|
41
|
+
log(
|
|
42
|
+
`[close-validation] ⏭ ${gate.name} skipped (no formatter-eligible changed files)`,
|
|
43
|
+
);
|
|
44
|
+
return { gate, cmd: gate.cmd, args: gate.args, skip: true };
|
|
45
|
+
}
|
|
46
|
+
const args =
|
|
47
|
+
gate.args[gate.args.length - 1] === '.'
|
|
48
|
+
? gate.args.slice(0, -1)
|
|
49
|
+
: gate.args;
|
|
50
|
+
log(
|
|
51
|
+
`[close-validation] ↳ ${gate.name} scoped to ${eligibleFiles.length} formatter-eligible changed file(s) from ${gate.changedFileScope.baseRef}...HEAD`,
|
|
52
|
+
);
|
|
53
|
+
return {
|
|
54
|
+
gate,
|
|
55
|
+
cmd: gate.cmd,
|
|
56
|
+
args: [...args, ...eligibleFiles],
|
|
57
|
+
skip: false,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Run every gate sequentially. Stops collecting after the first failure but
|
|
63
|
+
* still returns a summary so the caller decides how to surface the result.
|
|
64
|
+
*
|
|
65
|
+
* Worktree locality (Story #1120): when `worktreePath` is supplied, every
|
|
66
|
+
* gate runner is spawned with `cwd: worktreePath` so the gate sees the
|
|
67
|
+
* Story branch's post-rebase tree. Evidence reads/writes still key against
|
|
68
|
+
* `cwd` (the main checkout) because the per-Epic temp tree lives under
|
|
69
|
+
* the main `.git/`. Failure messages name the worktree path.
|
|
70
|
+
*
|
|
71
|
+
* Evidence-aware: when both `storyId` and `epicId` are provided and
|
|
72
|
+
* `useEvidence !== false`, each gate consults `validation-evidence
|
|
73
|
+
* .shouldSkip()` against current HEAD + the gate's command-config hash. A
|
|
74
|
+
* matching record skips the gate; a successful run is recorded so the
|
|
75
|
+
* next caller in the local hot path can skip in turn.
|
|
76
|
+
*
|
|
77
|
+
* `onGateStart` is invoked immediately before each gate's runner spawn.
|
|
78
|
+
* story-close uses it to drive `phaseTimer.mark(...)` for per-gate
|
|
79
|
+
* wall-clock telemetry. Errors thrown from the hook propagate.
|
|
80
|
+
*
|
|
81
|
+
* @param {{
|
|
82
|
+
* cwd: string,
|
|
83
|
+
* worktreePath?: string,
|
|
84
|
+
* gates?: Gate[],
|
|
85
|
+
* runner?: (cmd: string, args: string[], opts: { cwd: string, signal?: AbortSignal, gateName?: string, log?: (m: string) => void }) => Promise<{ status: number }> | { status: number },
|
|
86
|
+
* log?: (m: string) => void,
|
|
87
|
+
* onGateStart?: (gate: Gate) => void,
|
|
88
|
+
* storyId?: number|null,
|
|
89
|
+
* epicId?: number|null,
|
|
90
|
+
* useEvidence?: boolean,
|
|
91
|
+
* evidenceClock?: () => number,
|
|
92
|
+
* getHeadSha?: (cwd: string) => string|null,
|
|
93
|
+
* recordPass?: typeof defaultRecordPass,
|
|
94
|
+
* shouldSkip?: typeof defaultShouldSkip,
|
|
95
|
+
* }} opts
|
|
96
|
+
* @returns {{ ok: boolean, failed: Array<{ gate: Gate, status: number, cwd: string }>, skipped: Array<{ gate: Gate, reason: string }> }}
|
|
97
|
+
*/
|
|
98
|
+
export async function runCloseValidation({
|
|
99
|
+
cwd,
|
|
100
|
+
worktreePath,
|
|
101
|
+
gates = DEFAULT_GATES,
|
|
102
|
+
runner = defaultGateRunner,
|
|
103
|
+
log = () => {},
|
|
104
|
+
onGateStart,
|
|
105
|
+
storyId = null,
|
|
106
|
+
epicId = null,
|
|
107
|
+
useEvidence = true,
|
|
108
|
+
evidenceClock = () => Date.now(),
|
|
109
|
+
getHeadSha = (resolvedCwd) => defaultGetHeadSha(resolvedCwd),
|
|
110
|
+
recordPass = defaultRecordPass,
|
|
111
|
+
shouldSkip = defaultShouldSkip,
|
|
112
|
+
} = {}) {
|
|
113
|
+
const failed = [];
|
|
114
|
+
const skipped = [];
|
|
115
|
+
const evidenceActive = useEvidence && storyId != null && epicId != null;
|
|
116
|
+
// Evidence keys against the main checkout's HEAD because the per-Epic
|
|
117
|
+
// evidence file lives under the main `.git/`. Gate spawn, in contrast,
|
|
118
|
+
// runs in the worktree when one is supplied — that's the whole point of
|
|
119
|
+
// Story #1120.
|
|
120
|
+
const spawnCwd = worktreePath ?? cwd;
|
|
121
|
+
const headSha = evidenceActive ? getHeadSha(spawnCwd) : null;
|
|
122
|
+
|
|
123
|
+
// Helper closures so the parallel and serial passes share evidence
|
|
124
|
+
// bookkeeping bit-for-bit.
|
|
125
|
+
|
|
126
|
+
/** Returns a `{ skip: true }` verdict when evidence makes the gate redundant. */
|
|
127
|
+
const evidenceVerdict = (gate, configHash) => {
|
|
128
|
+
if (!(evidenceActive && headSha)) return { skip: false };
|
|
129
|
+
const verdict = shouldSkip(
|
|
130
|
+
{
|
|
131
|
+
storyId,
|
|
132
|
+
gateName: gate.name,
|
|
133
|
+
currentSha: headSha,
|
|
134
|
+
configHash,
|
|
135
|
+
inputFingerprint: gate.inputFingerprint ?? null,
|
|
136
|
+
},
|
|
137
|
+
{ cwd, epicId },
|
|
138
|
+
);
|
|
139
|
+
if (verdict.skip) {
|
|
140
|
+
const tsHint = verdict.record?.timestamp
|
|
141
|
+
? ` recorded ${verdict.record.timestamp}`
|
|
142
|
+
: '';
|
|
143
|
+
log(
|
|
144
|
+
`[close-validation] ⏭ ${gate.name} skipped (${verdict.reason}: SHA=${headSha.slice(0, 7)}${tsHint})`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
return verdict;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const recordIfActive = (gate, configHash, durationMs) => {
|
|
151
|
+
if (!(evidenceActive && headSha)) return;
|
|
152
|
+
try {
|
|
153
|
+
recordPass(
|
|
154
|
+
{
|
|
155
|
+
storyId,
|
|
156
|
+
gateName: gate.name,
|
|
157
|
+
sha: headSha,
|
|
158
|
+
configHash,
|
|
159
|
+
exitCode: 0,
|
|
160
|
+
durationMs,
|
|
161
|
+
inputFingerprint: gate.inputFingerprint ?? null,
|
|
162
|
+
},
|
|
163
|
+
{ cwd, epicId },
|
|
164
|
+
);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
log(
|
|
167
|
+
`[close-validation] ⚠ failed to record evidence for ${gate.name}: ${err?.message ?? err}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Run a single gate. When `gate.run` is a function the gate executes
|
|
174
|
+
* **in process** (Story #1973 / Task #1984 — per-kind baseline gates
|
|
175
|
+
* removed their `child_process.spawn(node check-<kind>.js)` arm and
|
|
176
|
+
* call `compare(head, base)` directly). The `run` callable receives
|
|
177
|
+
* the same `(cmd, args, opts)` argv shape as `runner` so it slots into
|
|
178
|
+
* the existing contract without churn at the runner boundary.
|
|
179
|
+
* Otherwise the supplied `runner` is used (default: spawn).
|
|
180
|
+
*
|
|
181
|
+
* @returns {Promise<{ status: number }>}
|
|
182
|
+
*/
|
|
183
|
+
const dispatchGate = async (gate, signal) => {
|
|
184
|
+
log(
|
|
185
|
+
`[close-validation] ▶ ${gate.name}${worktreePath ? ` (cwd=${worktreePath})` : ''}`,
|
|
186
|
+
);
|
|
187
|
+
if (typeof onGateStart === 'function') onGateStart(gate);
|
|
188
|
+
const dispatcher = typeof gate.run === 'function' ? gate.run : runner;
|
|
189
|
+
const result = await dispatcher(gate.cmd, gate.args, {
|
|
190
|
+
cwd: spawnCwd,
|
|
191
|
+
gateName: gate.name,
|
|
192
|
+
log,
|
|
193
|
+
signal,
|
|
194
|
+
...(gate.env ? { env: gate.env } : {}),
|
|
195
|
+
});
|
|
196
|
+
return { status: result?.status ?? 1 };
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const { independent, serial } = partitionGates(gates);
|
|
200
|
+
|
|
201
|
+
// ── Phase 1: independent gates in parallel ──────────────────────────
|
|
202
|
+
// First non-zero exit pins `firstFailure` and aborts every in-flight
|
|
203
|
+
// sibling via SIGTERM. Other gates' results are still awaited (so we
|
|
204
|
+
// never leak children) but their non-zero status is intentionally
|
|
205
|
+
// dropped: only one error surfaces.
|
|
206
|
+
const ac = new AbortController();
|
|
207
|
+
let firstIndepFailure = null;
|
|
208
|
+
|
|
209
|
+
const indepTasks = independent.map(async (gate) => {
|
|
210
|
+
let execution;
|
|
211
|
+
try {
|
|
212
|
+
execution = applyChangedFileScope({ gate, spawnCwd, log });
|
|
213
|
+
} catch (err) {
|
|
214
|
+
if (!firstIndepFailure) {
|
|
215
|
+
firstIndepFailure = { gate, status: 1, cwd: spawnCwd };
|
|
216
|
+
log(
|
|
217
|
+
`[close-validation] ✖ ${gate.name} failed to resolve changed-file scope: ${err?.message ?? err}`,
|
|
218
|
+
);
|
|
219
|
+
ac.abort();
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (execution.skip) {
|
|
224
|
+
skipped.push({ gate, reason: 'no-changed-files' });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const configHash = hashCommandConfig({
|
|
228
|
+
cmd: execution.cmd,
|
|
229
|
+
args: execution.args,
|
|
230
|
+
cwd: spawnCwd,
|
|
231
|
+
});
|
|
232
|
+
const verdict = evidenceVerdict(gate, configHash);
|
|
233
|
+
if (verdict.skip) {
|
|
234
|
+
skipped.push({ gate, reason: verdict.reason });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const startedAt = evidenceActive ? evidenceClock() : 0;
|
|
238
|
+
let result;
|
|
239
|
+
try {
|
|
240
|
+
result = await dispatchGate(
|
|
241
|
+
{ ...gate, cmd: execution.cmd, args: execution.args },
|
|
242
|
+
ac.signal,
|
|
243
|
+
);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
result = { status: 1, error: err };
|
|
246
|
+
}
|
|
247
|
+
if (result.status !== 0) {
|
|
248
|
+
if (!firstIndepFailure) {
|
|
249
|
+
firstIndepFailure = { gate, status: result.status, cwd: spawnCwd };
|
|
250
|
+
ac.abort();
|
|
251
|
+
}
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
log(`[close-validation] ✓ ${gate.name}`);
|
|
255
|
+
recordIfActive(
|
|
256
|
+
gate,
|
|
257
|
+
configHash,
|
|
258
|
+
evidenceActive ? evidenceClock() - startedAt : 0,
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
await Promise.all(indepTasks);
|
|
263
|
+
|
|
264
|
+
if (firstIndepFailure) {
|
|
265
|
+
failed.push(firstIndepFailure);
|
|
266
|
+
log(
|
|
267
|
+
`[close-validation] ✖ ${firstIndepFailure.gate.name} failed (exit ${firstIndepFailure.status}) in ${spawnCwd}`,
|
|
268
|
+
);
|
|
269
|
+
if (firstIndepFailure.gate.hint) {
|
|
270
|
+
log(`[close-validation] hint: ${firstIndepFailure.gate.hint}`);
|
|
271
|
+
}
|
|
272
|
+
return { ok: false, failed, skipped };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── Phase 2: serial gates in declared order ─────────────────────────
|
|
276
|
+
for (const gate of serial) {
|
|
277
|
+
let execution;
|
|
278
|
+
try {
|
|
279
|
+
execution = applyChangedFileScope({ gate, spawnCwd, log });
|
|
280
|
+
} catch (err) {
|
|
281
|
+
failed.push({ gate, status: 1, cwd: spawnCwd });
|
|
282
|
+
log(
|
|
283
|
+
`[close-validation] ✖ ${gate.name} failed to resolve changed-file scope: ${err?.message ?? err}`,
|
|
284
|
+
);
|
|
285
|
+
if (gate.hint) log(`[close-validation] hint: ${gate.hint}`);
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
if (execution.skip) {
|
|
289
|
+
skipped.push({ gate, reason: 'no-changed-files' });
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const configHash = hashCommandConfig({
|
|
293
|
+
cmd: execution.cmd,
|
|
294
|
+
args: execution.args,
|
|
295
|
+
cwd: spawnCwd,
|
|
296
|
+
});
|
|
297
|
+
const verdict = evidenceVerdict(gate, configHash);
|
|
298
|
+
if (verdict.skip) {
|
|
299
|
+
skipped.push({ gate, reason: verdict.reason });
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const startedAt = evidenceActive ? evidenceClock() : 0;
|
|
303
|
+
const result = await dispatchGate({
|
|
304
|
+
...gate,
|
|
305
|
+
cmd: execution.cmd,
|
|
306
|
+
args: execution.args,
|
|
307
|
+
});
|
|
308
|
+
if (result.status !== 0) {
|
|
309
|
+
failed.push({ gate, status: result.status, cwd: spawnCwd });
|
|
310
|
+
log(
|
|
311
|
+
`[close-validation] ✖ ${gate.name} failed (exit ${result.status}) in ${spawnCwd}`,
|
|
312
|
+
);
|
|
313
|
+
if (gate.hint) log(`[close-validation] hint: ${gate.hint}`);
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
log(`[close-validation] ✓ ${gate.name}`);
|
|
317
|
+
recordIfActive(
|
|
318
|
+
gate,
|
|
319
|
+
configHash,
|
|
320
|
+
evidenceActive ? evidenceClock() - startedAt : 0,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return { ok: failed.length === 0, failed, skipped };
|
|
325
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* close-validation/telemetry.js — gh-spawn telemetry emitter.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { writeFile as defaultWriteFile } from 'node:fs/promises';
|
|
6
|
+
import { storyArtifactPath } from '../config/temp-paths.js';
|
|
7
|
+
import { getSpawnCount as defaultGetSpawnCount } from '../gh-exec.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Throw-away ghSpawnCount emitter (Story #1795 / Epic #1788).
|
|
11
|
+
*
|
|
12
|
+
* Writes the current `gh-exec` spawn counter to
|
|
13
|
+
* `temp/epic-<eid>/stories/story-<sid>/gh-spawn-count.json` so the
|
|
14
|
+
* `analyze-execution.js` child process can read it and emit a
|
|
15
|
+
* `ghSpawnCount` field on the `story-perf-summary` payload. The Story-
|
|
16
|
+
* close orchestrator calls this inside `runPostMergeClose` right before
|
|
17
|
+
* the perf-summary phase, capturing every `gh` invocation from preflight
|
|
18
|
+
* through the merge in one counter snapshot.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} opts
|
|
21
|
+
* @param {number|string} opts.epicId
|
|
22
|
+
* @param {number|string} opts.storyId
|
|
23
|
+
* @param {object} [opts.config] - Resolved config bag so `tempRoot`
|
|
24
|
+
* resolution honours the consumer's configured path.
|
|
25
|
+
* @param {() => number} [opts.getSpawnCountFn=defaultGetSpawnCount] - Test seam.
|
|
26
|
+
* @param {typeof defaultWriteFile} [opts.writeFileFn=defaultWriteFile] - Test seam.
|
|
27
|
+
* @param {{ warn?: (s: string) => void }} [opts.logger] - Best-effort
|
|
28
|
+
* failure-path logger; never throws.
|
|
29
|
+
* @returns {Promise<{ status: 'ok'|'failed', path?: string, ghSpawnCount?: number, reason?: string }>}
|
|
30
|
+
*/
|
|
31
|
+
export async function emitGhSpawnCount({
|
|
32
|
+
epicId,
|
|
33
|
+
storyId,
|
|
34
|
+
config,
|
|
35
|
+
getSpawnCountFn = defaultGetSpawnCount,
|
|
36
|
+
writeFileFn = defaultWriteFile,
|
|
37
|
+
logger,
|
|
38
|
+
} = {}) {
|
|
39
|
+
const eid = Number(epicId);
|
|
40
|
+
const sid = Number(storyId);
|
|
41
|
+
if (!Number.isInteger(eid) || eid < 1 || !Number.isInteger(sid) || sid < 1) {
|
|
42
|
+
return { status: 'failed', reason: 'invalid-ids' };
|
|
43
|
+
}
|
|
44
|
+
let ghSpawnCount;
|
|
45
|
+
try {
|
|
46
|
+
ghSpawnCount = getSpawnCountFn();
|
|
47
|
+
} catch (err) {
|
|
48
|
+
logger?.warn?.(
|
|
49
|
+
`[close-validation] gh-spawn-count read failed: ${err?.message ?? err}`,
|
|
50
|
+
);
|
|
51
|
+
return { status: 'failed', reason: 'counter-read-failed' };
|
|
52
|
+
}
|
|
53
|
+
const targetPath = storyArtifactPath(eid, sid, 'gh-spawn-count.json', config);
|
|
54
|
+
const payload = {
|
|
55
|
+
kind: 'gh-spawn-count',
|
|
56
|
+
epicId: eid,
|
|
57
|
+
storyId: sid,
|
|
58
|
+
ghSpawnCount,
|
|
59
|
+
capturedAt: new Date().toISOString(),
|
|
60
|
+
};
|
|
61
|
+
try {
|
|
62
|
+
await writeFileFn(targetPath, JSON.stringify(payload, null, 2));
|
|
63
|
+
return { status: 'ok', path: targetPath, ghSpawnCount };
|
|
64
|
+
} catch (err) {
|
|
65
|
+
logger?.warn?.(
|
|
66
|
+
`[close-validation] gh-spawn-count emit failed: ${err?.message ?? err}`,
|
|
67
|
+
);
|
|
68
|
+
return { status: 'failed', reason: 'write-failed' };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -121,7 +121,7 @@ export const MAINTAINABILITY_GATE_DEFAULTS = Object.freeze({
|
|
|
121
121
|
* --write` autofix spawn. The SIGKILL → exit 124 mapping mirrors
|
|
122
122
|
* `gates.coverage.timeoutMs` (Story #2142).
|
|
123
123
|
*/
|
|
124
|
-
|
|
124
|
+
const FORMAT_AUTOFIX_DEFAULTS = Object.freeze({
|
|
125
125
|
timeoutMs: 60_000,
|
|
126
126
|
});
|
|
127
127
|
|
|
@@ -259,7 +259,7 @@ export function resolveMaintainabilityCrap(
|
|
|
259
259
|
* `targetDirs` + a scalar `tolerance` (when set) + scoping inherited
|
|
260
260
|
* from `gateScoping`.
|
|
261
261
|
*/
|
|
262
|
-
|
|
262
|
+
function resolveMaintainabilityQuality(userBlock, gateScoping) {
|
|
263
263
|
const defaults = MAINTAINABILITY_GATE_DEFAULTS;
|
|
264
264
|
const scoping = {
|
|
265
265
|
defaultScope: gateScoping?.scope ?? DEFAULT_GATE_SCOPING.scope,
|
|
@@ -321,7 +321,7 @@ function resolvePositiveIntegerMs(value, defaultMs) {
|
|
|
321
321
|
*/
|
|
322
322
|
const FORMAT_AUTOFIX_KEYS = new Set(['timeoutMs']);
|
|
323
323
|
|
|
324
|
-
|
|
324
|
+
function resolveFormatAutofix(userBlock) {
|
|
325
325
|
const defaults = FORMAT_AUTOFIX_DEFAULTS;
|
|
326
326
|
if (userBlock == null || typeof userBlock !== 'object') {
|
|
327
327
|
return { timeoutMs: defaults.timeoutMs };
|
|
@@ -336,7 +336,7 @@ export function resolveFormatAutofix(userBlock) {
|
|
|
336
336
|
}
|
|
337
337
|
|
|
338
338
|
/** Resolve the coverage gate. Owns `coveragePath` and `timeoutMs`. */
|
|
339
|
-
|
|
339
|
+
function resolveCoverageGate(userBlock) {
|
|
340
340
|
const defaults = COVERAGE_GATE_DEFAULTS;
|
|
341
341
|
if (userBlock == null || typeof userBlock !== 'object') {
|
|
342
342
|
return {
|
|
@@ -402,7 +402,7 @@ export function resolveCodingGuardrails(userBlock) {
|
|
|
402
402
|
};
|
|
403
403
|
}
|
|
404
404
|
|
|
405
|
-
|
|
405
|
+
const AUTO_REFRESH_DEFAULTS = Object.freeze({
|
|
406
406
|
enabled: true,
|
|
407
407
|
miDropCap: 1.5,
|
|
408
408
|
crapJumpCap: 5,
|
|
@@ -411,7 +411,7 @@ export const AUTO_REFRESH_DEFAULTS = Object.freeze({
|
|
|
411
411
|
|
|
412
412
|
const AUTO_REFRESH_KEYS = new Set(Object.keys(AUTO_REFRESH_DEFAULTS));
|
|
413
413
|
|
|
414
|
-
|
|
414
|
+
function resolveAutoRefresh(userBlock) {
|
|
415
415
|
const defaults = AUTO_REFRESH_DEFAULTS;
|
|
416
416
|
if (userBlock == null || typeof userBlock !== 'object') {
|
|
417
417
|
return {
|
|
@@ -24,7 +24,6 @@
|
|
|
24
24
|
|
|
25
25
|
import fs from 'node:fs';
|
|
26
26
|
import path from 'node:path';
|
|
27
|
-
import { fileURLToPath } from 'node:url';
|
|
28
27
|
import { getCiDelivery } from './config/ci.js';
|
|
29
28
|
import { getCommands } from './config/commands.js';
|
|
30
29
|
import { getGitHub } from './config/github.js';
|
|
@@ -34,6 +33,7 @@ import { validateOrchestrationConfig } from './config/validate-orchestration.js'
|
|
|
34
33
|
import { getWorktreeIsolation } from './config/worktree-isolation.js';
|
|
35
34
|
import { getAgentrcValidator } from './config-schema.js';
|
|
36
35
|
import { loadEnv } from './env-loader.js';
|
|
36
|
+
import { PROJECT_ROOT } from './project-root.js';
|
|
37
37
|
|
|
38
38
|
export { getAcceptanceEval } from './config/acceptance-eval.js';
|
|
39
39
|
export { BASELINES_DEFAULTS, getBaselines } from './config/baselines.js';
|
|
@@ -64,10 +64,7 @@ export {
|
|
|
64
64
|
export { resolveListValue } from './config/shared.js';
|
|
65
65
|
export { validateOrchestrationConfig } from './config/validate-orchestration.js';
|
|
66
66
|
export { WORKTREE_ISOLATION_DEFAULTS } from './config/worktree-isolation.js';
|
|
67
|
-
|
|
68
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
69
|
-
// scripts/lib/ → scripts/ → .agents/ → project root
|
|
70
|
-
export const PROJECT_ROOT = path.resolve(__dirname, '../../..');
|
|
67
|
+
export { PROJECT_ROOT } from './project-root.js';
|
|
71
68
|
|
|
72
69
|
// Cache keyed by absolute root path so callers passing different cwds
|
|
73
70
|
// (e.g. per-worktree) each get their own resolved config.
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* `isCoverageFresh` and decide whether to delegate to `runCapture`.
|
|
12
12
|
*/
|
|
13
13
|
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import crypto from 'node:crypto';
|
|
14
15
|
import fs from 'node:fs';
|
|
15
16
|
import path from 'node:path';
|
|
16
17
|
|
|
@@ -65,10 +66,130 @@ export function newestSourceMtime(cwd, targetDirs, io = {}) {
|
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
69
|
+
* Resolve the capture-stamp path that sits next to the coverage artifact.
|
|
70
|
+
* The stamp persists the content digest of the CRAP-target sources at the
|
|
71
|
+
* moment coverage was last captured, so freshness can be decided by content
|
|
72
|
+
* rather than mtime (mtime churns on branch switches / checkouts even when
|
|
73
|
+
* content is unchanged).
|
|
74
|
+
*
|
|
75
|
+
* @param {string} cwd Absolute repo root.
|
|
76
|
+
* @param {string} coveragePath Repo-relative coverage artifact path.
|
|
77
|
+
* @returns {string} Absolute stamp path (`<coverage-dir>/.capture-stamp.json`).
|
|
78
|
+
*/
|
|
79
|
+
export function captureStampPath(cwd, coveragePath) {
|
|
80
|
+
return path.join(
|
|
81
|
+
path.dirname(path.resolve(cwd, coveragePath)),
|
|
82
|
+
'.capture-stamp.json',
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const SOURCE_EXT_RE = /\.(?:js|mjs)$/;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Compute a stable content digest of the `.js`/`.mjs` sources under
|
|
90
|
+
* `targetDirs`: the `git ls-files -s` listing (mode + blob SHA + path) of
|
|
91
|
+
* tracked content, plus the on-disk bytes of any dirty working-tree files.
|
|
92
|
+
* Checkout/branch churn leaves blob SHAs untouched, so the digest only moves
|
|
93
|
+
* when content actually changes.
|
|
94
|
+
*
|
|
95
|
+
* Returns `null` when the digest cannot be computed (git unavailable, not a
|
|
96
|
+
* repo, empty target list) so callers can fall back to the mtime heuristic.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} cwd Absolute repo root.
|
|
99
|
+
* @param {string[]} targetDirs Repo-relative directories to digest.
|
|
100
|
+
* @param {{ spawnSync?: typeof spawnSync, readFileSync?: typeof fs.readFileSync }} [io]
|
|
101
|
+
* @returns {string | null} Hex SHA-256 digest, or null when unavailable.
|
|
102
|
+
*/
|
|
103
|
+
export function computeContentDigest(cwd, targetDirs, io = {}) {
|
|
104
|
+
const spawn = io.spawnSync ?? spawnSync;
|
|
105
|
+
const readFileSync = io.readFileSync ?? fs.readFileSync;
|
|
106
|
+
const dirs = (targetDirs ?? []).filter(
|
|
107
|
+
(d) => typeof d === 'string' && d.length > 0,
|
|
108
|
+
);
|
|
109
|
+
if (dirs.length === 0) return null;
|
|
110
|
+
|
|
111
|
+
const git = (...args) => {
|
|
112
|
+
const res = spawn('git', args, { cwd, encoding: 'utf8' });
|
|
113
|
+
if (res?.error || res?.status !== 0) {
|
|
114
|
+
throw res?.error ?? new Error(res?.stderr || `git ${args[0]} failed`);
|
|
115
|
+
}
|
|
116
|
+
return res.stdout ?? '';
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const hash = crypto.createHash('sha256');
|
|
121
|
+
const tracked = git('ls-files', '-s', '--', ...dirs)
|
|
122
|
+
.split('\n')
|
|
123
|
+
.filter((line) => SOURCE_EXT_RE.test(line.trimEnd()));
|
|
124
|
+
hash.update(tracked.join('\n'));
|
|
125
|
+
|
|
126
|
+
// Dirty working-tree files are not represented by their index blob SHA,
|
|
127
|
+
// so fold in their on-disk bytes (or absence) explicitly.
|
|
128
|
+
const dirty = git('status', '--porcelain', '--', ...dirs)
|
|
129
|
+
.split('\n')
|
|
130
|
+
.filter((line) => line.length > 3);
|
|
131
|
+
for (const line of dirty) {
|
|
132
|
+
let file = line.slice(3).trim();
|
|
133
|
+
if (file.includes(' -> ')) file = file.split(' -> ').pop();
|
|
134
|
+
file = file.replace(/^"|"$/g, '');
|
|
135
|
+
if (!SOURCE_EXT_RE.test(file)) continue;
|
|
136
|
+
hash.update(`\0${file}\0`);
|
|
137
|
+
try {
|
|
138
|
+
hash.update(readFileSync(path.resolve(cwd, file)));
|
|
139
|
+
} catch {
|
|
140
|
+
hash.update('<absent>');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return hash.digest('hex');
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Persist the capture stamp next to the coverage artifact. Best-effort: a
|
|
151
|
+
* write failure returns `false` rather than throwing — the worst case is a
|
|
152
|
+
* fall back to the mtime heuristic on the next freshness check.
|
|
153
|
+
*
|
|
154
|
+
* @param {{
|
|
155
|
+
* cwd: string,
|
|
156
|
+
* coveragePath: string,
|
|
157
|
+
* digest: string,
|
|
158
|
+
* writeFileSync?: typeof fs.writeFileSync,
|
|
159
|
+
* }} opts
|
|
160
|
+
* @returns {boolean} True when the stamp was written.
|
|
161
|
+
*/
|
|
162
|
+
export function writeCaptureStamp({
|
|
163
|
+
cwd,
|
|
164
|
+
coveragePath,
|
|
165
|
+
digest,
|
|
166
|
+
writeFileSync = fs.writeFileSync,
|
|
167
|
+
}) {
|
|
168
|
+
if (typeof digest !== 'string' || digest.length === 0) return false;
|
|
169
|
+
try {
|
|
170
|
+
writeFileSync(
|
|
171
|
+
captureStampPath(cwd, coveragePath),
|
|
172
|
+
`${JSON.stringify({ digest, capturedAt: new Date().toISOString() }, null, 2)}\n`,
|
|
173
|
+
);
|
|
174
|
+
return true;
|
|
175
|
+
} catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Decide whether the existing coverage artifact is "fresh".
|
|
182
|
+
*
|
|
183
|
+
* Primary test (content-aware, Story #3982): when a capture stamp exists
|
|
184
|
+
* next to the artifact, compare its persisted digest against the current
|
|
185
|
+
* content digest of `targetDirs`. Equal digests → fresh; different → stale.
|
|
186
|
+
* Branch switches and checkouts that bump mtimes without changing content
|
|
187
|
+
* no longer invalidate coverage.
|
|
188
|
+
*
|
|
189
|
+
* Fallback (stamp absent / unreadable / digest unavailable): the original
|
|
190
|
+
* mtime heuristic — artifact at least as new as the newest source file
|
|
191
|
+
* under `targetDirs`. Missing files, missing target dirs, or any IO error
|
|
192
|
+
* resolve to `false` so the caller captures rather than trusting stale data.
|
|
72
193
|
*
|
|
73
194
|
* @param {{
|
|
74
195
|
* coveragePath: string,
|
|
@@ -77,6 +198,8 @@ export function newestSourceMtime(cwd, targetDirs, io = {}) {
|
|
|
77
198
|
* statSync?: typeof fs.statSync,
|
|
78
199
|
* readdirSync?: typeof fs.readdirSync,
|
|
79
200
|
* existsSync?: typeof fs.existsSync,
|
|
201
|
+
* readFileSync?: typeof fs.readFileSync,
|
|
202
|
+
* computeDigest?: typeof computeContentDigest,
|
|
80
203
|
* }} opts
|
|
81
204
|
* @returns {{ fresh: boolean, reason: 'missing' | 'stale' | 'fresh' | 'no-sources' }}
|
|
82
205
|
*/
|
|
@@ -87,10 +210,30 @@ export function isCoverageFresh({
|
|
|
87
210
|
statSync = fs.statSync,
|
|
88
211
|
readdirSync = fs.readdirSync,
|
|
89
212
|
existsSync = fs.existsSync,
|
|
213
|
+
readFileSync = fs.readFileSync,
|
|
214
|
+
computeDigest = computeContentDigest,
|
|
90
215
|
}) {
|
|
91
216
|
const absCoverage = path.resolve(cwd, coveragePath);
|
|
92
217
|
if (!existsSync(absCoverage)) return { fresh: false, reason: 'missing' };
|
|
93
218
|
|
|
219
|
+
const stampPath = captureStampPath(cwd, coveragePath);
|
|
220
|
+
if (existsSync(stampPath)) {
|
|
221
|
+
let stamp = null;
|
|
222
|
+
try {
|
|
223
|
+
stamp = JSON.parse(readFileSync(stampPath, 'utf8'));
|
|
224
|
+
} catch {
|
|
225
|
+
// Corrupt/unreadable stamp → fall through to the mtime heuristic.
|
|
226
|
+
}
|
|
227
|
+
if (typeof stamp?.digest === 'string' && stamp.digest.length > 0) {
|
|
228
|
+
const current = computeDigest(cwd, targetDirs);
|
|
229
|
+
if (typeof current === 'string' && current.length > 0) {
|
|
230
|
+
return current === stamp.digest
|
|
231
|
+
? { fresh: true, reason: 'fresh' }
|
|
232
|
+
: { fresh: false, reason: 'stale' };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
94
237
|
let coverageMtime;
|
|
95
238
|
try {
|
|
96
239
|
coverageMtime = statSync(absCoverage).mtimeMs;
|
|
@@ -54,6 +54,20 @@ import { Worker } from 'node:worker_threads';
|
|
|
54
54
|
/** Default factory: spawn a real `worker_threads.Worker`. */
|
|
55
55
|
const defaultWorkerFactory = (script, options) => new Worker(script, options);
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Pool-vs-serial cutover for `runOnPool` callers.
|
|
59
|
+
*
|
|
60
|
+
* Below this batch size the pool's worker spawn overhead dominates, so
|
|
61
|
+
* callers fall back to in-process serial scoring. Tuned against the test
|
|
62
|
+
* suite's tmpdir fixtures (n=2 stays serial; the full repo n≈200–470
|
|
63
|
+
* takes the pool path). Single-sourced here so the maintainability
|
|
64
|
+
* baseline scan (`maintainability-utils.js`), the CRAP scanner
|
|
65
|
+
* (`crap-utils.js`), and the native review provider
|
|
66
|
+
* (`review-providers/native.js`) cannot silently desynchronize on a
|
|
67
|
+
* retune.
|
|
68
|
+
*/
|
|
69
|
+
export const POOL_SERIAL_THRESHOLD = 8;
|
|
70
|
+
|
|
57
71
|
/**
|
|
58
72
|
* @template TItem, TResult
|
|
59
73
|
* @param {string|URL} workerScript - File URL or path to the worker entry.
|