mandrel 2.25.0 → 2.27.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 (132) hide show
  1. package/.agents/agents/acceptance-critic.md +10 -6
  2. package/.agents/audit-checklists/baselines.md +21 -0
  3. package/.agents/docs/quality-gates.md +80 -18
  4. package/.agents/docs/workflows.md +3 -1
  5. package/.agents/instructions.md +1 -1
  6. package/.agents/schemas/audit-rules.json +15 -0
  7. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  8. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  9. package/.agents/schemas/baselines/crap.schema.json +8 -0
  10. package/.agents/schemas/model-attribution.schema.json +4 -0
  11. package/.agents/scripts/acceptance-eval.js +89 -6
  12. package/.agents/scripts/audit-baselines.js +136 -0
  13. package/.agents/scripts/check-arch-cycles.js +12 -93
  14. package/.agents/scripts/check-baseline-drift.js +16 -3
  15. package/.agents/scripts/check-baselines.js +19 -3
  16. package/.agents/scripts/check-cyclomatic.js +214 -0
  17. package/.agents/scripts/check-schema-references.js +392 -0
  18. package/.agents/scripts/check-test-temp-hygiene.js +38 -1
  19. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  20. package/.agents/scripts/diagnose-friction.js +85 -19
  21. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  22. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  23. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  24. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  25. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  26. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  27. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  28. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  29. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  30. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  31. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  32. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  33. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  34. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  35. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  36. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  37. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  38. package/.agents/scripts/lib/baselines/git-base.js +26 -4
  39. package/.agents/scripts/lib/baselines/kinds/crap.js +112 -15
  40. package/.agents/scripts/lib/baselines/reader.js +52 -38
  41. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  42. package/.agents/scripts/lib/baselines/scope.js +39 -90
  43. package/.agents/scripts/lib/baselines/writer.js +16 -11
  44. package/.agents/scripts/lib/changed-files.js +8 -1
  45. package/.agents/scripts/lib/cli-args.js +115 -1
  46. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  47. package/.agents/scripts/lib/crap-engine.js +32 -13
  48. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  49. package/.agents/scripts/lib/crap-utils.js +13 -0
  50. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  51. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  52. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  53. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  54. package/.agents/scripts/lib/git-utils.js +136 -80
  55. package/.agents/scripts/lib/import-graph.js +156 -0
  56. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  57. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  58. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  59. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  60. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  61. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  62. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  63. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  64. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  65. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  66. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  67. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +209 -109
  68. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
  69. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  70. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  71. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  72. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  73. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  74. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  75. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
  76. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  77. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  78. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  79. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
  80. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  81. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  82. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  83. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  84. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  85. package/.agents/scripts/lib/story-adjacency.js +3 -3
  86. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  87. package/.agents/scripts/lib/test-tiers.js +11 -2
  88. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  89. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  90. package/.agents/scripts/lib/wave-runner/live-probe.js +24 -14
  91. package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
  92. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
  93. package/.agents/scripts/lib/workers/crap-worker.js +2 -10
  94. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  95. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  96. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  97. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  98. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  99. package/.agents/scripts/lib/worktree-manager.js +14 -0
  100. package/.agents/scripts/plan-run-epilogue.js +17 -5
  101. package/.agents/scripts/providers/github/tickets.js +33 -10
  102. package/.agents/scripts/provision-git-hooks.js +85 -0
  103. package/.agents/scripts/quality-preview.js +112 -28
  104. package/.agents/scripts/resolve-stories.js +4 -1
  105. package/.agents/scripts/run-coverage.js +86 -35
  106. package/.agents/scripts/run-lint.js +20 -0
  107. package/.agents/scripts/run-tests.js +26 -36
  108. package/.agents/scripts/single-story-close.js +28 -2
  109. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  110. package/.agents/scripts/stories-wave-tick.js +214 -38
  111. package/.agents/scripts/update-coverage-baseline.js +34 -4
  112. package/.agents/scripts/update-duplication-baseline.js +209 -83
  113. package/.agents/scripts/validate-docs-freshness.js +1 -0
  114. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  115. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  116. package/.agents/skills/skills.index.json +2 -2
  117. package/.agents/workflows/audit-baselines.md +289 -0
  118. package/.agents/workflows/audit-navigability.md +5 -4
  119. package/.agents/workflows/deliver.md +13 -4
  120. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  121. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  122. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  123. package/.agents/workflows/helpers/deliver-reference.md +77 -1
  124. package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
  125. package/.agents/workflows/helpers/plan-reference.md +15 -5
  126. package/.agents/workflows/memory-consolidate.md +116 -0
  127. package/.agents/workflows/plan.md +3 -0
  128. package/README.md +13 -6
  129. package/docs/CHANGELOG.md +71 -0
  130. package/package.json +9 -4
  131. package/.agents/schemas/friction-event.schema.json +0 -56
  132. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -30,42 +30,6 @@ import { readAuditRulesSync } from './audit-rules-reader.js';
