mandrel 2.24.0 → 2.26.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 (189) hide show
  1. package/.agents/agents/acceptance-critic.md +19 -21
  2. package/.agents/agents/auditor.md +16 -19
  3. package/.agents/agents/story-worker.md +41 -54
  4. package/.agents/audit-checklists/architecture.md +5 -1
  5. package/.agents/audit-checklists/baselines.md +21 -0
  6. package/.agents/audit-checklists/quality.md +3 -0
  7. package/.agents/docs/agentrc-reference.json +2 -1
  8. package/.agents/docs/configuration.md +2 -1
  9. package/.agents/docs/quality-gates.md +80 -18
  10. package/.agents/docs/workflows.md +4 -2
  11. package/.agents/instructions.md +1 -1
  12. package/.agents/rules/ci-remediation.md +68 -3
  13. package/.agents/schemas/agentrc.schema.json +6 -1
  14. package/.agents/schemas/audit-rules.json +15 -0
  15. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  16. package/.agents/schemas/baselines/baseline-envelope.schema.json +8 -0
  17. package/.agents/schemas/baselines/crap.schema.json +13 -1
  18. package/.agents/schemas/crap-report.schema.json +37 -0
  19. package/.agents/schemas/model-attribution.schema.json +4 -0
  20. package/.agents/scripts/acceptance-eval.js +124 -15
  21. package/.agents/scripts/audit-baselines.js +136 -0
  22. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  23. package/.agents/scripts/audit-to-stories.js +33 -3
  24. package/.agents/scripts/check-arch-cycles.js +12 -93
  25. package/.agents/scripts/check-baseline-drift.js +16 -3
  26. package/.agents/scripts/check-baselines.js +19 -3
  27. package/.agents/scripts/check-context-budget.js +95 -14
  28. package/.agents/scripts/check-cyclomatic.js +214 -0
  29. package/.agents/scripts/check-schema-references.js +392 -0
  30. package/.agents/scripts/check-test-temp-hygiene.js +121 -1
  31. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  32. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  33. package/.agents/scripts/deliver-light.js +3 -4
  34. package/.agents/scripts/deliver-recover.js +13 -0
  35. package/.agents/scripts/diagnose-friction.js +85 -19
  36. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  37. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  38. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  39. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  40. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  41. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  42. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  43. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  44. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  45. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  46. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  47. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  48. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  49. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  50. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  51. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  52. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  53. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  54. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  55. package/.agents/scripts/lib/baseline-loader.js +0 -0
  56. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  57. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  58. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  59. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  60. package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
  61. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  62. package/.agents/scripts/lib/baselines/reader.js +10 -0
  63. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  64. package/.agents/scripts/lib/baselines/scope.js +39 -90
  65. package/.agents/scripts/lib/baselines/writer.js +16 -7
  66. package/.agents/scripts/lib/changed-files.js +8 -1
  67. package/.agents/scripts/lib/cli-args.js +115 -1
  68. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  69. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  70. package/.agents/scripts/lib/crap-engine.js +124 -27
  71. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  72. package/.agents/scripts/lib/crap-utils.js +86 -13
  73. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  74. package/.agents/scripts/lib/env-loader.js +46 -16
  75. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  76. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  77. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  78. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  79. package/.agents/scripts/lib/findings/severity.js +80 -2
  80. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  81. package/.agents/scripts/lib/git-utils.js +136 -80
  82. package/.agents/scripts/lib/import-graph.js +156 -0
  83. package/.agents/scripts/lib/label-constants.js +17 -0
  84. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  85. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  86. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  87. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  88. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  89. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  90. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  91. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  92. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  93. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  94. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  95. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  96. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  97. package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
  98. package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
  99. package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
  100. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  101. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  102. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +216 -109
  103. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
  104. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  105. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  106. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  107. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  108. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  109. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  110. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  111. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  112. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  113. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +125 -37
  114. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  115. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  116. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  117. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  118. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
  119. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  120. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  121. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  122. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  123. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  124. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  125. package/.agents/scripts/lib/story-adjacency.js +3 -3
  126. package/.agents/scripts/lib/story-plan.js +137 -42
  127. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  128. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  129. package/.agents/scripts/lib/test-tiers.js +11 -2
  130. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  131. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  132. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  133. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  134. package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
  135. package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
  136. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
  137. package/.agents/scripts/lib/workers/crap-worker.js +8 -13
  138. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  139. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  140. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  141. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  142. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  143. package/.agents/scripts/lib/worktree-manager.js +14 -0
  144. package/.agents/scripts/plan-run-epilogue.js +17 -5
  145. package/.agents/scripts/pr-watch-with-update.js +568 -213
  146. package/.agents/scripts/providers/github/tickets.js +33 -10
  147. package/.agents/scripts/provision-git-hooks.js +85 -0
  148. package/.agents/scripts/quality-preview.js +137 -28
  149. package/.agents/scripts/resolve-stories.js +4 -1
  150. package/.agents/scripts/run-coverage.js +86 -35
  151. package/.agents/scripts/run-lint.js +20 -0
  152. package/.agents/scripts/run-tests.js +36 -36
  153. package/.agents/scripts/single-story-close.js +34 -2
  154. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  155. package/.agents/scripts/single-story-init.js +7 -0
  156. package/.agents/scripts/stories-wave-tick.js +308 -47
  157. package/.agents/scripts/story-plan.js +65 -9
  158. package/.agents/scripts/update-coverage-baseline.js +34 -4
  159. package/.agents/scripts/update-crap-baseline.js +42 -4
  160. package/.agents/scripts/update-duplication-baseline.js +209 -83
  161. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  162. package/.agents/scripts/validate-docs-freshness.js +1 -0
  163. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  164. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  165. package/.agents/skills/skills.index.json +2 -2
  166. package/.agents/templates/single-story-body.md +16 -8
  167. package/.agents/workflows/audit-architecture.md +69 -13
  168. package/.agents/workflows/audit-baselines.md +289 -0
  169. package/.agents/workflows/audit-navigability.md +5 -4
  170. package/.agents/workflows/audit-quality.md +26 -0
  171. package/.agents/workflows/audit-to-stories.md +30 -4
  172. package/.agents/workflows/deliver.md +92 -97
  173. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  174. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  175. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  176. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  177. package/.agents/workflows/helpers/deliver-reference.md +95 -4
  178. package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
  179. package/.agents/workflows/helpers/deliver-story.md +104 -119
  180. package/.agents/workflows/helpers/plan-reference.md +49 -5
  181. package/.agents/workflows/memory-consolidate.md +116 -0
  182. package/.agents/workflows/plan.md +62 -75
  183. package/README.md +13 -6
  184. package/docs/CHANGELOG.md +93 -0
  185. package/lib/cli/update.js +14 -11
  186. package/lib/cli/version-check.js +9 -1
  187. package/package.json +9 -4
  188. package/.agents/schemas/friction-event.schema.json +0 -56
  189. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -30,10 +30,14 @@ import {
30
30
  import { calculateAll, scanDirectory } from '../maintainability-utils.js';
31
31
  import { resolveCrapEnvOverrides } from './env-overrides.js';
32
32
  import {
33
+ assertBaselineCompatible,
34
+ assessComparisonBasis,
33
35
  buildCrapReport,
34
36
  compareCrap,
35
37
  filterRowsByFileScope,
38
+ INCOMPATIBLE_BASELINE_DIAGNOSTIC,
36
39
  loadCrapBaseline,
40
+ suppressVerdicts,
37
41
  } from './kinds/crap.js';
38
42
  import {
39
43
  buildMaintainabilityReport,
@@ -131,6 +135,34 @@ function hasCrapRegressions(result) {
131
135
  return result.regressions > 0 || result.newViolations > 0;
132
136
  }
133
137
 
138
+ /**
139
+ * Build the zero-row CRAP envelope the preview returns when it has nothing to
140
+ * compare against — no baseline, or the gate disabled.
141
+ *
142
+ * @param {{scope: string, diffRef: string|null}} scopeInfo
143
+ * @returns {object}
144
+ */
145
+ function emptyCrapEnvelope({ scope, diffRef }) {
146
+ return {
147
+ kernelVersion: KERNEL_VERSION,
148
+ escomplexVersion: resolveEscomplexVersion(),
149
+ summary: {
150
+ total: 0,
151
+ regressions: 0,
152
+ newViolations: 0,
153
+ drifted: 0,
154
+ incomparable: 0,
155
+ provenanceMismatched: 0,
156
+ unscorable: 0,
157
+ removed: 0,
158
+ skippedNoCoverage: 0,
159
+ scope,
160
+ diffRef,
161
+ },
162
+ violations: [],
163
+ };
164
+ }
165
+
134
166
  function applyDiffScopeMi({ files, baseline, scopeSet, cwd }) {
135
167
  if (!scopeSet) {
136
168
  return { scopedFiles: files, scopedBaseline: baseline ?? {} };
@@ -236,23 +268,24 @@ export async function runCrapPreview({
236
268
  const quality = getQuality(config);
237
269
  const crap = quality.crap;
238
270
  if (!baseline || crap.enabled === false) {
271
+ return { exitCode: 0, envelope: emptyCrapEnvelope({ scope, diffRef }) };
272
+ }
273
+
274
+ // Story #4866 (AC-6): judge the loaded envelope BEFORE comparing anything
275
+ // against it. A baseline whose scoring semantics or transpiler coordinates
276
+ // predate the running scorer cannot yield a meaningful per-method verdict,
277
+ // and emitting regressions from it asks the operator to fix code that is
278
+ // not broken. Fail open — the authoritative `check-baselines` gate still
279
+ // gates the merge, so a permissive pre-commit hook costs no real coverage
280
+ // while a blocking one costs a provably defect-free commit.
281
+ const incompatible = assertBaselineCompatible(baseline);
282
+ if (incompatible) {
239
283
  return {
240
284
  exitCode: 0,
241
- envelope: {
242
- kernelVersion: KERNEL_VERSION,
243
- escomplexVersion: resolveEscomplexVersion(),
244
- summary: {
245
- total: 0,
246
- regressions: 0,
247
- newViolations: 0,
248
- drifted: 0,
249
- removed: 0,
250
- skippedNoCoverage: 0,
251
- scope,
252
- diffRef,
253
- },
254
- violations: [],
255
- },
285
+ envelope: suppressVerdicts(emptyCrapEnvelope({ scope, diffRef }), {
286
+ name: INCOMPATIBLE_BASELINE_DIAGNOSTIC,
287
+ message: incompatible,
288
+ }),
256
289
  };
257
290
  }
258
291
 
@@ -298,6 +331,16 @@ export async function runCrapPreview({
298
331
  diffRef,
299
332
  },
300
333
  });
334
+ // Story #4866 (AC-5): above the drifted-row ratio the basis is self-
335
+ // evidently unsound and every per-method verdict below it is an artefact of
336
+ // a mis-keyed join. Say so once, by name, and fail open.
337
+ const basis = assessComparisonBasis(result);
338
+ if (!basis.sound) {
339
+ return {
340
+ exitCode: 0,
341
+ envelope: suppressVerdicts(envelope, basis.diagnostic),
342
+ };
343
+ }
301
344
  const exitCode = hasCrapRegressions(result) ? 1 : 0;
302
345
  return { exitCode, envelope };
303
346
  }
@@ -221,6 +221,11 @@ function readAndShape(kind, absolutePath) {
221
221
  // dropping it here would make every baseline look unstamped and fail the
222
222
  // whole repo closed on a stamp that is actually present on disk.
223
223
  scoringSemantics: parsed.scoringSemantics,
224
+ // Story #4866 — same contract for the transpiler stamp. Dropping it here
225
+ // would leave the ts-transpiler compat axis reachable but blind: it would
226
+ // read `undefined` off every loaded envelope and pass vacuously, which is
227
+ // the exact deadness this Story exists to end.
228
+ tsTranspilerVersion: parsed.tsTranspilerVersion,
224
229
  };
225
230
  }
226
231
 
@@ -314,6 +319,11 @@ export function loadFile(absolutePath, opts = {}) {
314
319
  // dropping it here would make every baseline look unstamped and fail the
315
320
  // whole repo closed on a stamp that is actually present on disk.
316
321
  scoringSemantics: parsed.scoringSemantics,
322
+ // Story #4866 — same contract for the transpiler stamp. Dropping it here
323
+ // would leave the ts-transpiler compat axis reachable but blind: it would
324
+ // read `undefined` off every loaded envelope and pass vacuously, which is
325
+ // the exact deadness this Story exists to end.
326
+ tsTranspilerVersion: parsed.tsTranspilerVersion,
317
327
  };
318
328
  }
319
329
 
@@ -3,14 +3,16 @@
3
3
  * (Story #2197, Epic #2173).
4
4
  *
5
5
  * `refreshBaseline()` is the single funnel through which every baseline
6
- * regeneration (maintainability, crap, coverage) must flow. Callers that
7
- * previously assembled their own envelopes and called `fs.writeFileSync`
8
- * MUST migrate to this entry point — Stories 3/4/5 of Epic #2173 do that
9
- * migration; Story #2197 only lands the service surface and tests.
6
+ * regeneration (maintainability, crap, coverage, duplication) must flow.
7
+ * Callers that previously assembled their own envelopes and called
8
+ * `fs.writeFileSync` MUST migrate to this entry point — Stories 3/4/5 of
9
+ * Epic #2173 do that migration; Story #2197 only lands the service surface
10
+ * and tests. Story #4944 migrated the last holdout, `duplication`.
10
11
  *
11
12
  * The service is **scoring-agnostic**: it does not itself walk the
12
- * filesystem to compute MI / CRAP / coverage scores. Scoring is provided
13
- * by the per-kind default scorers resolved lazily via `resolveDefaultScorer`
13
+ * filesystem to compute MI / CRAP / coverage / duplication scores. Scoring
14
+ * is provided by the per-kind default scorers resolved lazily via
15
+ * `resolveDefaultScorer`
14
16
  * (built with the project config resolved against `cwd`) and, in tests or
15
17
  * production wiring, injected via the `scorer` option for hermetic
16
18
  * determinism. The service is the policy layer:
@@ -29,7 +31,8 @@
29
31
  * Public API (Task #2203, AC-1 / AC-2 / AC-7):
30
32
  *
31
33
  * refreshBaseline({
32
- * kind, // 'maintainability' | 'crap' | 'coverage' REQUIRED
34
+ * kind, // 'maintainability' | 'crap' | 'coverage'
35
+ * // | 'duplication' REQUIRED
33
36
  * baseRef, // git ref to diff against; default 'origin/main'
34
37
  * headRef, // git ref under inspection; default 'HEAD'
35
38
  * scopeFiles, // Array<string> | null
@@ -62,8 +65,9 @@
62
65
  *
63
66
  * Acceptance contract:
64
67
  *
65
- * AC-1: All callers that produce a maintainability/crap/coverage baseline
66
- * go through refreshBaseline(). Enforced by Task #2208 invariant.
68
+ * AC-1: All callers that produce a maintainability/crap/coverage/
69
+ * duplication baseline go through refreshBaseline(). Enforced by
70
+ * the Task #2208 invariant.
67
71
  * AC-2: scopeFiles=null && !fullScope -> diff-derived scope (Task #2207).
68
72
  * AC-4: Out-of-scope rows + their updatedAt fields are preserved byte-
69
73
  * for-byte (Task #2209).
@@ -93,6 +97,7 @@ import {
93
97
  isIgnoredByGlobs as isIgnoredByGlobsMi,
94
98
  scanDirectory as scanDirectoryMi,
95
99
  } from '../maintainability-utils.js';
100
+ import { resolveDetectClones, scanDuplication } from './duplication-scanner.js';
96
101
  import { filterExcludedRows } from './kinds/maintainability.js';
97
102
  import { canonicalizeBaselinePath } from './path-canon.js';
98
103
  import {
@@ -108,7 +113,12 @@ const execFileAsync = promisify(nodeExecFile);
108
113
  * Kinds the refresh service knows how to dispatch. Stays in lockstep with
109
114
  * the per-kind modules under `.agents/scripts/lib/baselines/kinds/`.
110
115
  */
111
- const SUPPORTED_KINDS = Object.freeze(['maintainability', 'crap', 'coverage']);
116
+ const SUPPORTED_KINDS = Object.freeze([
117
+ 'maintainability',
118
+ 'crap',
119
+ 'coverage',
120
+ 'duplication',
121
+ ]);
112
122
 
113
123
  /**
114
124
  * Per-kind file-extension predicate for diff-scope derivation (Task #2207).
@@ -125,6 +135,12 @@ const KIND_FILE_PREDICATES = Object.freeze({
125
135
  maintainability: (p) => /\.(?:m?[jt]sx?)$/i.test(p),
126
136
  crap: (p) => /\.(?:m?[jt]sx?)$/i.test(p),
127
137
  coverage: (p) => /\.(?:m?[jt]sx?)$/i.test(p),
138
+ // Duplication shares the source-extension filter deliberately. jscpd is
139
+ // configured for the `javascript` format only, so a changed `.ts` file
140
+ // admitted here simply yields no row on either side of the merge —
141
+ // over-inclusive is inert, under-inclusive would silently pin a changed
142
+ // file's prior row. Erring wide is the safe direction.
143
+ duplication: (p) => /\.(?:m?[jt]sx?)$/i.test(p),
128
144
  });
129
145
 
130
146
  /**
@@ -338,6 +354,7 @@ const KIND_SCORER_BUILDERS = Object.freeze({
338
354
  maintainability: buildDefaultMaintainabilityScorer,
339
355
  crap: buildDefaultCrapScorer,
340
356
  coverage: buildDefaultCoverageScorer,
357
+ duplication: buildDefaultDuplicationScorer,
341
358
  });
342
359
 
343
360
  /**
@@ -384,7 +401,7 @@ export function resolveDefaultScorer(kind, { cwd } = {}) {
384
401
  * so callers (and tests) can assert what actually got scored.
385
402
  *
386
403
  * @param {{
387
- * kind: 'maintainability' | 'crap' | 'coverage',
404
+ * kind: 'maintainability' | 'crap' | 'coverage' | 'duplication',
388
405
  * baseRef?: string,
389
406
  * headRef?: string,
390
407
  * scopeFiles?: string[] | null,
@@ -745,3 +762,44 @@ function validateOptions({ kind, scopeFiles, fullScope, writePath }) {
745
762
  );
746
763
  }
747
764
  }
765
+
766
+ /**
767
+ * Build the default duplication scorer.
768
+ *
769
+ * **This scorer always scans the whole target tree, in every scope mode.**
770
+ * That is not an oversight — duplication is the only kind here whose metric
771
+ * is *pairwise*: a clone is a relationship between two files, and jscpd can
772
+ * only report it if both sides are in the corpus it was handed. Narrowing the
773
+ * scan to the diff would drop every clone between a changed file and an
774
+ * unchanged one, i.e. exactly the duplication a refactor is most likely to
775
+ * introduce. So the `files` argument is intentionally unused: scope narrowing
776
+ * for this kind is a **write-side** concern, applied by the service handing
777
+ * `scope` to `writer.write()`, where `mergeRowsByScope` keeps the freshly
778
+ * scanned rows for in-scope files and preserves the prior rows verbatim for
779
+ * everything else.
780
+ *
781
+ * Reads `targetDirs` / `ignoreGlobs` off `gates.duplication`, not off a
782
+ * flattened accessor: `resolveQuality` never lifted duplication into the
783
+ * legacy bag (the kind post-dates it), so `quality.duplication` is always
784
+ * `undefined` and reading it would silently scan nothing.
785
+ *
786
+ * @param {{ cwd: string, config?: object, quality?: object, detect?: (opts: object) => Promise<object[]> }} opts
787
+ * @returns {(files: string[], opts: object) => Promise<object[]>}
788
+ */
789
+ function buildDefaultDuplicationScorer({ cwd, config, quality, detect } = {}) {
790
+ const dupCfg =
791
+ resolveQualityBlock({ quality, config })?.gates?.duplication ?? {};
792
+ const targetDirs = Array.isArray(dupCfg.targetDirs) ? dupCfg.targetDirs : [];
793
+ const ignoreGlobs = Array.isArray(dupCfg.ignoreGlobs)
794
+ ? dupCfg.ignoreGlobs
795
+ : [];
796
+ return async (_files, opts) => {
797
+ const effectiveCwd = opts?.cwd ?? cwd ?? process.cwd();
798
+ return scanDuplication({
799
+ targetDirs,
800
+ cwd: effectiveCwd,
801
+ ignoreGlobs,
802
+ detect: detect ?? resolveDetectClones(),
803
+ });
804
+ };
805
+ }
@@ -1,33 +1,44 @@
1
1
  // .agents/scripts/lib/baselines/scope.js
2
2
  //
3
- // Story #1962 / Task #1970 — One ScopeResolution helper that the
4
- // `check-baselines.js` dispatcher and the per-kind regression writers
5
- // (Epic #1943) both consume. Routing every scope decision through this
6
- // single function is what guarantees read/write parity: the dispatcher
7
- // can never decide "diff against epic/1943" while the writer assumes
8
- // "full repo", because both call `resolveScope()` with the same inputs.
3
+ // Story #1962 / Task #1970 — the ScopeResolution helper behind the
4
+ // `check-baselines.js` dispatcher: `resolveDispatchScope` in
5
+ // `lib/orchestration/check-baselines/phases/compare.js` is its one
6
+ // caller. Routing the read side's scope decision through a single pure
7
+ // function keeps the precedence rules from being re-implemented per gate.
8
+ //
9
+ // It is NOT the writers' resolver. `lib/baselines/refresh-service.js`
10
+ // defines its own private `resolveScope` (Story #3658) over a different
11
+ // input set, and the header here used to claim otherwise — a claim that
12
+ // made the read/write pair look coupled when it is not. Corrected in
13
+ // Story #4922.
9
14
  //
10
15
  // The resolver is intentionally pure — it takes already-extracted
11
- // inputs and returns a frozen ScopeResolution. CLI parsing, env
12
- // reading, and config loading happen in the caller; that keeps this
13
- // module trivially testable and prevents the precedence rules from
14
- // being silently re-implemented at every call site.
16
+ // inputs and returns a frozen ScopeResolution. Env reading and config
17
+ // loading happen in the caller; that keeps this module trivially
18
+ // testable.
15
19
  //
16
20
  // Precedence (highest → lowest):
17
21
  //
18
- // 1. CLI flags — `cliFlags.fullScope: true` or `cliFlags.changedSinceRef`.
19
- // Operator-typed beats anything in env/config. A CLI override of
20
- // `--full-scope` wins even if the config says `'diff'`.
21
- // 2. Environment — `BASELINE_SCOPE` ('full' | 'diff') and
22
+ // 1. Environment — `BASELINE_SCOPE` ('full' | 'diff') and
22
23
  // `BASELINE_REF` (any git ref). The dispatcher reads these from
23
- // `process.env` and forwards via `cliFlags.envScope` /
24
- // `cliFlags.envRef` so the resolver itself never touches process
25
- // state. CI usually sets these.
26
- // 3. Config — `configScope` ('full' | 'diff') and `configRef` (any
24
+ // `process.env` and forwards via `envScope` / `envRef` so the
25
+ // resolver itself never touches process state. CI sets these.
26
+ // 2. Config `configScope` ('full' | 'diff') and `configRef` (any
27
27
  // git ref) from `delivery.quality.gateScoping` in `.agentrc.json`.
28
- // 4. Default — `mode='diff'` against `ref='main'`. This is the
28
+ // 3. Default — `mode='diff'` against `ref='main'`. This is the
29
29
  // framework-wide fallback when nothing else is configured.
30
30
  //
31
+ // Story #4922 removed a fourth, highest-precedence layer: a
32
+ // `cliFlags.fullScope` / `cliFlags.changedSinceRef` operator override,
33
+ // plus the `cliFlags.changedFiles` → `files` plumbing that fed it. No
34
+ // production caller ever populated any of the three — only this module's
35
+ // own unit tests did — so the layer's only effect was to advertise a
36
+ // `--full-scope` / `--changed-since` contract that `check-baselines.js`
37
+ // does not implement, and a `files` set that no consumer read. Operators
38
+ // who need full scope set `BASELINE_SCOPE=full`, which is what CI does.
39
+ // (`mergeRowsByScope` below still takes a `files`-bearing scope — that
40
+ // one comes from the refresh service's own resolver, not from here.)
41
+ //
31
42
  // Missing-ref fallback: when the resolved mode is `'diff'` but no ref
32
43
  // is supplied at any layer, the resolver falls back to `'main'` rather
33
44
  // than producing a half-resolved scope with `ref=null`. The dispatcher
@@ -45,17 +56,8 @@
45
56
  // kind: string, // echoed back for caller convenience
46
57
  // mode: 'full' | 'diff',
47
58
  // ref: string | null, // null in full mode; ref string in diff mode
48
- // files: Set<string>, // empty Set in full mode (sentinel for "all")
49
59
  // source: string, // which layer won (debug / friction signal)
50
60
  // }
51
- //
52
- // `files` is intentionally a Set rather than an Array — callers
53
- // repeatedly check membership during per-row filtering, and Set lookup
54
- // is O(1). An empty Set in `'full'` mode means "no filter applies".
55
- // A non-empty Set in `'diff'` mode means "only these paths are in
56
- // scope" (the dispatcher pre-computes them via `git diff --name-only`
57
- // and forwards via `cliFlags.changedFiles`); when omitted, the writer
58
- // is expected to compute the diff itself against `ref`.
59
61
 
60
62
  const VALID_MODES = new Set(['full', 'diff']);
61
63
  const DEFAULT_DIFF_REF = 'main';
@@ -84,45 +86,19 @@ function asMode(v) {
84
86
  }
85
87
 
86
88
  /**
87
- * Coerce a candidate set/array of files to a frozen Set. Returns an
88
- * empty Set when the input is missing or empty.
89
- *
90
- * @param {unknown} v
91
- * @returns {Set<string>}
92
- */
93
- function asFilesSet(v) {
94
- if (v instanceof Set) {
95
- return new Set(
96
- Array.from(v).filter((f) => typeof f === 'string' && f.length > 0),
97
- );
98
- }
99
- if (Array.isArray(v)) {
100
- return new Set(v.filter((f) => typeof f === 'string' && f.length > 0));
101
- }
102
- return new Set();
103
- }
104
-
105
- /**
106
- * Resolve a scope against the layered precedence (CLI > env > config >
89
+ * Resolve a scope against the layered precedence (env > config >
107
90
  * default). Pure; no I/O.
108
91
  *
109
92
  * @param {object} input
110
- * @param {string} input.kind - Baseline kind (e.g. `'lint'`).
93
+ * @param {string} input.kind - Baseline kind (e.g. `'lint'`).
111
94
  * @param {string} [input.configScope] - `'full'` | `'diff'` from agentrc.
112
95
  * @param {string} [input.configRef] - Diff ref from agentrc.
113
- * @param {object} [input.cliFlags] - Pre-parsed CLI / env layer.
114
- * @param {boolean} [input.cliFlags.fullScope] - `--full-scope`.
115
- * @param {string} [input.cliFlags.changedSinceRef] - `--changed-since <ref>`.
116
- * @param {string} [input.cliFlags.envScope] - From `BASELINE_SCOPE`.
117
- * @param {string} [input.cliFlags.envRef] - From `BASELINE_REF`.
118
- * @param {Iterable<string>} [input.cliFlags.changedFiles]
119
- * Pre-computed diff paths (when caller already ran `git diff
120
- * --name-only`). Becomes `files`; only meaningful in `'diff'` mode.
96
+ * @param {string} [input.envScope] - From `BASELINE_SCOPE`.
97
+ * @param {string} [input.envRef] - From `BASELINE_REF`.
121
98
  * @returns {{
122
99
  * kind: string,
123
100
  * mode: 'full' | 'diff',
124
101
  * ref: string | null,
125
- * files: Set<string>,
126
102
  * source: string,
127
103
  * }}
128
104
  */
@@ -131,59 +107,34 @@ export function resolveScope(input = {}) {
131
107
  typeof input.kind === 'string' && input.kind.length > 0
132
108
  ? input.kind
133
109
  : 'unknown';
134
- const cli = input.cliFlags ?? {};
135
-
136
- // ---- Layer 1: CLI flags (highest precedence) -------------------------
137
- if (cli.fullScope === true) {
138
- return Object.freeze({
139
- kind,
140
- mode: 'full',
141
- ref: null,
142
- files: new Set(),
143
- source: 'cli:--full-scope',
144
- });
145
- }
146
- const cliRef = asNonEmptyString(cli.changedSinceRef);
147
- if (cliRef) {
148
- return Object.freeze({
149
- kind,
150
- mode: 'diff',
151
- ref: cliRef,
152
- files: asFilesSet(cli.changedFiles),
153
- source: 'cli:--changed-since',
154
- });
155
- }
156
110
 
157
- // ---- Layer 2: Environment (extracted by caller into cliFlags.env*) ---
158
- const envMode = asMode(cli.envScope);
111
+ // ---- Layer 1: Environment (extracted by the caller) ------------------
112
+ const envMode = asMode(input.envScope);
159
113
  if (envMode === 'full') {
160
114
  return Object.freeze({
161
115
  kind,
162
116
  mode: 'full',
163
117
  ref: null,
164
- files: new Set(),
165
118
  source: 'env:BASELINE_SCOPE=full',
166
119
  });
167
120
  }
168
- const envRef = asNonEmptyString(cli.envRef);
121
+ const envRef = asNonEmptyString(input.envRef);
169
122
  if (envMode === 'diff' || envRef) {
170
123
  return Object.freeze({
171
124
  kind,
172
125
  mode: 'diff',
173
126
  ref: envRef ?? DEFAULT_DIFF_REF,
174
- files: asFilesSet(cli.changedFiles),
175
127
  source: envRef ? 'env:BASELINE_REF' : 'env:BASELINE_SCOPE=diff',
176
128
  });
177
129
  }
178
130
 
179
- // ---- Layer 3: Config (delivery.quality.gateScoping) -----------------
131
+ // ---- Layer 2: Config (delivery.quality.gateScoping) ------------------
180
132
  const cfgMode = asMode(input.configScope);
181
133
  if (cfgMode === 'full') {
182
134
  return Object.freeze({
183
135
  kind,
184
136
  mode: 'full',
185
137
  ref: null,
186
- files: new Set(),
187
138
  source: 'config:gateScoping.scope=full',
188
139
  });
189
140
  }
@@ -193,19 +144,17 @@ export function resolveScope(input = {}) {
193
144
  kind,
194
145
  mode: 'diff',
195
146
  ref: cfgRef ?? DEFAULT_DIFF_REF,
196
- files: asFilesSet(cli.changedFiles),
197
147
  source: cfgRef
198
148
  ? 'config:gateScoping.diffRef'
199
149
  : 'config:gateScoping.scope=diff',
200
150
  });
201
151
  }
202
152
 
203
- // ---- Layer 4: Default ------------------------------------------------
153
+ // ---- Layer 3: Default ------------------------------------------------
204
154
  return Object.freeze({
205
155
  kind,
206
156
  mode: 'diff',
207
157
  ref: DEFAULT_DIFF_REF,
208
- files: asFilesSet(cli.changedFiles),
209
158
  source: 'default',
210
159
  });
211
160
  }
@@ -261,17 +261,26 @@ export function writeFile(absPath, envelope, opts = {}) {
261
261
  // across runs and platforms. Per-kind row keys retain their natural
262
262
  // declaration order; the row sort is done by `sortRows()`.
263
263
  //
264
- // Story #4775: the projection is deliberately explicit, so any per-kind
265
- // envelope stamp (`scoringSemantics`) must be carried through by name or it
266
- // is silently dropped on the way to disk the stamp would then be present
267
- // in memory, validated, and absent from the file it exists to protect.
264
+ // The projection is deliberately explicit (Story #4775): a per-kind envelope
265
+ // stamp reaches disk only if it is named here, so an unlisted one is present
266
+ // in memory, passes validation, and is absent from the file it exists to
267
+ // protect. That is not hypothetical Story #4901 added `provenanceStamped`
268
+ // to `envelopeExtras()` and not to this list, and every baseline written
269
+ // between then and Story #4969 lost it at this boundary, leaving the
270
+ // `provenance-unstamped` axis reading an absence its own writer had
271
+ // manufactured.
272
+ //
273
+ // A stamp a kind does not set is simply `undefined`, and `JSON.stringify`
274
+ // omits an undefined-valued key — so naming all three unconditionally emits
275
+ // exactly what the per-key `undefined` guards used to, with nothing to
276
+ // half-apply when the next stamp is added.
268
277
  const canonical = {
269
278
  $schema: envelope.$schema,
270
279
  kernelVersion: envelope.kernelVersion,
271
280
  generatedAt: envelope.generatedAt,
272
- ...(envelope.scoringSemantics === undefined
273
- ? {}
274
- : { scoringSemantics: envelope.scoringSemantics }),
281
+ scoringSemantics: envelope.scoringSemantics,
282
+ tsTranspilerVersion: envelope.tsTranspilerVersion,
283
+ provenanceStamped: envelope.provenanceStamped,
275
284
  rollup: envelope.rollup,
276
285
  rows: envelope.rows,
277
286
  };
@@ -8,10 +8,17 @@ import { createGitInterface } from './git-utils.js';
8
8
  *
9
9
  * Pure; no I/O.
10
10
  *
11
+ * Module-private since Story #4944. It was exported for
12
+ * `diff-scope-cli.js#resolveDiffScopeFiles`, which the duplication-CLI
13
+ * migration deleted; the three remaining callers all live in this file, so
14
+ * exporting it now would ship a seam only the tests reach. Its behaviour is
15
+ * covered through `diffNameOnly`, which returns this function's output
16
+ * verbatim.
17
+ *
11
18
  * @param {string | null | undefined} stdout
12
19
  * @returns {string[]}
13
20
  */
14
- export function parseNameOnlyStdout(stdout) {
21
+ function parseNameOnlyStdout(stdout) {
15
22
  if (!stdout) return [];
16
23
  return stdout
17
24
  .split('\n')