mandrel 2.35.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.
Files changed (108) hide show
  1. package/.agents/docs/agentrc-reference.json +3 -1
  2. package/.agents/docs/configuration.md +27 -0
  3. package/.agents/rules/testing-standards.md +56 -0
  4. package/.agents/schemas/agentrc.schema.json +11 -0
  5. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  6. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  7. package/.agents/scripts/boot-sweep.js +7 -1
  8. package/.agents/scripts/check-context-budget.js +195 -103
  9. package/.agents/scripts/check-cyclomatic.js +112 -42
  10. package/.agents/scripts/check-doc-links.js +23 -2
  11. package/.agents/scripts/check-generated-validator.js +202 -0
  12. package/.agents/scripts/check-lifecycle-lint.js +2 -68
  13. package/.agents/scripts/check-schema-references.js +2 -28
  14. package/.agents/scripts/drain-pending-cleanup.js +18 -1
  15. package/.agents/scripts/git-cleanup.js +2 -0
  16. package/.agents/scripts/lib/baselines/components.js +32 -2
  17. package/.agents/scripts/lib/baselines/env-overrides.js +1 -1
  18. package/.agents/scripts/lib/bootstrap/branch-protection.js +1 -1
  19. package/.agents/scripts/lib/bootstrap/install-ledger.js +1 -1
  20. package/.agents/scripts/lib/bootstrap/prompt.js +1 -1
  21. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +2 -2
  22. package/.agents/scripts/lib/checks/loop-health.js +1 -1
  23. package/.agents/scripts/lib/cli-args.js +0 -2
  24. package/.agents/scripts/lib/close-validation/gates.js +1 -1
  25. package/.agents/scripts/lib/close-validation/process.js +1 -1
  26. package/.agents/scripts/lib/config/ci.js +18 -0
  27. package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
  28. package/.agents/scripts/lib/config-settings-schema-delivery.js +13 -0
  29. package/.agents/scripts/lib/config-settings-schema.js +46 -6
  30. package/.agents/scripts/lib/coverage-baseline.js +2 -2
  31. package/.agents/scripts/lib/cpu-pool.js +90 -10
  32. package/.agents/scripts/lib/crap-utils.js +6 -2
  33. package/.agents/scripts/lib/cyclomatic-ceiling.js +28 -6
  34. package/.agents/scripts/lib/cyclomatic-scope.js +144 -0
  35. package/.agents/scripts/lib/dependency-version.js +86 -0
  36. package/.agents/scripts/lib/duplicate-search.js +1 -1
  37. package/.agents/scripts/lib/errors/index.js +1 -1
  38. package/.agents/scripts/lib/feedback-loop/graduator-core.js +2 -2
  39. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +1 -1
  40. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +1 -1
  41. package/.agents/scripts/lib/generated/agentrc-validator.js +16 -0
  42. package/.agents/scripts/lib/install-cmd-parser.js +1 -1
  43. package/.agents/scripts/lib/knip-entry-sync.js +1 -57
  44. package/.agents/scripts/lib/maintainability-utils.js +108 -10
  45. package/.agents/scripts/lib/observability/source-classifier.js +1 -1
  46. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +22 -7
  47. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -14
  48. package/.agents/scripts/lib/orchestration/git-cleanup/phases/merged-tip.js +132 -0
  49. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +56 -11
  50. package/.agents/scripts/lib/orchestration/merge-block-class.js +10 -1
  51. package/.agents/scripts/lib/orchestration/merge-poll.js +164 -0
  52. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +70 -9
  53. package/.agents/scripts/lib/orchestration/pr-watch.js +1 -30
  54. package/.agents/scripts/lib/orchestration/reassert-status-column.js +2 -2
  55. package/.agents/scripts/lib/orchestration/review-providers/codex.js +3 -3
  56. package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +1 -1
  57. package/.agents/scripts/lib/orchestration/review-providers/native.js +9 -2
  58. package/.agents/scripts/lib/orchestration/review-providers/review-depth.js +2 -2
  59. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -3
  60. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +145 -0
  61. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -5
  62. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +52 -1
  63. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +9 -1
  64. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +2 -5
  65. package/.agents/scripts/lib/orchestration/story-follow-ups.js +1 -1
  66. package/.agents/scripts/lib/orchestration/ticket-lease.js +34 -9
  67. package/.agents/scripts/lib/orchestration/ticketing/reads.js +3 -1
  68. package/.agents/scripts/lib/preflight-runner.js +3 -3
  69. package/.agents/scripts/lib/qa/qa-session.js +1 -1
  70. package/.agents/scripts/lib/runtime-deps/manifest.js +1 -7
  71. package/.agents/scripts/lib/runtime-deps/scan-imports.js +3 -58
  72. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +284 -25
  73. package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
  74. package/.agents/scripts/lib/source-text/strip-js-comments.js +115 -0
  75. package/.agents/scripts/lib/test-isolate/runner.js +3 -3
  76. package/.agents/scripts/lib/test-runner-contract.js +14 -6
  77. package/.agents/scripts/lib/test-tiers.js +135 -18
  78. package/.agents/scripts/lib/transpile.js +16 -3
  79. package/.agents/scripts/lib/worktree/lifecycle/pending-cleanup.js +169 -31
  80. package/.agents/scripts/lib/worktree/node-modules-strategy.js +2 -5
  81. package/.agents/scripts/notify.js +4 -10
  82. package/.agents/scripts/pr-watch-with-update.js +305 -137
  83. package/.agents/scripts/providers/github/tickets.js +109 -16
  84. package/.agents/scripts/quality-preview.js +162 -70
  85. package/.agents/scripts/run-test-profile.js +8 -5
  86. package/.agents/scripts/run-tests.js +79 -14
  87. package/.agents/scripts/single-story-close.js +0 -2
  88. package/.agents/scripts/single-story-init.js +5 -1
  89. package/.agents/workflows/audit-documentation.md +5 -6
  90. package/docs/CHANGELOG.md +50 -0
  91. package/lib/cli/migrate.js +100 -94
  92. package/lib/migrations/helpers/retire-agentrc-key.js +200 -0
  93. package/lib/migrations/index.js +32 -33
  94. package/lib/migrations/steps/2.1.0-retire-mi-drop-knobs.js +21 -80
  95. package/lib/migrations/steps/2.1.0-retire-verify-concurrency-cap.js +15 -87
  96. package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +13 -71
  97. package/lib/migrations/steps/2.20.0-retire-codebase-snapshot.js +13 -101
  98. package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +13 -100
  99. package/package.json +9 -5
  100. package/.agents/scripts/generate-lifecycle-docs.js +0 -237
  101. package/.agents/scripts/lib/audit-suite/frontmatter-lint.js +0 -32
  102. package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +0 -37
  103. package/.agents/scripts/lib/cli/parse-numeric.js +0 -60
  104. package/.agents/scripts/lib/close-validation/telemetry.js +0 -79
  105. package/.agents/scripts/lib/orchestration/label-transitions.js +0 -44
  106. package/.agents/scripts/lib/orchestration/parked-follow-ons.js +0 -147
  107. package/.agents/scripts/lib/orchestration/phase-runner.js +0 -88
  108. package/.agents/scripts/lib/orchestration/recut.js +0 -56