30
30
 
31
31
  const DEFAULT_GIT_TIMEOUT_MS = 30000;
32
32
 
33
- /**
34
- * The audit-lens identifier for the navigability lens (Epic #4131, F2/F3).
35
- * Authored as `.agents/workflows/audit-navigability.md`; registered here so the
36
- * roster, the global-lens allowlist, and the route-added routing seam all
37
- * reference one symbol rather than a hard-coded string.
38
- */
39
- export const NAVIGABILITY_LENS = 'audit-navigability';
40
-
41
- /**
42
- * The **global-lens allowlist** — lenses that evaluate a property of the
43
- * **whole** product (not just the Epic's change set) and are therefore exempt
44
- * from the cross-epic-leak guard (`#3362`) that narrows every other lens's
45
- * evidence to the Epic's `changedFiles`. A lens in this set still runs through
46
- * the SAME `runAuditSuite` / `selectAuditStrategy` engine; only the
47
- * change-set narrowing is bypassed, and only for the listed lenses. The guard
48
- * is **not** weakened for any lens absent from this set.
49
- *
50
- * Navigability is the founding member: reachability is a global property — a
51
- * change can orphan a route it never touched — so the lens must read the whole
52
- * route tree + nav registry regardless of which file triggered it.
53
- */
54
- export const GLOBAL_LENS_ALLOWLIST = Object.freeze([NAVIGABILITY_LENS]);
55
-
56
- /**
57
- * True when `lens` is on the global-lens allowlist and is therefore exempt
58
- * from the cross-epic-leak guard's change-set narrowing. Pure; the single
59
- * read-side of {@link GLOBAL_LENS_ALLOWLIST} so callers never hard-code the
60
- * membership test.
61
- *
62
- * @param {string} lens
63
- * @returns {boolean}
64
- */
65
- export function isGlobalLens(lens) {
66
- return GLOBAL_LENS_ALLOWLIST.includes(lens);
67
- }
68
-
69
33
  /**
70
34
  * The canonical concern-ownership tiers a lens can declare via its
71
35
  * `scope` field in [`audit-rules.json`](../../../schemas/audit-rules.json).
@@ -222,40 +186,23 @@ export function selectSensitivePathClasses({
222
186
  /**
223
187
  * Resolve the consumer's navigability route globs from the resolved config.
224
188
  * Reads `delivery.quality.navigability.routeGlobs` — the route-tree SSOT the
225
- * navigability lens enumerates and the route-added routing predicate matches
226
- * against. Returns an empty array when the block (or any ancestor) is absent,
227
- * so an unconfigured consumer routes nothing and the lens degrades to a silent
228
- * no-op (Epic #4131 — "no-op when unconfigured").
189
+ * navigability lens enumerates. Returns an empty array when the block (or any
190
+ * ancestor) is absent, so an unconfigured consumer contributes no web-surface
191
+ * evidence (Epic #4131 "no-op when unconfigured").
192
+ *
193
+ * Module-local: the sole reader is {@link hasWebSurface}. It was previously
194
+ * exported alongside a route-added routing seam that Story #4926 removed —
195
+ * lens routing is decided by the `scope` field in `audit-rules.json`, never by
196
+ * a second predicate here.
229
197
  *
230
198
  * @param {object|null|undefined} config Resolved `.agentrc.json` wrapper.
231
199
  * @returns {string[]} Route globs, or `[]` when unconfigured.
232
200
  */
