mandrel 2.41.0 → 2.43.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/story-worker.md +24 -14
- package/.agents/docs/agentrc-reference.json +11 -2
- package/.agents/docs/configuration.md +9 -3
- package/.agents/docs/workflows.md +1 -1
- package/.agents/schemas/agentrc.schema.json +37 -3
- package/.agents/schemas/validation-evidence.schema.json +3 -1
- package/.agents/scripts/acceptance-eval.js +68 -3
- package/.agents/scripts/coverage-capture.js +25 -8
- package/.agents/scripts/lib/baselines/crap-preview-incremental.js +7 -2
- package/.agents/scripts/lib/baselines/git-base.js +74 -38
- package/.agents/scripts/lib/close-validation/gates.js +153 -25
- package/.agents/scripts/lib/close-validation/process.js +30 -1
- package/.agents/scripts/lib/close-validation/runner.js +5 -0
- package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +33 -12
- package/.agents/scripts/lib/config/quality.js +36 -21
- package/.agents/scripts/lib/config-settings-schema-delivery.js +6 -0
- package/.agents/scripts/lib/config-settings-schema.js +29 -1
- package/.agents/scripts/lib/coverage-capture-incremental.js +12 -6
- package/.agents/scripts/lib/crap-baseline-join.js +11 -7
- package/.agents/scripts/lib/full-suite-lock.js +311 -0
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +11 -104
- package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +320 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
- package/.agents/scripts/lib/orchestration/plan-context.js +4 -0
- package/.agents/scripts/lib/orchestration/planning/authoring-context.js +9 -1
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +159 -55
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +83 -4
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +39 -7
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +70 -18
- package/.agents/scripts/lib/orchestration/verify-credit.js +207 -0
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +24 -0
- package/.agents/workflows/helpers/acceptance-self-eval.md +12 -0
- package/.agents/workflows/helpers/deliver-digest.md +31 -10
- package/.agents/workflows/helpers/deliver-story-reference.md +50 -30
- package/.agents/workflows/helpers/deliver-story.md +23 -21
- package/.agents/workflows/memory-consolidate.md +18 -6
- package/docs/CHANGELOG.md +25 -0
- package/package.json +1 -1
|
@@ -90,14 +90,25 @@ Do **not** re-read every file in `project.docsContextFiles`. Read the
|
|
|
90
90
|
at the line numbers it names. A null `docsDigestPath` means no docs
|
|
91
91
|
mandate — read a full doc only when the Story's context points at one.
|
|
92
92
|
|
|
93
|
-
## Close gates —
|
|
93
|
+
## Close gates — one credited run, no ad-hoc stamping
|
|
94
94
|
|
|
95
95
|
`single-story-close.js` runs the canonical close-validation chain
|
|
96
|
-
(**typecheck, lint, test, format, maintainability, coverage, crap**)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
96
|
+
(**typecheck, lint, test, format, maintainability, coverage, crap**) and is
|
|
97
|
+
the authoritative gate — do not pre-run the chain. The **one** exception is
|
|
98
|
+
the full suite: run it exactly once, after the self-eval loop's last fix
|
|
99
|
+
commit and immediately before the push, in the shape close credits. A bare
|
|
100
|
+
`npm test` / `pnpm run test` deposits **no** credit:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
# CRAP gate on (default) + a `test:coverage` script:
|
|
104
|
+
node <main-repo>/.agents/scripts/coverage-capture.js --cwd <workCwd>
|
|
105
|
+
# otherwise — <workCwd> ABSOLUTE, runner exactly `npm test`:
|
|
106
|
+
node <main-repo>/.agents/scripts/evidence-gate.js --standalone \
|
|
107
|
+
--scope-id <storyId> --gate test --worktree <workCwd> -- npm test
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Sharing `lint` / `typecheck` evidence with close via `evidence-gate.js` is
|
|
111
|
+
fine; never stamp coverage / CRAP fresh any other way.
|
|
101
112
|
|
|
102
113
|
Before trusting a gate's output — or diagnosing a red one — read
|
|
103
114
|
[`known-tooling-behavior.md`](../rules/known-tooling-behavior.md): measured
|
|
@@ -138,14 +149,13 @@ You do **not** run close. Push `story-<storyId>` to `origin` — confirming
|
|
|
138
149
|
the remote ref moved — and return. The dispatching orchestrator runs
|
|
139
150
|
`single-story-close.js` in its own session, serialized against your
|
|
140
151
|
siblings. Do not open the PR, do not flip `agent::done`, and do not spawn
|
|
141
|
-
a child to close on your behalf.
|
|
152
|
+
a child to close on your behalf. If the push itself fails, take the blocked
|
|
153
|
+
path above rather than returning a hand-off you cannot back.
|
|
142
154
|
|
|
143
155
|
## Return contract — the hand-off report
|
|
144
156
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
makes an unlanded Story look landed. If the push itself fails, take the
|
|
151
|
-
blocked path above rather than returning a hand-off you cannot back.
|
|
157
|
+
A short, literal hand-off your caller can act on: Story id, `workCwd`,
|
|
158
|
+
branch, pushed head SHA, self-eval verdict, `verify[]` evidence. Say plainly
|
|
159
|
+
that the branch is pushed and unclosed. Never hand-compose a terminal
|
|
160
|
+
envelope — that document belongs to close, and inventing one makes an
|
|
161
|
+
unlanded Story look landed.
|
|
@@ -72,6 +72,10 @@
|
|
|
72
72
|
"Monorepo-wide AST or text replacements touching overlapping files in parallel (catastrophic merge-conflict risk across concurrent agents).",
|
|
73
73
|
"Schema migrations that rewrite existing rows or drop columns without a backfill or rollback plan."
|
|
74
74
|
],
|
|
75
|
+
"memoryPool": {
|
|
76
|
+
"staleAfterDays": 30,
|
|
77
|
+
"growthDelta": 25
|
|
78
|
+
},
|
|
75
79
|
"failOnSharedEditors": false,
|
|
76
80
|
"requireExplicitCrossStoryDeps": false,
|
|
77
81
|
"crossCuttingRegistries": [
|
|
@@ -89,7 +93,8 @@
|
|
|
89
93
|
},
|
|
90
94
|
"delivery": {
|
|
91
95
|
"execution": {
|
|
92
|
-
"timeoutMs": 600000
|
|
96
|
+
"timeoutMs": 600000,
|
|
97
|
+
"fullSuiteLock": true
|
|
93
98
|
},
|
|
94
99
|
"docsFreshness": {
|
|
95
100
|
"paths": ["README.md"]
|
|
@@ -188,7 +193,11 @@
|
|
|
188
193
|
},
|
|
189
194
|
"refreshTag": "baseline-refresh:",
|
|
190
195
|
"refreshTimeoutMs": 60000,
|
|
191
|
-
"ignoreGlobs": []
|
|
196
|
+
"ignoreGlobs": [],
|
|
197
|
+
"incrementalCoverage": {
|
|
198
|
+
"skipWhenUnchanged": true,
|
|
199
|
+
"baselineJoin": false
|
|
200
|
+
}
|
|
192
201
|
},
|
|
193
202
|
"maintainability": {
|
|
194
203
|
"enabled": true,
|
|
@@ -118,7 +118,7 @@ GitHub provider identity plus the remote stance the bootstrap enforces. `owner`,
|
|
|
118
118
|
|
|
119
119
|
### `planning` (optional)
|
|
120
120
|
|
|
121
|
-
Inputs to `/mandrel-plan`: risk escalation heuristics, ceremony-lite routing, and the cross-Story conflict-finding severity gates.
|
|
121
|
+
Inputs to `/mandrel-plan`: risk escalation heuristics, ceremony-lite routing, the memory-hygiene advisory thresholds, and the cross-Story conflict-finding severity gates.
|
|
122
122
|
|
|
123
123
|
| Key | Required | Type | Default | Description |
|
|
124
124
|
| --- | --- | --- | --- | --- |
|
|
@@ -126,6 +126,9 @@ Inputs to `/mandrel-plan`: risk escalation heuristics, ceremony-lite routing, an
|
|
|
126
126
|
| `complexityGate` | No | `object` | — | Shape-derived ceremony-lite complexity routing. A lite claim is validated against the authored Story shape at persist and re-derived from the Story body at dispatch; conservative (full on any doubt). Never relaxes the Story-ticket / PR-to-main / repo-gates / security-baseline non-negotiables. |
|
|
127
127
|
| `complexityGate.enabled` | No | `boolean` | — | Master switch. When false, lite routing is disabled everywhere: persist refuses lite claims and dispatch always takes the sub-agent path. Default true. |
|
|
128
128
|
| `complexityGate.maxArtifacts` | No | `integer` | — | Enumerated-artifact threshold reported by the plan-context complexity signals. An input signal for the planner verdict — carries no routing authority. Default 1. |
|
|
129
|
+
| `memoryPool` | No | `object` | — | Thresholds for the memory-hygiene advisory `/mandrel-plan` surfaces at Gate #1. Advisory only: it recommends `/memory-consolidate` and never gates, reroutes, or mutates the memory pool. |
|
|
130
|
+
| `memoryPool.staleAfterDays` | No | `integer` | `30` | Recommend a consolidation pass once the pool's stamp is older than this many days. Default 30. |
|
|
131
|
+
| `memoryPool.growthDelta` | No | `integer` | `25` | Recommend a consolidation pass once this many entries have been written since the last one. Measured against the entry count the last pass stamped, so a stamp predating that field leaves growth unmeasured and only the age threshold applies. Default 25. |
|
|
129
132
|
| `failOnSharedEditors` | No | `boolean` | `false` | When true, upgrade shared-editor conflict findings to hard errors (default false — advisory soft findings only). |
|
|
130
133
|
| `requireExplicitCrossStoryDeps` | No | `boolean` | `false` | When true, upgrade implicit cross-Story dependency findings to hard errors (default false — advisory soft findings only). |
|
|
131
134
|
| `crossCuttingRegistries` | No | `string[]` or `{ append?, prepend? }` | `["lib/orchestration/lifecycle/listeners/index.js","**/listeners/index.js","**/handlers/index.js"]` | Registry path patterns whose concurrent edits across Stories are flagged as conflicts. Defaults to the framework listener/handler index patterns when omitted. |
|
|
@@ -144,6 +147,7 @@ Everything `/mandrel-deliver` and `single-story-close` consume: execution timeou
|
|
|
144
147
|
| --- | --- | --- | --- | --- |
|
|
145
148
|
| `execution` | No | `object` | — | Wall-clock bounds on the subprocesses delivery spawns. |
|
|
146
149
|
| `execution.timeoutMs` | No | `integer` | `600000` | Per-command timeout (ms) for the long-running spawns delivery drives — the close-validation chain and the gate CLIs. |
|
|
150
|
+
| `execution.fullSuiteLock` | No | `boolean` | `true` | Serialize full-suite spawns (`npm test` / `npm run test:coverage`) behind a host-level advisory lock, so two concurrent deliveries on one checkout do not run two suites against the same cores. Best-effort: a wait that expires spawns anyway, so the lock can never fail a delivery. Set false — or export `MANDREL_FULL_SUITE_LOCK=0` for one invocation — to disable. |
|
|
147
151
|
| `docsFreshness` | No | `object` | — | Documentation-freshness scope: the files a change of consequence is expected to touch. Read by the audit-documentation lens to seed its target set; no delivery gate enforces it. |
|
|
148
152
|
| `docsFreshness.paths` | No | `array<string>` | `["README.md"]` | Repo-relative documentation paths the audit-documentation lens adds to its target set. |
|
|
149
153
|
| `tempRetention` | No | `object` | — | Story #4794. Auto-purge of spent temp artifacts once their Story lands. Classification is an allowlist: only the declared classes below are ever deleted, so operator scratch files under tempRoot are reported with their size and left alone. signals.ndjson is never purged by any path. |
|
|
@@ -210,8 +214,10 @@ Everything `/mandrel-deliver` and `single-story-close` consume: execution timeou
|
|
|
210
214
|
| `quality.gates.crap.refreshTag` | No | `string` | `"baseline-refresh:"` | Commit-subject substring that acknowledges a deliberate CRAP baseline refresh in the compared range. A range commit carrying it that also touches the baseline file demotes head-vs-base regressions; floors stay enforced. |
|
|
211
215
|
| `quality.gates.crap.refreshTimeoutMs` | No | `integer` | `60000` | Bounded timeout (ms) for `npm run crap:update` spawned by the baseline-attribution refresh path. Mirrors `coverage.timeoutMs`: a SIGKILL fired at the budget boundary maps to exit 124 so the close orchestrator can flip the Story to `agent::blocked`. Default 60000 (Story #2165). |
|
|
212
216
|
| `quality.gates.crap.ignoreGlobs` | No | `array<string>` | `[]` | Minimatch glob patterns matched against the canonicalised repo-relative path of each discovered file. Files matching any pattern are excluded from CRAP discovery before scoring. Orthogonal to `components` (grouping) — a file excluded here never appears in any component bucket. Absent or empty preserves the existing IGNORED_DIRS-only behaviour (Story #3217). |
|
|
213
|
-
| `quality.gates.crap.incrementalCoverage` | No | `object` | — |
|
|
214
|
-
| `quality.gates.crap.incrementalCoverage.
|
|
217
|
+
| `quality.gates.crap.incrementalCoverage` | No | `object` | — | The two independent full-suite economies (Story #4981, split by Story #5173). `skipWhenUnchanged` (default true) decides WHETHER to capture — no changed file under `crap.targetDirs` versus `baseRef` means no capture at all — and is a pure saving. `baselineJoin` (default false) loosens gate semantics: the CRAP join resolves a method in an untouched file from its committed baseline row instead of requiring fresh coverage for it. Neither narrows the capture run itself: a capture that does happen is the ordinary full `npm run test:coverage` (Story #5065). |
|
|
218
|
+
| `quality.gates.crap.incrementalCoverage.skipWhenUnchanged` | No | `boolean` | `true` | Skip the capture entirely when no changed file under `crap.targetDirs` versus `baseRef` was touched. The only measured saving, and gate-semantics-neutral. Defaults to true. |
|
|
219
|
+
| `quality.gates.crap.incrementalCoverage.baselineJoin` | No | `boolean` | `false` | Let the CRAP join resolve a method in a file the diff did not touch from its committed baseline row instead of requiring fresh coverage for it. A gate loosening, not a saving — defaults to false. |
|
|
220
|
+
| `quality.gates.crap.incrementalCoverage.enabled` | No | `boolean` | — | DEPRECATED alias for setting both `skipWhenUnchanged` and `baselineJoin`. Prefer the two switches: they are not equally safe, and bundling them is why the earlier default flip was reverted. Either explicit switch overrides this alias. |
|
|
215
221
|
| `quality.gates.crap.incrementalCoverage.baseRef` | No | `string` | — | Git ref the changed-file set is computed against. Omitted falls back to the gate’s own `--ref` (`main`). |
|
|
216
222
|
| `quality.gates.maintainability` | No | `object` | — | Maintainability-index ratchet. Scores per file as the average over its methods, so deleting a small high-MI method can legitimately lower a file’s score. |
|
|
217
223
|
| `quality.gates.maintainability.enabled` | No | `boolean` | `true` | When false, the checker exits 0 with a skip line and the gate is reported as `skipped`, never omitted. |
|
|
@@ -59,7 +59,7 @@ description, edit the workflow file’s front-matter and regenerate.
|
|
|
59
59
|
| `/mandrel-deliver` | Unified delivery entry point. Takes Story ids or a plain-language prompt, derives which path the work belongs on, and lands it via the single deliver-story engine — story-<id> → PR → main. |
|
|
60
60
|
| `/mandrel-plan` | Unified planning entry point. Interrogate → author → persist. Emits one Story by default; splits into N>1 only under the default-single split policy. |
|
|
61
61
|
| `/mandrel-update` | npm-era upgrade wraparound for a Mandrel consumer. Runs `npx mandrel update` (resolve newest published version → install → re-materialize `.agents/` → migrate → doctor → surface changelog) as the single mechanical step, then walks the operator through the judgment wraparound the CLI deliberately leaves unowned: reconcile `.agentrc.json`, install the stabilized quality-gate surface, refresh the harness permission allowlist, reconcile the consumer's `AGENTS.md` / runbooks against the surfaced changelog, and stage + commit the staged lockfile bump. |
|
|
62
|
-
| `/memory-consolidate` | Attended consolidation pass over this project's agent memory pool — merge duplicates, verify claims against the current tree, prune with operator confirmation, rewrite the index, and stamp the pool
|
|
62
|
+
| `/memory-consolidate` | Attended consolidation pass over this project's agent memory pool — merge duplicates, verify claims against the current tree, prune with operator confirmation, rewrite the index, and stamp the pool with the date and entry count the /mandrel-plan advisory measures its next nudge against. |
|
|
63
63
|
| `/prototype` | Operator-invoked UI prototype pass. Discovers the consumer's design-system SSOT first, then — only after the operator confirms — writes exactly one self-contained HTML file under the gitignored workspace-root temp tree, so a layout can be reviewed before its UI acceptance criteria are authored. |
|
|
64
64
|
| `/qa-assist` | Human-led QA assist loop — set up, then ride a rolling multi-observation intake session. The operator reports observations in any order; the agent enriches each (repro + root-cause file:line + coverage verdict for bugs; analysis + options + recommendation for enhancements), asks clarifying questions only when ambiguous, and appends a redacted ledger item — recording, never planning — to a persistent, resumable session under temp/qa/. Only when the operator says they are done does it review the full ledger and hand off to /mandrel-plan. |
|
|
65
65
|
| `/qa-explore` | Agent-led exploratory-QA loop — the agent Plans a surface with an explicit static-vs-drive method choice, drives it (browser MCP or static), and captures ledger items read-only, then Triages — a bounded per-surface session, HITL-gated at every phase transition, routed through the shared dedup/coverage/classification/missing-test/redaction/session core under temp/qa/ |
|
|
@@ -298,7 +298,7 @@
|
|
|
298
298
|
},
|
|
299
299
|
"planning": {
|
|
300
300
|
"type": "object",
|
|
301
|
-
"description": "Inputs to `/mandrel-plan`: risk escalation heuristics, ceremony-lite routing, and the cross-Story conflict-finding severity gates.",
|
|
301
|
+
"description": "Inputs to `/mandrel-plan`: risk escalation heuristics, ceremony-lite routing, the memory-hygiene advisory thresholds, and the cross-Story conflict-finding severity gates.",
|
|
302
302
|
"properties": {
|
|
303
303
|
"riskHeuristics": {
|
|
304
304
|
"oneOf": [
|
|
@@ -352,6 +352,25 @@
|
|
|
352
352
|
},
|
|
353
353
|
"additionalProperties": false
|
|
354
354
|
},
|
|
355
|
+
"memoryPool": {
|
|
356
|
+
"type": "object",
|
|
357
|
+
"description": "Thresholds for the memory-hygiene advisory `/mandrel-plan` surfaces at Gate #1. Advisory only: it recommends `/memory-consolidate` and never gates, reroutes, or mutates the memory pool.",
|
|
358
|
+
"properties": {
|
|
359
|
+
"staleAfterDays": {
|
|
360
|
+
"type": "integer",
|
|
361
|
+
"minimum": 1,
|
|
362
|
+
"description": "Recommend a consolidation pass once the pool's stamp is older than this many days. Default 30.",
|
|
363
|
+
"default": 30
|
|
364
|
+
},
|
|
365
|
+
"growthDelta": {
|
|
366
|
+
"type": "integer",
|
|
367
|
+
"minimum": 1,
|
|
368
|
+
"description": "Recommend a consolidation pass once this many entries have been written since the last one. Measured against the entry count the last pass stamped, so a stamp predating that field leaves growth unmeasured and only the age threshold applies. Default 25.",
|
|
369
|
+
"default": 25
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
"additionalProperties": false
|
|
373
|
+
},
|
|
355
374
|
"failOnSharedEditors": {
|
|
356
375
|
"type": "boolean",
|
|
357
376
|
"description": "When true, upgrade shared-editor conflict findings to hard errors (default false — advisory soft findings only).",
|
|
@@ -451,6 +470,11 @@
|
|
|
451
470
|
"minimum": 1,
|
|
452
471
|
"description": "Per-command timeout (ms) for the long-running spawns delivery drives — the close-validation chain and the gate CLIs.",
|
|
453
472
|
"default": 600000
|
|
473
|
+
},
|
|
474
|
+
"fullSuiteLock": {
|
|
475
|
+
"type": "boolean",
|
|
476
|
+
"description": "Serialize full-suite spawns (`npm test` / `npm run test:coverage`) behind a host-level advisory lock, so two concurrent deliveries on one checkout do not run two suites against the same cores. Best-effort: a wait that expires spawns anyway, so the lock can never fail a delivery. Set false — or export `MANDREL_FULL_SUITE_LOCK=0` for one invocation — to disable.",
|
|
477
|
+
"default": true
|
|
454
478
|
}
|
|
455
479
|
},
|
|
456
480
|
"additionalProperties": false
|
|
@@ -978,11 +1002,21 @@
|
|
|
978
1002
|
},
|
|
979
1003
|
"incrementalCoverage": {
|
|
980
1004
|
"type": "object",
|
|
981
|
-
"description": "
|
|
1005
|
+
"description": "The two independent full-suite economies (Story #4981, split by Story #5173). `skipWhenUnchanged` (default true) decides WHETHER to capture — no changed file under `crap.targetDirs` versus `baseRef` means no capture at all — and is a pure saving. `baselineJoin` (default false) loosens gate semantics: the CRAP join resolves a method in an untouched file from its committed baseline row instead of requiring fresh coverage for it. Neither narrows the capture run itself: a capture that does happen is the ordinary full `npm run test:coverage` (Story #5065).",
|
|
982
1006
|
"properties": {
|
|
1007
|
+
"skipWhenUnchanged": {
|
|
1008
|
+
"type": "boolean",
|
|
1009
|
+
"description": "Skip the capture entirely when no changed file under `crap.targetDirs` versus `baseRef` was touched. The only measured saving, and gate-semantics-neutral. Defaults to true.",
|
|
1010
|
+
"default": true
|
|
1011
|
+
},
|
|
1012
|
+
"baselineJoin": {
|
|
1013
|
+
"type": "boolean",
|
|
1014
|
+
"description": "Let the CRAP join resolve a method in a file the diff did not touch from its committed baseline row instead of requiring fresh coverage for it. A gate loosening, not a saving — defaults to false.",
|
|
1015
|
+
"default": false
|
|
1016
|
+
},
|
|
983
1017
|
"enabled": {
|
|
984
1018
|
"type": "boolean",
|
|
985
|
-
"description": "
|
|
1019
|
+
"description": "DEPRECATED alias for setting both `skipWhenUnchanged` and `baselineJoin`. Prefer the two switches: they are not equally safe, and bundling them is why the earlier default flip was reverted. Either explicit switch overrides this alias."
|
|
986
1020
|
},
|
|
987
1021
|
"baseRef": {
|
|
988
1022
|
"type": "string",
|
|
@@ -38,10 +38,12 @@
|
|
|
38
38
|
"format",
|
|
39
39
|
"coverage-capture",
|
|
40
40
|
"check-baselines",
|
|
41
|
+
"check-baselines-independent",
|
|
42
|
+
"check-baselines-coverage",
|
|
41
43
|
"check-maintainability",
|
|
42
44
|
"check-crap"
|
|
43
45
|
],
|
|
44
|
-
"description": "Stable gate identifier. Closed enum — additions require a schema bump. Must be a superset of every gate name buildDefaultGates() can emit (lib/close-validation/gates.js); tests/close-validation-gates-enum.test.js pins that gate-list ⊆ enum invariant. `coverage-capture` and `check-baselines` are the real close-validation gates (Story #4697); `check-maintainability` / `check-crap` are the retired per-kind gates (Story #2210) kept so historical evidence records still validate."
|
|
46
|
+
"description": "Stable gate identifier. Closed enum — additions require a schema bump. Must be a superset of every gate name buildDefaultGates() can emit (lib/close-validation/gates.js); tests/close-validation-gates-enum.test.js pins that gate-list ⊆ enum invariant. `coverage-capture` and `check-baselines` are the real close-validation gates (Story #4697); `check-baselines-independent` / `check-baselines-coverage` are the split pair the gate registers as when its enabled-kind set resolves (Story #5172), with `check-baselines` kept as both the unsplit fail-closed fallback and the historical name; `check-maintainability` / `check-crap` are the retired per-kind gates (Story #2210) kept so historical evidence records still validate."
|
|
45
47
|
},
|
|
46
48
|
"commitSha": {
|
|
47
49
|
"type": "string",
|
|
@@ -57,8 +57,9 @@
|
|
|
57
57
|
* --no-signal Suppress the signal emit (tests).
|
|
58
58
|
*
|
|
59
59
|
* Stdout: a single JSON envelope
|
|
60
|
-
* { storyId, epicId, decision, round, cap,
|
|
61
|
-
* metCount, unmetCriteria[], signalEmitted,
|
|
60
|
+
* { storyId, epicId, fullSuiteVerifyCommands[], decision, round, cap,
|
|
61
|
+
* capReached, totalCriteria, metCount, unmetCriteria[], signalEmitted,
|
|
62
|
+
* replay, verdictFingerprint }
|
|
62
63
|
* (`epicId` is retained as a always-null field for envelope stability.)
|
|
63
64
|
*
|
|
64
65
|
* Reading is free (Story #4874): re-invoking the gate over a verdict the
|
|
@@ -88,6 +89,10 @@ import {
|
|
|
88
89
|
decideAcceptanceEval,
|
|
89
90
|
resolveAcceptanceEvalRound,
|
|
90
91
|
} from './lib/orchestration/acceptance-eval-decision.js';
|
|
92
|
+
import {
|
|
93
|
+
FULL_SUITE_SHAPE_WARNING,
|
|
94
|
+
isFullSuiteCommand,
|
|
95
|
+
} from './lib/orchestration/verify-credit.js';
|
|
91
96
|
|
|
92
97
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
93
98
|
|
|
@@ -225,6 +230,58 @@ export function assertCriteriaCoverage(verdict, expectedCriteria) {
|
|
|
225
230
|
);
|
|
226
231
|
}
|
|
227
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Collect the distinct `verify[]` commands a verdict recorded that are
|
|
235
|
+
* themselves full-suite runs (Story #5174).
|
|
236
|
+
*
|
|
237
|
+
* The intended `verify[]` shape is scoped entries **plus** the one credited
|
|
238
|
+
* full-suite run the worker makes before the hand-off push. A full-suite
|
|
239
|
+
* command sitting in `verify[]` is the misshapen case: it either re-pays for
|
|
240
|
+
* the credited run or, worse, gets skipped as "already covered" without
|
|
241
|
+
* anyone saying so. The gate is where every round's evidence passes through,
|
|
242
|
+
* so it is where the shape is called out.
|
|
243
|
+
*
|
|
244
|
+
* Exported for tests.
|
|
245
|
+
*
|
|
246
|
+
* @param {object} verdict — schema-validated verdict.
|
|
247
|
+
* @returns {string[]} distinct offending commands, in first-seen order.
|
|
248
|
+
*/
|
|
249
|
+
export function collectFullSuiteVerifyCommands(verdict) {
|
|
250
|
+
const seen = new Set();
|
|
251
|
+
for (const criterion of verdict?.criteria ?? []) {
|
|
252
|
+
for (const evidence of criterion?.verifyEvidence ?? []) {
|
|
253
|
+
const command = evidence?.command;
|
|
254
|
+
if (typeof command === 'string' && isFullSuiteCommand(command)) {
|
|
255
|
+
seen.add(command.trim());
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return [...seen];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Surface the misshapen-`verify[]` warning, if there is one.
|
|
264
|
+
*
|
|
265
|
+
* Extracted from `runAcceptanceEvalCli` rather than inlined: the CLI shell is
|
|
266
|
+
* already the file's worst-CRAP method, and a branch added there costs more
|
|
267
|
+
* than the same branch in a small, fully-covered helper. Module-private: it is
|
|
268
|
+
* reached through the CLI shell, which is where the tests drive it.
|
|
269
|
+
*
|
|
270
|
+
* @param {object} verdict — schema-validated verdict.
|
|
271
|
+
* @param {{ warn?: Function }} logger
|
|
272
|
+
* @returns {string[]} the offending commands (empty when the shape is fine).
|
|
273
|
+
*/
|
|
274
|
+
function warnOnFullSuiteVerify(verdict, logger) {
|
|
275
|
+
const commands = collectFullSuiteVerifyCommands(verdict);
|
|
276
|
+
if (commands.length > 0) {
|
|
277
|
+
logger?.warn?.(
|
|
278
|
+
`acceptance-eval: verify[] carries full-suite command(s) ${commands.join(', ')}. ` +
|
|
279
|
+
FULL_SUITE_SHAPE_WARNING,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
return commands;
|
|
283
|
+
}
|
|
284
|
+
|
|
228
285
|
/**
|
|
229
286
|
* Compose the operator-facing envelope and emit the per-criterion signal.
|
|
230
287
|
*
|
|
@@ -304,9 +361,12 @@ export async function runAcceptanceEval(
|
|
|
304
361
|
}
|
|
305
362
|
}
|
|
306
363
|
|
|
364
|
+
const fullSuiteVerifyCommands = collectFullSuiteVerifyCommands(verdict);
|
|
365
|
+
|
|
307
366
|
const envelope = {
|
|
308
367
|
storyId: storyId ?? null,
|
|
309
368
|
epicId: null,
|
|
369
|
+
fullSuiteVerifyCommands,
|
|
310
370
|
decision: outcome.decision,
|
|
311
371
|
round: outcome.round,
|
|
312
372
|
cap: outcome.cap,
|
|
@@ -349,7 +409,7 @@ export async function runAcceptanceEval(
|
|
|
349
409
|
* resolveConfigImpl?: typeof resolveConfig,
|
|
350
410
|
* validateVerdictImpl?: typeof validateVerdict,
|
|
351
411
|
* runAcceptanceEvalImpl?: typeof runAcceptanceEval,
|
|
352
|
-
* logger?: { info: Function },
|
|
412
|
+
* logger?: { info: Function, warn?: Function },
|
|
353
413
|
* }} [deps]
|
|
354
414
|
* @returns {Promise<object>} the emitted envelope.
|
|
355
415
|
*/
|
|
@@ -414,6 +474,11 @@ export async function runAcceptanceEvalCli(
|
|
|
414
474
|
);
|
|
415
475
|
}
|
|
416
476
|
|
|
477
|
+
// Story #5174 — the shape warning is derived from the VALIDATED verdict, not
|
|
478
|
+
// from the envelope, so it fires for the real decision path and for every
|
|
479
|
+
// caller that injects its own scorer.
|
|
480
|
+
warnOnFullSuiteVerify(verdict, logger);
|
|
481
|
+
|
|
417
482
|
const config = resolveConfigImpl();
|
|
418
483
|
const { envelope, exitCode } = await runAcceptanceEvalImpl({
|
|
419
484
|
storyId,
|
|
@@ -12,8 +12,15 @@
|
|
|
12
12
|
* 3. Test freshness: content digest of `crap.targetDirs` vs. the persisted
|
|
13
13
|
* capture stamp (`coverage/.capture-stamp.json`), falling back to the
|
|
14
14
|
* artifact-mtime heuristic when no stamp exists. Exit 0 when fresh.
|
|
15
|
-
* 4. Otherwise spawn `npm run test:coverage
|
|
16
|
-
*
|
|
15
|
+
* 4. Otherwise spawn `npm run test:coverage` — serialized behind the
|
|
16
|
+
* host-level full-suite lock (Story #5173) so two concurrent runs on one
|
|
17
|
+
* checkout do not race — write a fresh capture stamp on success, and
|
|
18
|
+
* propagate the exit code.
|
|
19
|
+
*
|
|
20
|
+
* Step 3 is preceded by the changed-file skip when
|
|
21
|
+
* `delivery.quality.gates.crap.incrementalCoverage.skipWhenUnchanged` is on
|
|
22
|
+
* (the default): no changed file under `crap.targetDirs` versus `baseRef`
|
|
23
|
+
* means no capture at all.
|
|
17
24
|
*
|
|
18
25
|
* Exit codes:
|
|
19
26
|
* 0 — coverage is fresh (or capture skipped/succeeded).
|
|
@@ -34,6 +41,7 @@ import {
|
|
|
34
41
|
import { runFullScopeCapture } from './lib/coverage-capture-fullscope.js';
|
|
35
42
|
import { tryIncrementalCapture } from './lib/coverage-capture-incremental.js';
|
|
36
43
|
import { handleCoverageCaptureHelp } from './lib/coverage-capture-usage.js';
|
|
44
|
+
import { lockedCapture } from './lib/full-suite-lock.js';
|
|
37
45
|
|
|
38
46
|
import { Logger } from './lib/Logger.js';
|
|
39
47
|
import { hasNpmScript, readPackageScripts } from './lib/npm-scripts.js';
|
|
@@ -124,10 +132,19 @@ export function runCoverageCapture(argv = process.argv, deps = {}) {
|
|
|
124
132
|
return 1;
|
|
125
133
|
}
|
|
126
134
|
|
|
127
|
-
// Story #
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
135
|
+
// Story #5173 — the host-level full-suite lock. Resolved once here, where
|
|
136
|
+
// the config is already in scope, and composed over the capture runner so
|
|
137
|
+
// whichever path reaches the spawn is serialized without either of them
|
|
138
|
+
// knowing about it. `delivery.execution.fullSuiteLock: false` and
|
|
139
|
+
// `MANDREL_FULL_SUITE_LOCK=0` each disable it; both hatches live in
|
|
140
|
+
// `isFullSuiteLockEnabled`.
|
|
141
|
+
const capture = lockedCapture(runCaptureImpl, config);
|
|
142
|
+
|
|
143
|
+
// Story #4981/#5173 — the capture skip, gated by
|
|
144
|
+
// `delivery.quality.gates.crap.incrementalCoverage.skipWhenUnchanged` (on
|
|
145
|
+
// by default). `null` means "not applicable" (switched off, or a
|
|
146
|
+
// ref-resolution error) — fall through to the full-scope path below rather
|
|
147
|
+
// than silently skipping capture.
|
|
131
148
|
const incrementalResult = tryIncrementalCapture({
|
|
132
149
|
crap,
|
|
133
150
|
coverage,
|
|
@@ -135,7 +152,7 @@ export function runCoverageCapture(argv = process.argv, deps = {}) {
|
|
|
135
152
|
getChangedFilesImpl,
|
|
136
153
|
filterFilesUnderTargetsImpl,
|
|
137
154
|
isCoverageFreshImpl,
|
|
138
|
-
runCaptureImpl,
|
|
155
|
+
runCaptureImpl: capture,
|
|
139
156
|
computeContentDigestImpl,
|
|
140
157
|
writeCaptureStampImpl,
|
|
141
158
|
logger,
|
|
@@ -148,7 +165,7 @@ export function runCoverageCapture(argv = process.argv, deps = {}) {
|
|
|
148
165
|
args,
|
|
149
166
|
getChangedFilesImpl,
|
|
150
167
|
isCoverageFreshImpl,
|
|
151
|
-
runCaptureImpl,
|
|
168
|
+
runCaptureImpl: capture,
|
|
152
169
|
computeContentDigestImpl,
|
|
153
170
|
writeCaptureStampImpl,
|
|
154
171
|
logger,
|
|
@@ -14,8 +14,13 @@ import { getChangedFiles } from '../changed-files.js';
|
|
|
14
14
|
* not be resolved — a resolution failure falls back to full-scope rather
|
|
15
15
|
* than silently relaxing the gate.
|
|
16
16
|
*
|
|
17
|
+
* Gated by `incrementalCoverage.baselineJoin` alone (Story #5173). It MUST
|
|
18
|
+
* NOT consult `skipWhenUnchanged`: the join loosens what the gate demands,
|
|
19
|
+
* while the skip only decides whether a capture runs, so a consumer that took
|
|
20
|
+
* the saving has not thereby asked for the loosening.
|
|
21
|
+
*
|
|
17
22
|
* @param {{
|
|
18
|
-
* crap: { incrementalCoverage?: {
|
|
23
|
+
* crap: { incrementalCoverage?: { baselineJoin?: boolean, baseRef?: string } },
|
|
19
24
|
* diffRef: string | null,
|
|
20
25
|
* cwd: string,
|
|
21
26
|
* baselineRows: Array<object>,
|
|
@@ -30,7 +35,7 @@ export function resolveCrapPreviewIncremental({
|
|
|
30
35
|
baselineRows,
|
|
31
36
|
getChangedFilesImpl = getChangedFiles,
|
|
32
37
|
}) {
|
|
33
|
-
if (crap.incrementalCoverage?.
|
|
38
|
+
if (crap.incrementalCoverage?.baselineJoin !== true) return null;
|
|
34
39
|
const baseRef = crap.incrementalCoverage.baseRef || diffRef || 'main';
|
|
35
40
|
try {
|
|
36
41
|
const touchedFiles = new Set(getChangedFilesImpl({ ref: baseRef, cwd }));
|
|
@@ -145,6 +145,32 @@ function cleanGitEnv() {
|
|
|
145
145
|
);
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/** Both reads reject the same way on a missing or non-string argument. */
|
|
149
|
+
function isNonEmptyString(value) {
|
|
150
|
+
return typeof value === 'string' && value.length > 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Both reads coerce a missing stdout to the empty string rather than throwing. */
|
|
154
|
+
function stdoutOf(result) {
|
|
155
|
+
return typeof result?.stdout === 'string' ? result.stdout : '';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Run a git subprocess through the shared child surface. Both reads below need
|
|
160
|
+
* the identical shape — injected runner, resolved cwd, `GIT_*`-scrubbed env,
|
|
161
|
+
* argv tokens rather than a shell string — so it lives here once.
|
|
162
|
+
*
|
|
163
|
+
* @param {string[]} args
|
|
164
|
+
* @param {{ cwd?: string }} opts
|
|
165
|
+
*/
|
|
166
|
+
function runGit(args, opts) {
|
|
167
|
+
return spawnChild('git', args, {
|
|
168
|
+
run: _spawnRunner,
|
|
169
|
+
cwd: opts.cwd ?? process.cwd(),
|
|
170
|
+
env: cleanGitEnv(),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
148
174
|
/**
|
|
149
175
|
* Read `<file>` at the given git ref. Returns the file contents as a
|
|
150
176
|
* UTF-8 string when the path exists, or `null` when git reports the
|
|
@@ -161,10 +187,10 @@ function cleanGitEnv() {
|
|
|
161
187
|
* @returns {string | null}
|
|
162
188
|
*/
|
|
163
189
|
export function readBaseFromGit(ref, file, opts = {}) {
|
|
164
|
-
if (
|
|
190
|
+
if (!isNonEmptyString(ref)) {
|
|
165
191
|
throw new TypeError('readBaseFromGit: ref must be a non-empty string');
|
|
166
192
|
}
|
|
167
|
-
if (
|
|
193
|
+
if (!isNonEmptyString(file)) {
|
|
168
194
|
throw new TypeError('readBaseFromGit: file must be a non-empty string');
|
|
169
195
|
}
|
|
170
196
|
|
|
@@ -176,19 +202,14 @@ export function readBaseFromGit(ref, file, opts = {}) {
|
|
|
176
202
|
return cached;
|
|
177
203
|
}
|
|
178
204
|
|
|
179
|
-
const cwd = opts.cwd ?? process.cwd();
|
|
180
205
|
const spec = `${ref}:${file}`;
|
|
181
|
-
const result =
|
|
182
|
-
run: _spawnRunner,
|
|
183
|
-
cwd,
|
|
184
|
-
env: cleanGitEnv(),
|
|
185
|
-
});
|
|
206
|
+
const result = runGit(['show', spec], opts);
|
|
186
207
|
|
|
187
208
|
// `child_process.spawnSync` returns `status: null` when the child died
|
|
188
209
|
// by signal. Treat that as a hard failure rather than "no file".
|
|
189
210
|
const status = result.status;
|
|
190
211
|
if (status === 0) {
|
|
191
|
-
const out =
|
|
212
|
+
const out = stdoutOf(result);
|
|
192
213
|
touch(key, out);
|
|
193
214
|
return out;
|
|
194
215
|
}
|
|
@@ -212,53 +233,68 @@ export function readBaseFromGit(ref, file, opts = {}) {
|
|
|
212
233
|
}
|
|
213
234
|
|
|
214
235
|
/**
|
|
215
|
-
* Read the
|
|
216
|
-
*
|
|
236
|
+
* Read the commits, within the range `<baseRef>..HEAD`, that touched `<file>`
|
|
237
|
+
* (Story #4731; widened to carry SHAs by Story #5179). Powers the baseline
|
|
217
238
|
* refresh-acknowledgment trigger: a `baseline-refresh:`-tagged commit in the
|
|
218
|
-
* compared range that touches
|
|
239
|
+
* compared range that touches a kind's baseline acknowledges that run's
|
|
219
240
|
* head-vs-base regressions.
|
|
220
241
|
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
242
|
+
* Story #5179 — this returned SUBJECTS ONLY, and that omission was load-bearing
|
|
243
|
+
* in the wrong direction. With no commit handle, the acknowledgment could not
|
|
244
|
+
* ask what the tagged commit actually rewrote, so it could only be a whole-run
|
|
245
|
+
* blanket: every regression in the range demoted, including rows that commit
|
|
246
|
+
* never touched and drift that landed after it. Carrying `sha` alongside
|
|
247
|
+
* `subject` is what lets the caller read the baseline blob AT the refresh
|
|
248
|
+
* commit and scope the acknowledgment to it.
|
|
249
|
+
*
|
|
250
|
+
* Restricting the log to `-- <file>` means the returned commits already satisfy
|
|
251
|
+
* the "commit whose diff touches the baseline file" half of the predicate; the
|
|
252
|
+
* caller only has to match the tag substring against each subject. Runs through
|
|
253
|
+
* the same shared `spawnChild` surface and injected runner as
|
|
254
|
+
* `readBaseFromGit`, so it inherits the identical env scrubbing (drop inherited
|
|
255
|
+
* `GIT_*`), the same stdout ceiling, and the spawn-not-exec security posture
|
|
256
|
+
* (argv tokens, `shell: false`).
|
|
257
|
+
*
|
|
258
|
+
* A NUL separator between the two fields is what makes this parse safely: a
|
|
259
|
+
* commit subject may contain anything except a newline, so any printable
|
|
260
|
+
* delimiter could appear inside one, but neither field may contain NUL.
|
|
228
261
|
*
|
|
229
|
-
* Returns an empty array whenever the range cannot be walked (missing base
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
262
|
+
* Returns an empty array whenever the range cannot be walked (missing base ref,
|
|
263
|
+
* git failure, empty range) — the acknowledgment path treats "no matching
|
|
264
|
+
* commit" and "could not determine" identically: the ratchet stays at full
|
|
265
|
+
* strength. Not cached: the range result depends on live HEAD, which the
|
|
266
|
+
* `(ref, file)` LRU key does not capture.
|
|
234
267
|
*
|
|
235
268
|
* @param {string} baseRef - Range base (e.g. `main`, `origin/main`, a SHA).
|
|
236
269
|
* @param {string} file - Repo-relative path to the baseline file.
|
|
237
270
|
* @param {{ cwd?: string }} [opts]
|
|
238
|
-
* @returns {string[]}
|
|
271
|
+
* @returns {{ sha: string, subject: string }[]} newest first; `[]` on any failure.
|
|
239
272
|
*/
|
|
240
|
-
export function
|
|
241
|
-
if (
|
|
242
|
-
if (typeof file !== 'string' || file.length === 0) return [];
|
|
273
|
+
export function readRangeCommitsTouchingFile(baseRef, file, opts = {}) {
|
|
274
|
+
if (!isNonEmptyString(baseRef) || !isNonEmptyString(file)) return [];
|
|
243
275
|
|
|
244
|
-
const cwd = opts.cwd ?? process.cwd();
|
|
245
276
|
let result;
|
|
246
277
|
try {
|
|
247
|
-
result =
|
|
248
|
-
'
|
|
249
|
-
|
|
250
|
-
{ run: _spawnRunner, cwd, env: cleanGitEnv() },
|
|
278
|
+
result = runGit(
|
|
279
|
+
['log', `${baseRef}..HEAD`, '--format=%H%x00%s', '--', file],
|
|
280
|
+
opts,
|
|
251
281
|
);
|
|
252
282
|
} catch {
|
|
253
283
|
return [];
|
|
254
284
|
}
|
|
255
285
|
|
|
256
|
-
if (
|
|
257
|
-
|
|
258
|
-
return out
|
|
286
|
+
if (result?.status !== 0) return [];
|
|
287
|
+
return stdoutOf(result)
|
|
259
288
|
.split('\n')
|
|
260
|
-
.map((line) =>
|
|
261
|
-
|
|
289
|
+
.map((line) => {
|
|
290
|
+
const sep = line.indexOf('\u0000');
|
|
291
|
+
if (sep === -1) return null;
|
|
292
|
+
return {
|
|
293
|
+
sha: line.slice(0, sep).trim(),
|
|
294
|
+
subject: line.slice(sep + 1).trim(),
|
|
295
|
+
};
|
|
296
|
+
})
|
|
297
|
+
.filter((commit) => commit !== null && commit.sha.length > 0);
|
|
262
298
|
}
|
|
263
299
|
|
|
264
300
|
/**
|