mandrel 2.36.0 → 2.37.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 +25 -0
- package/.agents/rules/testing-standards.md +56 -0
- package/.agents/scripts/boot-sweep.js +7 -1
- package/.agents/scripts/check-context-budget.js +195 -103
- package/.agents/scripts/check-cyclomatic.js +112 -42
- package/.agents/scripts/check-generated-validator.js +202 -0
- package/.agents/scripts/check-lifecycle-lint.js +2 -68
- package/.agents/scripts/check-schema-references.js +2 -28
- package/.agents/scripts/drain-pending-cleanup.js +18 -1
- package/.agents/scripts/lib/baselines/components.js +32 -2
- package/.agents/scripts/lib/baselines/env-overrides.js +1 -1
- package/.agents/scripts/lib/bootstrap/branch-protection.js +1 -1
- package/.agents/scripts/lib/bootstrap/install-ledger.js +1 -1
- package/.agents/scripts/lib/bootstrap/prompt.js +1 -1
- package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +2 -2
- package/.agents/scripts/lib/checks/loop-health.js +1 -1
- package/.agents/scripts/lib/cli-args.js +0 -2
- package/.agents/scripts/lib/close-validation/gates.js +1 -1
- package/.agents/scripts/lib/close-validation/process.js +1 -1
- package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
- package/.agents/scripts/lib/config-settings-schema.js +46 -6
- package/.agents/scripts/lib/coverage-baseline.js +2 -2
- package/.agents/scripts/lib/cpu-pool.js +90 -10
- package/.agents/scripts/lib/crap-utils.js +6 -2
- package/.agents/scripts/lib/cyclomatic-ceiling.js +28 -6
- package/.agents/scripts/lib/cyclomatic-scope.js +144 -0
- package/.agents/scripts/lib/dependency-version.js +86 -0
- package/.agents/scripts/lib/duplicate-search.js +1 -1
- package/.agents/scripts/lib/errors/index.js +1 -1
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +2 -2
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +1 -1
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +1 -1
- package/.agents/scripts/lib/generated/agentrc-validator.js +16 -0
- package/.agents/scripts/lib/install-cmd-parser.js +1 -1
- package/.agents/scripts/lib/knip-entry-sync.js +1 -57
- package/.agents/scripts/lib/maintainability-utils.js +108 -10
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +70 -9
- package/.agents/scripts/lib/orchestration/pr-watch.js +1 -30
- package/.agents/scripts/lib/orchestration/reassert-status-column.js +2 -2
- package/.agents/scripts/lib/orchestration/review-providers/codex.js +3 -3
- package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +1 -1
- package/.agents/scripts/lib/orchestration/review-providers/native.js +9 -2
- package/.agents/scripts/lib/orchestration/review-providers/review-depth.js +2 -2
- package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +52 -1
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +2 -5
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +1 -1
- package/.agents/scripts/lib/orchestration/ticket-lease.js +34 -9
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +3 -1
- package/.agents/scripts/lib/preflight-runner.js +3 -3
- package/.agents/scripts/lib/qa/qa-session.js +1 -1
- package/.agents/scripts/lib/runtime-deps/manifest.js +1 -7
- package/.agents/scripts/lib/runtime-deps/scan-imports.js +3 -58
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +284 -25
- package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
- package/.agents/scripts/lib/source-text/strip-js-comments.js +115 -0
- package/.agents/scripts/lib/test-isolate/runner.js +3 -3
- package/.agents/scripts/lib/test-runner-contract.js +14 -6
- package/.agents/scripts/lib/test-tiers.js +135 -18
- package/.agents/scripts/lib/transpile.js +16 -3
- package/.agents/scripts/lib/worktree/lifecycle/pending-cleanup.js +169 -31
- package/.agents/scripts/lib/worktree/node-modules-strategy.js +2 -5
- package/.agents/scripts/pr-watch-with-update.js +305 -137
- package/.agents/scripts/providers/github/tickets.js +109 -16
- package/.agents/scripts/quality-preview.js +162 -70
- package/.agents/scripts/run-test-profile.js +8 -5
- package/.agents/scripts/run-tests.js +79 -14
- package/.agents/scripts/single-story-close.js +0 -2
- package/.agents/scripts/single-story-init.js +5 -1
- package/docs/CHANGELOG.md +32 -0
- package/lib/cli/migrate.js +100 -94
- package/lib/migrations/helpers/retire-agentrc-key.js +200 -0
- package/lib/migrations/index.js +32 -33
- package/lib/migrations/steps/2.1.0-retire-mi-drop-knobs.js +21 -80
- package/lib/migrations/steps/2.1.0-retire-verify-concurrency-cap.js +15 -87
- package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +13 -71
- package/lib/migrations/steps/2.20.0-retire-codebase-snapshot.js +13 -101
- package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +13 -100
- package/package.json +7 -3
- package/.agents/scripts/lib/audit-suite/frontmatter-lint.js +0 -32
- package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +0 -37
- package/.agents/scripts/lib/cli/parse-numeric.js +0 -60
- package/.agents/scripts/lib/close-validation/telemetry.js +0 -79
- package/.agents/scripts/lib/orchestration/label-transitions.js +0 -44
- package/.agents/scripts/lib/orchestration/parked-follow-ons.js +0 -147
- package/.agents/scripts/lib/orchestration/phase-runner.js +0 -88
- package/.agents/scripts/lib/orchestration/recut.js +0 -56
|
@@ -683,6 +683,31 @@ Claude Code web environment-variables UI for web sessions.
|
|
|
683
683
|
| `NOTIFICATION_WEBHOOK_URL` | No | POST target for in-band Notifier events (Make.com / Slack / Discord). Unset disables the webhook channel; `log` and `epic-comment` channels still fire. |
|
|
684
684
|
| `WEBHOOK_SECRET` | No | Shared secret used to sign outbound webhook payloads as `X-Signature-256: sha256=<hmac>`. Unset ships unsigned payloads. |
|
|
685
685
|
| `MANDREL_ALLOW_TEST_WEBHOOKS` | No | Set to `1` to keep `NOTIFICATION_WEBHOOK_URL` live inside `npm test` / `npm run test:profile`. Default behaviour scrubs the env var from the test child so no URL resolves and the webhook never fires (see below). |
|
|
686
|
+
| `MANDREL_POOL_CONCURRENCY` | No | Upper bound on the width of every `runOnPool` worker pool in the process (the MI and CRAP scan pools). Precedence is: a caller's explicit `concurrency` → this variable → a clamp of 4 under `node:test` → `os.availableParallelism()`. Set it on a constrained or shared runner where one pool per core oversubscribes the host; a non-numeric value is ignored rather than collapsing the pool. |
|
|
687
|
+
| `MANDREL_AGENTRC_VALIDATOR` | No | Set to `dynamic` to compile the `.agentrc.json` AJV validator at runtime instead of loading the committed precompiled one (see below). Costs ~35 ms per process; the escape hatch exists for a hand-edited schema or a host where the generated module will not load. |
|
|
688
|
+
|
|
689
|
+
### The `.agentrc` validator is precompiled
|
|
690
|
+
|
|
691
|
+
`getAgentrcValidator()` returns AJV's **standalone emit** for
|
|
692
|
+
`AGENTRC_SCHEMA`, committed at
|
|
693
|
+
[`.agents/scripts/lib/generated/agentrc-validator.js`](../scripts/lib/generated/agentrc-validator.js).
|
|
694
|
+
Compiling that schema costs ~35 ms, and 36 of the framework's entry scripts
|
|
695
|
+
resolve configuration, so the compile was being paid over and over to
|
|
696
|
+
re-derive a function from a literal that is already in the repository.
|
|
697
|
+
|
|
698
|
+
The artifact is generated — never hand-edited — by
|
|
699
|
+
[`check-generated-validator.js`](../scripts/check-generated-validator.js):
|
|
700
|
+
|
|
701
|
+
```bash
|
|
702
|
+
npm run validator:gen # rewrite it after a deliberate schema change
|
|
703
|
+
npm run validator:check # fail when it is stale (also run by `npm run lint`)
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
`npm run lint` runs the `--check` mode, so a schema edit that skips
|
|
707
|
+
regeneration is refused rather than shipping a validator that accepts the
|
|
708
|
+
wrong config. Validation behaviour is unchanged in either mode: the generated
|
|
709
|
+
code is AJV's own, emitted with the same `allErrors: true` option the dynamic
|
|
710
|
+
path uses, and returns the same verdict and the same `errors` array.
|
|
686
711
|
|
|
687
712
|
### Test-mode webhook isolation
|
|
688
713
|
|
|
@@ -79,6 +79,13 @@ of the pyramid.
|
|
|
79
79
|
- **Location.** `tests/features/**/*.feature` with step definitions in
|
|
80
80
|
`tests/steps/**` (or the project's equivalent). The companion skill is
|
|
81
81
|
[`stack/qa/playwright-bdd`](../skills/stack/qa/playwright-bdd/SKILL.md).
|
|
82
|
+
- **Run tier.** This tier MUST NOT ride inside the default suite. It is slow
|
|
83
|
+
by construction — real installs, real browsers, real stacks — and the
|
|
84
|
+
default suite is what a pre-push hook and every local iteration pay for.
|
|
85
|
+
Give it its own runner tier and its own CI job, so its cost is charged to
|
|
86
|
+
the surface whose signal it is. In this repository that is `tests/e2e/**`,
|
|
87
|
+
the `e2e` tier (`npm run test:e2e`), and the per-PR `e2e` job; the coverage
|
|
88
|
+
run still measures those files, so nothing leaves the measured surface.
|
|
82
89
|
|
|
83
90
|
## Assertion Placement Rule {#assertion-placement}
|
|
84
91
|
|
|
@@ -236,3 +243,52 @@ Hypothesis keeps a failure DB, proptest writes `proptest-regressions/` — pin o
|
|
|
236
243
|
commit whichever the stack provides. Once shrinking surfaces a minimal
|
|
237
244
|
counterexample, **add it as an example-based regression test** alongside the
|
|
238
245
|
property: the property guards the domain, the pinned example guards the bug.
|
|
246
|
+
|
|
247
|
+
## The suite's child-process budget
|
|
248
|
+
|
|
249
|
+
`npm test` forks one process per test file, and the spawns those leaves make
|
|
250
|
+
dominate the suite's system time. Two instruments and one rule keep that
|
|
251
|
+
budget visible and honest (Story #5121).
|
|
252
|
+
|
|
253
|
+
### Measure it with `npm run test:census`
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
npm run test:census # writes temp/census.json
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
`tests/fixtures/spawn-census.cjs` is a `--require` preload that counts every
|
|
260
|
+
`child_process` call per binary, aggregates across all ~700 processes, and
|
|
261
|
+
reports `nodeInSuite` (node children spawned *by test files*, excluding the
|
|
262
|
+
runner's own fan-out), `git`, `gh`, `npm`, and any standalone
|
|
263
|
+
`git config user.*` spawns. Read the numbers from the census rather than
|
|
264
|
+
re-deriving them; two audits hand-rolled this measurement and lost it both
|
|
265
|
+
times with the gitignored temp tree.
|
|
266
|
+
|
|
267
|
+
The script interpolates `$PWD` deliberately. A **relative** `--require` path is
|
|
268
|
+
inherited by children that run with a different `cwd`, where it fails to
|
|
269
|
+
resolve and kills the child before it runs a line — measured as 15 spurious
|
|
270
|
+
failures in one file.
|
|
271
|
+
|
|
272
|
+
### Build a fixture repo once, then copy it
|
|
273
|
+
|
|
274
|
+
A multi-commit fixture repo rebuilt in `beforeEach` is the costly shape. Build
|
|
275
|
+
it once in `before()` and hand each test an `fs` copy via
|
|
276
|
+
`copyGitRepo(pristine)` from `tests/fixtures/git-fixture.js`: each test still
|
|
277
|
+
gets a private directory it may freely mutate, for **no subprocess at all**.
|
|
278
|
+
One file went from 92 `git` spawns to 27 this way.
|
|
279
|
+
|
|
280
|
+
`copyGitRepo` is safe only for a locally-`git init`ed repo, whose
|
|
281
|
+
`.git/config` holds no absolute paths. Do **not** copy a **clone** (its
|
|
282
|
+
`remote.origin.url` is absolute, so the copy would fetch from the original) or a
|
|
283
|
+
linked worktree (its `gitdir:` / `commondir` pointers would dangle).
|
|
284
|
+
|
|
285
|
+
### Never trade coverage for a spawn count
|
|
286
|
+
|
|
287
|
+
Most of the suite's remaining `git` spawns are integration tests exercising
|
|
288
|
+
real git against git-manipulating production code — the spawn **is** the
|
|
289
|
+
subject under test, and so is a CLI's exit code in an exit-code contract test.
|
|
290
|
+
Those are not fixture waste and must not be converted to in-process calls or
|
|
291
|
+
mocks to make a number smaller. Hoist shared setup; leave the assertions alone.
|
|
292
|
+
A spawn census also records argv, not what the binary resolved to: a
|
|
293
|
+
`gh pr view 4890` line may well be a fake `gh` the test put on `PATH`, so
|
|
294
|
+
verify resolution before calling a spawn a network call.
|
|
@@ -45,6 +45,7 @@ import { PROJECT_ROOT, resolveConfig } from './lib/config-resolver.js';
|
|
|
45
45
|
import { Logger } from './lib/Logger.js';
|
|
46
46
|
import { createProvider } from './lib/provider-factory.js';
|
|
47
47
|
import { buildProtectionCtx } from './lib/single-story-sweep/protection-ctx.js';
|
|
48
|
+
import { resolveSweepLockPath } from './lib/single-story-sweep/sweep-lock.js';
|
|
48
49
|
import { sweepMergedBranches } from './lib/single-story-sweep.js';
|
|
49
50
|
import { sweepTempRetention } from './lib/temp-retention.js';
|
|
50
51
|
|
|
@@ -141,8 +142,13 @@ export async function runBootSweep({
|
|
|
141
142
|
excludeGlobs.push(current);
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
// Story #5112 — one critical section, one lock. `single-story-init.js`
|
|
146
|
+
// reaps the same merged `story-*` branches through the same engine; when
|
|
147
|
+
// the two surfaces held differently named lockfiles they could run
|
|
148
|
+
// concurrently, each deleting branches the other had already planned.
|
|
149
|
+
// Both now resolve the path through `resolveSweepLockPath`.
|
|
144
150
|
const tempRoot = config?.project?.paths?.tempRoot ?? 'temp';
|
|
145
|
-
const lockPath =
|
|
151
|
+
const lockPath = resolveSweepLockPath({ cwd: root, tempRoot });
|
|
146
152
|
const lockTimeoutMs =
|
|
147
153
|
config.delivery?.worktreeIsolation?.sweepLockMs ?? 60_000;
|
|
148
154
|
|
|
@@ -481,6 +481,179 @@ export function renderReachable(tierMap, baseline) {
|
|
|
481
481
|
* @returns {Promise<number>} 0 = clean / within tolerance / shrink-only / no-op;
|
|
482
482
|
* 1 = a gated tier grew beyond tolerance
|
|
483
483
|
*/
|
|
484
|
+
/**
|
|
485
|
+
* Write a fresh budget, preserving the recorded tolerance so `--update` never
|
|
486
|
+
* silently widens the gate it is refreshing.
|
|
487
|
+
*
|
|
488
|
+
* @param {object} params
|
|
489
|
+
* @returns {0}
|
|
490
|
+
*/
|
|
491
|
+
function writeUpdatedBaseline({ tierMap, resolvedBaselinePath, json, stdout }) {
|
|
492
|
+
const existing = loadBaseline(resolvedBaselinePath);
|
|
493
|
+
const tolerance = Number.isFinite(existing?.toleranceBytes)
|
|
494
|
+
? existing.toleranceBytes
|
|
495
|
+
: DEFAULT_TOLERANCE_BYTES;
|
|
496
|
+
const envelope = buildBaseline(tierMap, tolerance);
|
|
497
|
+
fs.mkdirSync(path.dirname(resolvedBaselinePath), { recursive: true });
|
|
498
|
+
fs.writeFileSync(
|
|
499
|
+
resolvedBaselinePath,
|
|
500
|
+
`${JSON.stringify(envelope, null, 2)}\n`,
|
|
501
|
+
);
|
|
502
|
+
if (!json) {
|
|
503
|
+
stdout.write(
|
|
504
|
+
`[context-budget] wrote baseline ${resolvedBaselinePath} (tolerance ${tolerance} bytes)\n`,
|
|
505
|
+
);
|
|
506
|
+
} else {
|
|
507
|
+
stdout.write(
|
|
508
|
+
`${JSON.stringify({ kind: 'context-budget-update', baselinePath: resolvedBaselinePath, envelope }, null, 2)}\n`,
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
return 0;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* An absent budget is a no-op, not a failure: a consumer that has never
|
|
516
|
+
* recorded one has nothing to regress against.
|
|
517
|
+
*
|
|
518
|
+
* @param {object} params
|
|
519
|
+
* @returns {0}
|
|
520
|
+
*/
|
|
521
|
+
function reportMissingBaseline({
|
|
522
|
+
tierMap,
|
|
523
|
+
resolvedBaselinePath,
|
|
524
|
+
json,
|
|
525
|
+
stdout,
|
|
526
|
+
stderr,
|
|
527
|
+
}) {
|
|
528
|
+
if (json) {
|
|
529
|
+
stdout.write(
|
|
530
|
+
`${JSON.stringify({ kind: 'context-budget-report', baselinePath: resolvedBaselinePath, tiers: tierMap.tiers, grown: [], shrunk: [], absent: [], skipped: GATED_TIERS, exitCode: 0, noBaseline: true }, null, 2)}\n`,
|
|
531
|
+
);
|
|
532
|
+
} else {
|
|
533
|
+
stderr.write(
|
|
534
|
+
`[context-budget] ⚠ budget not found at ${resolvedBaselinePath} — skipping (no-op)\n`,
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
return 0;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Score the tree against the recorded budget. Pure — every verdict the two
|
|
542
|
+
* renderers below present is decided here, so they cannot disagree about what
|
|
543
|
+
* failed or drift apart in which fields they surface.
|
|
544
|
+
*
|
|
545
|
+
* @param {{ tierMap: object, baseline: object }} params
|
|
546
|
+
* @returns {{ diff: object, ceiling: number, bootOverflow: object[], bootDrift: object[], permissiveDrift: object[], exitCode: 0 | 1 }}
|
|
547
|
+
*/
|
|
548
|
+
function evaluateBudget({ tierMap, baseline }) {
|
|
549
|
+
const diff = diffBudget(tierMap, baseline);
|
|
550
|
+
const ceiling = Number.isFinite(baseline?.agentBoot?.ceilingBytes)
|
|
551
|
+
? baseline.agentBoot.ceilingBytes
|
|
552
|
+
: AGENT_BOOT_CEILING_BYTES;
|
|
553
|
+
const bootOverflow = agentBootOverflow(tierMap, ceiling);
|
|
554
|
+
const bootDrift = agentBootDrift(tierMap, baseline, ceiling);
|
|
555
|
+
const permissiveDrift = bootDrift.filter((d) => d.direction === 'permissive');
|
|
556
|
+
const exitCode =
|
|
557
|
+
budgetFailureCount(diff) > 0 ||
|
|
558
|
+
bootOverflow.length > 0 ||
|
|
559
|
+
permissiveDrift.length > 0
|
|
560
|
+
? 1
|
|
561
|
+
: 0;
|
|
562
|
+
return { diff, ceiling, bootOverflow, bootDrift, permissiveDrift, exitCode };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* @param {object} params
|
|
567
|
+
* @returns {void}
|
|
568
|
+
*/
|
|
569
|
+
function renderJsonReport({
|
|
570
|
+
tierMap,
|
|
571
|
+
baseline,
|
|
572
|
+
resolvedBaselinePath,
|
|
573
|
+
report,
|
|
574
|
+
stdout,
|
|
575
|
+
}) {
|
|
576
|
+
const { diff, ceiling, bootOverflow, bootDrift, exitCode } = report;
|
|
577
|
+
const envelope = {
|
|
578
|
+
kind: 'context-budget-report',
|
|
579
|
+
baselinePath: resolvedBaselinePath,
|
|
580
|
+
toleranceBytes: Number.isFinite(baseline.toleranceBytes)
|
|
581
|
+
? baseline.toleranceBytes
|
|
582
|
+
: 0,
|
|
583
|
+
current: Object.fromEntries(
|
|
584
|
+
GATED_TIERS.map((t) => [t, tierTotalBytes(tierMap.tiers[t] ?? [])]),
|
|
585
|
+
),
|
|
586
|
+
grown: diff.grown,
|
|
587
|
+
shrunk: diff.shrunk,
|
|
588
|
+
absent: diff.absent,
|
|
589
|
+
skipped: diff.skipped,
|
|
590
|
+
agentBootCeilingBytes: ceiling,
|
|
591
|
+
agentBootOverflow: bootOverflow,
|
|
592
|
+
agentBootDrift: bootDrift,
|
|
593
|
+
workflowReachableBytes: tierMap.workflowClosure?.reachableTotalBytes ?? 0,
|
|
594
|
+
exitCode,
|
|
595
|
+
};
|
|
596
|
+
stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Each failing condition gets its own remediation line: they are fixed
|
|
601
|
+
* differently (trim a role def vs refresh the budget), so a single generic
|
|
602
|
+
* message would leave the author guessing which applies.
|
|
603
|
+
*
|
|
604
|
+
* @param {object} params
|
|
605
|
+
* @returns {void}
|
|
606
|
+
*/
|
|
607
|
+
function renderFailureDiagnostics({ report, stderr }) {
|
|
608
|
+
const { diff, ceiling, bootOverflow, permissiveDrift } = report;
|
|
609
|
+
if (permissiveDrift.length > 0) {
|
|
610
|
+
stderr.write(
|
|
611
|
+
`[context-budget] ❌ a recorded agentBoot row understates the file it describes, so it overstates the headroom an author would size an edit against — refresh it with \`node .agents/scripts/check-context-budget.js --update\` (the ceiling is unchanged)\n`,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
if (bootOverflow.length > 0) {
|
|
615
|
+
stderr.write(
|
|
616
|
+
`[context-budget] ❌ a role-agent boot context exceeds the ${ceiling}-byte per-agent ceiling — trim the role def (the ceiling is a hard cap, not a starve target)\n`,
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
if (diff.grown.length > 0) {
|
|
620
|
+
stderr.write(
|
|
621
|
+
`[context-budget] ❌ a documentation tier grew beyond tolerance — refresh the budget consciously with \`node .agents/scripts/check-context-budget.js --update\` once the growth is intentional\n`,
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
if (diff.shrunk.length > 0) {
|
|
625
|
+
stderr.write(
|
|
626
|
+
`[context-budget] ❌ a documentation tier came in under its recorded total — the ratchet is holding slack the tree no longer spends, so the next growth would be absorbed silently. Lock the gain in with \`node .agents/scripts/check-context-budget.js --update\`\n`,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
if (diff.absent.length > 0) {
|
|
630
|
+
stderr.write(
|
|
631
|
+
`[context-budget] ❌ a recorded row names a path the measured tier no longer contains — its bytes inflate the recorded total against nothing. Refresh with \`node .agents/scripts/check-context-budget.js --update\`\n`,
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* @param {object} params
|
|
638
|
+
* @returns {void}
|
|
639
|
+
*/
|
|
640
|
+
function renderTextReport({ tierMap, baseline, report, stdout, stderr }) {
|
|
641
|
+
const { diff, bootOverflow, bootDrift, exitCode } = report;
|
|
642
|
+
stdout.write(`\n--- context-budget preview ---\n`);
|
|
643
|
+
stdout.write(`${renderDiff(diff)}\n`);
|
|
644
|
+
const reachable = renderReachable(tierMap, baseline);
|
|
645
|
+
if (reachable) stdout.write(`${reachable}\n`);
|
|
646
|
+
for (const o of bootOverflow) {
|
|
647
|
+
stdout.write(
|
|
648
|
+
`+ agentBoot: ${o.path} is ${o.bytes} bytes, over the ${o.ceiling}-byte per-agent ceiling\n`,
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
for (const line of renderBootDrift(bootDrift)) {
|
|
652
|
+
stdout.write(`${line}\n`);
|
|
653
|
+
}
|
|
654
|
+
if (exitCode === 1) renderFailureDiagnostics({ report, stderr });
|
|
655
|
+
}
|
|
656
|
+
|
|
484
657
|
export async function runCli({
|
|
485
658
|
argv = process.argv.slice(2),
|
|
486
659
|
cwd = process.cwd(),
|
|
@@ -498,120 +671,39 @@ export async function runCli({
|
|
|
498
671
|
const tierMap = resolveDocTiers(resolvedConfig, { root });
|
|
499
672
|
|
|
500
673
|
if (update) {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
? existing.toleranceBytes
|
|
504
|
-
: DEFAULT_TOLERANCE_BYTES;
|
|
505
|
-
const envelope = buildBaseline(tierMap, tolerance);
|
|
506
|
-
fs.mkdirSync(path.dirname(resolvedBaselinePath), { recursive: true });
|
|
507
|
-
fs.writeFileSync(
|
|
674
|
+
return writeUpdatedBaseline({
|
|
675
|
+
tierMap,
|
|
508
676
|
resolvedBaselinePath,
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
stdout.write(
|
|
513
|
-
`[context-budget] wrote baseline ${resolvedBaselinePath} (tolerance ${tolerance} bytes)\n`,
|
|
514
|
-
);
|
|
515
|
-
} else {
|
|
516
|
-
stdout.write(
|
|
517
|
-
`${JSON.stringify({ kind: 'context-budget-update', baselinePath: resolvedBaselinePath, envelope }, null, 2)}\n`,
|
|
518
|
-
);
|
|
519
|
-
}
|
|
520
|
-
return 0;
|
|
677
|
+
json,
|
|
678
|
+
stdout,
|
|
679
|
+
});
|
|
521
680
|
}
|
|
522
681
|
|
|
523
682
|
const baseline = loadBaseline(resolvedBaselinePath);
|
|
524
683
|
if (!baseline) {
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
stderr
|
|
531
|
-
|
|
532
|
-
);
|
|
533
|
-
}
|
|
534
|
-
return 0;
|
|
684
|
+
return reportMissingBaseline({
|
|
685
|
+
tierMap,
|
|
686
|
+
resolvedBaselinePath,
|
|
687
|
+
json,
|
|
688
|
+
stdout,
|
|
689
|
+
stderr,
|
|
690
|
+
});
|
|
535
691
|
}
|
|
536
692
|
|
|
537
|
-
const
|
|
538
|
-
const ceiling = Number.isFinite(baseline?.agentBoot?.ceilingBytes)
|
|
539
|
-
? baseline.agentBoot.ceilingBytes
|
|
540
|
-
: AGENT_BOOT_CEILING_BYTES;
|
|
541
|
-
const bootOverflow = agentBootOverflow(tierMap, ceiling);
|
|
542
|
-
const bootDrift = agentBootDrift(tierMap, baseline, ceiling);
|
|
543
|
-
const permissiveDrift = bootDrift.filter((d) => d.direction === 'permissive');
|
|
544
|
-
const exitCode =
|
|
545
|
-
budgetFailureCount(diff) > 0 ||
|
|
546
|
-
bootOverflow.length > 0 ||
|
|
547
|
-
permissiveDrift.length > 0
|
|
548
|
-
? 1
|
|
549
|
-
: 0;
|
|
550
|
-
|
|
693
|
+
const report = evaluateBudget({ tierMap, baseline });
|
|
551
694
|
if (json) {
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
GATED_TIERS.map((t) => [t, tierTotalBytes(tierMap.tiers[t] ?? [])]),
|
|
560
|
-
),
|
|
561
|
-
grown: diff.grown,
|
|
562
|
-
shrunk: diff.shrunk,
|
|
563
|
-
absent: diff.absent,
|
|
564
|
-
skipped: diff.skipped,
|
|
565
|
-
agentBootCeilingBytes: ceiling,
|
|
566
|
-
agentBootOverflow: bootOverflow,
|
|
567
|
-
agentBootDrift: bootDrift,
|
|
568
|
-
workflowReachableBytes: tierMap.workflowClosure?.reachableTotalBytes ?? 0,
|
|
569
|
-
exitCode,
|
|
570
|
-
};
|
|
571
|
-
stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
|
|
695
|
+
renderJsonReport({
|
|
696
|
+
tierMap,
|
|
697
|
+
baseline,
|
|
698
|
+
resolvedBaselinePath,
|
|
699
|
+
report,
|
|
700
|
+
stdout,
|
|
701
|
+
});
|
|
572
702
|
} else {
|
|
573
|
-
|
|
574
|
-
stdout.write(`${renderDiff(diff)}\n`);
|
|
575
|
-
const reachable = renderReachable(tierMap, baseline);
|
|
576
|
-
if (reachable) stdout.write(`${reachable}\n`);
|
|
577
|
-
for (const o of bootOverflow) {
|
|
578
|
-
stdout.write(
|
|
579
|
-
`+ agentBoot: ${o.path} is ${o.bytes} bytes, over the ${o.ceiling}-byte per-agent ceiling\n`,
|
|
580
|
-
);
|
|
581
|
-
}
|
|
582
|
-
for (const line of renderBootDrift(bootDrift)) {
|
|
583
|
-
stdout.write(`${line}\n`);
|
|
584
|
-
}
|
|
585
|
-
if (exitCode === 1) {
|
|
586
|
-
if (permissiveDrift.length > 0) {
|
|
587
|
-
stderr.write(
|
|
588
|
-
`[context-budget] ❌ a recorded agentBoot row understates the file it describes, so it overstates the headroom an author would size an edit against — refresh it with \`node .agents/scripts/check-context-budget.js --update\` (the ceiling is unchanged)\n`,
|
|
589
|
-
);
|
|
590
|
-
}
|
|
591
|
-
if (bootOverflow.length > 0) {
|
|
592
|
-
stderr.write(
|
|
593
|
-
`[context-budget] ❌ a role-agent boot context exceeds the ${ceiling}-byte per-agent ceiling — trim the role def (the ceiling is a hard cap, not a starve target)\n`,
|
|
594
|
-
);
|
|
595
|
-
}
|
|
596
|
-
if (diff.grown.length > 0) {
|
|
597
|
-
stderr.write(
|
|
598
|
-
`[context-budget] ❌ a documentation tier grew beyond tolerance — refresh the budget consciously with \`node .agents/scripts/check-context-budget.js --update\` once the growth is intentional\n`,
|
|
599
|
-
);
|
|
600
|
-
}
|
|
601
|
-
if (diff.shrunk.length > 0) {
|
|
602
|
-
stderr.write(
|
|
603
|
-
`[context-budget] ❌ a documentation tier came in under its recorded total — the ratchet is holding slack the tree no longer spends, so the next growth would be absorbed silently. Lock the gain in with \`node .agents/scripts/check-context-budget.js --update\`\n`,
|
|
604
|
-
);
|
|
605
|
-
}
|
|
606
|
-
if (diff.absent.length > 0) {
|
|
607
|
-
stderr.write(
|
|
608
|
-
`[context-budget] ❌ a recorded row names a path the measured tier no longer contains — its bytes inflate the recorded total against nothing. Refresh with \`node .agents/scripts/check-context-budget.js --update\`\n`,
|
|
609
|
-
);
|
|
610
|
-
}
|
|
611
|
-
}
|
|
703
|
+
renderTextReport({ tierMap, baseline, report, stdout, stderr });
|
|
612
704
|
}
|
|
613
705
|
|
|
614
|
-
return exitCode;
|
|
706
|
+
return report.exitCode;
|
|
615
707
|
}
|
|
616
708
|
|
|
617
709
|
async function main() {
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
resolveCyclomaticPolicy,
|
|
36
36
|
scanCyclomatic,
|
|
37
37
|
} from './lib/cyclomatic-ceiling.js';
|
|
38
|
+
import { resolveScanScope } from './lib/cyclomatic-scope.js';
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* Parse `--baseline <path>`, `--json`, and `--update`.
|
|
@@ -82,6 +83,86 @@ function loadCyclomaticBaseline(baselinePath) {
|
|
|
82
83
|
}
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
/**
|
|
87
|
+
* `--update`: rewrite the baseline from the current (whole-tree) scan. The
|
|
88
|
+
* sanctioned motion after a deliberate refactor lands, and the only way the
|
|
89
|
+
* recorded breach count is allowed to rise.
|
|
90
|
+
*
|
|
91
|
+
* @param {{ scan: object, ceiling: number, baselinePath: string, writeFileImpl: Function, stdout: { write: (s: string) => void } }} args
|
|
92
|
+
* @returns {number} Always 0 — writing a baseline cannot fail the ratchet.
|
|
93
|
+
*/
|
|
94
|
+
function writeUpdatedBaseline({
|
|
95
|
+
scan,
|
|
96
|
+
ceiling,
|
|
97
|
+
baselinePath,
|
|
98
|
+
writeFileImpl,
|
|
99
|
+
stdout,
|
|
100
|
+
}) {
|
|
101
|
+
const envelope = buildCyclomaticEnvelope({ rows: scan.rows, ceiling });
|
|
102
|
+
writeFileImpl(baselinePath, `${JSON.stringify(envelope, null, 2)}\n`);
|
|
103
|
+
stdout.write(
|
|
104
|
+
`[cyclomatic] wrote ${scan.rows.length} breach row(s) at ceiling c=${ceiling} to ${baselinePath}\n`,
|
|
105
|
+
);
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* `--json`: the machine-readable comparison envelope.
|
|
111
|
+
*
|
|
112
|
+
* @param {{ policy: object, baseline: object|null, baselineRows: Array<object>, baselinePath: string, scan: object, diff: object, exitCode: number }} args
|
|
113
|
+
* @returns {string}
|
|
114
|
+
*/
|
|
115
|
+
function renderJsonReport({
|
|
116
|
+
policy,
|
|
117
|
+
baseline,
|
|
118
|
+
baselineRows,
|
|
119
|
+
baselinePath,
|
|
120
|
+
scan,
|
|
121
|
+
diff,
|
|
122
|
+
exitCode,
|
|
123
|
+
}) {
|
|
124
|
+
return `${JSON.stringify(
|
|
125
|
+
{
|
|
126
|
+
kind: 'cyclomatic-report',
|
|
127
|
+
ceiling: policy.mustFix,
|
|
128
|
+
flag: policy.flag,
|
|
129
|
+
baselinePath,
|
|
130
|
+
baselineCeiling: baseline?.ceiling ?? null,
|
|
131
|
+
scannedFiles: scan.scannedFiles,
|
|
132
|
+
parseErrors: scan.parseErrors,
|
|
133
|
+
baselineRows,
|
|
134
|
+
currentRows: scan.rows,
|
|
135
|
+
...diff,
|
|
136
|
+
exitCode,
|
|
137
|
+
},
|
|
138
|
+
null,
|
|
139
|
+
2,
|
|
140
|
+
)}\n`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Announce a baseline the comparison cannot fully trust: absent, or recorded
|
|
145
|
+
* at a ceiling the config no longer uses. Both are warnings rather than
|
|
146
|
+
* failures — the diff still runs, and staying silent is what would let an
|
|
147
|
+
* operator read a meaningless verdict as a clean one.
|
|
148
|
+
*
|
|
149
|
+
* @param {{ baseline: object|null, mustFix: number, baselinePath: string, stderr: { write: (s: string) => void } }} args
|
|
150
|
+
* @returns {void}
|
|
151
|
+
*/
|
|
152
|
+
function warnAboutBaseline({ baseline, mustFix, baselinePath, stderr }) {
|
|
153
|
+
if (!baseline) {
|
|
154
|
+
stderr.write(
|
|
155
|
+
`[cyclomatic] ⚠ baseline not found at ${baselinePath} — treating as empty\n`,
|
|
156
|
+
);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (typeof baseline.ceiling === 'number' && baseline.ceiling !== mustFix) {
|
|
160
|
+
stderr.write(
|
|
161
|
+
`[cyclomatic] ⚠ baseline was recorded at ceiling c=${baseline.ceiling} but the configured cyclomaticMustFix is c=${mustFix} — re-run with --update\n`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
85
166
|
/**
|
|
86
167
|
* Top-level CLI entry. Exported so tests can drive the whole pipeline through
|
|
87
168
|
* the injected seams below without spawning a process.
|
|
@@ -109,75 +190,64 @@ export async function runCli({
|
|
|
109
190
|
writeFileImpl = (p, data) => fs.writeFileSync(p, data),
|
|
110
191
|
} = {}) {
|
|
111
192
|
const { baselinePath, json, update } = parseArgv(argv);
|
|
112
|
-
const
|
|
193
|
+
const config = resolveConfigImpl({ cwd });
|
|
194
|
+
const quality = getQuality(config);
|
|
113
195
|
const policy = resolveCyclomaticPolicy(quality);
|
|
114
196
|
const resolvedBaselinePath = path.resolve(
|
|
115
197
|
cwd,
|
|
116
198
|
baselinePath ?? DEFAULT_CYCLOMATIC_BASELINE,
|
|
117
199
|
);
|
|
118
200
|
|
|
201
|
+
// Read the baseline before scanning: its rows are half the diff scope
|
|
202
|
+
// (Story #5109). `--update` rewrites the baseline from the whole tree, and
|
|
203
|
+
// `BASELINE_SCOPE=full` is the operator's explicit "re-derive everything",
|
|
204
|
+
// so both opt out of scoping entirely.
|
|
205
|
+
const baseline = loadBaselineImpl(resolvedBaselinePath);
|
|
206
|
+
const baselineRows = Array.isArray(baseline?.rows) ? baseline.rows : [];
|
|
207
|
+
const scopeFiles = resolveScanScope({ cwd, config, update, baselineRows });
|
|
208
|
+
|
|
119
209
|
const scan = scanImpl({
|
|
120
210
|
targetDirs: policy.targetDirs,
|
|
121
211
|
ignoreGlobs: policy.ignoreGlobs,
|
|
122
212
|
ceiling: policy.mustFix,
|
|
123
213
|
cwd,
|
|
214
|
+
scopeFiles,
|
|
124
215
|
});
|
|
125
216
|
|
|
126
217
|
if (update) {
|
|
127
|
-
|
|
128
|
-
|
|
218
|
+
return writeUpdatedBaseline({
|
|
219
|
+
scan,
|
|
129
220
|
ceiling: policy.mustFix,
|
|
221
|
+
baselinePath: resolvedBaselinePath,
|
|
222
|
+
writeFileImpl,
|
|
223
|
+
stdout,
|
|
130
224
|
});
|
|
131
|
-
writeFileImpl(
|
|
132
|
-
resolvedBaselinePath,
|
|
133
|
-
`${JSON.stringify(envelope, null, 2)}\n`,
|
|
134
|
-
);
|
|
135
|
-
stdout.write(
|
|
136
|
-
`[cyclomatic] wrote ${scan.rows.length} breach row(s) at ceiling c=${policy.mustFix} to ${resolvedBaselinePath}\n`,
|
|
137
|
-
);
|
|
138
|
-
return 0;
|
|
139
225
|
}
|
|
140
226
|
|
|
141
|
-
const baseline = loadBaselineImpl(resolvedBaselinePath);
|
|
142
|
-
const baselineRows = Array.isArray(baseline?.rows) ? baseline.rows : [];
|
|
143
227
|
const diff = diffCyclomaticRows(baselineRows, scan.rows);
|
|
144
228
|
const exitCode = diff.added.length + diff.worsened.length > 0 ? 1 : 0;
|
|
145
229
|
|
|
146
230
|
if (json) {
|
|
147
231
|
stdout.write(
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
baselineRows,
|
|
158
|
-
currentRows: scan.rows,
|
|
159
|
-
...diff,
|
|
160
|
-
exitCode,
|
|
161
|
-
},
|
|
162
|
-
null,
|
|
163
|
-
2,
|
|
164
|
-
)}\n`,
|
|
232
|
+
renderJsonReport({
|
|
233
|
+
policy,
|
|
234
|
+
baseline,
|
|
235
|
+
baselineRows,
|
|
236
|
+
baselinePath: resolvedBaselinePath,
|
|
237
|
+
scan,
|
|
238
|
+
diff,
|
|
239
|
+
exitCode,
|
|
240
|
+
}),
|
|
165
241
|
);
|
|
166
242
|
return exitCode;
|
|
167
243
|
}
|
|
168
244
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
baseline.ceiling !== policy.mustFix
|
|
176
|
-
) {
|
|
177
|
-
stderr.write(
|
|
178
|
-
`[cyclomatic] ⚠ baseline was recorded at ceiling c=${baseline.ceiling} but the configured cyclomaticMustFix is c=${policy.mustFix} — re-run with --update\n`,
|
|
179
|
-
);
|
|
180
|
-
}
|
|
245
|
+
warnAboutBaseline({
|
|
246
|
+
baseline,
|
|
247
|
+
mustFix: policy.mustFix,
|
|
248
|
+
baselinePath: resolvedBaselinePath,
|
|
249
|
+
stderr,
|
|
250
|
+
});
|
|
181
251
|
stdout.write('\n--- cyclomatic preview ---\n');
|
|
182
252
|
stdout.write(`${renderCyclomaticDiff(diff, policy.mustFix)}\n`);
|
|
183
253
|
return exitCode;
|