233
- export function resolveNavigabilityRouteGlobs(config) {
201
+ function resolveNavigabilityRouteGlobs(config) {
234
202
  const globs = config?.delivery?.quality?.navigability?.routeGlobs;
235
203
  return Array.isArray(globs) ? globs.filter((g) => typeof g === 'string') : [];
236
204
  }
237
205
 
238
- /**
239
- * Decide whether a change set routes the navigability lens. The lens is routed
240
- * when any `changedFiles` entry matches a consumer-configured route glob
241
- * (`delivery.quality.navigability.routeGlobs`) — i.e. the change set adds or
242
- * touches a route file. When no route globs are configured, this returns
243
- * `false` (the unconfigured no-op), so the existing change-set-scoped lens
244
- * selection is unchanged.
245
- *
246
- * A pure predicate over a change set — the same input {@link selectAudits} and
247
- * {@link selectLocalLenses} already match against. The caller unions its result
248
- * into the lens roster it is assembling; no new routing machinery is added.
249
- *
250
- * @param {{ changedFiles?: string[], config?: object|null }} params
251
- * @returns {boolean}
252
- */
253
- export function routesNavigabilityLens({ changedFiles, config } = {}) {
254
- const globs = resolveNavigabilityRouteGlobs(config);
255
- if (globs.length === 0) return false;
256
- return matchesAnyFilePattern(globs, changedFiles ?? []);
257
- }
258
-
259
206
  /**
260
207
  * Package names (or scope/name segments of them) that declare a **web
261
208
  * rendering surface**. Matched against the consumer's root `package.json`
@@ -27,6 +27,7 @@
27
27
  export const AUDIT_LENSES = Object.freeze([
28
28
  'accessibility',
29
29
  'architecture',
30
+ 'baselines',
30
31
  'clean-code',
31
32
  'data-model',
32
33
  'dependencies',
@@ -45,10 +45,22 @@ export const BASELINE_KIND_SCHEMA_FILES = Object.freeze([
45
45
  'duplication.schema.json',
46
46
  ]);
47
47
 
48
- /** Every baseline schema filename (envelope + per-kind) in registration order. */
48
+ /**
49
+ * Report schemas that live under `.agents/schemas/baselines/` but describe
50
+ * an *engine's output about* the baselines rather than a committed baseline
51
+ * (Story #4902). They are registered here for two reasons: the AJV instance
52
+ * below is the one place a caller can compile a baselines-directory schema
53
+ * without re-registering the envelope, and the mirror-drift test compares
54
+ * this registry against the on-disk listing — an unregistered file there is
55
+ * drift regardless of which category it belongs to.
56
+ */
57
+ const BASELINE_REPORT_SCHEMA_FILES = ['audit-baselines-envelope.schema.json'];
58
+
59
+ /** Every baseline schema filename (envelope + per-kind + report) in registration order. */
49
60
  export const BASELINE_SCHEMA_FILES = Object.freeze([
50
61
  BASELINE_ENVELOPE_FILE,
51
62
  ...BASELINE_KIND_SCHEMA_FILES,
63
+ ...BASELINE_REPORT_SCHEMA_FILES,
52
64
  ]);
53
65
 
54
66
  /**
@@ -3,26 +3,32 @@
3
3
  * the manual baseline-update CLIs (Story #1974 / Task #1986, Epic #1943).
4
4
  *
5
5
  * `update-coverage-baseline.js`, `update-crap-baseline.js`,
6
- * `update-maintainability-baseline.js`, and `update-mutation-baseline.js`
7
- * all accept an opt-in `--diff-scope <ref>` flag. When supplied, the
8
- * baseline write narrows to files changed since `<ref>` (resolved via
9
- * `git diff --name-only <ref>...HEAD`). Out-of-scope rows are preserved
10
- * verbatim from the prior on-disk baseline via the per-kind `mergeRows`.
6
+ * `update-maintainability-baseline.js`, and
7
+ * `update-duplication-baseline.js` all accept an opt-in `--diff-scope <ref>`
8
+ * flag. When supplied, the baseline write narrows to files changed since
9
+ * `<ref>`; out-of-scope rows are preserved verbatim from the prior on-disk
10
+ * baseline.
11
11
  *
12
- * When the flag is absent, the CLIs behave exactly as they did before
13
- * #1974 full regenerate + write preserving operator workflows that
14
- * intentionally rewrite the whole baseline.
12
+ * The helper is shared to keep the flag's contract identical across the four
13
+ * scripts: one argv parser, one spelling, one error message.
15
14
  *
16
- * The helper is shared to keep the flag's contract identical across the
17
- * four scripts: same argv parser, same git invocation, same forward-slash
18
- * path normalisation. The four CLIs differ only in how they pipe the
19
- * resolved scope through to their writer.
15
+ * **Scope of this module, post-Story #4944.** It parses the flag and nothing
16
+ * else. Everything downstream of the parse resolving the changed-file set,
17
+ * reading the prior envelope, and assembling the writer call used to live
18
+ * here too, in `buildWriterScopeArgs` and its helpers. Those were the
19
+ * pre-service path; `lib/baselines/refresh-service.js` owns all of it now
20
+ * (`resolveScope`, `readPriorEnvelope`, and the `writer.write()` handoff),
21
+ * and `refreshBaseline()` derives its own diff via `execFile` rather than
22
+ * `spawnSync`. `update-duplication-baseline.js` was the last caller of the
23
+ * legacy path; when it migrated, the whole write-side half of this module
24
+ * became unreachable and was removed rather than left shipped-but-uncalled.
25
+ *
26
+ * Note for anyone tracing the per-kind prior-row reader that used to live
27
+ * here: it is gone, not relocated. The service reads the prior envelope
28
+ * kind-agnostically (any `{ rows: [], rollup: {} }` document), so the
29
+ * per-kind row-shape filter it needed no longer has a job to do.
20
30
  */
21
31
 
22
- import { spawnSync } from 'node:child_process';
23
- import fs from 'node:fs';
24
- import { parseNameOnlyStdout } from '../changed-files.js';
25
-
26
32
  /**
27
33
  * Parse `--diff-scope <ref>` (and the legacy `--diff-scope=<ref>` form)
28
34
  * from an argv slice. Returns `null` when the flag is absent. Throws a
@@ -57,147 +63,3 @@ export function parseDiffScopeFlag(argv = []) {
57
63
  }
58
64
  return null;
59
65
  }
60
-
61
- /**
62
- * Resolve the file footprint of `git diff --name-only <ref>...HEAD`.
63
- * Returns a `Set<string>` of repo-relative paths with forward-slash
64
- * normalisation. Returns an empty Set when the diff is empty or git
65
- * exits non-zero (best-effort; a missing-ref or corrupt repo is the
66
- * operator's signal to inspect the working tree).
67
- *
68
- * The `spawnImpl` seam exists for unit tests — production callers omit it.
69
- *
70
- * @param {{ ref: string, cwd?: string, spawnImpl?: typeof spawnSync }} args
71
- * @returns {Set<string>}
72
- */
73
- export function resolveDiffScopeFiles({
74
- ref,
75
- cwd = process.cwd(),
76
- spawnImpl = spawnSync,
77
- } = {}) {
78
- if (typeof ref !== 'string' || ref.length === 0) return new Set();
79
- const res = spawnImpl('git', ['diff', '--name-only', `${ref}...HEAD`], {
80
- cwd,
81
- encoding: 'utf8',
82
- });
83
- if (!res || res.status !== 0) return new Set();
84
- return new Set(parseNameOnlyStdout(res.stdout));
85
- }
86
-
87
- /**
88
- * Convenience: parse `--diff-scope` and resolve files in one call.
89
- * Returns `null` when the flag is absent (so the caller can branch on
90
- * "scope was opted in?"); otherwise returns
91
- * `{ ref, files: Set<string>, scope: { mode: 'diff', files } }` ready to
92
- * pass into `writer.write({ scope })`.
93
- *
94
- * @param {{ argv: string[], cwd?: string, spawnImpl?: typeof spawnSync }} args
95
- * @returns {{ ref: string, files: Set<string>, scope: {mode: 'diff', files: Set<string>} } | null}
96
- */
97
- export function resolveDiffScope({ argv, cwd, spawnImpl } = {}) {
98
- const ref = parseDiffScopeFlag(argv);
99
- if (ref === null) return null;
100
- const files = resolveDiffScopeFiles({ ref, cwd, spawnImpl });
101
- return { ref, files, scope: { mode: 'diff', files } };
102
- }
103
-
104
- /**
105
- * Read + parse the prior baseline at `absBaselinePath` and return the
106
- * canonical `rows[]` array (per-kind row shape with `path:` keys, as
107
- * expected by the per-kind `mergeRows` / `applyEpsilon` helpers from
108
- * Story #1974). Returns `null` when the file is absent, malformed, or
109
- * missing a `rows[]` envelope; the caller treats `null` as "skip the
110
- * merge" (regression-fail-safe — equivalent to a fresh write).
111
- *
112
- * Pure-by-design (file I/O through the injected `fsImpl` seam).
113
- *
114
- * @param {{ kind: 'maintainability' | 'crap', absBaselinePath: string, fsImpl?: typeof fs }} args
115
- * @returns {Array<object> | null}
116
- */
117
- // Read + JSON-parse a baseline file. Returns `null` on any I/O or parse
118
- // failure (the caller treats "no prior" the same as "unreadable prior").
119
- function readBaselineJson(absBaselinePath, fsImpl) {
120
- let raw;
121
- try {
122
- raw = fsImpl.readFileSync(absBaselinePath, 'utf8');
123
- } catch {
124
- return null;
125
- }
126
- try {
127
- const parsed = JSON.parse(raw);
128
- return parsed && typeof parsed === 'object' ? parsed : null;
129
- } catch {
130
- return null;
131
- }
132
- }
133
-
134
- // CRAP path: envelope `rows[]` only; rows already carry canonical `path:`.
135
- function readCrapPriorRows(parsed) {
136
- if (!Array.isArray(parsed.rows)) return null;
137
- return parsed.rows;
138
- }
139
-
140
- // Maintainability path: envelope `rows[]` only; rows already carry
141
- // canonical `path:` + `mi:`.
142
- function readMaintainabilityPriorRows(parsed) {
143
- if (!Array.isArray(parsed.rows)) return null;
144
- return parsed.rows.filter(
145
- (r) => r && typeof r.path === 'string' && typeof r.mi === 'number',
146
- );
147
- }
148
-
149
- export function readPriorBaselineRows({ kind, absBaselinePath, fsImpl = fs }) {
150
- const parsed = readBaselineJson(absBaselinePath, fsImpl);
151
- if (!parsed) return null;
152
- if (kind === 'crap') return readCrapPriorRows(parsed);
153
- return readMaintainabilityPriorRows(parsed);
154
- }
155
-
156
- /**
157
- * Compose the full Story #1974 write-side payload for a manual baseline
158
- * CLI: read prior rows, resolve `--diff-scope`, log the scope decision,
159
- * and return the four params (`prior`, `epsilon`, `scope`, plus the
160
- * resolved `diffScope` for caller-side logging) that the CLI feeds into
161
- * `writer.write({ ..., prior, epsilon, scope })`.
162
- *
163
- * Returns a flat record so each CLI can spread it into the writer call.
164
- *
165
- * @param {{
166
- * kind: 'maintainability' | 'crap',
167
- * absBaselinePath: string,
168
- * epsilon: number,
169
- * argv?: string[],
170
- * cwd?: string,
171
- * logger?: { info?: (msg: string) => void },
172
- * logTag: string,
173
- * }} args
174
- * @returns {{
175
- * prior: Array<object> | undefined,
176
- * epsilon: number | undefined,
177
- * scope: {mode: 'diff', files: Set<string>} | undefined,
178
- * diffScope: {ref: string, files: Set<string>, scope: object} | null,
179
- * }}
180
- */
181
- export function buildWriterScopeArgs({
182
- kind,
183
- absBaselinePath,
184
- epsilon,
185
- argv = process.argv.slice(2),
186
- cwd,
187
- logger,
188
- logTag,
189
- }) {
190
- const prior = readPriorBaselineRows({ kind, absBaselinePath });
191
- const diffScope = resolveDiffScope({ argv, cwd });
192
- if (diffScope && logger?.info) {
193
- logger.info(
194
- `${logTag} --diff-scope ${diffScope.ref}: ${diffScope.files.size} file(s) in scope; out-of-scope rows preserved verbatim.`,
195
- );
196
- }
197
- return {
198
- prior: prior ?? undefined,
199
- epsilon: prior ? epsilon : undefined,
200
- scope: diffScope?.scope,
201
- diffScope,
202
- };
203
- }
@@ -26,11 +26,38 @@
26
26
  */
27
27
 
28
28
  import { readFileSync } from 'node:fs';
29
+ import { createRequire } from 'node:module';
29
30
  import path from 'node:path';
30
31
 
31
32
  const DEFAULT_MIN_TOKENS = 50;
32
33
  const DEFAULT_FORMATS = Object.freeze(['javascript']);
33
34
 
35
+ const require = createRequire(import.meta.url);
36
+
37
+ /**
38
+ * Resolve jscpd's `detectClones` lazily. The jscpd ESM entrypoint has a
39
+ * broken transitive `colors/safe` specifier under Node's strict ESM
40
+ * resolver, so we load the CJS build via `createRequire`. Isolated behind a
41
+ * function so the rest of the module stays import-pure and testable — no
42
+ * importer of this file pays the jscpd load unless it actually scans.
43
+ *
44
+ * Lives here rather than in `update-duplication-baseline.js` since Story
45
+ * #4944: the refresh service's default duplication scorer needs the same
46
+ * seam, and a second copy in the CLI would be the classic "two
47
+ * implementations of one probe" divergence.
48
+ *
49
+ * @returns {(opts: object) => Promise<Array<object>>}
50
+ */
51
+ export function resolveDetectClones() {
52
+ const jscpd = require('jscpd');
53
+ if (typeof jscpd.detectClones !== 'function') {
54
+ throw new Error(
55
+ "[Duplication] jscpd.detectClones is not available — run 'npm install'",
56
+ );
57
+ }
58
+ return jscpd.detectClones;
59
+ }
60
+
34
61
  /**
35
62
  * Normalise a jscpd `sourceId` (or any path) to a canonical POSIX
36
63
  * repo-relative path. jscpd already emits cwd-relative paths, but a future
@@ -29,10 +29,15 @@
29
29
  // loader treats this as a normal "no baseline at base ref" case
30
30
  // and returns `null`. Callers branch on `result === null` rather
31
31
  // than catching an error.
32
- // - Any other non-zero exit (bad ref, corrupted repo, git binary
33
- // missing): rethrown so the dispatcher can surface a config-error
34
- // exit code rather than silently treating the failure as "no
35
- // baseline". A bad ref is a config bug, not a missing file.
32
+ // - Any other outcome (bad ref, corrupted repo, git binary missing, or
33
+ // the child killed by signal so `status` is `null`): thrown so the
34
+ // dispatcher can surface a config-error exit code rather than
35
+ // silently treating the failure as "no baseline". A bad ref is a
36
+ // config bug, not a missing file.
37
+ //
38
+ // That two-way split is load-bearing (Story #4914): `null` means and only
39
+ // means "path absent at ref". Callers may therefore treat a throw as a
40
+ // hard failure without having to re-diagnose it.
36
41
 
37
42
  import { spawnSync } from 'node:child_process';
38
43
 
@@ -47,6 +52,21 @@ import { spawnSync } from 'node:child_process';
47
52
 
48
53
  const DEFAULT_MAX_ENTRIES = 64;
49
54
 
55
+ /**
56
+ * Story #4914 — explicit stdout ceiling for every git read in this module.
57
+ *
58
+ * `child_process.spawnSync` defaults `maxBuffer` to 1 MB. A committed
59
+ * baseline legitimately grows past that (a real consumer's crap baseline
60
+ * measured 1,178,910 bytes), at which point the child is killed —
61
+ * `status: null`, `signal: 'SIGTERM'`, `error.code: 'ENOBUFS'` — and the
62
+ * read fails for a reason that has nothing to do with the repository.
63
+ *
64
+ * 64 MB is not a new number: it is the bound already used at
65
+ * `run-test-profile.js:87`, `audit-baselines/trend.js:61` and
66
+ * `audit-baselines/weights.js:65`. This module was the outlier.
67
+ */
68
+ const MAX_BUFFER_BYTES = 64 * 1024 * 1024;
69
+
50
70
  let _spawnSync = spawnSync;
51
71
  let _cache = new Map();
52
72
  let _maxEntries = DEFAULT_MAX_ENTRIES;
@@ -165,6 +185,7 @@ export function readBaseFromGit(ref, file, opts = {}) {
165
185
  encoding: 'utf-8',
166
186
  shell: false,
167
187
  env: cleanGitEnv(),
188
+ maxBuffer: MAX_BUFFER_BYTES,
168
189
  });
