mandrel 2.24.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 +19 -21
- package/.agents/agents/auditor.md +16 -19
- package/.agents/agents/story-worker.md +41 -54
- package/.agents/audit-checklists/architecture.md +5 -1
- package/.agents/audit-checklists/baselines.md +21 -0
- package/.agents/audit-checklists/quality.md +3 -0
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +2 -1
- package/.agents/docs/quality-gates.md +80 -18
- package/.agents/docs/workflows.md +4 -2
- package/.agents/instructions.md +1 -1
- package/.agents/rules/ci-remediation.md +68 -3
- package/.agents/schemas/agentrc.schema.json +6 -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 +8 -0
- package/.agents/schemas/baselines/crap.schema.json +13 -1
- package/.agents/schemas/crap-report.schema.json +37 -0
- package/.agents/schemas/model-attribution.schema.json +4 -0
- package/.agents/scripts/acceptance-eval.js +124 -15
- package/.agents/scripts/audit-baselines.js +136 -0
- package/.agents/scripts/audit-labels-bootstrap.js +16 -95
- package/.agents/scripts/audit-to-stories.js +33 -3
- 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-context-budget.js +95 -14
- 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 +121 -1
- package/.agents/scripts/check-workflow-timeouts.js +291 -0
- package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
- package/.agents/scripts/deliver-light.js +3 -4
- package/.agents/scripts/deliver-recover.js +13 -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-label-taxonomy.js +177 -0
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
- package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
- package/.agents/scripts/lib/baseline-loader.js +0 -0
- package/.agents/scripts/lib/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 +0 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
- package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
- package/.agents/scripts/lib/baselines/reader.js +10 -0
- 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 -7
- 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/config-settings-schema-delivery.js +5 -0
- package/.agents/scripts/lib/crap-engine.js +124 -27
- package/.agents/scripts/lib/crap-method-identity.js +153 -0
- package/.agents/scripts/lib/crap-utils.js +86 -13
- package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
- package/.agents/scripts/lib/env-loader.js +46 -16
- 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/findings/route-finding.js +123 -17
- package/.agents/scripts/lib/findings/severity.js +80 -2
- package/.agents/scripts/lib/git/cached-fetch.js +0 -0
- package/.agents/scripts/lib/git-utils.js +136 -80
- package/.agents/scripts/lib/import-graph.js +156 -0
- package/.agents/scripts/lib/label-constants.js +17 -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/acceptance-eval-decision.js +110 -17
- 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/ci-rerun-guard.js +548 -0
- package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
- 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/lifecycle/listeners/watcher.js +71 -20
- package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
- package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
- package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
- 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 +216 -109
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
- 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/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
- package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +125 -37
- 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/pull-request.js +129 -21
- package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
- 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/reserved-test-ids.js +77 -0
- package/.agents/scripts/lib/story-adjacency.js +3 -3
- package/.agents/scripts/lib/story-plan.js +137 -42
- package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
- package/.agents/scripts/lib/test-runner-contract.js +134 -0
- package/.agents/scripts/lib/test-tiers.js +11 -2
- package/.agents/scripts/lib/ticket-body-sections.js +1 -1
- 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/util/poll-loop.js +18 -2
- package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
- package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
- package/.agents/scripts/lib/workers/crap-worker.js +8 -13
- 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/pr-watch-with-update.js +568 -213
- package/.agents/scripts/providers/github/tickets.js +33 -10
- package/.agents/scripts/provision-git-hooks.js +85 -0
- package/.agents/scripts/quality-preview.js +137 -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 +36 -36
- package/.agents/scripts/single-story-close.js +34 -2
- package/.agents/scripts/single-story-confirm-merge.js +22 -6
- package/.agents/scripts/single-story-init.js +7 -0
- package/.agents/scripts/stories-wave-tick.js +308 -47
- package/.agents/scripts/story-plan.js +65 -9
- package/.agents/scripts/update-coverage-baseline.js +34 -4
- package/.agents/scripts/update-crap-baseline.js +42 -4
- package/.agents/scripts/update-duplication-baseline.js +209 -83
- package/.agents/scripts/update-maintainability-baseline.js +31 -4
- 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/templates/single-story-body.md +16 -8
- package/.agents/workflows/audit-architecture.md +69 -13
- package/.agents/workflows/audit-baselines.md +289 -0
- package/.agents/workflows/audit-navigability.md +5 -4
- package/.agents/workflows/audit-quality.md +26 -0
- package/.agents/workflows/audit-to-stories.md +30 -4
- package/.agents/workflows/deliver.md +92 -97
- package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
- package/.agents/workflows/helpers/audit-lens-core.md +25 -4
- 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 +95 -4
- package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
- package/.agents/workflows/helpers/deliver-story.md +104 -119
- package/.agents/workflows/helpers/plan-reference.md +49 -5
- package/.agents/workflows/memory-consolidate.md +116 -0
- package/.agents/workflows/plan.md +62 -75
- package/README.md +13 -6
- package/docs/CHANGELOG.md +93 -0
- package/lib/cli/update.js +14 -11
- package/lib/cli/version-check.js +9 -1
- 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
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
|
|
31
31
|
import crypto from 'node:crypto';
|
|
32
32
|
|
|
33
|
+
import { fingerprintSeverity } from './severity.js';
|
|
34
|
+
|
|
33
35
|
const SEP = '␟'; // unit separator — keeps fingerprint fields unambiguous
|
|
34
36
|
const MARKER = 'audit-fingerprints:';
|
|
35
37
|
const SEMANTIC_MARKER = 'audit-semantic-keys:';
|
|
@@ -65,6 +67,20 @@ function normaliseLabels(labels) {
|
|
|
65
67
|
|
|
66
68
|
/**
|
|
67
69
|
* Compute the stable identity payload for a finding.
|
|
70
|
+
*
|
|
71
|
+
* **Severity is projected, not raw (Story #4877).** The severity vocabulary was
|
|
72
|
+
* normalised onto the canonical five-level scale in the same change that wrote
|
|
73
|
+
* this comment, and severity is an identity field — so a naive
|
|
74
|
+
* `normaliseField(finding.severity)` here would have re-minted the fingerprint
|
|
75
|
+
* of every finding whose spelling the normalisation touched, silently breaking
|
|
76
|
+
* dedup against every Issue already filed. {@link fingerprintSeverity} is the
|
|
77
|
+
* projection that makes the hash **invariant** under that normalisation: it
|
|
78
|
+
* resolves aliases onto their canonical level, keeps an absent severity as the
|
|
79
|
+
* empty string (what the raw call produced), and passes an unrecognised value
|
|
80
|
+
* through verbatim. Labels are deliberately left on the raw
|
|
81
|
+
* lower-case/trim/sort path for the same reason — order- and case-insensitive
|
|
82
|
+
* already, and any further folding would move existing shas.
|
|
83
|
+
*
|
|
68
84
|
* @param {object} finding
|
|
69
85
|
* @returns {{ title: string, area: string, primaryFile: string, severity: string, labels: string }}
|
|
70
86
|
*/
|
|
@@ -73,7 +89,7 @@ function fingerprintComponents(finding) {
|
|
|
73
89
|
title: normaliseField(finding?.title),
|
|
74
90
|
area: normaliseField(finding?.area),
|
|
75
91
|
primaryFile: normaliseField(finding?.primaryFile),
|
|
76
|
-
severity:
|
|
92
|
+
severity: fingerprintSeverity(finding?.severity),
|
|
77
93
|
labels: normaliseLabels(finding?.labels),
|
|
78
94
|
};
|
|
79
95
|
}
|
|
@@ -142,20 +158,18 @@ export function semanticKeyFooter(keys) {
|
|
|
142
158
|
|
|
143
159
|
/**
|
|
144
160
|
* Extract semantic keys from an Issue body carrying the semantic-key footer.
|
|
145
|
-
*
|
|
146
|
-
*
|
|
161
|
+
* The audit filers stamp the footer via {@link semanticKeyFooter}; the
|
|
162
|
+
* confirmation path here and {@link carryProvenanceFooters} read it back.
|
|
147
163
|
*
|
|
148
164
|
* @param {string} body
|
|
149
165
|
* @returns {string[]}
|
|
150
166
|
*/
|
|
151
167
|
function parseSemanticKeyFooter(body) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
.map((s) => s.trim())
|
|
158
|
-
.filter((s) => s.length > 0);
|
|
168
|
+
return parseAllFooterValues(
|
|
169
|
+
body,
|
|
170
|
+
/<!--\s*audit-semantic-keys:\s*([^>]*?)\s*-->/g,
|
|
171
|
+
(s) => s.length > 0,
|
|
172
|
+
);
|
|
159
173
|
}
|
|
160
174
|
|
|
161
175
|
/**
|
|
@@ -190,13 +204,104 @@ export function fingerprintFooter(shas) {
|
|
|
190
204
|
* @returns {string[]}
|
|
191
205
|
*/
|
|
192
206
|
export function parseFingerprintFooter(body) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
207
|
+
return parseAllFooterValues(
|
|
208
|
+
body,
|
|
209
|
+
/<!--\s*audit-fingerprints:\s*([^>]+?)\s*-->/g,
|
|
210
|
+
(s) => SHA1_RE.test(s),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Collect the comma-separated values out of **every** occurrence of a footer
|
|
216
|
+
* marker in `text`, de-duplicated, in first-seen order.
|
|
217
|
+
*
|
|
218
|
+
* Scanning every occurrence rather than only the first matters for the
|
|
219
|
+
* provenance carry (Story #4877): the audit Single-plan seed stamps one footer
|
|
220
|
+
* pair per MVP Scope bullet, so a multi-group seed carries several. A
|
|
221
|
+
* first-match-only parse silently dropped every group but the first, which
|
|
222
|
+
* would have made the carry look wired while leaking most of the provenance.
|
|
223
|
+
* Reading all footers is also strictly more correct for issue-body confirmation
|
|
224
|
+
* — a body that accumulated two footer lines confirms against either.
|
|
225
|
+
*
|
|
226
|
+
* @param {unknown} text
|
|
227
|
+
* @param {RegExp} pattern — a global regex whose first capture group is the
|
|
228
|
+
* comma-separated value list.
|
|
229
|
+
* @param {(value: string) => boolean} isValid
|
|
230
|
+
* @returns {string[]}
|
|
231
|
+
*/
|
|
232
|
+
function parseAllFooterValues(text, pattern, isValid) {
|
|
233
|
+
if (typeof text !== 'string') return [];
|
|
234
|
+
const out = [];
|
|
235
|
+
const seen = new Set();
|
|
236
|
+
for (const match of text.matchAll(pattern)) {
|
|
237
|
+
for (const raw of match[1].split(',')) {
|
|
238
|
+
const value = raw.trim();
|
|
239
|
+
if (!isValid(value) || seen.has(value)) continue;
|
|
240
|
+
seen.add(value);
|
|
241
|
+
out.push(value);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Carry audit dedup provenance from a source document into a target body
|
|
249
|
+
* (Story #4877).
|
|
250
|
+
*
|
|
251
|
+
* The audit sweep's Single-plan path emits a `/plan` seed whose MVP Scope
|
|
252
|
+
* bullets already carry the `audit-fingerprints` / `audit-semantic-keys`
|
|
253
|
+
* footers (Story #4626). Nothing then copied them into the Story `/plan`
|
|
254
|
+
* actually persisted, so the recommended path filed Stories that the next
|
|
255
|
+
* sweep could not recognise and re-filed as new. It was left to the authoring
|
|
256
|
+
* agent to notice HTML comments in a one-pager and hand-carry them — a
|
|
257
|
+
* remembered step, which is to say no step at all.
|
|
258
|
+
*
|
|
259
|
+
* This is that carry, as a function: harvest both footers out of `from`, and
|
|
260
|
+
* append whichever provenance `into` is missing. It is deliberately:
|
|
261
|
+
*
|
|
262
|
+
* - **Additive.** Shas and keys already present in `into` are never duplicated,
|
|
263
|
+
* and a footer `into` already carries is left exactly as authored.
|
|
264
|
+
* - **Union-preserving.** When both sides carry footers the result carries the
|
|
265
|
+
* union, so a hand-authored fingerprint is not dropped in favour of the seed's.
|
|
266
|
+
* - **Idempotent.** Re-running over its own output is a no-op, so a resumed
|
|
267
|
+
* persist cannot stack footers.
|
|
268
|
+
* - **Silent on nothing-to-do.** No provenance in `from` returns `into`
|
|
269
|
+
* unchanged with `carried: false`, so a non-audit plan run is untouched.
|
|
270
|
+
*
|
|
271
|
+
* @param {{ from?: string, into?: string }} args — `from` is the provenance
|
|
272
|
+
* source (the seed markdown); `into` is the body being persisted.
|
|
273
|
+
* @returns {{ body: string, carried: boolean, fingerprints: string[], semanticKeys: string[] }}
|
|
274
|
+
* `body` is the augmented text; `fingerprints` / `semanticKeys` are the values
|
|
275
|
+
* newly carried (empty when there was nothing to carry).
|
|
276
|
+
*/
|
|
277
|
+
export function carryProvenanceFooters({ from = '', into = '' } = {}) {
|
|
278
|
+
const body = typeof into === 'string' ? into : '';
|
|
279
|
+
const source = typeof from === 'string' ? from : '';
|
|
280
|
+
|
|
281
|
+
const have = new Set(parseFingerprintFooter(body));
|
|
282
|
+
const haveKeys = new Set(parseSemanticKeyFooter(body));
|
|
283
|
+
const fingerprints = parseFingerprintFooter(source).filter(
|
|
284
|
+
(sha) => !have.has(sha),
|
|
285
|
+
);
|
|
286
|
+
const semanticKeys = parseSemanticKeyFooter(source).filter(
|
|
287
|
+
(key) => !haveKeys.has(key),
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
if (fingerprints.length === 0 && semanticKeys.length === 0) {
|
|
291
|
+
return { body, carried: false, fingerprints: [], semanticKeys: [] };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const appended = [];
|
|
295
|
+
if (fingerprints.length > 0) appended.push(fingerprintFooter(fingerprints));
|
|
296
|
+
if (semanticKeys.length > 0) appended.push(semanticKeyFooter(semanticKeys));
|
|
297
|
+
|
|
298
|
+
const separator = body.length === 0 || body.endsWith('\n') ? '' : '\n';
|
|
299
|
+
return {
|
|
300
|
+
body: `${body}${separator}\n${appended.join('\n')}\n`,
|
|
301
|
+
carried: true,
|
|
302
|
+
fingerprints,
|
|
303
|
+
semanticKeys,
|
|
304
|
+
};
|
|
200
305
|
}
|
|
201
306
|
|
|
202
307
|
/**
|
|
@@ -378,4 +483,5 @@ export const __testing = {
|
|
|
378
483
|
confirmCandidates,
|
|
379
484
|
decideFromConfirmed,
|
|
380
485
|
issueCarriesSemanticKey,
|
|
486
|
+
parseSemanticKeyFooter,
|
|
381
487
|
};
|
|
@@ -40,10 +40,38 @@ export const SEVERITIES = Object.freeze([
|
|
|
40
40
|
* malformed input — and because both the classify and promote paths share it,
|
|
41
41
|
* malformed input still fingerprints identically across the two paths.
|
|
42
42
|
*/
|
|
43
|
-
|
|
43
|
+
const DEFAULT_SEVERITY = 'info';
|
|
44
44
|
|
|
45
45
|
const SEVERITY_SET = new Set(SEVERITIES);
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Non-canonical spellings that resolve onto a canonical level (Story #4877).
|
|
49
|
+
*
|
|
50
|
+
* The vocabulary drifted because four modules each carried their own partial
|
|
51
|
+
* copy of it: `audit-to-stories/parse-audit-md.js` recognised
|
|
52
|
+
* `critical|high|medium|mod|moderate|low` and nothing else, `audit-to-stories.js`
|
|
53
|
+
* ranked `critical|high|medium|low`, and `audit-to-stories/seed-from-findings.js`
|
|
54
|
+
* ordered the same four. None of them knew `info` — the canonical floor — so an
|
|
55
|
+
* `Info` / `Informational` finding parsed to `null`, tallied as `unknown`, and
|
|
56
|
+
* was dropped by EVERY severity-filtered run (including `--severity low`).
|
|
57
|
+
* Folding the alias table in here makes this module the only place the
|
|
58
|
+
* vocabulary is written down.
|
|
59
|
+
*
|
|
60
|
+
* Keys are already lower-cased and trimmed by {@link normalizeSeverity}.
|
|
61
|
+
*
|
|
62
|
+
* @type {Readonly<Record<string, string>>}
|
|
63
|
+
*/
|
|
64
|
+
const SEVERITY_ALIASES = Object.freeze({
|
|
65
|
+
blocker: 'critical',
|
|
66
|
+
major: 'high',
|
|
67
|
+
mod: 'medium',
|
|
68
|
+
moderate: 'medium',
|
|
69
|
+
minor: 'low',
|
|
70
|
+
informational: 'info',
|
|
71
|
+
nit: 'info',
|
|
72
|
+
trivial: 'info',
|
|
73
|
+
});
|
|
74
|
+
|
|
47
75
|
/**
|
|
48
76
|
* Numeric rank for "highest severity wins" comparisons, derived from the
|
|
49
77
|
* canonical order so the ranking has exactly one source. `critical` is the
|
|
@@ -74,7 +102,44 @@ export const SEVERITY_RANK = Object.freeze(
|
|
|
74
102
|
export function normalizeSeverity(value, fallback = DEFAULT_SEVERITY) {
|
|
75
103
|
if (typeof value !== 'string') return fallback;
|
|
76
104
|
const normalized = value.trim().toLowerCase();
|
|
77
|
-
|
|
105
|
+
if (SEVERITY_SET.has(normalized)) return normalized;
|
|
106
|
+
return SEVERITY_ALIASES[normalized] ?? fallback;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The **identity projection** of a severity, for use inside a finding
|
|
111
|
+
* fingerprint — and nowhere else (Story #4877).
|
|
112
|
+
*
|
|
113
|
+
* `severity` is a `fingerprintFinding` identity field
|
|
114
|
+
* (`route-finding.js`), so whatever this returns is folded into the sha that
|
|
115
|
+
* deduplicates findings against already-filed Issues. That makes the severity
|
|
116
|
+
* pipeline and the severity *identity* two different jobs with two different
|
|
117
|
+
* failure modes:
|
|
118
|
+
*
|
|
119
|
+
* - {@link normalizeSeverity} resolves an absent or unrecognised severity to
|
|
120
|
+
* `info` so downstream filtering and tallies have a level to work with.
|
|
121
|
+
* - This function must NOT. Folding `info` in where the previous
|
|
122
|
+
* implementation folded the empty string would re-mint the fingerprint of
|
|
123
|
+
* every finding that carries no severity, silently breaking dedup for all of
|
|
124
|
+
* them. An absent severity therefore stays the empty string, exactly as the
|
|
125
|
+
* raw `String(value).toLowerCase().trim()` it replaces produced.
|
|
126
|
+
*
|
|
127
|
+
* What it *does* change is alias resolution: `Informational` and `info` project
|
|
128
|
+
* onto the same `info`, so the fingerprint is **invariant** under the
|
|
129
|
+
* normalization this Story introduces — a finding hashes the same whether it is
|
|
130
|
+
* fingerprinted before or after {@link normalizeSeverity} has run over it. An
|
|
131
|
+
* unrecognised non-empty value is passed through verbatim rather than collapsed,
|
|
132
|
+
* again so no already-filed fingerprint moves.
|
|
133
|
+
*
|
|
134
|
+
* @param {unknown} value — the raw severity field off a finding.
|
|
135
|
+
* @returns {string} a canonical level, the empty string when absent, or the
|
|
136
|
+
* lower-cased raw value when it is neither canonical nor a known alias.
|
|
137
|
+
*/
|
|
138
|
+
export function fingerprintSeverity(value) {
|
|
139
|
+
if (typeof value !== 'string') return '';
|
|
140
|
+
const raw = value.trim().toLowerCase();
|
|
141
|
+
if (raw.length === 0) return '';
|
|
142
|
+
return normalizeSeverity(raw, raw);
|
|
78
143
|
}
|
|
79
144
|
|
|
80
145
|
/**
|
|
@@ -99,3 +164,16 @@ export function highestSeverity(values) {
|
|
|
99
164
|
}
|
|
100
165
|
return best;
|
|
101
166
|
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Internals reached by the unit tests only. The floor and the alias table are
|
|
170
|
+
* consumed exclusively by this module's own exported functions, so they are
|
|
171
|
+
* not public API — but the alias table carries a structural invariant (no
|
|
172
|
+
* alias shadows a canonical level, every target IS one) that cannot be proven
|
|
173
|
+
* through `normalizeSeverity` alone, since that function returns a canonical
|
|
174
|
+
* value by construction.
|
|
175
|
+
*/
|
|
176
|
+
export const __testing = {
|
|
177
|
+
DEFAULT_SEVERITY,
|
|
178
|
+
SEVERITY_ALIASES,
|
|
179
|
+
};
|
|
Binary file
|
|
@@ -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
|
/**
|