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
@@ -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 quality = getQuality(resolveConfigImpl({ cwd }));
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
- const envelope = buildCyclomaticEnvelope({
128
- rows: scan.rows,
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
- `${JSON.stringify(
149
- {
150
- kind: 'cyclomatic-report',
151
- ceiling: policy.mustFix,
152
- flag: policy.flag,
153
- baselinePath: resolvedBaselinePath,
154
- baselineCeiling: baseline?.ceiling ?? null,
155
- scannedFiles: scan.scannedFiles,
156
- parseErrors: scan.parseErrors,
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
- if (!baseline) {
170
- stderr.write(
171
- `[cyclomatic] ⚠ baseline not found at ${resolvedBaselinePath} — treating as empty\n`,
172
- );
173
- } else if (
174
- typeof baseline.ceiling === 'number' &&
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;
@@ -325,6 +325,22 @@ function stripAnchorAndQuery(target) {
325
325
  return t;
326
326
  }
327
327
 
328
+ // Percent-decode a link target's path portion (Story #5090). A correctly
329
+ // encoded Markdown destination escapes URL-reserved characters — a
330
+ // file-based router's `[token]` segment is written `%5Btoken%5D`, the form
331
+ // CommonMark renderers require — but the filesystem knows only the decoded
332
+ // name. The decode is one-way and total: a malformed escape (`%zz`) degrades
333
+ // to the raw string instead of throwing `URIError`, so an undecodable target
334
+ // is resolved exactly as it was before.
335
+ function decodeLinkPath(pathOnly) {
336
+ if (!pathOnly.includes('%')) return pathOnly;
337
+ try {
338
+ return decodeURIComponent(pathOnly);
339
+ } catch {
340
+ return pathOnly;
341
+ }
342
+ }
343
+
328
344
  // --- Slash-token extraction ------------------------------------------------
329
345
 
330
346
  // Tokens look like `/<lowercase-alphanum-with-hyphens>`. We exclude tokens
@@ -394,8 +410,13 @@ export function checkFile(absPath, repoRoot) {
394
410
  // 2. Relative-link resolution.
395
411
  for (const { target, line } of extractLinks(masked)) {
396
412
  if (isExternalOrInternalAnchor(target)) continue;
397
- const pathOnly = stripAnchorAndQuery(target);
398
- if (!pathOnly) continue; // pure anchor that survived earlier check
413
+ const rawPathOnly = stripAnchorAndQuery(target);
414
+ if (!rawPathOnly) continue; // pure anchor that survived earlier check
415
+ // Decode AFTER anchor/query stripping — so an escaped `%23` cannot
416
+ // collapse into an anchor delimiter and truncate the target — and BEFORE
417
+ // resolution, so the payload-boundary branch below reports the decoded
418
+ // path rather than the escaped one.
419
+ const pathOnly = decodeLinkPath(rawPathOnly);
399
420
  let resolved;
400
421
  if (pathOnly.startsWith('/')) {
401
422
  // Treat root-absolute paths as repo-root relative.
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI: keep the committed `.agentrc` validator in step with its schema
4
+ * (Story #5109).
5
+ *
6
+ * `getAgentrcValidator()` used to compile `AGENTRC_SCHEMA` with AJV in every
7
+ * process that touched configuration — 36 of the 79 entry scripts, at ~35 ms
8
+ * each — to produce a function that is a pure derivative of a literal already
9
+ * committed to the repository. The compile is now done **once, here**, and its
10
+ * output is committed as `lib/generated/agentrc-validator.js`; the runtime
11
+ * imports that module and only falls back to a live compile behind
12
+ * `MANDREL_AGENTRC_VALIDATOR=dynamic`.
13
+ *
14
+ * A generated artifact that nothing re-derives is a lie waiting to happen, so
15
+ * this script joins the standalone-ratchet family — `check-arch-cycles.js`,
16
+ * `check-cyclomatic.js`, `check-dead-exports.js` — and follows their contract:
17
+ *
18
+ * - `--check` regenerates in memory and compares against the committed
19
+ * file. Exit 0 when they agree, exit 1 (naming the fix) when they do not.
20
+ * `npm run lint` runs this, so a schema edit that skips regeneration is
21
+ * refused before it can ship a validator that accepts the wrong config.
22
+ * - No flag rewrites the file. That is the sanctioned motion after a
23
+ * deliberate schema change.
24
+ *
25
+ * The comparison is over the exact bytes, which is what makes the gate
26
+ * meaningful: AJV's standalone emit is deterministic for a given schema and
27
+ * AJV version, so a diff means either the schema moved or the compiler did,
28
+ * and both demand a fresh artifact.
29
+ */
30
+
31
+ import fs from 'node:fs';
32
+ import { createRequire } from 'node:module';
33
+ import path from 'node:path';
34
+ import process from 'node:process';
35
+ import { runAsCli } from './lib/cli-utils.js';
36
+ import { AGENTRC_SCHEMA } from './lib/config-settings-schema.js';
37
+
38
+ const require = createRequire(import.meta.url);
39
+
40
+ /** Repo-relative home of the committed artifact. */
41
+ export const GENERATED_VALIDATOR_PATH =
42
+ '.agents/scripts/lib/generated/agentrc-validator.js';
43
+
44
+ /** The command that rewrites it — quoted in every failure message. */
45
+ const REGEN_COMMAND = 'npm run validator:gen';
46
+
47
+ /**
48
+ * AJV options for the generated validator.
49
+ *
50
+ * `allErrors: true` MUST match the runtime fallback in
51
+ * `config-settings-schema.js#getAgentrcValidator` — it is the difference
52
+ * between reporting one error and reporting all of them, and every caller
53
+ * that renders `validate.errors` would change behaviour if the two diverged.
54
+ * `code.source` is what makes the instance emittable; `code.esm` selects the
55
+ * ES-module emit. Neither touches validation semantics.
56
+ */
57
+ const AJV_OPTIONS = Object.freeze({
58
+ allErrors: true,
59
+ code: { source: true, esm: true },
60
+ });
61
+
62
+ /**
63
+ * Banner prepended to the emitted module.
64
+ *
65
+ * The `createRequire` shim is load-bearing: AJV's `esm` emit still reaches for
66
+ * its shared runtime helpers (`ajv/dist/runtime/*`) with `require(...)`, which
67
+ * is not defined inside an ES module. Handing the generated code a real
68
+ * `require` is the smallest fix that keeps the emit verbatim — rewriting those
69
+ * calls into imports would mean re-deriving AJV's own codegen by hand, and
70
+ * would silently rot the next time it changes shape.
71
+ *
72
+ * The coverage pragma matches `config-settings-schema.js`: generated code has
73
+ * no branches anyone authored, so a coverage figure over it measures nothing.
74
+ */
75
+ const BANNER = `/* node:coverage ignore file -- generated artifact; see check-generated-validator.js */
76
+ /**
77
+ * GENERATED FILE — DO NOT EDIT.
78
+ *
79
+ * Precompiled AJV validator for \`AGENTRC_SCHEMA\`
80
+ * (.agents/scripts/lib/config-settings-schema.js), emitted by
81
+ * .agents/scripts/check-generated-validator.js.
82
+ *
83
+ * Regenerate with \`${REGEN_COMMAND}\`; \`npm run lint\` fails when this file
84
+ * and the schema disagree.
85
+ */
86
+ import { createRequire } from 'node:module';
87
+
88
+ const require = createRequire(import.meta.url);
89
+ `;
90
+
91
+ /**
92
+ * Emit the standalone validator source for `AGENTRC_SCHEMA`.
93
+ *
94
+ * Exported so the parity test can assert the committed artifact and a fresh
95
+ * emit agree without shelling out to this CLI.
96
+ *
97
+ * @returns {string} Complete module source, newline-terminated.
98
+ */
99
+ export function generateValidatorSource() {
100
+ const ajvModule = require('ajv');
101
+ const Ajv = ajvModule.default ?? ajvModule;
102
+ const standaloneModule = require('ajv/dist/standalone');
103
+ const standaloneCode = standaloneModule.default ?? standaloneModule;
104
+ const ajv = new Ajv(AJV_OPTIONS);
105
+ const validate = ajv.compile(AGENTRC_SCHEMA);
106
+ const body = standaloneCode(ajv, validate);
107
+ return `${BANNER}\n${body.trim()}\n`;
108
+ }
109
+
110
+ /**
111
+ * Read the committed artifact, or `null` when it is absent.
112
+ *
113
+ * @param {string} absPath
114
+ * @returns {string | null}
115
+ */
116
+ function readCommitted(absPath) {
117
+ try {
118
+ return fs.readFileSync(absPath, 'utf-8');
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Top-level CLI entry. Exported so tests can drive both modes through the
126
+ * injected seams rather than by spawning a process.
127
+ *
128
+ * @param {{
129
+ * argv?: string[],
130
+ * cwd?: string,
131
+ * stdout?: { write: (s: string) => void },
132
+ * stderr?: { write: (s: string) => void },
133
+ * generateImpl?: () => string,
134
+ * writeFileImpl?: (p: string, data: string) => void,
135
+ * }} [opts]
136
+ * @returns {number} 0 = in step (or written); 1 = stale artifact.
137
+ */
138
+ export function runCli({
139
+ argv = process.argv.slice(2),
140
+ cwd = process.cwd(),
141
+ stdout = process.stdout,
142
+ stderr = process.stderr,
143
+ generateImpl = generateValidatorSource,
144
+ writeFileImpl = (p, data) => fs.writeFileSync(p, data),
145
+ } = {}) {
146
+ const checkOnly = argv.includes('--check');
147
+ const absPath = path.resolve(cwd, GENERATED_VALIDATOR_PATH);
148
+ const expected = generateImpl();
149
+
150
+ if (!checkOnly) {
151
+ fs.mkdirSync(path.dirname(absPath), { recursive: true });
152
+ writeFileImpl(absPath, expected);
153
+ stdout.write(
154
+ `[generated-validator] wrote ${expected.length} bytes to ${GENERATED_VALIDATOR_PATH}\n`,
155
+ );
156
+ return 0;
157
+ }
158
+
159
+ const actual = readCommitted(absPath);
160
+ if (actual === null) {
161
+ stderr.write(
162
+ `[generated-validator] ❌ ${GENERATED_VALIDATOR_PATH} is missing — run \`${REGEN_COMMAND}\`\n`,
163
+ );
164
+ return 1;
165
+ }
166
+ if (actual !== expected) {
167
+ stderr.write(
168
+ `[generated-validator] ❌ ${GENERATED_VALIDATOR_PATH} is stale against AGENTRC_SCHEMA ` +
169
+ `(committed ${actual.length} bytes, regenerated ${expected.length}) — run \`${REGEN_COMMAND}\`\n`,
170
+ );
171
+ return 1;
172
+ }
173
+ stdout.write(
174
+ `[generated-validator] ✅ ${GENERATED_VALIDATOR_PATH} is in step with AGENTRC_SCHEMA\n`,
175
+ );
176
+ return 0;
177
+ }
178
+
179
+ async function main() {
180
+ return runCli();
181
+ }
182
+
183
+ runAsCli(import.meta.url, main, {
184
+ source: 'generated-validator',
185
+ propagateExitCode: true,
186
+ errorPrefix: '[generated-validator] ❌ Fatal error',
187
+ usage: {
188
+ invocation: 'node .agents/scripts/check-generated-validator.js [--check]',
189
+ summary:
190
+ 'Regenerate the committed AJV validator for `AGENTRC_SCHEMA`, or (with --check) fail when the committed artifact is stale against the schema.',
191
+ flags: [
192
+ [
193
+ '--check',
194
+ 'Compare only: exit 1 when the committed artifact differs from a fresh emit. Writes nothing.',
195
+ ],
196
+ ],
197
+ notes: [
198
+ 'Exit codes:\n 0 artifact written, or already in step\n 1 committed artifact is missing or stale',
199
+ `Regenerate with \`${REGEN_COMMAND}\`. The runtime falls back to a live AJV compile when MANDREL_AGENTRC_VALIDATOR=dynamic.`,
200
+ ],
201
+ },
202
+ });
@@ -78,6 +78,7 @@ import { fileURLToPath } from 'node:url';
78
78
 
79
79
  import { runAsCli } from './lib/cli-utils.js';
80
80
  import { walkFilesByExtension } from './lib/fs-walk.js';
81
+ import { stripJsComments } from './lib/source-text/strip-js-comments.js';
81
82
 
82
83
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
83
84
  const REPO_ROOT = path.resolve(__dirname, '..', '..');
@@ -162,73 +163,6 @@ export function findPromiseAllViolations(
162
163
  return violations;
163
164
  }
164
165
 
165
- /**
166
- * Strip block (`/* … *​/`) and line (`// …`) comments from a source
167
- * string. Pure — exported for tests so the comment-stripping contract
168
- * is explicit. Defends against the corner case where the literal
169
- * `'gh pr merge'` appears INSIDE a justification comment at the
170
- * deletion site (Story #2253 deliberately leaves a prose explanation
171
- * referencing the removed CLI call).
172
- *
173
- * The implementation is a tiny state machine rather than a regex so it
174
- * correctly handles the (legal) case of a string literal containing
175
- * `//` or `/​*` characters.
176
- *
177
- * @param {string} src
178
- * @returns {string} source with comments replaced by spaces (line
179
- * numbers preserved so violations report the original line).
180
- */
181
- export function stripComments(src) {
182
- let out = '';
183
- let i = 0;
184
- const n = src.length;
185
- while (i < n) {
186
- const ch = src[i];
187
- const nx = src[i + 1];
188
- // line comment
189
- if (ch === '/' && nx === '/') {
190
- while (i < n && src[i] !== '\n') {
191
- i += 1;
192
- }
193
- continue;
194
- }
195
- // block comment
196
- if (ch === '/' && nx === '*') {
197
- i += 2;
198
- while (i < n && !(src[i] === '*' && src[i + 1] === '/')) {
199
- // preserve newlines so line numbers in violation reports stay
200
- // aligned with the original file.
201
- if (src[i] === '\n') out += '\n';
202
- i += 1;
203
- }
204
- i += 2; // skip closing */
205
- continue;
206
- }
207
- // string literal — copy through unchanged (we WANT to keep these
208
- // so Rule 3 can flag them).
209
- if (ch === '"' || ch === "'" || ch === '`') {
210
- const quote = ch;
211
- out += ch;
212
- i += 1;
213
- while (i < n) {
214
- const c = src[i];
215
- out += c;
216
- if (c === '\\' && i + 1 < n) {
217
- out += src[i + 1];
218
- i += 2;
219
- continue;
220
- }
221
- i += 1;
222
- if (c === quote) break;
223
- }
224
- continue;
225
- }
226
- out += ch;
227
- i += 1;
228
- }
229
- return out;
230
- }
231
-
232
166
  /**
233
167
  * Rule 3 enforcement (Story #2253 / Task #2255, Epic #2172 review
234
168
  * High-1). Returns an array of `{ file, line, hint }` violations for
@@ -263,7 +197,7 @@ export function findMergeLockoutViolations(
263
197
 
264
198
  const raw = read(file, 'utf8');
265
199
  const rawLines = raw.split('\n');
266
- const stripped = stripComments(raw);
200
+ const stripped = stripJsComments(raw);
267
201
  const lines = stripped.split('\n');
268
202
  for (let i = 0; i < lines.length; i += 1) {
269
203
  const line = lines[i];
@@ -88,6 +88,7 @@ import fs from 'node:fs';
88
88
  import path from 'node:path';
89
89
  import process from 'node:process';
90
90
  import { runAsCli } from './lib/cli-utils.js';
91
+ import { stripJsComments } from './lib/source-text/strip-js-comments.js';
91
92
 
92
93
  /** Schema root, relative to the repository root. */
93
94
  const SCHEMA_ROOT = path.join('.agents', 'schemas');
@@ -114,33 +115,6 @@ const EXEMPTION_KEY = 'x-mandrel-uncompiled';
114
115
  */
115
116
  const COMPUTED_PATH_RE = /\$\{[^}]*\}\.schema\.json/;
116
117
 
117
- /** Block comments, including the JSDoc prose every module here opens with. */
118
- const BLOCK_COMMENT_RE = /\/\*[\s\S]*?\*\//g;
119
-
120
- /**
121
- * Line comments. Anchored to start-of-line or whitespace so a `//` inside a
122
- * URL (`http://…`) is left alone — truncating that line could delete a real
123
- * reference sitting after it.
124
- */
125
- const LINE_COMMENT_RE = /(^|\s)\/\/[^\n]*/g;
126
-
127
- /**
128
- * Strip comments from a source before matching.
129
- *
130
- * Prose is not a compile. This module's own header names
131
- * `friction-event.schema.json` a dozen times while compiling nothing, and
132
- * every schema-loading module documents the path it loads directly above the
133
- * line that loads it — matching raw text would let a gate certify itself and
134
- * would count a docblock as enforcement, which is precisely the confusion
135
- * Story #4938 exists to end.
136
- *
137
- * @param {string} text raw source
138
- * @returns {string} source with comment bodies removed
139
- */
140
- function stripComments(text) {
141
- return text.replace(BLOCK_COMMENT_RE, ' ').replace(LINE_COMMENT_RE, '$1');
142
- }
143
-
144
118
  /**
145
119
  * Parse argv. Exported so a unit test can pin the parser without spawning.
146
120
  *
@@ -194,7 +168,7 @@ function walk(dir, extensions, acc = []) {
194
168
  function readSources(root) {
195
169
  return walk(root, JS_EXTENSIONS).map((file) => ({
196
170
  rel: path.relative(root, file),
197
- text: stripComments(fs.readFileSync(file, 'utf8')),
171
+ text: stripJsComments(fs.readFileSync(file, 'utf8')),
198
172
  }));
199
173
  }
200
174
 
@@ -39,6 +39,18 @@ const progress = Logger.createProgress('drain-pending-cleanup', {
39
39
  stderr: false,
40
40
  });
41
41
 
42
+ /**
43
+ * The escalation posture, as a real boolean. `allowNegative` resolves
44
+ * `--no-escalate` to boolean `false`, but under `strict: false` the other
45
+ * documented spelling `--escalate=false` arrives as the *string* `'false'`,
46
+ * which is truthy while `forceDrainPendingCleanup` gates on `!escalate`.
47
+ * Both must reach the engine as a boolean or the flag is advertised and inert.
48
+ *
49
+ * @param {boolean|string|undefined} value
50
+ * @returns {boolean}
51
+ */
52
+ const escalateFrom = (value) => value !== false && value !== 'false';
53
+
42
54
  /**
43
55
  * The drain core, extracted from the CLI shell so the whole decision table —
44
56
  * empty manifest, dry-run report, drain + escalate reporting — is reachable
@@ -83,6 +95,11 @@ export async function runDrainPendingCleanup(
83
95
  'worktree-root': { type: 'string' },
84
96
  },
85
97
  strict: false,
98
+ // `node:util.parseArgs` honours a `--no-<flag>` spelling ONLY when this is
99
+ // set. Without it `--no-escalate` landed in `values['no-escalate']` and
100
+ // left `values.escalate` at its `true` default, so the documented passive
101
+ // drain performed the forced removal it promises to suppress.
102
+ allowNegative: true,
86
103
  });
87
104
 
88
105
  const config = resolveConfigImpl();
@@ -126,7 +143,7 @@ export async function runDrainPendingCleanup(
126
143
  repoRoot: projectRoot,
127
144
  worktreeRoot,
128
145
  git: gitImpl,
129
- escalate: values.escalate,
146
+ escalate: escalateFrom(values.escalate),
130
147
  logger: {
131
148
  info: (m) => progressImpl('DRAIN', m),
132
149
  warn: (m) => progressImpl('DRAIN', `⚠️ ${m}`),
@@ -64,6 +64,7 @@ import {
64
64
  probeMergedPr,
65
65
  refExists,
66
66
  } from './lib/orchestration/git-cleanup/phases/git-probes.js';
67
+ import { probeAncestry } from './lib/orchestration/git-cleanup/phases/merged-tip.js';
67
68
  import { parseCleanupArgs } from './lib/orchestration/git-cleanup/phases/parse-args.js';
68
69
  import {
69
70
  executePrune,
@@ -110,6 +111,7 @@ export {
110
111
  planFastForward,
111
112
  planStashes,
112
113
  probeAllPrs,
114
+ probeAncestry,
113
115
  probeContentEquivalent,
114
116
  probeLatestPr,
115
117
  probeMergedPr,
@@ -19,11 +19,41 @@
19
19
  // the literal `*` are treated as the whole-repo rollup and always match
20
20
  // every row regardless of declared globs.
21
21
 
22
- import { minimatch } from 'minimatch';
22
+ import { Minimatch } from 'minimatch';
23
23
 
24
24
  /** The default components map, used when a gate omits `components`. */
25
25
  const DEFAULT_COMPONENTS = Object.freeze({ '*': Object.freeze(['**']) });
26
26
 
27
+ /**
28
+ * Compiled-matcher cache for `groupRows`, keyed on the glob string.
29
+ *
30
+ * `groupRows` is called once per gate over the whole baseline row set — tens
31
+ * of thousands of rows for `crap.json` — and the functional `minimatch()` it
32
+ * used re-parsed each component glob for every single row. The component
33
+ * globs come from config, so the distinct set is small and immutable; caching
34
+ * the compiled matcher turns an O(rows × globs) parse into an O(globs) one
35
+ * (Story #5109). Matching semantics are unchanged: same patterns, same
36
+ * `{ dot: true }`.
37
+ *
38
+ * @type {Map<string, import('minimatch').Minimatch>}
39
+ */
40
+ const GLOB_MATCHER_CACHE = new Map();
41
+
42
+ /**
43
+ * Compile (once) the `Minimatch` for one glob.
44
+ *
45
+ * @param {string} glob
46
+ * @returns {import('minimatch').Minimatch}
47
+ */
48
+ function matcherFor(glob) {
49
+ let matcher = GLOB_MATCHER_CACHE.get(glob);
50
+ if (!matcher) {
51
+ matcher = new Minimatch(glob, { dot: true });
52
+ GLOB_MATCHER_CACHE.set(glob, matcher);
53
+ }
54
+ return matcher;
55
+ }
56
+
27
57
  /**
28
58
  * Resolve the components map for a single gate config.
29
59
  *
@@ -112,7 +142,7 @@ export function groupRows(rows, components, keyField = 'path') {
112
142
  const list = Array.isArray(globs) ? globs : [];
113
143
  for (const glob of list) {
114
144
  if (typeof glob !== 'string' || glob.length === 0) continue;
115
- if (minimatch(normalized, glob, { dot: true })) {
145
+ if (matcherFor(glob).match(normalized)) {
116
146
  buckets[name].push(row);
117
147
  break;
118
148
  }