169
190
 
170
191
  // `child_process.spawnSync` returns `status: null` when the child died
@@ -232,6 +253,7 @@ export function readRangeSubjectsTouchingFile(baseRef, file, opts = {}) {
232
253
  encoding: 'utf-8',
233
254
  shell: false,
234
255
  env: cleanGitEnv(),
256
+ maxBuffer: MAX_BUFFER_BYTES,
235
257
  },
236
258
  );
237
259
  } catch {
@@ -19,6 +19,7 @@ import {
19
19
  COORDINATE_TRANSPILED,
20
20
  deriveFixGuidance,
21
21
  } from '../../crap-engine.js';
22
+ import { isAnonymousMethodLabel } from '../../crap-method-identity.js';
22
23
  import { getCrapBaseline } from '../../crap-utils.js';
23
24
  import { loadBaseline } from '../../gates/baseline-store.js';
24
25
  import { Logger } from '../../Logger.js';
@@ -95,15 +96,23 @@ export function kernelVersion() {
95
96
  * worse, phantom passes.
96
97
  *
97
98
  * The stamp makes the boundary explicit and fails closed. Bump it whenever
98
- * the coverage join, the line coordinate system, or the unresolved-method
99
- * policy changes.
99
+ * the coverage join, the line coordinate system, the unresolved-method policy,
100
+ * or the **method identity rule** changes.
101
+ *
102
+ * Story #4969 bumped it to `method-identity-v3` for the last of those. An
103
+ * anonymous method used to be keyed by escomplex's `<anon method-N>` ordinal
104
+ * and is now keyed by its enclosing-scope path; 34.6% of rows changed identity
105
+ * in one step. Rows keyed the old way and rows keyed the new way describe the
106
+ * same functions under different names, so pairing them is exactly the
107
+ * mis-keyed join this stamp exists to refuse — hence the bump, which is what
108
+ * makes the migration report nothing rather than a wall of phantom verdicts.
100
109
  *
101
110
  * Deliberately module-local: `envelopeExtras()` is the single production door
102
111
  * to this value, so exporting the bare constant would add a second entry
103
112
  * point that nothing in production reaches. Callers and tests that need the
104
113
  * string read it off `envelopeExtras().scoringSemantics`.
105
114
  */
106
- const SCORING_SEMANTICS = 'coverage-join-v2';
115
+ const SCORING_SEMANTICS = 'method-identity-v3';
107
116
 
108
117
  /**
109
118
  * Envelope-level stamps this kind contributes beyond the shared envelope
@@ -116,12 +125,22 @@ const SCORING_SEMANTICS = 'coverage-join-v2';
116
125
  * the stamp on disk the `ts-transpiler-drift` axis had nothing to compare and
117
126
  * passed vacuously.
118
127
  *
119
- * @returns {{scoringSemantics: string, tsTranspilerVersion: string}}
128
+ * `provenanceStamped` joined in Story #4901 as a **positive** marker: the
129
+ * writer asserting it recorded per-row provenance at all. Absence is the only
130
+ * evidence a pre-#4866 baseline leaves, and no other stamp detects it —
131
+ * `kernelVersion` / `escomplexVersion` track the escomplex package (unmoved
132
+ * by the fix), `scoringSemantics` did not change, and `tsTranspilerVersion`
133
+ * is unreliable in the negative (the `'0.0.0'` sentinel). See the
134
+ * `provenance-unstamped` axis.
135
+ *
136
+ * @returns {{scoringSemantics: string, tsTranspilerVersion: string,
137
+ * provenanceStamped: boolean}}
120
138
  */
121
139
  export function envelopeExtras() {
122
140
  return {
123
141
  scoringSemantics: SCORING_SEMANTICS,
124
142
  tsTranspilerVersion: resolveTsTranspilerVersion(),
143
+ provenanceStamped: true,
125
144
  };
126
145
  }
127
146
 
@@ -144,6 +163,12 @@ export function projectRow(row) {
144
163
  if (row.coordinateSystem === COORDINATE_TRANSPILED) {
145
164
  projected.coordinateSystem = COORDINATE_TRANSPILED;
146
165
  }
166
+ // Story #4969, same write-only-when-non-default idiom: `anonymous` marks a
167
+ // `method` that is a derived scope-path identity rather than a name the
168
+ // source carries. A named row stays the exact four-key row it always was.
169
+ if (row.anonymous === true) {
170
+ projected.anonymous = true;
171
+ }
147
172
  return projected;
148
173
  }
149
174
 
@@ -636,6 +661,17 @@ export function assessComparisonBasis(compareResult, opts = {}) {
636
661
  * and `tsTranspilerVersion` drift to **warn**, not fail; `escomplexVersion`
637
662
  * mismatch continues to fail closed.
638
663
  */
664
+ /**
665
+ * The one re-seed recipe every coordinate-invalidating axis ends on. Three
666
+ * axes and the unsound-basis diagnostic previously carried their own copy of
667
+ * this sentence; a single constant keeps them from drifting apart on the
668
+ * command an operator is told to run.
669
+ */
670
+ const RESEED_REMEDY =
671
+ "Re-derive the baseline: run 'npm run test:coverage' then " +
672
+ "'npm run crap:update -- --full-scope' and commit the result with a " +
673
+ "'baseline-refresh:' subject.";
674
+
639
675
  export const CRAP_COMPAT_AXES = [
640
676
  // Universal axes hoisted into envelope.js (Story #2467, Task #2492). The
641
677
  // missing-baseline and kernel-drift checks live in exactly one place;
@@ -661,9 +697,7 @@ export const CRAP_COMPAT_AXES = [
661
697
  `[CRAP] scoring semantics changed: baseline=${stamped ?? '<unstamped>'} ` +
662
698
  `running=${SCORING_SEMANTICS}. Rows scored by the previous per-method ` +
663
699
  'coverage join are not comparable to rows scored by the current one, ' +
664
- 'so this baseline cannot be compared — it must be re-derived. Run ' +
665
- "'npm run test:coverage' then 'npm run crap:update -- --full-scope' " +
666
- "and commit the result with a 'baseline-refresh:' subject."
700
+ `so this baseline cannot be compared. ${RESEED_REMEDY}`
667
701
  );
668
702
  },
669
703
  },
@@ -688,10 +722,65 @@ export const CRAP_COMPAT_AXES = [
688
722
  `[CRAP] tsTranspilerVersion changed: baseline=${baselineTs} running=${runningTsTranspilerVersion}. ` +
689
723
  "A TS row's startLine is an original-source coordinate only because the transpiler's " +
690
724
  'sourcemap said so, and that coordinate is half the row identity key — so rows scored ' +
691
- 'under the previous transpiler are not comparable to rows scored under this one. ' +
692
- "Re-derive the baseline: run 'npm run test:coverage' then " +
693
- "'npm run crap:update -- --full-scope' and commit the result with a " +
694
- "'baseline-refresh:' subject."
725
+ `under the previous transpiler are not comparable to rows scored under this one. ${RESEED_REMEDY}`
726
+ );
727
+ },
728
+ },
729
+ {
730
+ // Story #4901. Closes the exemption directly above: `ts-transpiler-drift`
731
+ // returns null for an unstamped baseline rather than fail every
732
+ // pre-existing one for want of evidence — and a pre-#4866 baseline is
733
+ // exactly that shape, so the one door that could catch it lets it through
734
+ // while it asserts by omission that all its rows are original coordinates.
735
+ // Keyed on a positive marker because absence alone cannot separate
736
+ // "written before provenance existed" from "typescript was unresolvable".
737
+ // Scoped like `ts-transpiler-drift`: a pure-JavaScript baseline's two
738
+ // coordinate systems coincide, so it was never affected and must not fail.
739
+ name: 'provenance-unstamped',
740
+ severity: 'fatal',
741
+ check: ({ baseline }) =>
742
+ baseline &&
743
+ baseline.provenanceStamped !== true &&
744
+ hasTranspiledRows(baseline)
745
+ ? '[CRAP] baseline predates coordinate-provenance stamping: it carries ' +
746
+ 'transpiled-source rows but no `provenanceStamped` marker, so it ' +
747
+ 'asserts by omission that every row is an original-source coordinate. ' +
748
+ '`startLine` is half the row identity key, so the comparator would ' +
749
+ 'key those rows against a coordinate space they are not in and ' +
750
+ `report regressions no edit can satisfy. ${RESEED_REMEDY}`
751
+ : null,
752
+ },
753
+ {
754
+ // Story #4969. The `scoring-semantics-drift` axis above rejects a baseline
755
+ // stamped with the OLD semantics wholesale, which covers a clean migration.
756
+ // It cannot see a HALF-migrated one: a diff-scoped refresh preserves
757
+ // out-of-scope rows verbatim, so a baseline can carry ordinal-keyed rows
758
+ // for the files that were never re-scored while the writer stamps the
759
+ // envelope with the current semantics — the stamp says v3, some rows are
760
+ // still v2, and the one axis that would catch it has already passed.
761
+ //
762
+ // Such a row cannot be paired with anything: its `<anon method-N>` label
763
+ // matches no scope-path identity, so the comparator files the live method
764
+ // as NEW (scored against the ceiling, not its own baseline) and the stale
765
+ // row as removed. Keyed on the positive `anonymous` marker for the same
766
+ // reason `provenance-unstamped` is: absence is the only trace the old
767
+ // writer leaves.
768
+ name: 'anon-identity-unstamped',
769
+ severity: 'fatal',
770
+ check: ({ baseline }) => {
771
+ if (!baseline) return null;
772
+ const stale = (baseline.rows ?? []).filter(
773
+ (row) => isAnonymousMethodLabel(row?.method) && row?.anonymous !== true,
774
+ );
775
+ if (stale.length === 0) return null;
776
+ return (
777
+ `[CRAP] baseline carries ${stale.length} anonymous row(s) keyed by the ` +
778
+ 'superseded `<anon method-N>` ordinal (e.g. ' +
779
+ `${stale[0].path ?? stale[0].file}::${stale[0].method}) while the ` +
780
+ 'envelope claims the current scoring semantics. Those ordinals ' +
781
+ 'renumber whenever any anonymous function is added or removed, so the ' +
782
+ 'comparator would score the live methods against the new-method ' +
783
+ `ceiling instead of their own baseline. ${RESEED_REMEDY}`
695
784
  );
696
785
  },
697
786
  },