@@ -16,7 +16,7 @@ import { Logger } from '../Logger.js';
16
16
  // rounding) routinely drifts +/- 0.05 to 0.3 on otherwise-unchanged files —
17
17
  // well below the threshold of "actually less maintainable." A 0.5 floor
18
18
  // stops the pre-push hook from auto-ratcheting the baseline on noise.
19
- export const MI_DEFAULT_TOLERANCE = 0.5;
19
+ const MI_DEFAULT_TOLERANCE = 0.5;
20
20
 
21
21
  /**
22
22
  * Pure helper: resolve the effective CRAP config by layering env-var
@@ -43,7 +43,7 @@ const TARGET_APPROVAL_COUNT = 0;
43
43
  * return value is a structured diff suitable for `hitlConfirm` so the
44
44
  * operator sees exactly what would flip.
45
45
  */
46
- export function diffProtection(current, targetContexts) {
46
+ function diffProtection(current, targetContexts) {
47
47
  if (!current) return null; // create-from-scratch path; no diff needed.
48
48
 
49
49
  const diff = {};
@@ -80,7 +80,7 @@ const TARGET_TO_PHASE = Object.freeze({
80
80
  * @param {Record<string, { action?: string }>} [report]
81
81
  * @returns {string|undefined}
82
82
  */
83
- export function resolveExecutedAction(entry, report) {
83
+ function resolveExecutedAction(entry, report) {
84
84
  if (!report) return undefined;
85
85
  const phaseName = TARGET_TO_PHASE[entry.target];
86
86
  if (!phaseName) return undefined;
@@ -28,7 +28,7 @@ import readline from 'node:readline/promises';
28
28
  * Flags the bootstrap CLI accepts. Keep this list in sync with the
29
29
  * `--help` text in bootstrap.js.
30
30
  */
31
- export const KNOWN_FLAGS = Object.freeze({
31
+ const KNOWN_FLAGS = Object.freeze({
32
32
  string: [
33
33
  'owner',
34
34
  'repo',
@@ -41,7 +41,7 @@ import { deepEqual } from '../json-utils.js';
41
41
  * the hook-installer can detect a verbatim framework hook (overwrite-safe)
42
42
  * vs a custom hook (preserve and warn).
43
43
  */
44
- export const FRAMEWORK_PRE_COMMIT = `node scripts/check-version-sync.js
44
+ const FRAMEWORK_PRE_COMMIT = `node scripts/check-version-sync.js
45
45
  npx lint-staged
46
46
  # Story #1395 / Epic #1386: catch MI/CRAP drift at git-commit time so the
47
47
  # agent refactors before the diff is closed. quality:preview wraps both gates
@@ -78,7 +78,7 @@ export const PRE_COMMIT_MARKER =
78
78
  * when the keys are absent. Mirrors `.agents/docs/agentrc-reference.json` — keep in
79
79
  * sync when those numbers move.
80
80
  */
81
- export const QUALITY_CONFIG_DEFAULTS = Object.freeze({
81
+ const QUALITY_CONFIG_DEFAULTS = Object.freeze({
82
82
  codingGuardrails: Object.freeze({
83
83
  cyclomaticFlag: 8,
84
84
  cyclomaticMustFix: 12,
@@ -42,7 +42,7 @@ import { validateSignal } from '../observability/signal-validator.js';
42
42
  * a health probe, not an exhaustive audit — the most recent window is a
43
43
  * representative sample that keeps the read bounded on long streams.
44
44
  */
45
- export const MAX_SAMPLE_LINES = 200;
45
+ const MAX_SAMPLE_LINES = 200;
46
46
 
47
47
  /**
48
48
  * Locate the most-recently-touched `run-<id>` temp tree under
@@ -210,7 +210,6 @@ export function parseSprintArgs(
210
210
  'recut-of': { type: 'string' },
211
211
  resume: { type: 'boolean', default: false },
212
212
  restart: { type: 'boolean', default: false },
213
- 'no-evidence': { type: 'boolean', default: false },
214
213
  },
215
214
  allowPositionals: true,
216
215
  strict: false,
@@ -260,7 +259,6 @@ export function parseSprintArgs(
260
259
  // CLI compatibility).
261
260
  resume: values.resume ?? false,
262
261
  restart: values.restart ?? false,
263
- noEvidence: values['no-evidence'] ?? false,
264
262
  };
265
263
 
266
264
  parsed.ticketId =
@@ -392,7 +392,7 @@ export const DEFAULT_GATES = buildDefaultGates();
392
392
  * state, no overlapping ports/sockets). Safe to run concurrently — see
393
393
  * `runCloseValidation` for the Promise.all + AbortController plumbing.
394
394
  */
395
- export const INDEPENDENT_GATE_NAMES = new Set(['lint', 'format', 'typecheck']);
395
+ const INDEPENDENT_GATE_NAMES = new Set(['lint', 'format', 'typecheck']);
396
396
 
397
397
  /**
398
398
  * Partition a gate list into the parallel-safe set and the order-sensitive
@@ -113,7 +113,7 @@ const MARKER_PROBE_TAIL_LINES = 32;
113
113
  * @param {string} output - Combined stdout/stderr captured from the gate child.
114
114
  * @returns {boolean}
115
115
  */
116
- export function isBiomeNoFilesProcessed(output) {
116
+ function isBiomeNoFilesProcessed(output) {
117
117
  return (
118
118
  typeof output === 'string' && output.includes(BIOME_NO_FILES_PROCESSED)
119
119
  );
@@ -5,6 +5,13 @@
5
5
  * (default `"trust-ci"`) selects the merge posture — `"trust-ci"` merges once
6
6
  * required checks pass, `"strict"` additionally requires a clean review gate.
7
7
  *
8
+ * Story #5096 added `blockOnAdvisoryFailure` (default `true`) and
9
+ * `advisoryAllowlist` (default `[]`). GitHub native auto-merge waits on
10
+ * REQUIRED contexts only, so a red ADVISORY gate would otherwise be merged
11
+ * straight past; these knobs own mandrel's side of that decision. Set
12
+ * `blockOnAdvisoryFailure: false` to restore the pre-#5096 behaviour verbatim,
13
+ * or list a job name in `advisoryAllowlist` to exempt just that one.
14
+ *
8
15
  * Retired (no production readers on v2 Story-only delivery): `earlyPr`
9
16
  * (Epic early-PR warmup) and `requireChecks` (AutomergePredicate escape hatch
10
17
  * whose listener was never landed).
@@ -12,6 +19,8 @@
12
19
 
13
20
  export const CI_DELIVERY_DEFAULTS = Object.freeze({
14
21
  autoMerge: 'trust-ci',
22
+ blockOnAdvisoryFailure: true,
23
+ advisoryAllowlist: Object.freeze([]),
15
24
  });
16
25
 
17
26
  /**
@@ -31,6 +40,15 @@ export function getCiDelivery(config) {
31
40
  ci.autoMerge === 'trust-ci' || ci.autoMerge === 'strict'
32
41
  ? ci.autoMerge
33
42
  : CI_DELIVERY_DEFAULTS.autoMerge,
43
+ blockOnAdvisoryFailure:
44
+ typeof ci.blockOnAdvisoryFailure === 'boolean'
45
+ ? ci.blockOnAdvisoryFailure
46
+ : CI_DELIVERY_DEFAULTS.blockOnAdvisoryFailure,
47
+ advisoryAllowlist: Array.isArray(ci.advisoryAllowlist)
48
+ ? ci.advisoryAllowlist.filter(
49
+ (entry) => typeof entry === 'string' && entry,
50
+ )
51
+ : [...CI_DELIVERY_DEFAULTS.advisoryAllowlist],
34
52
  watch:
35
53
  ci.watch && typeof ci.watch === 'object' ? { ...ci.watch } : undefined,
36
54
  };
@@ -173,7 +173,7 @@ export function collectRedundantAdvisories(
173
173
  * @param {string} dottedPath
174
174
  * @returns {boolean}
175
175
  */
176
- export function isLeafSchemaRemovable(rootSchema, dottedPath) {
176
+ function isLeafSchemaRemovable(rootSchema, dottedPath) {
177
177
  if (!rootSchema || typeof rootSchema !== 'object') return true;
178
178
  const parts = dottedPath.split('.');
179
179
  let cursor = rootSchema;
@@ -389,6 +389,19 @@ const CI_DELIVERY_SCHEMA = {
389
389
  "Story #4356 (Epic #4355). Merge posture. 'trust-ci' (default) merges once required checks pass; 'strict' additionally requires a clean review gate.",
390
390
  default: CI_DELIVERY_DEFAULTS.autoMerge,
391
391
  },
392
+ blockOnAdvisoryFailure: {
393
+ type: 'boolean',
394
+ description:
395
+ 'Story #5096. When true (default), delivery refuses to arm — and disarms — GitHub native auto-merge while a non-required (advisory) check is genuinely red on the PR head and GitHub reports the PR mergeable anyway (mergeStateStatus=UNSTABLE). `--auto` waits on REQUIRED contexts only, so without this a red advisory quality gate merges unattended. Set false to restore the pre-#5096 behaviour verbatim.',
396
+ default: CI_DELIVERY_DEFAULTS.blockOnAdvisoryFailure,
397
+ },
398
+ advisoryAllowlist: {
399
+ type: 'array',
400
+ items: { type: 'string' },
401
+ description:
402
+ 'Story #5096. Check-run names exempt from blockOnAdvisoryFailure — a red run whose name matches exactly never blocks arming. Matching is exact; an unnamed run can never match and always blocks.',
403
+ default: [...CI_DELIVERY_DEFAULTS.advisoryAllowlist],
404
+ },
392
405
  },
393
406
  additionalProperties: false,
394
407
  };
@@ -1,7 +1,7 @@
1
1
  /* node:coverage ignore file -- AJV schema declaration (data-as-code); MI < 70 is inherent to large flat schema literals, no business logic to test */
2
2
 
3
- import Ajv from 'ajv';
4
-
3
+ import { createRequire } from 'node:module';
4
+ import process from 'node:process';
5
5
  import { COMMANDS_DEFAULTS } from './config/commands.js';
6
6
  import {
7
7
  BRANCH_PROTECTION_DEFAULTS,
@@ -15,6 +15,7 @@ import { SHELL_INJECTION_PATTERN_STRING } from './config-schema-shared.js';
15
15
  // to keep this aggregate module above the maintainability floor. The
16
16
  // resolved AGENTRC_SCHEMA is unchanged.
17
17
  import { DELIVERY_SCHEMA } from './config-settings-schema-delivery.js';
18
+ import compiledAgentrcValidator from './generated/agentrc-validator.js';
18
19
 
19
20
  /**
20
21
  * Annotation contract (Story #5007). These schema literals are the SINGLE
@@ -861,10 +862,49 @@ export const AGENTRC_SCHEMA = {
861
862
  };
862
863
 
863
864
  let _agentrcValidator = null;
865
+
866
+ /**
867
+ * Compile `AGENTRC_SCHEMA` with a live AJV instance.
868
+ *
869
+ * `ajv` is pulled in through `createRequire` rather than a top-level
870
+ * `import` so the module never enters the graph on the fast path
871
+ * (Story #5109). Loading AJV was itself a measurable share of the ~35 ms
872
+ * this function used to cost in each of the 36 config-touching entry
873
+ * scripts, and the precompiled validator needs none of it.
874
+ *
875
+ * @returns {import('ajv').ValidateFunction}
876
+ */
877
+ function compileAgentrcValidatorDynamically() {
878
+ const require = createRequire(import.meta.url);
879
+ const ajvModule = require('ajv');
880
+ const Ajv = ajvModule.default ?? ajvModule;
881
+ const ajv = new Ajv({ allErrors: true });
882
+ return ajv.compile(AGENTRC_SCHEMA);
883
+ }
884
+
885
+ /**
886
+ * The `.agentrc.json` validator.
887
+ *
888
+ * Returns the **precompiled** validator committed at
889
+ * `lib/generated/agentrc-validator.js` — AJV's standalone emit for the exact
890
+ * `AGENTRC_SCHEMA` literal above, kept in step by
891
+ * `check-generated-validator.js --check` inside `npm run lint`. The verdicts
892
+ * are AJV's own, produced by AJV's own generated code with the same
893
+ * `allErrors: true` option the dynamic path uses, so nothing about what a
894
+ * config is allowed to contain changes — only when the codegen runs.
895
+ *
896
+ * Set `MANDREL_AGENTRC_VALIDATOR=dynamic` to compile at runtime instead. That
897
+ * escape hatch exists so a consumer who has hand-edited the schema (or hit a
898
+ * platform where the generated module will not load) is never stuck with a
899
+ * validator they cannot regenerate; it costs the ~35 ms the artifact removes.
900
+ *
901
+ * @returns {import('ajv').ValidateFunction}
902
+ */
864
903
  export function getAgentrcValidator() {
865
- if (!_agentrcValidator) {
866
- const ajv = new Ajv({ allErrors: true });
867
- _agentrcValidator = ajv.compile(AGENTRC_SCHEMA);
868
- }
904
+ if (_agentrcValidator) return _agentrcValidator;
905
+ _agentrcValidator =
906
+ process.env.MANDREL_AGENTRC_VALIDATOR === 'dynamic'
907
+ ? compileAgentrcValidatorDynamically()
908
+ : compiledAgentrcValidator;
869
909
  return _agentrcValidator;
870
910
  }
@@ -22,7 +22,7 @@ import path from 'node:path';
22
22
  import picomatch from 'picomatch';
23
23
  import { write, writeFile } from './baselines/writer.js';
24
24
 
25
- export const COVERAGE_FINAL_PATH = 'coverage/coverage-final.json';
25
+ const COVERAGE_FINAL_PATH = 'coverage/coverage-final.json';
26
26
  export const COVERAGE_BASELINE_PATH = 'baselines/coverage.json';
27
27
  // Absolute floating-point tolerance (percentage points). Values in the
28
28
  // baseline are stored to two decimals, so anything below 0.01 is noise.
@@ -32,7 +32,7 @@ export const COVERAGE_TOLERANCE = 0.01;
32
32
  // branch flipping covered↔uncovered between runs is the natural noise floor
33
33
  // under non-deterministic Windows/Node 22 V8 instrumentation. We absorb up
34
34
  // to one event of slack per axis. Anything beyond one event is real signal.
35
- export const NOISE_EVENT_HEADROOM = 1.0;
35
+ const NOISE_EVENT_HEADROOM = 1.0;
36
36
 
37
37
  function toForwardSlash(p) {
38
38
  return p.replace(/\\/g, '/');
@@ -2,7 +2,9 @@
2
2
  * lib/cpu-pool.js — Generic worker_threads pool for CPU-bound work.
3
3
  *
4
4
  * `runOnPool(workerScript, items, opts)` spawns up to
5
- * `opts.concurrency ?? os.availableParallelism()` persistent workers,
5
+ * `resolvePoolConcurrency(opts.concurrency)` persistent workers — an
6
+ * explicit option, else `MANDREL_POOL_CONCURRENCY`, else a clamp of 4 under
7
+ * `node:test`, else `os.availableParallelism()` —
6
8
  * dispatches `items` to whichever worker is idle, and resolves with an
7
9
  * array of per-item results in input order. The queue is bounded by
8
10
  * worker count — we only have N items in flight at once, where N =
@@ -58,15 +60,93 @@ const defaultWorkerFactory = (script, options) => new Worker(script, options);
58
60
  * Pool-vs-serial cutover for `runOnPool` callers.
59
61
  *
60
62
  * Below this batch size the pool's worker spawn overhead dominates, so
61
- * callers fall back to in-process serial scoring. Tuned against the test
62
- * suite's tmpdir fixtures (n=2 stays serial; the full repo n≈200–470
63
- * takes the pool path). Single-sourced here so the maintainability
64
- * baseline scan (`maintainability-utils.js`), the CRAP scanner
65
- * (`crap-utils.js`), and the native review provider
66
- * (`review-providers/native.js`) cannot silently desynchronize on a
67
- * retune.
63
+ * callers fall back to in-process serial scoring. Single-sourced here so the
64
+ * maintainability baseline scan (`maintainability-utils.js`), the CRAP
65
+ * scanner (`crap-utils.js`), and the native review provider
66
+ * (`review-providers/native.js`) cannot silently desynchronize on a retune.
67
+ *
68
+ * **Retuned from 8 to 256 in Story #5109.** The original 8 was set against
69
+ * tmpdir fixtures and never measured against a real batch, so every
70
+ * pre-commit preview — the most frequent path in the framework — paid for a
71
+ * pool it could not amortise. Measured on this repository (18 logical cores,
72
+ * `.agents/scripts` + `bin` + `lib`, MI scoring):
73
+ *
74
+ * ```text
75
+ * n=16 serial 125 ms pooled 444 ms
76
+ * n=64 serial 175 ms pooled 535 ms
77
+ * n=128 serial 241 ms pooled 422 ms
78
+ * n=256 serial 440 ms pooled 707 ms <- still serial-favourable
79
+ * n=384 serial 943 ms pooled 782 ms <- crossover
80
+ * n=619 serial 1054 ms pooled 710 ms
81
+ * ```
82
+ *
83
+ * The crossover sits between 256 and 384, so 256 is the last power-of-two
84
+ * step the serial path provably wins. A whole-tree baseline refresh
85
+ * (n≈619) still takes the pool; a diff-scoped preview (n≈50) no longer
86
+ * spawns a worker at all. Scoring output is identical on either path — the
87
+ * worker runs the same scorer — so this is a cost retune, never a verdict
88
+ * change.
89
+ */
90
+ export const POOL_SERIAL_THRESHOLD = 256;
91
+
92
+ /**
93
+ * Hard ceiling on pool width when the process is a `node:test` child.
94
+ *
95
+ * Node's test runner already fans test *files* out across processes, so a
96
+ * pool inside one of them multiplies against that fan-out and can oversubscribe
97
+ * the host by an order of magnitude. Four is wide enough to keep the pooled
98
+ * code path genuinely concurrent (so the scheduling branches under test stay
99
+ * exercised) without letting a suite of parallel test processes each claim
100
+ * every core.
101
+ */
102
+ const NODE_TEST_CONCURRENCY_CLAMP = 4;
103
+
104
+ /**
105
+ * True when this process was spawned by Node's own test runner, which sets
106
+ * `NODE_TEST_CONTEXT` in every test child.
107
+ *
108
+ * @param {NodeJS.ProcessEnv} env
109
+ * @returns {boolean}
110
+ */
111
+ function inNodeTestContext(env) {
112
+ return (
113
+ typeof env.NODE_TEST_CONTEXT === 'string' && env.NODE_TEST_CONTEXT !== ''
114
+ );
115
+ }
116
+
117
+ /**
118
+ * Resolve the requested pool width, in strict precedence order:
119
+ *
120
+ * 1. an explicit `opts.concurrency` — the caller knows its own budget;
121
+ * 2. `MANDREL_POOL_CONCURRENCY` — the operator/CI override, so a
122
+ * constrained runner can bound every pool in the process tree at once;
123
+ * 3. `NODE_TEST_CONCURRENCY_CLAMP` when running under `node:test`;
124
+ * 4. `os.availableParallelism()`.
125
+ *
126
+ * Only *finite, positive* values are honoured at each step; anything else
127
+ * falls through to the next, so a typo in the env var degrades to the
128
+ * default rather than collapsing the pool to a single worker.
129
+ *
130
+ * Module-private on purpose: `runOnPool` is the only caller, and the
131
+ * precedence is pinned through its observable worker count rather than by
132
+ * an export whose sole importer would be a test.
133
+ *
134
+ * @param {number|undefined} explicit
135
+ * @param {NodeJS.ProcessEnv} [env]
136
+ * @returns {number}
68
137
  */
69
- export const POOL_SERIAL_THRESHOLD = 8;
138
+ function resolvePoolConcurrency(explicit, env = process.env) {
139
+ const positive = (value) => {
140
+ const n = Number(value);
141
+ return Number.isFinite(n) && n >= 1 ? Math.floor(n) : null;
142
+ };
143
+ return (
144
+ positive(explicit) ??
145
+ positive(env.MANDREL_POOL_CONCURRENCY) ??
146
+ (inNodeTestContext(env) ? NODE_TEST_CONCURRENCY_CLAMP : null) ??
147
+ os.availableParallelism()
148
+ );
149
+ }
70
150
 
71
151
  /**
72
152
  * @template TItem, TResult
@@ -90,7 +170,7 @@ export async function runOnPool(workerScript, items, opts = {}) {
90
170
  const itemsArr = [...items];
91
171
  if (itemsArr.length === 0) return [];
92
172
 
93
- const requested = opts.concurrency ?? os.availableParallelism();
173
+ const requested = resolvePoolConcurrency(opts.concurrency);
94
174
  const concurrency = Math.max(1, Math.min(requested, itemsArr.length));
95
175
  const workerData = opts.workerData;
96
176
  const throwOnItemError = opts.throwOnItemError === true;
@@ -21,7 +21,10 @@ import {
21
21
  const CRAP_WORKER_URL = new URL('./workers/crap-worker.js', import.meta.url);
22
22
 
23
23
  // Pool-vs-serial cutover — single-sourced in cpu-pool.js (see the
24
- // POOL_SERIAL_THRESHOLD docstring for the tuning rationale).
24
+ // POOL_SERIAL_THRESHOLD docstring for the tuning rationale). Callers may
25
+ // override it per scan via `serialThreshold`, which is how the parity tests
26
+ // drive the pooled path without materialising a batch bigger than the
27
+ // cutover (Story #5109 raised it from 8 to 256).
25
28
  const SERIAL_THRESHOLD = POOL_SERIAL_THRESHOLD;
26
29
  // 1.1.0 — TypeScript support landed in 5.29.0. Bumped from 1.0.0 because
27
30
  // the scanner now emits CRAP rows for TS/TSX paths that the previous
@@ -393,6 +396,7 @@ export async function scanAndScore({
393
396
  ignoreGlobs = [],
394
397
  preScannedFiles = null,
395
398
  incremental = null,
399
+ serialThreshold = SERIAL_THRESHOLD,
396
400
  }) {
397
401
  if (!Array.isArray(targetDirs)) {
398
402
  throw new TypeError('scanAndScore: targetDirs must be an array');
@@ -430,7 +434,7 @@ export async function scanAndScore({
430
434
  // Serial below the pool cutover, and ALWAYS in incremental mode: the
431
435
  // per-file baseline lookup Maps the join needs do not cross the worker
432
436
  // boundary (Story #4981).
433
- const runSerial = queue.length < SERIAL_THRESHOLD || Boolean(incremental);
437
+ const runSerial = queue.length < serialThreshold || Boolean(incremental);
434
438
  const perFile = runSerial
435
439
  ? queue.map((item) => ({ item, result: scoreFileSerial(item, coverage) }))
436
440
  : await scoreFilesViaPool(queue, coverage);
@@ -30,8 +30,9 @@
30
30
  */
31
31
 
32
32
  import path from 'node:path';
33
+ import { selectFilesToScore } from './cyclomatic-scope.js';
33
34
  import { calculateReportForFile } from './maintainability-engine.js';
34
- import { isIgnoredByGlobs, scanDirectory } from './maintainability-utils.js';
35
+ import { scanDirectory } from './maintainability-utils.js';
35
36
 
36
37
  /** Default location of the committed breach baseline. */
37
38
  export const DEFAULT_CYCLOMATIC_BASELINE = 'baselines/cyclomatic.json';
@@ -96,20 +97,37 @@ function breachRowFor(file, methods, ceiling) {
96
97
  * `scoreFile` is a seam so tests can drive the reduction without the kernel;
97
98
  * production callers omit it.
98
99
  *
100
+ * **Scoped scoring (Story #5109).** The directory walk is cheap — a few
101
+ * `readdir` calls — but scoring is an escomplex parse per file, and re-parsing
102
+ * the whole tree on every CI run cost 0.94 s to re-derive rows that could not
103
+ * have moved. `scopeFiles`, when supplied, is the set of repo-relative POSIX
104
+ * paths that are still *scored*; everything else is walked, counted, and
105
+ * skipped. That is sound precisely because this gate is a ratchet: a verdict
106
+ * exists only for a file that either changed (so it must be re-scored) or is
107
+ * already recorded in the baseline (so its recorded count must be re-derived
108
+ * to detect an improvement or a removal). A file that is neither cannot
109
+ * produce `added` or `worsened`, and `removed` is computed from baseline files
110
+ * — all of which are in scope by construction. `scannedFiles` therefore keeps
111
+ * reporting the **whole walk**, so the reported scan surface does not shrink
112
+ * just because the scoring did. Callers pass `null` (the default) for the
113
+ * whole-tree scan, which `--update` and `BASELINE_SCOPE=full` always do.
114
+ *
99
115
  * @param {{
100
116
  * targetDirs: string[],
101
117
  * ignoreGlobs?: string[],
102
118
  * ceiling: number,
103
119
  * cwd?: string,
120
+ * scopeFiles?: Set<string> | null,
104
121
  * scoreFile?: (absPath: string) => { methods?: Array<{ cyclomatic?: number }>, parseError?: boolean },
105
122
  * }} args
106
- * @returns {{ rows: Array<object>, scannedFiles: number, parseErrors: number }}
123
+ * @returns {{ rows: Array<object>, scannedFiles: number, scoredFiles: number, parseErrors: number }}
107
124
  */
108
125
  export function scanCyclomatic({
109
126
  targetDirs,
110
127
  ignoreGlobs = [],
111
128
  ceiling,
112
129
  cwd = process.cwd(),
130
+ scopeFiles = null,
113
131
  scoreFile = calculateReportForFile,
114
132
  }) {
115
133
  const files = [];
@@ -118,21 +136,25 @@ export function scanCyclomatic({
118
136
  scanDirectory(abs, files, { cwd, ignoreGlobs });
119
137
  }
120
138
  files.sort();
139
+ const selected = selectFilesToScore(files, { cwd, ignoreGlobs, scopeFiles });
121
140
  const rows = [];
122
141
  let parseErrors = 0;
123
- for (const abs of files) {
124
- if (isIgnoredByGlobs(abs, ignoreGlobs, cwd)) continue;
142
+ for (const { abs, rel } of selected) {
125
143
  const report = scoreFile(abs);
126
144
  if (!report || report.parseError) {
127
145
  parseErrors += 1;
128
146
  continue;
129
147
  }
130
- const rel = path.relative(cwd, abs).split(path.sep).join('/');
131
148
  const row = breachRowFor(rel, report.methods, ceiling);
132
149
  if (row) rows.push(row);
133
150
  }
134
151
  rows.sort((a, b) => a.file.localeCompare(b.file));
135
- return { rows, scannedFiles: files.length, parseErrors };
152
+ return {
153
+ rows,
154
+ scannedFiles: files.length,
155
+ scoredFiles: selected.length,
156
+ parseErrors,
157
+ };
136
158
  }
137
159
 
138
160
  /**
@@ -0,0 +1,144 @@
1
+ /**
2
+ * cyclomatic-scope.js — which files the cyclomatic ratchet still has to score
3
+ * (Story #5109).
4
+ *
5
+ * `check-cyclomatic.js` re-parsed the whole tree on every run — 0.94 s of
6
+ * escomplex work to re-derive rows that could not have moved. The scan surface
7
+ * is unchanged; only the *scoring* is narrowed, and it is narrowed to exactly
8
+ * the two sets a ratchet verdict can come from:
9
+ *
10
+ * - **every file already recorded in the baseline**, because `improved`,
11
+ * `worsened` and `removed` are all computed by re-deriving a recorded
12
+ * file's current count;
13
+ * - **everything this branch changed** against `project.baseBranch`,
14
+ * including the staged, unstaged and untracked working tree, because
15
+ * `added` is what a change introduces — and a pre-push gate that only
16
+ * looked at committed work would wave through the breach an operator is
17
+ * about to commit.
18
+ *
19
+ * A file in neither set is unchanged and unrecorded: it cannot produce a
20
+ * verdict in any bucket.
21
+ *
22
+ * @module lib/cyclomatic-scope
23
+ */
24
+
25
+ import path from 'node:path';
26
+ import { createGitInterface } from './git-utils.js';
27
+ import { isIgnoredByGlobs } from './maintainability-utils.js';
28
+
29
+ /**
30
+ * Git probes whose union is "everything this working copy has touched".
31
+ *
32
+ * Three, not four: `git diff --name-only HEAD` already reports the staged
33
+ * *and* unstaged deltas, so a separate `--cached` pass would only re-list
34
+ * paths this one has, at the cost of another process spawn.
35
+ *
36
+ * @type {ReadonlyArray<ReadonlyArray<string>>}
37
+ */
38
+ const CHANGE_PROBES = Object.freeze([
39
+ Object.freeze(['diff', '--name-only', '__BASE__...HEAD']),
40
+ Object.freeze(['diff', '--name-only', 'HEAD']),
41
+ Object.freeze(['ls-files', '--others', '--exclude-standard']),
42
+ ]);
43
+
44
+ /**
45
+ * Resolve the set of repo-relative POSIX paths the ratchet still needs to
46
+ * score, or `null` when it must scan the whole tree.
47
+ *
48
+ * **Fails open, never closed.** Any git failure — a shallow clone, a missing
49
+ * base ref, a fixture directory that is not a repository at all — returns
50
+ * `null`, and the caller scans everything. Narrowing on an unreadable scope
51
+ * is how a ratchet silently stops ratcheting; paying for a whole-tree scan is
52
+ * not.
53
+ *
54
+ * @param {{
55
+ * cwd: string,
56
+ * baseRef: string,
57
+ * baselineRows?: Array<{ file?: string }>,
58
+ * git?: ReturnType<typeof createGitInterface>,
59
+ * }} args
60
+ * Module-private: `resolveScanScope` below is the only caller, and it is the
61
+ * one the CLI actually asks. Exporting both would ship a seam whose only
62
+ * importer is a test, which the `--production` dead-export pass discounts.
63
+ *
64
+ * @returns {Set<string> | null}
65
+ */
66
+ function resolveCyclomaticScope({ cwd, baseRef, baselineRows = [], git }) {
67
+ const gitIface = git ?? createGitInterface({});
68
+ const scope = new Set();
69
+ for (const row of baselineRows) {
70
+ if (typeof row?.file === 'string' && row.file) scope.add(row.file);
71
+ }
72
+ for (const probe of CHANGE_PROBES) {
73
+ const argv = probe.map((arg) =>
74
+ arg === '__BASE__...HEAD' ? `${baseRef}...HEAD` : arg,
75
+ );
76
+ const res = gitIface.gitSpawn(cwd, ...argv);
77
+ if (res?.status !== 0) return null;
78
+ for (const line of String(res.stdout ?? '').split('\n')) {
79
+ const file = line.trim().replace(/\\/g, '/');
80
+ if (file) scope.add(file);
81
+ }
82
+ }
83
+ return scope;
84
+ }
85
+
86
+ /**
87
+ * Decide the scan scope for one `check-cyclomatic` invocation.
88
+ *
89
+ * `--update` rewrites the baseline from the current tree, and
90
+ * `BASELINE_SCOPE=full` is the operator's explicit "re-derive everything" —
91
+ * both must see the whole tree, so both return `null`.
92
+ *
93
+ * @param {{
94
+ * cwd: string,
95
+ * config?: { project?: { baseBranch?: string } } | null,
96
+ * update?: boolean,
97
+ * env?: NodeJS.ProcessEnv,
98
+ * baselineRows?: Array<{ file?: string }>,
99
+ * git?: ReturnType<typeof createGitInterface>,
100
+ * }} args
101
+ * @returns {Set<string> | null} `null` means "scan everything".
102
+ */
103
+ export function resolveScanScope({
104
+ cwd,
105
+ config = null,
106
+ update = false,
107
+ env = process.env,
108
+ baselineRows = [],
109
+ git,
110
+ }) {
111
+ if (update || env?.BASELINE_SCOPE === 'full') return null;
112
+ return resolveCyclomaticScope({
113
+ cwd,
114
+ baseRef: config?.project?.baseBranch ?? 'main',
115
+ baselineRows,
116
+ git,
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Reduce a walked file list to the `{ abs, rel }` pairs that will actually be
122
+ * scored, applying the gate's `ignoreGlobs` and then the diff scope.
123
+ *
124
+ * Kept here rather than inside `scanCyclomatic` so "which files count" lives
125
+ * in one module: the ignore rule and the scope rule are the same decision
126
+ * asked twice, and splitting them across two files is how they drift.
127
+ *
128
+ * @param {string[]} files Absolute paths, as walked.
129
+ * @param {{ cwd: string, ignoreGlobs?: string[], scopeFiles?: Set<string> | null }} args
130
+ * @returns {Array<{ abs: string, rel: string }>}
131
+ */
132
+ export function selectFilesToScore(
133
+ files,
134
+ { cwd, ignoreGlobs = [], scopeFiles = null },
135
+ ) {
136
+ const selected = [];
137
+ for (const abs of files) {
138
+ if (isIgnoredByGlobs(abs, ignoreGlobs, cwd)) continue;
139
+ const rel = path.relative(cwd, abs).split(path.sep).join('/');
140
+ if (scopeFiles && !scopeFiles.has(rel)) continue;
141
+ selected.push({ abs, rel });
142
+ }
143
+ return selected;
144
+ }