mandrel 2.25.0 → 2.27.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 +10 -6
- package/.agents/audit-checklists/baselines.md +21 -0
- package/.agents/docs/quality-gates.md +80 -18
- package/.agents/docs/workflows.md +3 -1
- package/.agents/instructions.md +1 -1
- package/.agents/schemas/audit-rules.json +15 -0
- package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +8 -0
- package/.agents/schemas/model-attribution.schema.json +4 -0
- package/.agents/scripts/acceptance-eval.js +89 -6
- package/.agents/scripts/audit-baselines.js +136 -0
- package/.agents/scripts/check-arch-cycles.js +12 -93
- package/.agents/scripts/check-baseline-drift.js +16 -3
- package/.agents/scripts/check-baselines.js +19 -3
- package/.agents/scripts/check-cyclomatic.js +214 -0
- package/.agents/scripts/check-schema-references.js +392 -0
- package/.agents/scripts/check-test-temp-hygiene.js +38 -1
- package/.agents/scripts/check-workflow-timeouts.js +291 -0
- package/.agents/scripts/diagnose-friction.js +85 -19
- package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
- package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
- package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
- package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
- package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
- package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
- package/.agents/scripts/lib/audit-baselines/read.js +87 -0
- package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
- package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
- package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
- package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
- package/.agents/scripts/lib/audit-suite/index.js +0 -5
- package/.agents/scripts/lib/audit-suite/selector.js +9 -62
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
- package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
- package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
- package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
- package/.agents/scripts/lib/baselines/git-base.js +26 -4
- package/.agents/scripts/lib/baselines/kinds/crap.js +112 -15
- package/.agents/scripts/lib/baselines/reader.js +52 -38
- package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
- package/.agents/scripts/lib/baselines/scope.js +39 -90
- package/.agents/scripts/lib/baselines/writer.js +16 -11
- package/.agents/scripts/lib/changed-files.js +8 -1
- package/.agents/scripts/lib/cli-args.js +115 -1
- package/.agents/scripts/lib/close-validation/runner.js +70 -25
- package/.agents/scripts/lib/crap-engine.js +32 -13
- package/.agents/scripts/lib/crap-method-identity.js +153 -0
- package/.agents/scripts/lib/crap-utils.js +13 -0
- package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
- package/.agents/scripts/lib/git-utils.js +136 -80
- package/.agents/scripts/lib/import-graph.js +156 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
- package/.agents/scripts/lib/observability/source-classifier.js +175 -2
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
- package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
- package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
- package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +209 -109
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
- package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
- package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
- package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
- package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
- package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
- package/.agents/scripts/lib/story-adjacency.js +3 -3
- package/.agents/scripts/lib/test-runner-contract.js +134 -0
- package/.agents/scripts/lib/test-tiers.js +11 -2
- package/.agents/scripts/lib/util/concurrent-map.js +17 -0
- package/.agents/scripts/lib/util/parse-id-list.js +103 -0
- package/.agents/scripts/lib/wave-runner/live-probe.js +24 -14
- package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
- package/.agents/scripts/lib/workers/crap-worker.js +2 -10
- package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
- package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
- package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
- package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
- package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
- package/.agents/scripts/lib/worktree-manager.js +14 -0
- package/.agents/scripts/plan-run-epilogue.js +17 -5
- package/.agents/scripts/providers/github/tickets.js +33 -10
- package/.agents/scripts/provision-git-hooks.js +85 -0
- package/.agents/scripts/quality-preview.js +112 -28
- package/.agents/scripts/resolve-stories.js +4 -1
- package/.agents/scripts/run-coverage.js +86 -35
- package/.agents/scripts/run-lint.js +20 -0
- package/.agents/scripts/run-tests.js +26 -36
- package/.agents/scripts/single-story-close.js +28 -2
- package/.agents/scripts/single-story-confirm-merge.js +22 -6
- package/.agents/scripts/stories-wave-tick.js +214 -38
- package/.agents/scripts/update-coverage-baseline.js +34 -4
- package/.agents/scripts/update-duplication-baseline.js +209 -83
- package/.agents/scripts/validate-docs-freshness.js +1 -0
- package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
- package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
- package/.agents/skills/skills.index.json +2 -2
- package/.agents/workflows/audit-baselines.md +289 -0
- package/.agents/workflows/audit-navigability.md +5 -4
- package/.agents/workflows/deliver.md +13 -4
- package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
- package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
- package/.agents/workflows/helpers/deliver-digest.md +41 -21
- package/.agents/workflows/helpers/deliver-reference.md +77 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
- package/.agents/workflows/helpers/plan-reference.md +15 -5
- package/.agents/workflows/memory-consolidate.md +116 -0
- package/.agents/workflows/plan.md +3 -0
- package/README.md +13 -6
- package/docs/CHANGELOG.md +71 -0
- package/package.json +9 -4
- package/.agents/schemas/friction-event.schema.json +0 -56
- package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cyclomatic-ceiling.js — the enforcing core behind
|
|
3
|
+
* `delivery.quality.codingGuardrails.cyclomaticMustFix` (Story #4923).
|
|
4
|
+
*
|
|
5
|
+
* The two `codingGuardrails` cyclomatic knobs shipped schema-validated,
|
|
6
|
+
* bootstrap-defaulted and resolver-resolved, and were then read by nothing:
|
|
7
|
+
* `cyclomaticMustFix` had no consumer at all, and `cyclomaticFlag` was
|
|
8
|
+
* shadowed by a hardcoded `8` in a `quality-preview` display column. A ceiling
|
|
9
|
+
* nothing enforces is worse than no ceiling, because the workflow docs promise
|
|
10
|
+
* the merge will be refused.
|
|
11
|
+
*
|
|
12
|
+
* This module is that enforcement, shaped as a **ratchet** rather than a
|
|
13
|
+
* cliff. The repository already carries dozens of functions above the
|
|
14
|
+
* must-fix ceiling; failing every one of them at once would have made the
|
|
15
|
+
* gate un-landable and it would have been disabled the same day. So the
|
|
16
|
+
* committed `baselines/cyclomatic.json` records the existing breaches per
|
|
17
|
+
* file, and the gate fails only when a change **adds** an over-ceiling
|
|
18
|
+
* function to a file that had none, adds one **beyond** that file's recorded
|
|
19
|
+
* count, or pushes a file's worst function **higher** than recorded. Burning
|
|
20
|
+
* the recorded breaches down is a separate, always-permitted motion — a
|
|
21
|
+
* shrinking baseline is the success signal.
|
|
22
|
+
*
|
|
23
|
+
* Scope: the module walks the `maintainability` gate's `targetDirs` /
|
|
24
|
+
* `ignoreGlobs`. Both instruments read the same coverage-free escomplex
|
|
25
|
+
* surface, so re-declaring the scope under `codingGuardrails` would have
|
|
26
|
+
* added two config keys whose only correct value is "whatever maintainability
|
|
27
|
+
* says".
|
|
28
|
+
*
|
|
29
|
+
* @module lib/cyclomatic-ceiling
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
import { calculateReportForFile } from './maintainability-engine.js';
|
|
34
|
+
import { isIgnoredByGlobs, scanDirectory } from './maintainability-utils.js';
|
|
35
|
+
|
|
36
|
+
/** Default location of the committed breach baseline. */
|
|
37
|
+
export const DEFAULT_CYCLOMATIC_BASELINE = 'baselines/cyclomatic.json';
|
|
38
|
+
|
|
39
|
+
/** Baseline `$schema` marker, matching the sibling ratchet baselines. */
|
|
40
|
+
const CYCLOMATIC_BASELINE_SCHEMA =
|
|
41
|
+
'https://mandrel.dev/baselines/cyclomatic.schema.json';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the enforcement policy from a resolved `delivery.quality` block.
|
|
45
|
+
*
|
|
46
|
+
* `mustFix` and `flag` come straight from `resolveCodingGuardrails`, so a
|
|
47
|
+
* consumer that tunes either knob tunes this gate — which is the whole point
|
|
48
|
+
* of the Story. `targetDirs` / `ignoreGlobs` are borrowed from the
|
|
49
|
+
* maintainability gate (see the module note).
|
|
50
|
+
*
|
|
51
|
+
* @param {object | null | undefined} quality resolved `delivery.quality`
|
|
52
|
+
* @returns {{ mustFix: number, flag: number, targetDirs: string[], ignoreGlobs: string[] }}
|
|
53
|
+
*/
|
|
54
|
+
export function resolveCyclomaticPolicy(quality) {
|
|
55
|
+
const guardrails = quality?.codingGuardrails ?? {};
|
|
56
|
+
const mi = quality?.maintainability ?? {};
|
|
57
|
+
return {
|
|
58
|
+
mustFix: Number(guardrails.cyclomaticMustFix ?? 12),
|
|
59
|
+
flag: Number(guardrails.cyclomaticFlag ?? 8),
|
|
60
|
+
targetDirs: Array.isArray(mi.targetDirs) ? mi.targetDirs : [],
|
|
61
|
+
ignoreGlobs: Array.isArray(mi.ignoreGlobs) ? mi.ignoreGlobs : [],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Reduce one file's escomplex method list to a breach row, or `null` when the
|
|
67
|
+
* file carries no function above `ceiling`.
|
|
68
|
+
*
|
|
69
|
+
* Pure. Module-private: tests reach it through `scanCyclomatic`'s `scoreFile`
|
|
70
|
+
* seam, which keeps the row math exercised without adding an export whose only
|
|
71
|
+
* importer is a test (the `--production` dead-export pass discounts those).
|
|
72
|
+
*
|
|
73
|
+
* @param {string} file repo-relative POSIX path
|
|
74
|
+
* @param {Array<{ cyclomatic?: number }>} methods
|
|
75
|
+
* @param {number} ceiling
|
|
76
|
+
* @returns {{ file: string, methodsAboveCeiling: number, maxCyclomatic: number } | null}
|
|
77
|
+
*/
|
|
78
|
+
function breachRowFor(file, methods, ceiling) {
|
|
79
|
+
let count = 0;
|
|
80
|
+
let max = 0;
|
|
81
|
+
for (const method of methods ?? []) {
|
|
82
|
+
const c = Number(method?.cyclomatic ?? 0);
|
|
83
|
+
if (!Number.isFinite(c)) continue;
|
|
84
|
+
if (c > ceiling) count += 1;
|
|
85
|
+
if (c > max) max = c;
|
|
86
|
+
}
|
|
87
|
+
return count === 0
|
|
88
|
+
? null
|
|
89
|
+
: { file, methodsAboveCeiling: count, maxCyclomatic: max };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Walk the configured scope and score every file, returning the breach rows
|
|
94
|
+
* sorted by path.
|
|
95
|
+
*
|
|
96
|
+
* `scoreFile` is a seam so tests can drive the reduction without the kernel;
|
|
97
|
+
* production callers omit it.
|
|
98
|
+
*
|
|
99
|
+
* @param {{
|
|
100
|
+
* targetDirs: string[],
|
|
101
|
+
* ignoreGlobs?: string[],
|
|
102
|
+
* ceiling: number,
|
|
103
|
+
* cwd?: string,
|
|
104
|
+
* scoreFile?: (absPath: string) => { methods?: Array<{ cyclomatic?: number }>, parseError?: boolean },
|
|
105
|
+
* }} args
|
|
106
|
+
* @returns {{ rows: Array<object>, scannedFiles: number, parseErrors: number }}
|
|
107
|
+
*/
|
|
108
|
+
export function scanCyclomatic({
|
|
109
|
+
targetDirs,
|
|
110
|
+
ignoreGlobs = [],
|
|
111
|
+
ceiling,
|
|
112
|
+
cwd = process.cwd(),
|
|
113
|
+
scoreFile = calculateReportForFile,
|
|
114
|
+
}) {
|
|
115
|
+
const files = [];
|
|
116
|
+
for (const dir of targetDirs ?? []) {
|
|
117
|
+
const abs = path.isAbsolute(dir) ? dir : path.resolve(cwd, dir);
|
|
118
|
+
scanDirectory(abs, files, { cwd, ignoreGlobs });
|
|
119
|
+
}
|
|
120
|
+
files.sort();
|
|
121
|
+
const rows = [];
|
|
122
|
+
let parseErrors = 0;
|
|
123
|
+
for (const abs of files) {
|
|
124
|
+
if (isIgnoredByGlobs(abs, ignoreGlobs, cwd)) continue;
|
|
125
|
+
const report = scoreFile(abs);
|
|
126
|
+
if (!report || report.parseError) {
|
|
127
|
+
parseErrors += 1;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const rel = path.relative(cwd, abs).split(path.sep).join('/');
|
|
131
|
+
const row = breachRowFor(rel, report.methods, ceiling);
|
|
132
|
+
if (row) rows.push(row);
|
|
133
|
+
}
|
|
134
|
+
rows.sort((a, b) => a.file.localeCompare(b.file));
|
|
135
|
+
return { rows, scannedFiles: files.length, parseErrors };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Diff current breach rows against the committed baseline.
|
|
140
|
+
*
|
|
141
|
+
* Four buckets, only the first two of which fail the gate:
|
|
142
|
+
*
|
|
143
|
+
* - `added` — a file whose over-ceiling function count rose (including
|
|
144
|
+
* 0 → 1, i.e. a brand-new breach in new or changed code).
|
|
145
|
+
* - `worsened` — a file whose worst function got worse than recorded.
|
|
146
|
+
* - `removed` — a file that no longer breaches at all.
|
|
147
|
+
* - `improved` — a file that breaches less than recorded.
|
|
148
|
+
*
|
|
149
|
+
* Pure; identity is the repo-relative file path.
|
|
150
|
+
*
|
|
151
|
+
* @param {Array<{file: string, methodsAboveCeiling: number, maxCyclomatic: number}>} baselineRows
|
|
152
|
+
* @param {Array<{file: string, methodsAboveCeiling: number, maxCyclomatic: number}>} currentRows
|
|
153
|
+
* @returns {{ added: Array<object>, worsened: Array<object>, removed: Array<object>, improved: Array<object> }}
|
|
154
|
+
*/
|
|
155
|
+
export function diffCyclomaticRows(baselineRows, currentRows) {
|
|
156
|
+
const base = new Map(
|
|
157
|
+
(baselineRows ?? [])
|
|
158
|
+
.filter((r) => typeof r?.file === 'string')
|
|
159
|
+
.map((r) => [r.file, r]),
|
|
160
|
+
);
|
|
161
|
+
const added = [];
|
|
162
|
+
const worsened = [];
|
|
163
|
+
const improved = [];
|
|
164
|
+
const seen = new Set();
|
|
165
|
+
for (const row of currentRows ?? []) {
|
|
166
|
+
if (typeof row?.file !== 'string') continue;
|
|
167
|
+
seen.add(row.file);
|
|
168
|
+
const prior = base.get(row.file);
|
|
169
|
+
const priorCount = Number(prior?.methodsAboveCeiling ?? 0);
|
|
170
|
+
const priorMax = Number(prior?.maxCyclomatic ?? 0);
|
|
171
|
+
if (row.methodsAboveCeiling > priorCount) {
|
|
172
|
+
added.push({ ...row, baselineCount: priorCount });
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (row.maxCyclomatic > priorMax) {
|
|
176
|
+
worsened.push({ ...row, baselineMax: priorMax });
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (row.methodsAboveCeiling < priorCount || row.maxCyclomatic < priorMax) {
|
|
180
|
+
improved.push({
|
|
181
|
+
...row,
|
|
182
|
+
baselineCount: priorCount,
|
|
183
|
+
baselineMax: priorMax,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const removed = (baselineRows ?? []).filter(
|
|
188
|
+
(r) => typeof r?.file === 'string' && !seen.has(r.file),
|
|
189
|
+
);
|
|
190
|
+
const byFile = (a, b) => a.file.localeCompare(b.file);
|
|
191
|
+
return {
|
|
192
|
+
added: added.sort(byFile),
|
|
193
|
+
worsened: worsened.sort(byFile),
|
|
194
|
+
removed: removed.sort(byFile),
|
|
195
|
+
improved: improved.sort(byFile),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Assemble the committed baseline envelope. The rollup is deliberately
|
|
201
|
+
* derived, never hand-written: a zero-row baseline here reports zero breaches
|
|
202
|
+
* because the repository has none, not because nobody produced it.
|
|
203
|
+
*
|
|
204
|
+
* @param {{ rows: Array<object>, ceiling: number, generatedAt?: string }} args
|
|
205
|
+
* @returns {object}
|
|
206
|
+
*/
|
|
207
|
+
export function buildCyclomaticEnvelope({ rows, ceiling, generatedAt }) {
|
|
208
|
+
const safeRows = rows ?? [];
|
|
209
|
+
let methods = 0;
|
|
210
|
+
let max = 0;
|
|
211
|
+
for (const row of safeRows) {
|
|
212
|
+
methods += Number(row.methodsAboveCeiling ?? 0);
|
|
213
|
+
if (Number(row.maxCyclomatic ?? 0) > max) max = Number(row.maxCyclomatic);
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
$schema: CYCLOMATIC_BASELINE_SCHEMA,
|
|
217
|
+
generatedAt: generatedAt ?? new Date().toISOString(),
|
|
218
|
+
ceiling,
|
|
219
|
+
rollup: {
|
|
220
|
+
'*': {
|
|
221
|
+
filesAboveCeiling: safeRows.length,
|
|
222
|
+
methodsAboveCeiling: methods,
|
|
223
|
+
maxCyclomatic: max,
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
rows: safeRows,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Render the human-readable diff. Emits a summary line even on a clean run so
|
|
232
|
+
* operators see the "no drift" signal rather than silence.
|
|
233
|
+
*
|
|
234
|
+
* @param {{ added: Array, worsened: Array, removed: Array, improved: Array }} diff
|
|
235
|
+
* @param {number} ceiling
|
|
236
|
+
* @returns {string}
|
|
237
|
+
*/
|
|
238
|
+
export function renderCyclomaticDiff(diff, ceiling) {
|
|
239
|
+
const lines = [];
|
|
240
|
+
for (const r of diff.added) {
|
|
241
|
+
lines.push(
|
|
242
|
+
`+ ${r.file}: ${r.methodsAboveCeiling} function(s) over c=${ceiling} (recorded ${r.baselineCount}), worst c=${r.maxCyclomatic}`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
for (const r of diff.worsened) {
|
|
246
|
+
lines.push(
|
|
247
|
+
`! ${r.file}: worst function c=${r.maxCyclomatic} (recorded ${r.baselineMax})`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
for (const r of diff.improved) {
|
|
251
|
+
lines.push(
|
|
252
|
+
`~ ${r.file}: ${r.methodsAboveCeiling} over c=${ceiling} (recorded ${r.baselineCount}), worst c=${r.maxCyclomatic} (recorded ${r.baselineMax})`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
for (const r of diff.removed) {
|
|
256
|
+
lines.push(`- ${r.file}: no longer over c=${ceiling}`);
|
|
257
|
+
}
|
|
258
|
+
const failing = diff.added.length + diff.worsened.length;
|
|
259
|
+
lines.push(
|
|
260
|
+
`[cyclomatic] ceiling=${ceiling} added=${diff.added.length} worsened=${diff.worsened.length} improved=${diff.improved.length} removed=${diff.removed.length} ${
|
|
261
|
+
failing > 0 ? '(gate fail)' : '(ok)'
|
|
262
|
+
}`,
|
|
263
|
+
);
|
|
264
|
+
return lines.join('\n');
|
|
265
|
+
}
|
|
@@ -40,6 +40,23 @@ import { execFileSync, spawnSync } from 'node:child_process';
|
|
|
40
40
|
* @property {string} stderr - Trimmed stderr.
|
|
41
41
|
*/
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Explicit stdout ceiling for every git invocation in this module.
|
|
45
|
+
*
|
|
46
|
+
* Both child-process runners default `maxBuffer` to 1 MB, at which point the
|
|
47
|
+
* child is killed and the call fails with `ENOBUFS` for a reason unrelated to
|
|
48
|
+
* git. The push path is the sharp edge: `git push` relays the whole `pre-push`
|
|
49
|
+
* hook output, which is unbounded by design — this repo's hook emits a full
|
|
50
|
+
* `check-baselines` envelope, measured at 2,166,643 bytes, so every Story
|
|
51
|
+
* close failed at `phase: push` once hooks became reachable inside worktrees.
|
|
52
|
+
*
|
|
53
|
+
* 64 MB is the bound Story #4914 already set on `baselines/git-base.js` for
|
|
54
|
+
* the identical failure, matching `run-test-profile.js`,
|
|
55
|
+
* `audit-baselines/trend.js` and `audit-baselines/weights.js`. This module was
|
|
56
|
+
* missed by that sweep.
|
|
57
|
+
*/
|
|
58
|
+
const MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
59
|
+
|
|
43
60
|
let _execFileSync = execFileSync;
|
|
44
61
|
let _spawnSync = spawnSync;
|
|
45
62
|
|
|
@@ -108,38 +125,44 @@ export function __setGitRunners(exec, spawn) {
|
|
|
108
125
|
}
|
|
109
126
|
|
|
110
127
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
128
|
+
* The **single** throwing git runner. Both the module-level {@link gitSync} and
|
|
129
|
+
* the interface returned by {@link createGitInterface} route through this —
|
|
130
|
+
* they differ only in which `execFileSync` they hand it.
|
|
113
131
|
*
|
|
114
|
-
* @param {
|
|
115
|
-
* @param {
|
|
132
|
+
* @param {typeof execFileSync} exec
|
|
133
|
+
* @param {string} cwd
|
|
134
|
+
* @param {string[]} args
|
|
116
135
|
* @returns {string} Trimmed stdout text.
|
|
117
136
|
*/
|
|
118
|
-
|
|
119
|
-
return
|
|
137
|
+
function runGitSync(exec, cwd, args) {
|
|
138
|
+
return exec('git', args, {
|
|
120
139
|
cwd,
|
|
121
140
|
encoding: 'utf8',
|
|
122
141
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
123
142
|
shell: false,
|
|
124
143
|
env: cleanGitEnv(),
|
|
144
|
+
maxBuffer: MAX_BUFFER_BYTES,
|
|
125
145
|
}).trim();
|
|
126
146
|
}
|
|
127
147
|
|
|
128
148
|
/**
|
|
129
|
-
*
|
|
130
|
-
*
|
|
149
|
+
* The **single** non-throwing git runner — the `spawnSync` counterpart of
|
|
150
|
+
* {@link runGitSync}, normalising `status`/stdout/stderr into a
|
|
151
|
+
* {@link GitResult}.
|
|
131
152
|
*
|
|
132
|
-
* @param {
|
|
133
|
-
* @param {
|
|
153
|
+
* @param {typeof spawnSync} spawn
|
|
154
|
+
* @param {string} cwd
|
|
155
|
+
* @param {string[]} args
|
|
134
156
|
* @returns {GitResult}
|
|
135
157
|
*/
|
|
136
|
-
|
|
137
|
-
const result =
|
|
158
|
+
function runGitSpawn(spawn, cwd, args) {
|
|
159
|
+
const result = spawn('git', args, {
|
|
138
160
|
cwd,
|
|
139
161
|
stdio: 'pipe',
|
|
140
162
|
encoding: 'utf-8',
|
|
141
163
|
shell: false,
|
|
142
164
|
env: cleanGitEnv(),
|
|
165
|
+
maxBuffer: MAX_BUFFER_BYTES,
|
|
143
166
|
});
|
|
144
167
|
return {
|
|
145
168
|
status: result.status ?? 1,
|
|
@@ -148,6 +171,30 @@ export function gitSpawn(cwd, ...args) {
|
|
|
148
171
|
};
|
|
149
172
|
}
|
|
150
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Run a git command synchronously, returning trimmed stdout.
|
|
176
|
+
* Throws an Error if the command exits with a non-zero code.
|
|
177
|
+
*
|
|
178
|
+
* @param {string} cwd - Working directory for the git process.
|
|
179
|
+
* @param {...string} args - Git sub-command and arguments.
|
|
180
|
+
* @returns {string} Trimmed stdout text.
|
|
181
|
+
*/
|
|
182
|
+
export function gitSync(cwd, ...args) {
|
|
183
|
+
return runGitSync(_execFileSync, cwd, args);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Run a git command synchronously, returning a result object.
|
|
188
|
+
* Never throws — callers must inspect `status` to detect failure.
|
|
189
|
+
*
|
|
190
|
+
* @param {string} cwd - Working directory for the git process.
|
|
191
|
+
* @param {...string} args - Git sub-command and arguments.
|
|
192
|
+
* @returns {GitResult}
|
|
193
|
+
*/
|
|
194
|
+
export function gitSpawn(cwd, ...args) {
|
|
195
|
+
return runGitSpawn(_spawnSync, cwd, args);
|
|
196
|
+
}
|
|
197
|
+
|
|
151
198
|
/**
|
|
152
199
|
* Build a git interface closed over injected child-process runners. Preferred
|
|
153
200
|
* seam for callers that want explicit injection without touching the
|
|
@@ -165,57 +212,25 @@ export function gitSpawn(cwd, ...args) {
|
|
|
165
212
|
export function createGitInterface(deps = {}) {
|
|
166
213
|
const exec = deps.exec ?? execFileSync;
|
|
167
214
|
const spawn = deps.spawn ?? spawnSync;
|
|
168
|
-
const sleep =
|
|
169
|
-
deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
215
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
170
216
|
const jitterFactor = deps.jitter ?? 0.5;
|
|
171
217
|
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const result = spawn('git', args, {
|
|
183
|
-
cwd,
|
|
184
|
-
stdio: 'pipe',
|
|
185
|
-
encoding: 'utf-8',
|
|
186
|
-
shell: false,
|
|
187
|
-
env: cleanGitEnv(),
|
|
188
|
-
});
|
|
189
|
-
return {
|
|
190
|
-
status: result.status ?? 1,
|
|
191
|
-
stdout: (result.stdout ?? '').trim(),
|
|
192
|
-
stderr: (result.stderr ?? '').trim(),
|
|
193
|
-
};
|
|
194
|
-
};
|
|
195
|
-
|
|
196
|
-
async function runWithRetry(leadingArgs, cwd, args) {
|
|
197
|
-
const backoff = [250, 500, 1000];
|
|
198
|
-
let attempt = 0;
|
|
199
|
-
let last;
|
|
200
|
-
for (;;) {
|
|
201
|
-
attempt++;
|
|
202
|
-
last = gitSpawn(cwd, ...leadingArgs, ...args);
|
|
203
|
-
if (last.status === 0) return { ...last, attempts: attempt };
|
|
204
|
-
if (!isPackedRefsContention(last.stderr))
|
|
205
|
-
return { ...last, attempts: attempt };
|
|
206
|
-
if (attempt > backoff.length) return { ...last, attempts: attempt };
|
|
207
|
-
const base = backoff[attempt - 1];
|
|
208
|
-
const jitter = Math.floor(Math.random() * base * jitterFactor);
|
|
209
|
-
await sleep(base + jitter);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
218
|
+
const boundGitSpawn = (cwd, ...args) => runGitSpawn(spawn, cwd, args);
|
|
219
|
+
const withRetry =
|
|
220
|
+
(argvPrefix) =>
|
|
221
|
+
(cwd, ...args) =>
|
|
222
|
+
gitWithContentionRetry(
|
|
223
|
+
{ spawnGit: boundGitSpawn, sleep, jitterFactor },
|
|
224
|
+
cwd,
|
|
225
|
+
argvPrefix,
|
|
226
|
+
args,
|
|
227
|
+
);
|
|
212
228
|
|
|
213
229
|
return {
|
|
214
|
-
gitSync,
|
|
215
|
-
gitSpawn,
|
|
216
|
-
gitFetchWithRetry: (
|
|
217
|
-
gitPullWithRetry: (
|
|
218
|
-
runWithRetry(['pull', '--rebase'], cwd, args),
|
|
230
|
+
gitSync: (cwd, ...args) => runGitSync(exec, cwd, args),
|
|
231
|
+
gitSpawn: boundGitSpawn,
|
|
232
|
+
gitFetchWithRetry: withRetry(['fetch']),
|
|
233
|
+
gitPullWithRetry: withRetry(['pull', '--rebase']),
|
|
219
234
|
};
|
|
220
235
|
}
|
|
221
236
|
|
|
@@ -238,12 +253,21 @@ function isPackedRefsContention(stderr) {
|
|
|
238
253
|
}
|
|
239
254
|
|
|
240
255
|
/**
|
|
241
|
-
*
|
|
242
|
-
*
|
|
256
|
+
* Real wall-clock sleep — the default backoff delay for both the module-level
|
|
257
|
+
* retry helpers and {@link createGitInterface}.
|
|
243
258
|
* @param {number} ms
|
|
244
259
|
* @returns {Promise<void>}
|
|
245
260
|
*/
|
|
246
|
-
|
|
261
|
+
function defaultSleep(ms) {
|
|
262
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Sleep helper for retry backoff. Overridable via `__setSleep` so tests
|
|
267
|
+
* can skip real wall-clock delays without relying on node:test timer mocks.
|
|
268
|
+
* @type {(ms: number) => Promise<void>}
|
|
269
|
+
*/
|
|
270
|
+
let _sleep = defaultSleep;
|
|
247
271
|
let _jitterFactor = 0.5;
|
|
248
272
|
|
|
249
273
|
/**
|
|
@@ -258,38 +282,65 @@ export function __setSleep(fn, opts = {}) {
|
|
|
258
282
|
}
|
|
259
283
|
|
|
260
284
|
/**
|
|
261
|
-
*
|
|
262
|
-
*
|
|
285
|
+
* Backoff schedule for {@link gitWithContentionRetry}: 250ms, 500ms, 1000ms
|
|
286
|
+
* (3 retries → 4 attempts total).
|
|
287
|
+
*/
|
|
288
|
+
const CONTENTION_BACKOFF_MS = Object.freeze([250, 500, 1000]);
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The **single** bounded retry loop for git commands that can hit packed-refs
|
|
292
|
+
* lock contention. Only contention signatures trigger a retry — non-contention
|
|
263
293
|
* failures surface immediately, and success short-circuits the loop.
|
|
264
294
|
*
|
|
265
|
-
* Backoff schedule: 250ms, 500ms, 1000ms (3 retries → 4 attempts total).
|
|
266
295
|
* Deliberately no global lock — a mutex would erase the parallelism the
|
|
267
|
-
* worktree-isolation model is designed to enable. The schedule and the
|
|
268
|
-
*
|
|
269
|
-
*
|
|
296
|
+
* worktree-isolation model is designed to enable. The schedule and the jitter
|
|
297
|
+
* policy live only here, so a backoff tuning change has a single point of
|
|
298
|
+
* application: the module-level `gitFetchWithRetry` / `gitPullWithRetry` pass
|
|
299
|
+
* the `_sleep` / `_jitterFactor` seams, and {@link createGitInterface} passes
|
|
300
|
+
* its injected equivalents.
|
|
270
301
|
*
|
|
302
|
+
* @param {{ spawnGit: (cwd: string, ...args: string[]) => GitResult,
|
|
303
|
+
* sleep: (ms: number) => Promise<void>, jitterFactor: number }} runners
|
|
271
304
|
* @param {string} cwd
|
|
272
305
|
* @param {string[]} argvPrefix - Leading git argv (e.g. `['fetch']`).
|
|
273
306
|
* @param {string[]} args - Trailing arguments (e.g. `['origin']`).
|
|
274
307
|
* @returns {Promise<{ status: number, stdout: string, stderr: string, attempts: number }>}
|
|
275
308
|
*/
|
|
276
|
-
async function gitWithContentionRetry(
|
|
277
|
-
|
|
309
|
+
async function gitWithContentionRetry(
|
|
310
|
+
{ spawnGit, sleep, jitterFactor },
|
|
311
|
+
cwd,
|
|
312
|
+
argvPrefix,
|
|
313
|
+
args,
|
|
314
|
+
) {
|
|
278
315
|
let attempt = 0;
|
|
279
|
-
let last;
|
|
280
316
|
for (;;) {
|
|
281
317
|
attempt++;
|
|
282
|
-
last =
|
|
283
|
-
|
|
284
|
-
if (
|
|
318
|
+
const last = spawnGit(cwd, ...argvPrefix, ...args);
|
|
319
|
+
const exhausted = attempt > CONTENTION_BACKOFF_MS.length;
|
|
320
|
+
if (
|
|
321
|
+
last.status === 0 ||
|
|
322
|
+
exhausted ||
|
|
323
|
+
!isPackedRefsContention(last.stderr)
|
|
324
|
+
) {
|
|
285
325
|
return { ...last, attempts: attempt };
|
|
286
|
-
|
|
287
|
-
const base =
|
|
288
|
-
|
|
289
|
-
await _sleep(base + jitter);
|
|
326
|
+
}
|
|
327
|
+
const base = CONTENTION_BACKOFF_MS[attempt - 1];
|
|
328
|
+
await sleep(base + Math.floor(Math.random() * base * jitterFactor));
|
|
290
329
|
}
|
|
291
330
|
}
|
|
292
331
|
|
|
332
|
+
/**
|
|
333
|
+
* The module-level retry runners — the `__setSleep`-overridable seams bound to
|
|
334
|
+
* the module-global {@link gitSpawn}. Read lazily so `__setSleep` and
|
|
335
|
+
* `__setGitRunners` still take effect after import.
|
|
336
|
+
*
|
|
337
|
+
* @returns {{ spawnGit: typeof gitSpawn, sleep: (ms: number) => Promise<void>,
|
|
338
|
+
* jitterFactor: number }}
|
|
339
|
+
*/
|
|
340
|
+
function moduleRetryRunners() {
|
|
341
|
+
return { spawnGit: gitSpawn, sleep: _sleep, jitterFactor: _jitterFactor };
|
|
342
|
+
}
|
|
343
|
+
|
|
293
344
|
/**
|
|
294
345
|
* Run `git fetch …` with the bounded packed-refs-contention retry loop
|
|
295
346
|
* (see `gitWithContentionRetry`).
|
|
@@ -299,7 +350,7 @@ async function gitWithContentionRetry(cwd, argvPrefix, args) {
|
|
|
299
350
|
* @returns {Promise<{ status: number, stdout: string, stderr: string, attempts: number }>}
|
|
300
351
|
*/
|
|
301
352
|
export function gitFetchWithRetry(cwd, ...args) {
|
|
302
|
-
return gitWithContentionRetry(cwd, ['fetch'], args);
|
|
353
|
+
return gitWithContentionRetry(moduleRetryRunners(), cwd, ['fetch'], args);
|
|
303
354
|
}
|
|
304
355
|
|
|
305
356
|
/**
|
|
@@ -312,7 +363,12 @@ export function gitFetchWithRetry(cwd, ...args) {
|
|
|
312
363
|
* @returns {Promise<{ status: number, stdout: string, stderr: string, attempts: number }>}
|
|
313
364
|
*/
|
|
314
365
|
export function gitPullWithRetry(cwd, ...args) {
|
|
315
|
-
return gitWithContentionRetry(
|
|
366
|
+
return gitWithContentionRetry(
|
|
367
|
+
moduleRetryRunners(),
|
|
368
|
+
cwd,
|
|
369
|
+
['pull', '--rebase'],
|
|
370
|
+
args,
|
|
371
|
+
);
|
|
316
372
|
}
|
|
317
373
|
|
|
318
374
|
/**
|