mandrel 2.25.0 → 2.26.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/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 +64 -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,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI: GitHub Actions job-timeout gate.
|
|
3
|
+
*
|
|
4
|
+
* Story #4936. Every job in this repository inherited GitHub's 360-minute
|
|
5
|
+
* default because not one of the four workflow files set `timeout-minutes`.
|
|
6
|
+
* That is not a theoretical cost: a deadlocked `Windows Smoke` job burned 44
|
|
7
|
+
* minutes of a `windows-latest` runner, and — because the run stayed
|
|
8
|
+
* `in_progress` — GitHub withheld the logs of the *required* check that had
|
|
9
|
+
* already failed on the same run, so the real merge blocker could not be
|
|
10
|
+
* diagnosed until the run was cancelled by hand. An unbounded job costs
|
|
11
|
+
* runner time and diagnosability at once.
|
|
12
|
+
*
|
|
13
|
+
* A ceiling only helps if it is bounded on both ends, so this gate fails on
|
|
14
|
+
* two shapes:
|
|
15
|
+
*
|
|
16
|
+
* - a job with no `timeout-minutes` at all (unbounded, 360-minute default);
|
|
17
|
+
* - a job whose `timeout-minutes` exceeds `MAX_TIMEOUT_MINUTES`, i.e. a
|
|
18
|
+
* number generous enough to be useless as a deadlock detector.
|
|
19
|
+
*
|
|
20
|
+
* `MIN_TIMEOUT_MINUTES` is the floor a value must clear to be plausible
|
|
21
|
+
* headroom over a job that takes a couple of minutes today; below it, a
|
|
22
|
+
* transient slow runner reds the build for no defect.
|
|
23
|
+
*
|
|
24
|
+
* Contract:
|
|
25
|
+
* - Scans the workflows directory (default `.github/workflows`, override
|
|
26
|
+
* with `--dir <path>`), parsing each `*.yml` / `*.yaml` with js-yaml and
|
|
27
|
+
* enumerating every `jobs.<id>` key.
|
|
28
|
+
* - Prints `<file> job <id> — <reason>` per violation, then a one-line
|
|
29
|
+
* summary even on a clean scan so the "ok" signal is visible in CI.
|
|
30
|
+
* - With `--json`: writes a structured envelope to stdout and skips the
|
|
31
|
+
* human summary.
|
|
32
|
+
* - Exit codes: 0 = every job bounded in range; 1 = at least one violation.
|
|
33
|
+
* A missing / empty workflows directory exits 0 (nothing to gate).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import process from 'node:process';
|
|
39
|
+
import yaml from 'js-yaml';
|
|
40
|
+
import { runAsCli } from './lib/cli-utils.js';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Inclusive bounds every job's `timeout-minutes` must sit within.
|
|
44
|
+
*
|
|
45
|
+
* The ceiling is the load-bearing half — it is what turns a deadlock into a
|
|
46
|
+
* bounded loss instead of a six-hour one. The floor keeps a well-meaning
|
|
47
|
+
* `timeout-minutes: 2` from reddening the build on a slow runner.
|
|
48
|
+
*/
|
|
49
|
+
export const MIN_TIMEOUT_MINUTES = 10;
|
|
50
|
+
export const MAX_TIMEOUT_MINUTES = 30;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Enumerate workflow files (`*.yml` / `*.yaml`) directly under `dir`.
|
|
54
|
+
* Returns absolute paths sorted for deterministic output. A missing directory
|
|
55
|
+
* yields an empty list.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} dir Absolute workflows directory.
|
|
58
|
+
* @param {typeof fs} [fsLike]
|
|
59
|
+
* @returns {string[]}
|
|
60
|
+
*/
|
|
61
|
+
export function listWorkflowFiles(dir, fsLike = fs) {
|
|
62
|
+
let entries;
|
|
63
|
+
try {
|
|
64
|
+
entries = fsLike.readdirSync(dir, { withFileTypes: true });
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
return entries
|
|
69
|
+
.filter((e) => e.isFile() && /\.ya?ml$/i.test(e.name))
|
|
70
|
+
.map((e) => path.join(dir, e.name))
|
|
71
|
+
.sort();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read the `jobs` mapping out of one workflow document.
|
|
76
|
+
*
|
|
77
|
+
* The single parse point for both the violation scan and the job count, so
|
|
78
|
+
* the two can never disagree about what a workflow's job set is.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} text The file contents.
|
|
81
|
+
* @param {(s: string) => unknown} parse YAML parser; injected in tests.
|
|
82
|
+
* @returns {{ jobs: Record<string, unknown> | null, error: Error | null }}
|
|
83
|
+
*/
|
|
84
|
+
function readJobs(text, parse) {
|
|
85
|
+
let doc;
|
|
86
|
+
try {
|
|
87
|
+
doc = parse(text);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
return { jobs: null, error: err };
|
|
90
|
+
}
|
|
91
|
+
const jobs = doc && typeof doc === 'object' ? doc.jobs : null;
|
|
92
|
+
return {
|
|
93
|
+
jobs: jobs && typeof jobs === 'object' ? jobs : null,
|
|
94
|
+
error: null,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Judge one job body's `timeout-minutes`, returning the violation shape or
|
|
100
|
+
* `null` when the job is bounded in range.
|
|
101
|
+
*
|
|
102
|
+
* Reusable-workflow calls (`jobs.<id>.uses:`) are exempt: `timeout-minutes`
|
|
103
|
+
* is not a valid key on a job that delegates to another workflow — the
|
|
104
|
+
* callee's own jobs carry their timeouts, and setting one here is a schema
|
|
105
|
+
* error rather than a fix.
|
|
106
|
+
*
|
|
107
|
+
* @param {unknown} body The `jobs.<id>` value.
|
|
108
|
+
* @returns {{ timeout: number | null, reason: string } | null}
|
|
109
|
+
*/
|
|
110
|
+
function judgeJobTimeout(body) {
|
|
111
|
+
if (!body || typeof body !== 'object') return null;
|
|
112
|
+
if (typeof body.uses === 'string') return null;
|
|
113
|
+
|
|
114
|
+
const timeout = body['timeout-minutes'];
|
|
115
|
+
if (timeout === undefined || timeout === null) {
|
|
116
|
+
return {
|
|
117
|
+
timeout: null,
|
|
118
|
+
reason: `no timeout-minutes — the job inherits GitHub's 360-minute default`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (typeof timeout !== 'number' || !Number.isFinite(timeout)) {
|
|
122
|
+
return {
|
|
123
|
+
timeout: null,
|
|
124
|
+
reason: `timeout-minutes is not a number (${JSON.stringify(timeout)})`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (timeout > MAX_TIMEOUT_MINUTES) {
|
|
128
|
+
return {
|
|
129
|
+
timeout,
|
|
130
|
+
reason: `timeout-minutes ${timeout} exceeds the ${MAX_TIMEOUT_MINUTES}-minute ceiling`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (timeout < MIN_TIMEOUT_MINUTES) {
|
|
134
|
+
return {
|
|
135
|
+
timeout,
|
|
136
|
+
reason: `timeout-minutes ${timeout} is below the ${MIN_TIMEOUT_MINUTES}-minute floor`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Pure helper: enumerate every `jobs.<id>` key in one workflow document and
|
|
144
|
+
* return the violations.
|
|
145
|
+
*
|
|
146
|
+
* An unparseable document is itself a violation: a workflow whose jobs cannot
|
|
147
|
+
* be enumerated cannot be certified as bounded, and failing open would let
|
|
148
|
+
* the very drift this gate exists to catch through.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} file Relative file label used in violation rows.
|
|
151
|
+
* @param {string} text The file contents.
|
|
152
|
+
* @param {(s: string) => unknown} [parse] YAML parser; injected in tests.
|
|
153
|
+
* @returns {Array<{ file: string, job: string, timeout: number | null, reason: string }>}
|
|
154
|
+
*/
|
|
155
|
+
export function scanWorkflowText(file, text, parse = yaml.load) {
|
|
156
|
+
const { jobs, error } = readJobs(text, parse);
|
|
157
|
+
if (error) {
|
|
158
|
+
return [
|
|
159
|
+
{
|
|
160
|
+
file,
|
|
161
|
+
job: '(document)',
|
|
162
|
+
timeout: null,
|
|
163
|
+
reason: `workflow could not be parsed as YAML: ${error.message}`,
|
|
164
|
+
},
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
if (!jobs) return [];
|
|
168
|
+
|
|
169
|
+
const violations = [];
|
|
170
|
+
for (const [job, body] of Object.entries(jobs)) {
|
|
171
|
+
const verdict = judgeJobTimeout(body);
|
|
172
|
+
if (verdict) violations.push({ file, job, ...verdict });
|
|
173
|
+
}
|
|
174
|
+
return violations;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Pure helper: render the human-readable report. One line per violation
|
|
179
|
+
* followed by a one-line summary carrying a `(gate fail)` / `(ok)` marker.
|
|
180
|
+
*
|
|
181
|
+
* @param {Array<{ file: string, job: string, timeout: number | null, reason: string }>} violations
|
|
182
|
+
* @param {number} jobsScanned
|
|
183
|
+
* @returns {string}
|
|
184
|
+
*/
|
|
185
|
+
export function renderReport(violations, jobsScanned) {
|
|
186
|
+
const lines = violations.map((v) => `${v.file} job ${v.job} — ${v.reason}`);
|
|
187
|
+
const tag = violations.length > 0 ? '(gate fail)' : '(ok)';
|
|
188
|
+
lines.push(
|
|
189
|
+
`[workflow-timeouts] jobs=${jobsScanned} violations=${violations.length} ${tag}`,
|
|
190
|
+
);
|
|
191
|
+
return lines.join('\n');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Count every `jobs.<id>` key in a parsed workflow document, including the
|
|
196
|
+
* reusable-workflow calls the violation scan exempts — the summary reports
|
|
197
|
+
* how many jobs were enumerated, not how many were eligible.
|
|
198
|
+
*
|
|
199
|
+
* @param {string} text
|
|
200
|
+
* @param {(s: string) => unknown} [parse]
|
|
201
|
+
* @returns {number}
|
|
202
|
+
*/
|
|
203
|
+
export function countJobs(text, parse = yaml.load) {
|
|
204
|
+
const { jobs } = readJobs(text, parse);
|
|
205
|
+
return jobs ? Object.keys(jobs).length : 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Top-level CLI entry. Exported so tests can drive the full pipeline against
|
|
210
|
+
* a fixture workflows directory without touching the repo's real workflows.
|
|
211
|
+
*
|
|
212
|
+
* @param {{
|
|
213
|
+
* argv?: string[],
|
|
214
|
+
* cwd?: string,
|
|
215
|
+
* stdout?: { write: (s: string) => void },
|
|
216
|
+
* stderr?: { write: (s: string) => void },
|
|
217
|
+
* }} [opts]
|
|
218
|
+
* @returns {Promise<number>} exit code: 0 = every job bounded; 1 = violation
|
|
219
|
+
*/
|
|
220
|
+
export async function runCli({
|
|
221
|
+
argv = process.argv.slice(2),
|
|
222
|
+
cwd = process.cwd(),
|
|
223
|
+
stdout = process.stdout,
|
|
224
|
+
stderr = process.stderr,
|
|
225
|
+
} = {}) {
|
|
226
|
+
const dirIdx = argv.indexOf('--dir');
|
|
227
|
+
const dir =
|
|
228
|
+
dirIdx !== -1 && argv[dirIdx + 1] && !argv[dirIdx + 1].startsWith('--')
|
|
229
|
+
? argv[dirIdx + 1]
|
|
230
|
+
: null;
|
|
231
|
+
const json = argv.includes('--json');
|
|
232
|
+
const resolvedDir = path.resolve(
|
|
233
|
+
cwd,
|
|
234
|
+
dir ?? path.join('.github', 'workflows'),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const files = listWorkflowFiles(resolvedDir);
|
|
238
|
+
const violations = [];
|
|
239
|
+
let jobsScanned = 0;
|
|
240
|
+
for (const file of files) {
|
|
241
|
+
let text;
|
|
242
|
+
try {
|
|
243
|
+
text = fs.readFileSync(file, 'utf-8');
|
|
244
|
+
} catch {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const label = path.relative(cwd, file);
|
|
248
|
+
jobsScanned += countJobs(text);
|
|
249
|
+
violations.push(...scanWorkflowText(label, text));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const exitCode = violations.length > 0 ? 1 : 0;
|
|
253
|
+
|
|
254
|
+
if (json) {
|
|
255
|
+
stdout.write(
|
|
256
|
+
`${JSON.stringify(
|
|
257
|
+
{
|
|
258
|
+
kind: 'workflow-timeouts-report',
|
|
259
|
+
dir: resolvedDir,
|
|
260
|
+
filesScanned: files.length,
|
|
261
|
+
jobsScanned,
|
|
262
|
+
minTimeoutMinutes: MIN_TIMEOUT_MINUTES,
|
|
263
|
+
maxTimeoutMinutes: MAX_TIMEOUT_MINUTES,
|
|
264
|
+
violations,
|
|
265
|
+
exitCode,
|
|
266
|
+
},
|
|
267
|
+
null,
|
|
268
|
+
2,
|
|
269
|
+
)}\n`,
|
|
270
|
+
);
|
|
271
|
+
} else {
|
|
272
|
+
if (files.length === 0) {
|
|
273
|
+
stderr.write(
|
|
274
|
+
`[workflow-timeouts] ⚠ no workflow files found under ${resolvedDir}\n`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
stdout.write(`${renderReport(violations, jobsScanned)}\n`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return exitCode;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function main() {
|
|
284
|
+
return runCli();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
runAsCli(import.meta.url, main, {
|
|
288
|
+
source: 'workflow-timeouts',
|
|
289
|
+
propagateExitCode: true,
|
|
290
|
+
errorPrefix: '[workflow-timeouts] ❌ Fatal error',
|
|
291
|
+
});
|
|
@@ -16,7 +16,11 @@
|
|
|
16
16
|
*
|
|
17
17
|
* Usage:
|
|
18
18
|
* node diagnose-friction.js [--story <STORY_ID>] \
|
|
19
|
-
* [--epic <EPIC_ID>] --cmd <
|
|
19
|
+
* [--epic <EPIC_ID>] --cmd <cmd> <args...>
|
|
20
|
+
*
|
|
21
|
+
* `--cmd` consumes the remaining argv as separate words and spawns them with
|
|
22
|
+
* no shell. Quoting the whole command as one string is a usage error, not
|
|
23
|
+
* friction — it is refused loudly and writes no ledger row.
|
|
20
24
|
*
|
|
21
25
|
* Story/Epic resolution order:
|
|
22
26
|
* 1. CLI flags (--story, --epic).
|
|
@@ -108,6 +112,16 @@ function classifyFrictionCategory(errorOutput) {
|
|
|
108
112
|
*/
|
|
109
113
|
const INTERCEPTOR_TIMEOUT_SIGNAL = 'SIGTERM';
|
|
110
114
|
|
|
115
|
+
/**
|
|
116
|
+
* A `maxBuffer` overflow presents identically to a timeout — `status: null`,
|
|
117
|
+
* `signal: 'SIGTERM'` — because Node kills the child the same way. The only
|
|
118
|
+
* discriminator is `result.error.code`, so a SIGTERM carrying this code is a
|
|
119
|
+
* buffer overflow and must never be reported as a timeout (Story #4915).
|
|
120
|
+
*
|
|
121
|
+
* @type {string}
|
|
122
|
+
*/
|
|
123
|
+
const OVERFLOW_ERROR_CODE = 'ENOBUFS';
|
|
124
|
+
|
|
111
125
|
/** Shell convention for "the process died by signal N": exit `128 + N`. */
|
|
112
126
|
const SIGNAL_EXIT_BASE = 128;
|
|
113
127
|
|
|
@@ -118,8 +132,10 @@ const SIGNAL_EXIT_BASE = 128;
|
|
|
118
132
|
* interceptor would report success for a command it just watched get killed
|
|
119
133
|
* (Story #4851).
|
|
120
134
|
*
|
|
121
|
-
* Which signal fired is the diagnostic value: SIGTERM points at the
|
|
122
|
-
* interceptor's own
|
|
135
|
+
* Which signal fired is the diagnostic value: SIGTERM points at one of the
|
|
136
|
+
* interceptor's own bounds, anything else at the host. A SIGTERM splits again
|
|
137
|
+
* on `error.code`: `ENOBUFS` means the output blew past `executionMaxBuffer`,
|
|
138
|
+
* anything else means `executionTimeoutMs` fired. Recording that plus the
|
|
123
139
|
* bound itself is what makes the row actionable to a consumer who cannot edit
|
|
124
140
|
* the materialized framework tree.
|
|
125
141
|
*
|
|
@@ -128,13 +144,17 @@ const SIGNAL_EXIT_BASE = 128;
|
|
|
128
144
|
* the file's per-file maintainability-delta headroom. The CLI contract is the
|
|
129
145
|
* seam the unit tests drive.
|
|
130
146
|
*
|
|
131
|
-
* @param {{signal: (string|null), error?: {message?: string}}}
|
|
132
|
-
* `spawnSync` result whose `status` is `null`.
|
|
133
|
-
* @param {
|
|
147
|
+
* @param {{signal: (string|null), error?: {message?: string, code?: string}}}
|
|
148
|
+
* result A `spawnSync` result whose `status` is `null`.
|
|
149
|
+
* @param {{executionTimeoutMs: number, executionMaxBuffer: number}} bounds The
|
|
150
|
+
* resolved interceptor bounds — the timeout in ms, the buffer in bytes.
|
|
134
151
|
* @returns {{category: string, remediation: string, details: object,
|
|
135
152
|
* preview: string, exitCode: number}}
|
|
136
153
|
*/
|
|
137
|
-
function describeAbnormalExit(
|
|
154
|
+
function describeAbnormalExit(
|
|
155
|
+
result,
|
|
156
|
+
{ executionTimeoutMs, executionMaxBuffer },
|
|
157
|
+
) {
|
|
138
158
|
const signal = typeof result.signal === 'string' ? result.signal : null;
|
|
139
159
|
if (signal === null) {
|
|
140
160
|
return {
|
|
@@ -150,15 +170,42 @@ function describeAbnormalExit(result, executionTimeoutMs) {
|
|
|
150
170
|
};
|
|
151
171
|
}
|
|
152
172
|
|
|
153
|
-
const
|
|
154
|
-
const
|
|
173
|
+
const sentByInterceptor = signal === INTERCEPTOR_TIMEOUT_SIGNAL;
|
|
174
|
+
const overflowed =
|
|
175
|
+
sentByInterceptor && result.error?.code === OVERFLOW_ERROR_CODE;
|
|
176
|
+
let killOrigin = 'external';
|
|
177
|
+
if (overflowed) killOrigin = 'buffer-overflow';
|
|
178
|
+
else if (sentByInterceptor) killOrigin = 'interceptor-timeout';
|
|
179
|
+
|
|
180
|
+
const shapes = {
|
|
181
|
+
'buffer-overflow': {
|
|
182
|
+
category: 'Tool Limitation',
|
|
183
|
+
remediation: ` - ${signal} was sent because the command's output overflowed the interceptor's executionMaxBuffer bound (${executionMaxBuffer} bytes / 10 MiB) — the executionTimeoutMs bound (${executionTimeoutMs}ms) did not fire. Do NOT split the command into smaller steps: quieten or redirect its output, or raise the buffer bound.`,
|
|
184
|
+
extraDetails: { executionMaxBuffer },
|
|
185
|
+
},
|
|
186
|
+
'interceptor-timeout': {
|
|
187
|
+
category: 'Execution Timeout',
|
|
188
|
+
remediation: ` - ${signal} matches the interceptor's own executionTimeoutMs bound (${executionTimeoutMs}ms), so the command was almost certainly cut off rather than broken. Split it into smaller steps, or raise the bound.`,
|
|
189
|
+
extraDetails: {},
|
|
190
|
+
},
|
|
191
|
+
external: {
|
|
192
|
+
category: 'Execution Killed',
|
|
193
|
+
remediation: ` - ${signal} originated outside the interceptor — the executionTimeoutMs bound (${executionTimeoutMs}ms) did not fire, so suspect an OOM kill or a hard kill from the host. Reduce the command's memory footprint or give the host more headroom.`,
|
|
194
|
+
extraDetails: {},
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const shape = shapes[killOrigin];
|
|
155
199
|
const signum = osConstants.signals[signal];
|
|
156
200
|
return {
|
|
157
|
-
category:
|
|
158
|
-
remediation:
|
|
159
|
-
|
|
160
|
-
:
|
|
161
|
-
|
|
201
|
+
category: shape.category,
|
|
202
|
+
remediation: shape.remediation,
|
|
203
|
+
details: {
|
|
204
|
+
killedBySignal: signal,
|
|
205
|
+
killOrigin,
|
|
206
|
+
executionTimeoutMs,
|
|
207
|
+
...shape.extraDetails,
|
|
208
|
+
},
|
|
162
209
|
preview: `Command terminated by signal ${signal} (${killOrigin}); executionTimeoutMs=${executionTimeoutMs}.`,
|
|
163
210
|
exitCode: Number.isInteger(signum) ? SIGNAL_EXIT_BASE + signum : 1,
|
|
164
211
|
};
|
|
@@ -221,7 +268,23 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
221
268
|
|
|
222
269
|
if (cmdArgs.length === 0) {
|
|
223
270
|
throw new Error(
|
|
224
|
-
'Usage: node diagnose-friction.js [--story <STORY_ID>] [--epic <EPIC_ID>] --cmd <
|
|
271
|
+
'Usage: node diagnose-friction.js [--story <STORY_ID>] [--epic <EPIC_ID>] --cmd <cmd> <args...>',
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Story #4915 — the interceptor spawns `cmdArgs[0]` directly, with no shell.
|
|
276
|
+
// A single argument containing whitespace therefore names an executable that
|
|
277
|
+
// cannot exist, and the resulting ENOENT is a usage error in the
|
|
278
|
+
// interceptor's OWN invocation, not friction in the wrapped command. It must
|
|
279
|
+
// be reported as one and MUST NOT reach the ledger — otherwise the real
|
|
280
|
+
// friction is discarded and the roll-up eventually auto-files a framework-gap
|
|
281
|
+
// ticket about the framework's own instrumentation being misused. The
|
|
282
|
+
// discriminator is this argv shape, never the ENOENT result: a correctly
|
|
283
|
+
// split command whose binary is genuinely absent yields the identical
|
|
284
|
+
// `spawnSync` result and stays real friction.
|
|
285
|
+
if (cmdArgs.length === 1 && /\s/.test(cmdArgs[0])) {
|
|
286
|
+
throw new Error(
|
|
287
|
+
`Usage: --cmd takes the command as separate argv words, not one quoted string. Received a single quoted argument: "${cmdArgs[0]}". Drop the quotes so each word is its own argv entry — \`--cmd ${cmdArgs[0]}\`. No friction signal was recorded.`,
|
|
225
288
|
);
|
|
226
289
|
}
|
|
227
290
|
|
|
@@ -251,7 +314,10 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
251
314
|
// `result.signal`, not in the status.
|
|
252
315
|
const abnormal =
|
|
253
316
|
result.status === null
|
|
254
|
-
? describeAbnormalExit(result,
|
|
317
|
+
? describeAbnormalExit(result, {
|
|
318
|
+
executionTimeoutMs,
|
|
319
|
+
executionMaxBuffer,
|
|
320
|
+
})
|
|
255
321
|
: null;
|
|
256
322
|
// With both streams empty an abnormal termination names its signal; the
|
|
257
323
|
// `Unknown exit code` fallback is therefore reachable only with a real
|
|
@@ -343,15 +409,15 @@ runAsCli(import.meta.url, main, {
|
|
|
343
409
|
source: 'DiagnoseFriction',
|
|
344
410
|
usage: {
|
|
345
411
|
invocation:
|
|
346
|
-
'node .agents/scripts/diagnose-friction.js [--story <id>] [--epic <id>] --cmd <
|
|
412
|
+
'node .agents/scripts/diagnose-friction.js [--story <id>] [--epic <id>] --cmd <cmd> <args...>',
|
|
347
413
|
summary:
|
|
348
414
|
'Run a command through the diagnostic interceptor: stream its output, then append a local friction signal describing the failure. Never posts to the ticket.',
|
|
349
415
|
flags: [
|
|
350
416
|
['--story <id>', 'Story the friction belongs to.'],
|
|
351
417
|
['--epic <id>', 'Epic the friction belongs to.'],
|
|
352
418
|
[
|
|
353
|
-
'--cmd <
|
|
354
|
-
'The command to execute; everything after it is the argv (required).',
|
|
419
|
+
'--cmd <cmd> <args...>',
|
|
420
|
+
'The command to execute; everything after it is the argv, as separate words — never one quoted string (required).',
|
|
355
421
|
],
|
|
356
422
|
],
|
|
357
423
|
},
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine.js — assemble the `/audit-baselines` evidence envelope
|
|
3
|
+
* (Story #4902).
|
|
4
|
+
*
|
|
5
|
+
* Strictly read-only and strictly offline: it reads committed baselines, the
|
|
6
|
+
* resolved config, git history, the static import graph, and any friction
|
|
7
|
+
* ledger it finds. It never writes under `baselines/`, never refreshes a
|
|
8
|
+
* baseline, and never runs a test, coverage, or mutation suite — the whole
|
|
9
|
+
* point is that a baseline review costs a file read, not a CI run.
|
|
10
|
+
*
|
|
11
|
+
* Findings are evidence, not a verdict: assembling the envelope is success,
|
|
12
|
+
* however alarming its contents, so the CLI exits 0 whenever it got this far.
|
|
13
|
+
* Judgment belongs to the lens that reads the envelope.
|
|
14
|
+
*
|
|
15
|
+
* @module lib/audit-baselines/engine
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { mainCheckoutRoot, tempRootFrom } from '../config/temp-paths.js';
|
|
20
|
+
import { getQuality, resolveConfig } from '../config-resolver.js';
|
|
21
|
+
import { buildGateSurface } from './gate-surface.js';
|
|
22
|
+
import { buildHeadroom } from './headroom.js';
|
|
23
|
+
import { buildHotspots } from './hotspots.js';
|
|
24
|
+
import { ALL_KINDS, baselinePathFor, GATE_KINDS } from './kinds.js';
|
|
25
|
+
import { DEFAULT_TOP_N, extractOutliers } from './outliers.js';
|
|
26
|
+
import { buildTrend } from './trend.js';
|
|
27
|
+
import {
|
|
28
|
+
makeWeightResolver,
|
|
29
|
+
readCentrality,
|
|
30
|
+
readChurn,
|
|
31
|
+
readFriction,
|
|
32
|
+
} from './weights.js';
|
|
33
|
+
|
|
34
|
+
/** Envelope `kind` discriminator; matches the shipped schema's const. */
|
|
35
|
+
const ENVELOPE_KIND = 'audit-baselines-envelope';
|
|
36
|
+
|
|
37
|
+
/** Envelope schema version — bumped on any breaking shape change. */
|
|
38
|
+
const ENVELOPE_SCHEMA_VERSION = '1';
|
|
39
|
+
|
|
40
|
+
/** Cap on emitted hotspot clusters, independent of the per-gate `topN`. */
|
|
41
|
+
export const DEFAULT_HOTSPOT_LIMIT = 50;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the repository config without letting a broken `.agentrc.json`
|
|
45
|
+
* abort the run — an unreadable config still leaves the baseline files
|
|
46
|
+
* themselves readable at their default paths.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} cwd
|
|
49
|
+
* @returns {{ quality: object, configError: string | null }}
|
|
50
|
+
*/
|
|
51
|
+
function resolveQualityBlock(cwd) {
|
|
52
|
+
try {
|
|
53
|
+
return { quality: getQuality(resolveConfig({ cwd })), configError: null };
|
|
54
|
+
} catch (err) {
|
|
55
|
+
return { quality: { gates: {} }, configError: err?.message ?? String(err) };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Absolute temp root the friction ledger is searched under, anchored to the
|
|
61
|
+
* **analysed** repository rather than the process cwd. `resolvedTempRoot()`
|
|
62
|
+
* anchors to whichever checkout the current process sits in, which is the
|
|
63
|
+
* right answer for a writer and the wrong one here: an engine pointed at
|
|
64
|
+
* another repo with `--cwd` must read that repo's ledger, not this one's.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} cwd
|
|
67
|
+
* @returns {string}
|
|
68
|
+
*/
|
|
69
|
+
function tempRootFor(cwd) {
|
|
70
|
+
let relative = 'temp';
|
|
71
|
+
try {
|
|
72
|
+
relative = tempRootFrom(resolveConfig({ cwd }));
|
|
73
|
+
} catch {
|
|
74
|
+
// Unreadable config — the framework default root is still worth probing.
|
|
75
|
+
}
|
|
76
|
+
if (path.isAbsolute(relative)) return relative;
|
|
77
|
+
return path.join(mainCheckoutRoot(cwd) ?? cwd, relative);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Run the engine and return the envelope object. Pure with respect to the
|
|
82
|
+
* filesystem apart from the reads named in the module docstring — writing
|
|
83
|
+
* the result is the caller's job.
|
|
84
|
+
*
|
|
85
|
+
* @param {{
|
|
86
|
+
* cwd: string,
|
|
87
|
+
* topN?: number,
|
|
88
|
+
* hotspotLimit?: number,
|
|
89
|
+
* trendDepth?: number,
|
|
90
|
+
* now?: Date,
|
|
91
|
+
* }} args
|
|
92
|
+
* @returns {object} the `audit-baselines-envelope`
|
|
93
|
+
*/
|
|
94
|
+
export function runEngine({
|
|
95
|
+
cwd,
|
|
96
|
+
topN = DEFAULT_TOP_N,
|
|
97
|
+
hotspotLimit = DEFAULT_HOTSPOT_LIMIT,
|
|
98
|
+
trendDepth = 5,
|
|
99
|
+
now = new Date(),
|
|
100
|
+
}) {
|
|
101
|
+
const { quality, configError } = resolveQualityBlock(cwd);
|
|
102
|
+
const { entries, baselines } = buildGateSurface({ cwd, quality, now });
|
|
103
|
+
|
|
104
|
+
const outliers = ALL_KINDS.flatMap((kind) =>
|
|
105
|
+
extractOutliers({ kind, baseline: baselines.get(kind) ?? null, topN }),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const churn = readChurn({ cwd });
|
|
109
|
+
const centrality = readCentrality({ cwd });
|
|
110
|
+
const friction = readFriction({ tempRootAbs: tempRootFor(cwd) });
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
kind: ENVELOPE_KIND,
|
|
114
|
+
schemaVersion: ENVELOPE_SCHEMA_VERSION,
|
|
115
|
+
generatedAt: now.toISOString(),
|
|
116
|
+
cwd,
|
|
117
|
+
topN,
|
|
118
|
+
configError,
|
|
119
|
+
degradations: {
|
|
120
|
+
gitHistory: churn.degraded,
|
|
121
|
+
importGraph: centrality.degraded,
|
|
122
|
+
frictionLedger: friction.degraded,
|
|
123
|
+
},
|
|
124
|
+
gateSurface: entries,
|
|
125
|
+
hotspots: buildHotspots({
|
|
126
|
+
outliers,
|
|
127
|
+
weightsFor: makeWeightResolver({ churn, centrality, friction }),
|
|
128
|
+
limit: hotspotLimit,
|
|
129
|
+
}),
|
|
130
|
+
trend: buildTrend({
|
|
131
|
+
cwd,
|
|
132
|
+
kinds: ALL_KINDS,
|
|
133
|
+
pathFor: (kind) => baselinePathFor(kind, quality),
|
|
134
|
+
depth: trendDepth,
|
|
135
|
+
}),
|
|
136
|
+
headroom: buildHeadroom({ kinds: GATE_KINDS, quality, baselines }),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Condense an envelope into the pure-JSON stdout summary. Small enough to
|
|
142
|
+
* read in a terminal, and never carrying a row set.
|
|
143
|
+
*
|
|
144
|
+
* @param {object} envelope
|
|
145
|
+
* @param {string} outPath absolute path the full envelope was written to
|
|
146
|
+
* @returns {object}
|
|
147
|
+
*/
|
|
148
|
+
export function summarize(envelope, outPath) {
|
|
149
|
+
return {
|
|
150
|
+
kind: 'audit-baselines-summary',
|
|
151
|
+
schemaVersion: ENVELOPE_SCHEMA_VERSION,
|
|
152
|
+
out: outPath,
|
|
153
|
+
generatedAt: envelope.generatedAt,
|
|
154
|
+
gateSurface: {
|
|
155
|
+
total: envelope.gateSurface.length,
|
|
156
|
+
configured: envelope.gateSurface.filter((g) => g.configured).length,
|
|
157
|
+
missingBaseline: envelope.gateSurface
|
|
158
|
+
.filter((g) => !g.baselineExists)
|
|
159
|
+
.map((g) => g.kind),
|
|
160
|
+
stubs: envelope.gateSurface.filter((g) => g.stub).map((g) => g.kind),
|
|
161
|
+
deadIgnoreGlobs: envelope.gateSurface.reduce(
|
|
162
|
+
(n, g) => n + g.deadIgnoreGlobs.length,
|
|
163
|
+
0,
|
|
164
|
+
),
|
|
165
|
+
},
|
|
166
|
+
hotspots: {
|
|
167
|
+
total: envelope.hotspots.length,
|
|
168
|
+
multiGate: envelope.hotspots.filter((h) => h.gateCount > 1).length,
|
|
169
|
+
top: envelope.hotspots
|
|
170
|
+
.slice(0, 5)
|
|
171
|
+
.map((h) => ({ path: h.path, gates: h.gateKinds, rank: h.rank })),
|
|
172
|
+
},
|
|
173
|
+
trend: { kinds: envelope.trend.length },
|
|
174
|
+
headroom: { axes: envelope.headroom.length },
|
|
175
|
+
degradations: envelope.degradations,
|
|
176
|
+
};
|
|
177
|
+
}
|