mandrel 2.15.0 → 2.17.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/docs/configuration.md +1 -0
- package/.agents/docs/quality-gates.md +137 -0
- package/.agents/docs/workflows.md +2 -1
- package/.agents/schemas/agentrc.schema.json +6 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +4 -0
- package/.agents/scripts/acceptance-eval.js +52 -12
- package/.agents/scripts/audit-to-stories.js +92 -25
- package/.agents/scripts/boot-sweep.js +28 -6
- package/.agents/scripts/check-baseline-drift.js +138 -0
- package/.agents/scripts/coverage-capture.js +74 -25
- package/.agents/scripts/deliver-light.js +31 -3
- package/.agents/scripts/deliver-recover.js +45 -18
- package/.agents/scripts/drain-pending-cleanup.js +67 -23
- package/.agents/scripts/generate-lens-checklists.js +81 -30
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
- package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
- package/.agents/scripts/lib/baselines/envelope.js +7 -0
- package/.agents/scripts/lib/baselines/kernel.js +31 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
- package/.agents/scripts/lib/baselines/reader.js +12 -1
- package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
- package/.agents/scripts/lib/baselines/writer.js +10 -0
- package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
- package/.agents/scripts/lib/cli-utils.js +48 -13
- package/.agents/scripts/lib/close-validation/process.js +61 -15
- package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
- package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
- package/.agents/scripts/lib/close-validation/runner.js +68 -0
- package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
- package/.agents/scripts/lib/config/quality.js +40 -0
- package/.agents/scripts/lib/coverage-utils.js +92 -9
- package/.agents/scripts/lib/crap-engine.js +113 -23
- package/.agents/scripts/lib/crap-utils.js +159 -93
- package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
- package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
- package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +307 -89
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
- package/.agents/scripts/lib/orchestration/light-suitability.js +31 -9
- package/.agents/scripts/lib/orchestration/plan-context.js +190 -10
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +87 -13
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +38 -15
- package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
- package/.agents/scripts/lib/stdio-flush.js +71 -0
- package/.agents/scripts/lib/transpile.js +133 -6
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
- package/.agents/scripts/lib/workers/crap-worker.js +49 -76
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
- package/.agents/scripts/nav-registry-diff.js +30 -8
- package/.agents/scripts/plan-context.js +4 -1
- package/.agents/scripts/plan-run-epilogue.js +27 -11
- package/.agents/scripts/resolve-doc-tiers.js +18 -8
- package/.agents/scripts/single-story-close.js +9 -92
- package/.agents/scripts/update-crap-baseline.js +13 -0
- package/.agents/workflows/helpers/deliver-light.md +34 -8
- package/.agents/workflows/helpers/plan-reference.md +27 -6
- package/.agents/workflows/plan.md +4 -2
- package/.agents/workflows/prototype.md +104 -0
- package/README.md +14 -6
- package/docs/CHANGELOG.md +41 -0
- package/lib/cli/version-helpers.js +7 -0
- package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
- package/package.json +5 -1
|
@@ -174,6 +174,7 @@ top-level keys are validation errors.
|
|
|
174
174
|
| `quality.gates.crap.targetDirs` | No | `string[]` or `{ append?, prepend? }` | — | Directories whose JS sources the CRAP gate scores. Mandrel ships a `src/`-centric default; projects whose executable code lives elsewhere (e.g. this repo's `.agents/scripts/`) override here. The framework default is intentionally not auto-discovered, so an override is the explicit, auditable signal. |
|
|
175
175
|
| `quality.gates.crap.newMethodCeiling` | No | `integer` | — | — |
|
|
176
176
|
| `quality.gates.crap.requireCoverage` | No | `boolean` | — | — |
|
|
177
|
+
| `quality.gates.crap.minMethodResolutionRate` | No | `number` | — | Fail-closed floor on the per-method coverage JOIN (Story #4775): the fraction of methods that must resolve a coverage entry, counted only over files that HAVE one, before `update-crap-baseline.js` will persist. A broken join is silent by construction — unresolved methods are simply absent — so the updater refuses rather than writing a thin baseline and logging it as success. Not enforced below 25 joinable methods, where a diff-scoped run's rate is noise. Default 0.75; a healthy repo resolves ~98%. |
|
|
177
178
|
| `quality.gates.crap.friction` | No | `object` | — | Nested configuration block. |
|
|
178
179
|
| `quality.gates.crap.friction.markerKey` | No | `string` | — | — |
|
|
179
180
|
| `quality.gates.crap.refreshTag` | No | `string` | — | — |
|
|
@@ -361,6 +361,143 @@ There is no CI guardrail rejecting unlabeled baseline edits; the convention is
|
|
|
361
361
|
preserved so the operator can grep refresh commits in a PR diff, but
|
|
362
362
|
self-policing is the operator's job during `/deliver`'s watch loop.
|
|
363
363
|
|
|
364
|
+
### The per-method coverage join (Story #4775)
|
|
365
|
+
|
|
366
|
+
CRAP is the only gate that joins two independently-produced artifacts: the
|
|
367
|
+
per-method complexity escomplex derives from the source, and the per-function
|
|
368
|
+
coverage istanbul derives from the test run. Everything below exists because
|
|
369
|
+
that join is silent when it fails — an unresolved method is simply absent from
|
|
370
|
+
the baseline, so a broken join looks exactly like a small repo.
|
|
371
|
+
|
|
372
|
+
**One coordinate system.** For a TS/TSX source, escomplex parses the
|
|
373
|
+
*transpiled* output and reports each method's `lineStart` in transpiled
|
|
374
|
+
coordinates, while `coverage-final.json` is keyed against the *original*
|
|
375
|
+
source. The scorer therefore asks `transpileIfNeeded` for a source map
|
|
376
|
+
(`{ withLineMap: true }`, backed by Node's built-in `SourceMap` — no extra
|
|
377
|
+
runtime dependency) and remaps each method start into original coordinates
|
|
378
|
+
before the lookup. JavaScript is a passthrough: its coordinates already are
|
|
379
|
+
original coordinates, so no map is computed and nothing changes. The
|
|
380
|
+
maintainability path never requests a map, and the emitted code is
|
|
381
|
+
byte-identical either way, so MI scores are unaffected.
|
|
382
|
+
|
|
383
|
+
**Tolerant matching.** Remapping alone is insufficient: escomplex's method
|
|
384
|
+
start and istanbul's `decl.start.line` disagree by a line when a decorator, a
|
|
385
|
+
leading `export`, or a wrapped parameter list sits between them. The lookup is
|
|
386
|
+
exact-line first (so every already-resolving row keeps its exact prior value),
|
|
387
|
+
then innermost containment, then nearest declaration within ±1.
|
|
388
|
+
|
|
389
|
+
**`requireCoverage: false` means score it.** A method with no coverage entry
|
|
390
|
+
scores as 0% covered — `crap = c² + c`, the formula's own treatment of
|
|
391
|
+
untested code — and lands in the baseline. It used to be dropped individually
|
|
392
|
+
regardless of the flag, which made the flag a no-op for baseline population.
|
|
393
|
+
`requireCoverage: true` still skips and counts it.
|
|
394
|
+
|
|
395
|
+
**The updater fails closed on a thin result.** `update-crap-baseline.js`
|
|
396
|
+
reports `resolved/joinable` over files that *have* coverage and refuses to
|
|
397
|
+
persist below `delivery.quality.gates.crap.minMethodResolutionRate` (default
|
|
398
|
+
`0.75`), naming the worst unresolved files. The floor is not enforced below 25
|
|
399
|
+
joinable methods, where a diff-scoped run's rate is noise. A healthy repo
|
|
400
|
+
resolves ~98%; the 4–6% signature of a coordinate-system mismatch is far below
|
|
401
|
+
the floor.
|
|
402
|
+
|
|
403
|
+
**Re-derive your floors after adopting this — but do not re-pin `max`.** A
|
|
404
|
+
`crap.floors` `max` ceiling pinned before the fix was computed over the
|
|
405
|
+
minority of methods the join could see, so it is not a real ceiling — it is an
|
|
406
|
+
artefact. The honest scan sees far more (in this repository, 2215 → 4058
|
|
407
|
+
visible methods), and the newly-visible methods include the worst ones.
|
|
408
|
+
|
|
409
|
+
The tempting response — raise `*.max` until the gate is green again — produces a
|
|
410
|
+
floor fitted to the tree's current high-water mark, which **can never fire**:
|
|
411
|
+
nothing breaches it until something becomes worse than the worst method already
|
|
412
|
+
present. Prefer a *count* budget over a max ceiling:
|
|
413
|
+
|
|
414
|
+
```jsonc
|
|
415
|
+
"crap": {
|
|
416
|
+
// Number of methods allowed to score above 20. Ratchet this down; it
|
|
417
|
+
// breaches the moment the count grows, which a `max` ceiling cannot do.
|
|
418
|
+
"floors": { "*": { "methodsAbove20": 40 } }
|
|
419
|
+
}
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
`max` remains available and is the right instrument when you genuinely have a
|
|
423
|
+
hard per-method ceiling to hold. It is the wrong instrument for absorbing
|
|
424
|
+
pre-existing debt.
|
|
425
|
+
|
|
426
|
+
Note that neither choice is what protects new code. `floors` is an absolute
|
|
427
|
+
tree-wide comparison against the rollup; the forward pressure lives in
|
|
428
|
+
`newMethodCeiling` (a *new* method scoring above it fails, default 30) and in
|
|
429
|
+
`compareCrap`'s ratchet (an *existing* method fails when it regresses against
|
|
430
|
+
its own baseline row). Both are unaffected by how much old debt the gate can
|
|
431
|
+
now see, and neither consults `floors`.
|
|
432
|
+
|
|
433
|
+
**Old baselines are invalidated explicitly.** Rows scored by the previous join
|
|
434
|
+
are not comparable to rows scored by this one, and neither `kernelVersion` nor
|
|
435
|
+
`escomplexVersion` moves (both track the same upstream package). The envelope
|
|
436
|
+
therefore carries a `scoringSemantics` stamp; `check-baselines` fails closed on
|
|
437
|
+
a mismatch with the exact re-baseline command rather than comparing across the
|
|
438
|
+
boundary. Bump the stamp whenever the coverage join, the line coordinate
|
|
439
|
+
system, or the unresolved-method policy changes.
|
|
440
|
+
|
|
441
|
+
---
|
|
442
|
+
|
|
443
|
+
## Keeping a baseline fresh (Story #4776)
|
|
444
|
+
|
|
445
|
+
Populating a baseline correctly is only half the loop. The other half is
|
|
446
|
+
keeping it correct as the tree grows, and that half has two distinct holes —
|
|
447
|
+
one at close time, one over the long run. Both are **advisory**:
|
|
448
|
+
`check-baselines` already fails closed on a real regression, and duplicating
|
|
449
|
+
that would double-gate the same defect.
|
|
450
|
+
|
|
451
|
+
### Pre-merge projections — the refresh nudge at close time
|
|
452
|
+
|
|
453
|
+
Close-validation projects, after its gates pass, which committed baseline rows
|
|
454
|
+
the post-merge tree would breach, and names the exact remedy while the operator
|
|
455
|
+
still has the branch in hand:
|
|
456
|
+
|
|
457
|
+
- `lib/close-validation/projections/maintainability.js` — per-file MI.
|
|
458
|
+
- `lib/close-validation/projections/crap.js` — per-method CRAP, against each
|
|
459
|
+
method's baseline row or, for methods with no row, `newMethodCeiling`.
|
|
460
|
+
|
|
461
|
+
Both are wired through `projections/advisories.js`, which
|
|
462
|
+
`close-validation/runner.js` calls once. Each self-skips — logging the reason,
|
|
463
|
+
never erroring — when its gate is disabled, when no baseline exists, when the
|
|
464
|
+
diff has no scorable files, or when the CRAP scorer finds no coverage
|
|
465
|
+
artifact. A projected breach never changes the close verdict.
|
|
466
|
+
|
|
467
|
+
> The maintainability projection shipped in v1 fully written and fully
|
|
468
|
+
> unit-tested, and the v2 Epic-tier collapse removed its only caller. It sat
|
|
469
|
+
> importable-but-unimported for the whole of v2, so its advisory never fired
|
|
470
|
+
> once. `tests/lib/close-validation/runner-projections.test.js` now walks the
|
|
471
|
+
> import graph and fails if **any** module under `projections/` is reachable
|
|
472
|
+
> from nothing in production — the orphaning itself is the regression.
|
|
473
|
+
|
|
474
|
+
### `check-baseline-drift.js` — the scheduled full-scope re-score
|
|
475
|
+
|
|
476
|
+
Every per-PR enforcement site (close-validation, pre-push, CI) is
|
|
477
|
+
**diff-scoped**: it compares the files a branch touched against their baseline
|
|
478
|
+
rows. A file nobody touches after its row is written is therefore never
|
|
479
|
+
re-scored, so drift introduced *indirectly* — a dependency getting more
|
|
480
|
+
complex, coverage moving underneath a method — stays invisible indefinitely.
|
|
481
|
+
Full-scope scoring on every push is far too expensive to be the answer.
|
|
482
|
+
|
|
483
|
+
```bash
|
|
484
|
+
node .agents/scripts/check-baseline-drift.js # both kinds
|
|
485
|
+
node .agents/scripts/check-baseline-drift.js --gate crap # one kind
|
|
486
|
+
node .agents/scripts/check-baseline-drift.js --tolerance 1 --json
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
It re-scores full-scope through the *same* scorer that writes the baseline
|
|
490
|
+
(`refresh-service.resolveDefaultScorer`) — scoring by a second implementation
|
|
491
|
+
would report the two implementations' disagreement as drift — and prints a
|
|
492
|
+
per-row before/after table for everything that moved beyond the gate's
|
|
493
|
+
tolerance, **in either direction**. A row that silently improved is equally
|
|
494
|
+
strong evidence the baseline no longer describes the tree.
|
|
495
|
+
|
|
496
|
+
Exit codes: `0` no drift (or every kind skipped), `1` drift detected, `2` the
|
|
497
|
+
check could not run. It is designed to be wired as a scheduled CI job;
|
|
498
|
+
scheduling it is deliberately consumer-side work, and nothing in this
|
|
499
|
+
repository runs it automatically.
|
|
500
|
+
|
|
364
501
|
---
|
|
365
502
|
|
|
366
503
|
## Bundle-size ratchet — one-shot refresh/acknowledge (Story #151)
|
|
@@ -32,7 +32,7 @@ by `node .agents/scripts/generate-workflows-doc.js`; `npm run docs:check`
|
|
|
32
32
|
fails when it drifts from the on-disk workflow set. To change a command’s
|
|
33
33
|
description, edit the workflow file’s front-matter and regenerate.
|
|
34
34
|
|
|
35
|
-
## Commands (
|
|
35
|
+
## Commands (25)
|
|
36
36
|
|
|
37
37
|
| Command | Description |
|
|
38
38
|
| --- | --- |
|
|
@@ -57,6 +57,7 @@ description, edit the workflow file’s front-matter and regenerate.
|
|
|
57
57
|
| `/git-deliver` | Single ad-hoc delivery command for working-tree changes. Detects the git setup and escalates to the right terminal step — commit only, commit + push, or commit + push + open a PR with native auto-merge — picking the default from observable state and letting flags pin any level explicitly. Replaces the retired git-commit-all, git-push, and git-pr-all trio. |
|
|
58
58
|
| `/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. |
|
|
59
59
|
| `/plan` | Unified planning entry point. Interrogate → author → persist. Emits one Story by default; splits into N>1 only under the default-single split policy. |
|
|
60
|
+
| `/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. |
|
|
60
61
|
| `/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 /plan. |
|
|
61
62
|
| `/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/ |
|
|
62
63
|
| `/qa-run` | Drive Gherkin scenarios through a real browser as an agent-driven QA sweep |
|
|
@@ -774,6 +774,12 @@
|
|
|
774
774
|
"requireCoverage": {
|
|
775
775
|
"type": "boolean"
|
|
776
776
|
},
|
|
777
|
+
"minMethodResolutionRate": {
|
|
778
|
+
"type": "number",
|
|
779
|
+
"minimum": 0,
|
|
780
|
+
"maximum": 1,
|
|
781
|
+
"description": "Fail-closed floor on the per-method coverage JOIN (Story #4775): the fraction of methods that must resolve a coverage entry, counted only over files that HAVE one, before `update-crap-baseline.js` will persist. A broken join is silent by construction — unresolved methods are simply absent — so the updater refuses rather than writing a thin baseline and logging it as success. Not enforced below 25 joinable methods, where a diff-scoped run's rate is noise. Default 0.75; a healthy repo resolves ~98%."
|
|
782
|
+
},
|
|
777
783
|
"friction": {
|
|
778
784
|
"type": "object",
|
|
779
785
|
"properties": {
|
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
"format": "date-time",
|
|
22
22
|
"description": "ISO-8601 timestamp of the baseline run. Used for stale-baseline detection and audit trails."
|
|
23
23
|
},
|
|
24
|
+
"scoringSemantics": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"description": "Optional per-kind stamp identifying the SCORING SEMANTICS that produced these rows — the dimension kernelVersion cannot express, because a kind's own scoring can change while the upstream package it stamps does not. A baseline whose stamp differs from the running scorer's is incomparable and the gate fails closed with re-baseline guidance rather than comparing across the boundary."
|
|
27
|
+
},
|
|
24
28
|
"rollup": {
|
|
25
29
|
"type": "object",
|
|
26
30
|
"description": "Aggregate metrics keyed by component. The '*' key is reserved for the whole-repo rollup and is REQUIRED; additional keys MAY be present, one per declared component.",
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
"allOf": [{ "$ref": "baseline-envelope.schema.json" }],
|
|
7
7
|
"type": "object",
|
|
8
8
|
"properties": {
|
|
9
|
+
"scoringSemantics": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"description": "Identifier for the per-method coverage-join semantics that produced these rows (Story #4775). Deliberately NOT constrained to the current value here: an old-semantics baseline must reach the gate's compat axis, which fails it closed with an explicit re-baseline command, rather than being rejected with a raw schema error."
|
|
12
|
+
},
|
|
9
13
|
"rollup": {
|
|
10
14
|
"type": "object",
|
|
11
15
|
"required": ["*"],
|
|
@@ -82,10 +82,14 @@ const VERDICT_SCHEMA_PATH = path.resolve(
|
|
|
82
82
|
'acceptance-eval-verdict.schema.json',
|
|
83
83
|
);
|
|
84
84
|
|
|
85
|
-
let cachedValidator = null;
|
|
86
|
-
|
|
87
85
|
/**
|
|
88
|
-
* Compile
|
|
86
|
+
* Compile the Ajv2020 validator for the verdict schema.
|
|
87
|
+
*
|
|
88
|
+
* Deliberately **not** memoised in a module-level variable: a module-level
|
|
89
|
+
* cache is reset only on re-import, so it silently defeats the `io` seam below
|
|
90
|
+
* (the second caller's injected reader is never consulted) and makes parallel
|
|
91
|
+
* test isolation impossible — `.agents/rules/test-seams.md` rule 3. The gate
|
|
92
|
+
* compiles the schema once per CLI run, so there is nothing to memoise.
|
|
89
93
|
*
|
|
90
94
|
* @param {string} [schemaPath]
|
|
91
95
|
* @param {{ readFileSync: typeof readFileSync }} [io]
|
|
@@ -95,12 +99,10 @@ function getVerdictValidator(
|
|
|
95
99
|
schemaPath = VERDICT_SCHEMA_PATH,
|
|
96
100
|
io = { readFileSync },
|
|
97
101
|
) {
|
|
98
|
-
if (cachedValidator) return cachedValidator;
|
|
99
102
|
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
100
103
|
addFormats(ajv);
|
|
101
104
|
const schema = JSON.parse(io.readFileSync(schemaPath, 'utf8'));
|
|
102
|
-
|
|
103
|
-
return cachedValidator;
|
|
105
|
+
return ajv.compile(schema);
|
|
104
106
|
}
|
|
105
107
|
|
|
106
108
|
/**
|
|
@@ -235,7 +237,37 @@ export async function runAcceptanceEval(
|
|
|
235
237
|
return { envelope, exitCode };
|
|
236
238
|
}
|
|
237
239
|
|
|
238
|
-
|
|
240
|
+
/**
|
|
241
|
+
* The gate's CLI core: argv → verdict read → schema validation → decision →
|
|
242
|
+
* envelope. Extracted from the `main` shell so the whole error table (missing
|
|
243
|
+
* flags, unreadable verdict, non-JSON verdict, storyId mismatch, block) is
|
|
244
|
+
* reachable without spawning the CLI or writing a real verdict file.
|
|
245
|
+
*
|
|
246
|
+
* Every seam on the optional final `deps` parameter defaults to the real
|
|
247
|
+
* implementation (`.agents/rules/test-seams.md` rules 1-2, 4), so `main` and
|
|
248
|
+
* every production invocation are unchanged.
|
|
249
|
+
*
|
|
250
|
+
* @param {string[]} [argv]
|
|
251
|
+
* @param {{
|
|
252
|
+
* readFileSyncImpl?: typeof readFileSync,
|
|
253
|
+
* resolveConfigImpl?: typeof resolveConfig,
|
|
254
|
+
* validateVerdictImpl?: typeof validateVerdict,
|
|
255
|
+
* runAcceptanceEvalImpl?: typeof runAcceptanceEval,
|
|
256
|
+
* logger?: { info: Function },
|
|
257
|
+
* }} [deps]
|
|
258
|
+
* @returns {Promise<object>} the emitted envelope.
|
|
259
|
+
*/
|
|
260
|
+
export async function runAcceptanceEvalCli(
|
|
261
|
+
argv = process.argv.slice(2),
|
|
262
|
+
deps = {},
|
|
263
|
+
) {
|
|
264
|
+
const {
|
|
265
|
+
readFileSyncImpl = readFileSync,
|
|
266
|
+
resolveConfigImpl = resolveConfig,
|
|
267
|
+
validateVerdictImpl = validateVerdict,
|
|
268
|
+
runAcceptanceEvalImpl = runAcceptanceEval,
|
|
269
|
+
logger = Logger,
|
|
270
|
+
} = deps;
|
|
239
271
|
const { storyId, verdictPath, emitSignal } = parseCliArgs(argv);
|
|
240
272
|
|
|
241
273
|
if (!storyId) {
|
|
@@ -249,7 +281,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
249
281
|
|
|
250
282
|
let raw;
|
|
251
283
|
try {
|
|
252
|
-
raw =
|
|
284
|
+
raw = readFileSyncImpl(path.resolve(verdictPath), 'utf8');
|
|
253
285
|
} catch (err) {
|
|
254
286
|
throw new Error(
|
|
255
287
|
`acceptance-eval: cannot read verdict file at ${verdictPath}: ${
|
|
@@ -269,7 +301,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
269
301
|
);
|
|
270
302
|
}
|
|
271
303
|
|
|
272
|
-
const verdict =
|
|
304
|
+
const verdict = validateVerdictImpl(parsed);
|
|
273
305
|
|
|
274
306
|
// A verdict whose embedded storyId disagrees with the CLI flag is a
|
|
275
307
|
// wiring error worth failing on, not a silent mismatch.
|
|
@@ -279,15 +311,15 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
279
311
|
);
|
|
280
312
|
}
|
|
281
313
|
|
|
282
|
-
const config =
|
|
283
|
-
const { envelope, exitCode } = await
|
|
314
|
+
const config = resolveConfigImpl();
|
|
315
|
+
const { envelope, exitCode } = await runAcceptanceEvalImpl({
|
|
284
316
|
storyId,
|
|
285
317
|
verdict,
|
|
286
318
|
config,
|
|
287
319
|
emitSignal,
|
|
288
320
|
});
|
|
289
321
|
|
|
290
|
-
|
|
322
|
+
logger.info(JSON.stringify(envelope));
|
|
291
323
|
|
|
292
324
|
if (exitCode !== 0) {
|
|
293
325
|
const names = envelope.unmetCriteria
|
|
@@ -302,6 +334,14 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
302
334
|
return envelope;
|
|
303
335
|
}
|
|
304
336
|
|
|
337
|
+
/**
|
|
338
|
+
* @param {string[]} [argv]
|
|
339
|
+
* @returns {Promise<object>}
|
|
340
|
+
*/
|
|
341
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
342
|
+
return runAcceptanceEvalCli(argv);
|
|
343
|
+
}
|
|
344
|
+
|
|
305
345
|
runAsCli(import.meta.url, main, {
|
|
306
346
|
source: 'acceptance-eval',
|
|
307
347
|
usage: {
|
|
@@ -224,8 +224,38 @@ function dedupDegradedWarning(entries) {
|
|
|
224
224
|
);
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
-
|
|
228
|
-
|
|
227
|
+
/**
|
|
228
|
+
* Scan → group → dedup → (optionally) reconcile the cross-run ledger, and
|
|
229
|
+
* return the plan envelope.
|
|
230
|
+
*
|
|
231
|
+
* Every seam on the optional final `deps` parameter defaults to the real
|
|
232
|
+
* implementation (`.agents/rules/test-seams.md` rules 1-2, 4), so `main`,
|
|
233
|
+
* `runAuto`, and every production caller are unchanged.
|
|
234
|
+
*
|
|
235
|
+
* @param {{ glob?: string, severity?: string, useProvider?: boolean, ledger?: object }} params
|
|
236
|
+
* @param {{
|
|
237
|
+
* collectReportPathsImpl?: typeof collectReportPaths,
|
|
238
|
+
* readReportsImpl?: typeof readReports,
|
|
239
|
+
* loadProviderImpl?: typeof loadProvider,
|
|
240
|
+
* classifyGroupsImpl?: typeof classifyGroupsAgainstGitHub,
|
|
241
|
+
* reconcileScanLedgerImpl?: typeof reconcileScanLedger,
|
|
242
|
+
* logger?: { warn: Function },
|
|
243
|
+
* }} [deps]
|
|
244
|
+
* @returns {Promise<object>} the plan envelope.
|
|
245
|
+
*/
|
|
246
|
+
async function buildPlan(
|
|
247
|
+
{ glob: pattern, severity, useProvider, ledger },
|
|
248
|
+
deps = {},
|
|
249
|
+
) {
|
|
250
|
+
const {
|
|
251
|
+
collectReportPathsImpl = collectReportPaths,
|
|
252
|
+
readReportsImpl = readReports,
|
|
253
|
+
loadProviderImpl = loadProvider,
|
|
254
|
+
classifyGroupsImpl = classifyGroupsAgainstGitHub,
|
|
255
|
+
reconcileScanLedgerImpl = reconcileScanLedger,
|
|
256
|
+
logger = Logger,
|
|
257
|
+
} = deps;
|
|
258
|
+
const reportPaths = await collectReportPathsImpl(pattern ?? DEFAULT_GLOB);
|
|
229
259
|
if (reportPaths.length === 0) {
|
|
230
260
|
return {
|
|
231
261
|
generatedAt: new Date().toISOString(),
|
|
@@ -245,8 +275,8 @@ async function buildPlan({ glob: pattern, severity, useProvider, ledger }) {
|
|
|
245
275
|
};
|
|
246
276
|
}
|
|
247
277
|
|
|
248
|
-
const reports =
|
|
249
|
-
const allFindings = parseAuditReports(reports);
|
|
278
|
+
const reports = readReportsImpl(reportPaths);
|
|
279
|
+
const allFindings = parseAuditReports(reports, { repoRoot: process.cwd() });
|
|
250
280
|
const filtered = allFindings.filter((f) => meetsSeverity(f, severity));
|
|
251
281
|
const stamped = withFingerprints(filtered);
|
|
252
282
|
const { groups, edges } = groupFindings(stamped);
|
|
@@ -261,9 +291,9 @@ async function buildPlan({ glob: pattern, severity, useProvider, ledger }) {
|
|
|
261
291
|
let dedupApplied = false;
|
|
262
292
|
|
|
263
293
|
if (useProvider) {
|
|
264
|
-
const provider = await
|
|
294
|
+
const provider = await loadProviderImpl();
|
|
265
295
|
if (provider) {
|
|
266
|
-
const result = await
|
|
296
|
+
const result = await classifyGroupsImpl({
|
|
267
297
|
groups,
|
|
268
298
|
provider,
|
|
269
299
|
searchCandidates: provider.searchCandidates,
|
|
@@ -275,19 +305,19 @@ async function buildPlan({ glob: pattern, severity, useProvider, ledger }) {
|
|
|
275
305
|
// the --scan JSON on stdout stays clean) naming the groups that degraded
|
|
276
306
|
// to create because their lookup could not complete (Story #4678).
|
|
277
307
|
if (summary.dedupDegraded?.count > 0) {
|
|
278
|
-
|
|
308
|
+
logger.warn(dedupDegradedWarning(summary.dedupDegraded.groups));
|
|
279
309
|
}
|
|
280
310
|
} else {
|
|
281
311
|
// The provider could not resolve a searchIssues port — the dedup gate
|
|
282
312
|
// is silently a no-op without this. Surface it loudly (stderr, so the
|
|
283
313
|
// --scan JSON on stdout stays clean) so the operator does not read a
|
|
284
314
|
// create-only plan as "no duplicates found".
|
|
285
|
-
|
|
315
|
+
logger.warn(dedupSkippedWarning('no-provider-port'));
|
|
286
316
|
}
|
|
287
317
|
} else {
|
|
288
318
|
// Operator explicitly opted out via --no-provider. Still warn so a
|
|
289
319
|
// duplicate-opening re-run is never a surprise.
|
|
290
|
-
|
|
320
|
+
logger.warn(dedupSkippedWarning('disabled'));
|
|
291
321
|
}
|
|
292
322
|
|
|
293
323
|
// Cross-run ledger (Story #4626): fold this scan onto the committed memory,
|
|
@@ -296,7 +326,7 @@ async function buildPlan({ glob: pattern, severity, useProvider, ledger }) {
|
|
|
296
326
|
// --scan path leaves it untouched so it never mutates a committed file.
|
|
297
327
|
let ledgerSummary;
|
|
298
328
|
if (ledger) {
|
|
299
|
-
const suppressed =
|
|
329
|
+
const suppressed = reconcileScanLedgerImpl({
|
|
300
330
|
ledgerPath: ledger.path ?? DEFAULT_LEDGER_PATH,
|
|
301
331
|
findings: stamped,
|
|
302
332
|
classifications,
|
|
@@ -546,9 +576,42 @@ export const __testing = {
|
|
|
546
576
|
issueStatesFromClassifications,
|
|
547
577
|
};
|
|
548
578
|
|
|
549
|
-
|
|
579
|
+
/**
|
|
580
|
+
* The CLI core: dispatch one of the four sub-commands and persist its output.
|
|
581
|
+
* Extracted from the `main` shell so the whole sub-command table (including
|
|
582
|
+
* the no-sub-command usage throw) is reachable without spawning the CLI.
|
|
583
|
+
*
|
|
584
|
+
* Every seam on the optional final `deps` parameter defaults to the real
|
|
585
|
+
* implementation (`.agents/rules/test-seams.md` rules 1-2, 4), so `main` and
|
|
586
|
+
* every production invocation are unchanged.
|
|
587
|
+
*
|
|
588
|
+
* @param {string[]} [argv]
|
|
589
|
+
* @param {{
|
|
590
|
+
* buildPlanImpl?: typeof buildPlan,
|
|
591
|
+
* runAutoImpl?: typeof runAuto,
|
|
592
|
+
* loadPlanImpl?: typeof loadPlan,
|
|
593
|
+
* buildAndGateStoriesImpl?: typeof buildAndGateStories,
|
|
594
|
+
* buildPlanSeedMarkdownImpl?: typeof buildPlanSeedMarkdown,
|
|
595
|
+
* persistImpl?: typeof persist,
|
|
596
|
+
* stdout?: { write: (s: string) => void },
|
|
597
|
+
* }} [deps]
|
|
598
|
+
* @returns {Promise<void>}
|
|
599
|
+
*/
|
|
600
|
+
export async function runAuditToStories(
|
|
601
|
+
argv = process.argv.slice(2),
|
|
602
|
+
deps = {},
|
|
603
|
+
) {
|
|
604
|
+
const {
|
|
605
|
+
buildPlanImpl = buildPlan,
|
|
606
|
+
runAutoImpl = runAuto,
|
|
607
|
+
loadPlanImpl = loadPlan,
|
|
608
|
+
buildAndGateStoriesImpl = buildAndGateStories,
|
|
609
|
+
buildPlanSeedMarkdownImpl = buildPlanSeedMarkdown,
|
|
610
|
+
persistImpl = persist,
|
|
611
|
+
stdout = process.stdout,
|
|
612
|
+
} = deps;
|
|
550
613
|
const { values } = parseArgs({
|
|
551
|
-
args:
|
|
614
|
+
args: argv,
|
|
552
615
|
options: {
|
|
553
616
|
scan: { type: 'boolean' },
|
|
554
617
|
auto: { type: 'boolean' },
|
|
@@ -567,47 +630,47 @@ async function main() {
|
|
|
567
630
|
});
|
|
568
631
|
|
|
569
632
|
if (values.auto) {
|
|
570
|
-
const { summary } = await
|
|
633
|
+
const { summary } = await runAutoImpl({
|
|
571
634
|
glob: values.glob,
|
|
572
635
|
severity: values.severity,
|
|
573
636
|
dryRun: values['dry-run'],
|
|
574
637
|
useProvider: !values['no-provider'],
|
|
575
638
|
ledgerPath: values.ledger,
|
|
576
639
|
});
|
|
577
|
-
|
|
578
|
-
if (!values.out)
|
|
640
|
+
persistImpl(JSON.stringify(summary, null, 2), values.out);
|
|
641
|
+
if (!values.out) stdout.write('\n');
|
|
579
642
|
return;
|
|
580
643
|
}
|
|
581
644
|
|
|
582
645
|
if (values.scan) {
|
|
583
|
-
const plan = await
|
|
646
|
+
const plan = await buildPlanImpl({
|
|
584
647
|
glob: values.glob,
|
|
585
648
|
severity: values.severity,
|
|
586
649
|
useProvider: !values['no-provider'],
|
|
587
650
|
});
|
|
588
651
|
const out = JSON.stringify(plan, null, 2);
|
|
589
|
-
|
|
590
|
-
if (!values.out)
|
|
652
|
+
persistImpl(out, values.out);
|
|
653
|
+
if (!values.out) stdout.write('\n');
|
|
591
654
|
return;
|
|
592
655
|
}
|
|
593
656
|
|
|
594
657
|
if (values['emit-plan-seed']) {
|
|
595
|
-
const plan =
|
|
596
|
-
const md =
|
|
658
|
+
const plan = loadPlanImpl(values.plan);
|
|
659
|
+
const md = buildPlanSeedMarkdownImpl({
|
|
597
660
|
groups: plan.groups ?? [],
|
|
598
661
|
findings: plan.findings ?? [],
|
|
599
662
|
sourceReports: plan.sourceReports ?? [],
|
|
600
663
|
});
|
|
601
|
-
|
|
664
|
+
persistImpl(md, values.out);
|
|
602
665
|
return;
|
|
603
666
|
}
|
|
604
667
|
|
|
605
668
|
if (values['emit-stories']) {
|
|
606
|
-
const plan =
|
|
669
|
+
const plan = loadPlanImpl(values.plan);
|
|
607
670
|
const eligible = (plan.classifications ?? [])
|
|
608
671
|
.filter((c) => c.action === 'create')
|
|
609
672
|
.map((c) => c.group);
|
|
610
|
-
const built =
|
|
673
|
+
const built = buildAndGateStoriesImpl(eligible, plan.edges ?? []);
|
|
611
674
|
const out = values.json
|
|
612
675
|
? JSON.stringify(built, null, 2)
|
|
613
676
|
: built
|
|
@@ -616,8 +679,8 @@ async function main() {
|
|
|
616
679
|
`--- story ${i + 1} ---\nTitle: ${s.title}\nLabels: ${s.labels.join(', ')}\n\n${s.body}\n`,
|
|
617
680
|
)
|
|
618
681
|
.join('\n');
|
|
619
|
-
|
|
620
|
-
if (!values.out)
|
|
682
|
+
persistImpl(out, values.out);
|
|
683
|
+
if (!values.out) stdout.write('\n');
|
|
621
684
|
return;
|
|
622
685
|
}
|
|
623
686
|
|
|
@@ -626,6 +689,10 @@ async function main() {
|
|
|
626
689
|
);
|
|
627
690
|
}
|
|
628
691
|
|
|
692
|
+
async function main() {
|
|
693
|
+
await runAuditToStories();
|
|
694
|
+
}
|
|
695
|
+
|
|
629
696
|
runAsCli(import.meta.url, main, {
|
|
630
697
|
source: 'audit-to-stories',
|
|
631
698
|
usage: {
|
|
@@ -176,8 +176,25 @@ export function buildSummaryLine(result) {
|
|
|
176
176
|
return `[boot-sweep] reaped ${result.localDeleted} local + ${result.remoteDeleted} remote; protected ${protectedCount}${contentMergedSuffix}.`;
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
|
|
179
|
+
/**
|
|
180
|
+
* The CLI core: parse argv, run the sweep, render the report. Extracted from
|
|
181
|
+
* the `main` shell so the argv → render decision table is reachable without
|
|
182
|
+
* spawning a real sweep against a real git tree.
|
|
183
|
+
*
|
|
184
|
+
* Both seams on the optional final `deps` parameter default to the real
|
|
185
|
+
* implementation (`.agents/rules/test-seams.md` rules 1-2), so `main` and any
|
|
186
|
+
* production caller are unchanged.
|
|
187
|
+
*
|
|
188
|
+
* @param {string[]} [argv]
|
|
189
|
+
* @param {{ runBootSweepImpl?: typeof runBootSweep, logger?: { info: Function } }} [deps]
|
|
190
|
+
* @returns {Promise<object>} the sweep envelope that was rendered.
|
|
191
|
+
*/
|
|
192
|
+
export async function runBootSweepCli(
|
|
193
|
+
argv = process.argv.slice(2),
|
|
194
|
+
{ runBootSweepImpl = runBootSweep, logger = Logger } = {},
|
|
195
|
+
) {
|
|
180
196
|
const { values } = parseArgs({
|
|
197
|
+
args: argv,
|
|
181
198
|
options: {
|
|
182
199
|
base: { type: 'string' },
|
|
183
200
|
cwd: { type: 'string' },
|
|
@@ -192,11 +209,11 @@ async function main() {
|
|
|
192
209
|
});
|
|
193
210
|
|
|
194
211
|
if (values.help) {
|
|
195
|
-
|
|
196
|
-
return;
|
|
212
|
+
logger.info(HELP);
|
|
213
|
+
return undefined;
|
|
197
214
|
}
|
|
198
215
|
|
|
199
|
-
const result = await
|
|
216
|
+
const result = await runBootSweepImpl({
|
|
200
217
|
cwd: typeof values.cwd === 'string' ? values.cwd : undefined,
|
|
201
218
|
base: typeof values.base === 'string' ? values.base : undefined,
|
|
202
219
|
include: Array.isArray(values.include) ? values.include : [],
|
|
@@ -206,10 +223,15 @@ async function main() {
|
|
|
206
223
|
});
|
|
207
224
|
|
|
208
225
|
if (values.json) {
|
|
209
|
-
|
|
226
|
+
logger.info(JSON.stringify(result, null, 2));
|
|
210
227
|
} else {
|
|
211
|
-
|
|
228
|
+
logger.info(buildSummaryLine(result));
|
|
212
229
|
}
|
|
230
|
+
return result;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function main() {
|
|
234
|
+
await runBootSweepCli();
|
|
213
235
|
}
|
|
214
236
|
|
|
215
237
|
runAsCli(import.meta.url, main, {
|