@@ -742,15 +831,23 @@ export function evaluateBaselineCompatibility(ctx) {
742
831
 
743
832
  /**
744
833
  * The compat axes a *loaded* envelope can be judged against on its own,
745
- * without a second baseline to diff. Both are coordinate-invalidating: one
746
- * names the join that produced the rows, the other names the transpiler whose
747
- * sourcemap decided what a TS row's `startLine` even means.
834
+ * without a second baseline to diff. Each invalidates one half of the row
835
+ * identity key. Three are about the `startLine` half: one names the join that
836
+ * produced the rows, one names the transpiler whose sourcemap decided what a
837
+ * TS row's `startLine` even means, and one (Story #4901) catches a baseline
838
+ * predating both questions. The fourth (Story #4969) is about the `method`
839
+ * half — a baseline still carrying ordinal-keyed anonymous rows.
748
840
  *
749
841
  * `escomplex-mismatch` and `kernel-drift` stay out — the v2 envelope carries
750
842
  * no `escomplexVersion`, so that axis would compare `undefined` to `undefined`
751
843
  * and pass vacuously, which is worse than not running it.
752
844
  */
753
- const LOADED_ENVELOPE_AXES = ['scoring-semantics-drift', 'ts-transpiler-drift'];
845
+ const LOADED_ENVELOPE_AXES = [
846
+ 'scoring-semantics-drift',
847
+ 'ts-transpiler-drift',
848
+ 'provenance-unstamped',
849
+ 'anon-identity-unstamped',
850
+ ];
754
851
 
755
852
  /**
756
853
  * Kind-module hook (Story #4775, extended by Story #4866): judge a *loaded*