mandrel 2.31.0 → 2.33.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 (267) hide show
  1. package/.agents/README.md +13 -17
  2. package/.agents/agents/acceptance-critic.md +1 -2
  3. package/.agents/docs/SDLC.md +12 -9
  4. package/.agents/docs/agentrc-reference.json +63 -58
  5. package/.agents/docs/configuration.md +275 -227
  6. package/.agents/docs/execution-reference.md +13 -14
  7. package/.agents/docs/quality-gates.md +195 -23
  8. package/.agents/instructions.md +2 -5
  9. package/.agents/rules/git-conventions-reference.md +27 -27
  10. package/.agents/rules/git-conventions.md +4 -2
  11. package/.agents/rules/known-tooling-behavior.md +66 -30
  12. package/.agents/rules/testing-standards.md +35 -71
  13. package/.agents/runtime-deps.json +2 -2
  14. package/.agents/schemas/agentrc.schema.json +1955 -1410
  15. package/.agents/schemas/lifecycle/README.md +21 -14
  16. package/.agents/schemas/lifecycle/ledger-record.schema.json +76 -22
  17. package/.agents/schemas/story-deliver-terminal.schema.json +2 -2
  18. package/.agents/scripts/README.md +16 -29
  19. package/.agents/scripts/apply-quality-bootstrap.js +27 -34
  20. package/.agents/scripts/audit-to-stories.js +160 -41
  21. package/.agents/scripts/bootstrap.js +28 -26
  22. package/.agents/scripts/check-baseline-drift.js +73 -13
  23. package/.agents/scripts/check-baseline-scope.js +362 -0
  24. package/.agents/scripts/check-dead-exports.js +9 -1
  25. package/.agents/scripts/check-gherkin-corpus.js +508 -0
  26. package/.agents/scripts/check-knip-entries.js +159 -0
  27. package/.agents/scripts/check-lifecycle-lint.js +108 -124
  28. package/.agents/scripts/check-schema-references.js +1 -1
  29. package/.agents/scripts/diagnose-friction.js +7 -4
  30. package/.agents/scripts/generate-config-docs.js +263 -171
  31. package/.agents/scripts/install-matrix-assert.js +0 -1
  32. package/.agents/scripts/lib/ITicketingProvider.js +0 -58
  33. package/.agents/scripts/lib/audit-baselines/staleness.js +6 -6
  34. package/.agents/scripts/lib/audit-baselines/trend.js +7 -8
  35. package/.agents/scripts/lib/audit-baselines/weights.js +4 -5
  36. package/.agents/scripts/lib/audit-suite/checklist-threading.js +1 -1
  37. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +81 -35
  38. package/.agents/scripts/lib/audit-to-stories/wire-dependencies.js +185 -0
  39. package/.agents/scripts/lib/baselines/envelope.js +41 -60
  40. package/.agents/scripts/lib/baselines/git-base.js +30 -37
  41. package/.agents/scripts/lib/baselines/kinds/_crap-new-method-gate.js +103 -0
  42. package/.agents/scripts/lib/baselines/kinds/_crap-read.js +150 -0
  43. package/.agents/scripts/lib/baselines/kinds/crap.js +25 -65
  44. package/.agents/scripts/lib/baselines/orphan-pruner.js +233 -0
  45. package/.agents/scripts/lib/baselines/refresh-service.js +6 -8
  46. package/.agents/scripts/lib/baselines/scope-assert.js +223 -0
  47. package/.agents/scripts/lib/baselines/scope-inventory.js +314 -0
  48. package/.agents/scripts/lib/bdd-step-index.js +326 -0
  49. package/.agents/scripts/lib/bootstrap/install-ledger.js +5 -3
  50. package/.agents/scripts/lib/bootstrap/issue-forms-template.js +4 -6
  51. package/.agents/scripts/lib/bootstrap/manifest.js +17 -40
  52. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +12 -59
  53. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +62 -2
  54. package/.agents/scripts/lib/checks/loop-health.js +9 -37
  55. package/.agents/scripts/lib/child-exec.js +193 -0
  56. package/.agents/scripts/lib/cli/standard-args.js +1 -1
  57. package/.agents/scripts/lib/cli-args.js +64 -0
  58. package/.agents/scripts/lib/close-validation/gates.js +2 -2
  59. package/.agents/scripts/lib/close-validation/runner.js +3 -3
  60. package/.agents/scripts/lib/config/acceptance-eval.js +5 -52
  61. package/.agents/scripts/lib/config/commands.js +3 -5
  62. package/.agents/scripts/lib/config/explain.js +5 -7
  63. package/.agents/scripts/lib/config/gates/bundle-size.schema.js +32 -6
  64. package/.agents/scripts/lib/config/gates/coverage.schema.js +25 -5
  65. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +12 -2
  66. package/.agents/scripts/lib/config/gates/crap.schema.js +68 -23
  67. package/.agents/scripts/lib/config/gates/duplication.schema.js +29 -17
  68. package/.agents/scripts/lib/config/gates/index.js +5 -2
  69. package/.agents/scripts/lib/config/gates/lighthouse.schema.js +34 -6
  70. package/.agents/scripts/lib/config/gates/lint.schema.js +11 -2
  71. package/.agents/scripts/lib/config/gates/maintainability.schema.js +37 -15
  72. package/.agents/scripts/lib/config/gates/mutation.schema.js +15 -3
  73. package/.agents/scripts/lib/config/gates/shared.js +58 -9
  74. package/.agents/scripts/lib/config/github.js +0 -1
  75. package/.agents/scripts/lib/config/limits.js +3 -48
  76. package/.agents/scripts/lib/config/qa.js +105 -0
  77. package/.agents/scripts/lib/config/runners.js +38 -16
  78. package/.agents/scripts/lib/config/temp-paths.js +6 -5
  79. package/.agents/scripts/lib/config-settings-schema-delivery.js +245 -56
  80. package/.agents/scripts/lib/config-settings-schema-quality.js +209 -29
  81. package/.agents/scripts/lib/config-settings-schema.js +386 -39
  82. package/.agents/scripts/lib/crap-baseline-join.js +126 -9
  83. package/.agents/scripts/lib/crap-utils.js +84 -520
  84. package/.agents/scripts/lib/dead-exports-knip.js +79 -10
  85. package/.agents/scripts/lib/degraded-mode.js +2 -2
  86. package/.agents/scripts/lib/dependency-parser.js +20 -7
  87. package/.agents/scripts/lib/doc-tiers.js +3 -3
  88. package/.agents/scripts/lib/feedback-loop/graduator-core.js +46 -104
  89. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +10 -8
  90. package/.agents/scripts/lib/findings/provenance-field.js +135 -0
  91. package/.agents/scripts/lib/findings/route-finding.js +57 -8
  92. package/.agents/scripts/lib/fs-walk.js +52 -0
  93. package/.agents/scripts/lib/git-branch-lifecycle.js +2 -2
  94. package/.agents/scripts/lib/git-utils.js +16 -36
  95. package/.agents/scripts/lib/knip-config-resolver.js +181 -0
  96. package/.agents/scripts/lib/knip-entry-sync.js +508 -0
  97. package/.agents/scripts/lib/observability/metrics-ledger.js +1 -1
  98. package/.agents/scripts/lib/observability/runtime-friction.js +10 -0
  99. package/.agents/scripts/lib/observability/signal-validator.js +5 -85
  100. package/.agents/scripts/lib/observability/signals-writer.js +19 -62
  101. package/.agents/scripts/lib/observability/source-classifier.js +5 -7
  102. package/.agents/scripts/lib/observability/terse-result.js +3 -3
  103. package/.agents/scripts/lib/orchestration/behind-recovery.js +114 -0
  104. package/.agents/scripts/lib/orchestration/ceremony-routing.js +7 -8
  105. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +34 -33
  106. package/.agents/scripts/lib/orchestration/code-review.js +2 -2
  107. package/.agents/scripts/lib/orchestration/complexity-gate.js +43 -161
  108. package/.agents/scripts/lib/orchestration/diff-magnitude.js +4 -4
  109. package/.agents/scripts/lib/orchestration/label-transitions.js +3 -2
  110. package/.agents/scripts/lib/orchestration/lease-guard-shared.js +12 -38
  111. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +5 -6
  112. package/.agents/scripts/lib/orchestration/plan-metrics.js +2 -3
  113. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +7 -26
  114. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +69 -5
  115. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +69 -13
  116. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +49 -0
  117. package/.agents/scripts/lib/orchestration/{lifecycle/listeners/watcher.js → pr-watch.js} +58 -208
  118. package/.agents/scripts/lib/orchestration/resolve-stories.js +77 -50
  119. package/.agents/scripts/lib/orchestration/review-providers/codex.js +1 -1
  120. package/.agents/scripts/lib/orchestration/review-providers/mi-exemptions.js +130 -0
  121. package/.agents/scripts/lib/orchestration/review-providers/native.js +30 -16
  122. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +1 -1
  123. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +37 -26
  124. package/.agents/scripts/lib/orchestration/single-story-close/phases/conventional-subject.js +376 -0
  125. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +161 -151
  126. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +15 -3
  127. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +10 -15
  128. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-block.js +5 -0
  129. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +157 -0
  130. package/.agents/scripts/lib/orchestration/single-story-close/phases/worktree-reap.js +0 -14
  131. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +59 -25
  132. package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +20 -31
  133. package/.agents/scripts/lib/orchestration/spec-spill.js +17 -3
  134. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +7 -6
  135. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +2 -1
  136. package/.agents/scripts/lib/orchestration/task-body-validator.js +4 -1
  137. package/.agents/scripts/lib/orchestration/ticket-lease.js +28 -127
  138. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +116 -1
  139. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +1 -1
  140. package/.agents/scripts/lib/orchestration/ticket-validator.js +38 -0
  141. package/.agents/scripts/lib/orchestration/ticketing/reads.js +5 -5
  142. package/.agents/scripts/lib/orchestration/ticketing/transition.js +5 -4
  143. package/.agents/scripts/lib/orchestration/verify-tier-repair.js +107 -0
  144. package/.agents/scripts/lib/qa/coverage-verdict.js +5 -87
  145. package/.agents/scripts/lib/signals/detectors/common.js +1 -1
  146. package/.agents/scripts/lib/signals/index.js +8 -6
  147. package/.agents/scripts/lib/signals/schema.js +20 -25
  148. package/.agents/scripts/lib/signals/write.js +8 -8
  149. package/.agents/scripts/lib/story-body/footer-block.js +97 -0
  150. package/.agents/scripts/lib/story-body/story-body.js +18 -81
  151. package/.agents/scripts/lib/temp-retention.js +1 -1
  152. package/.agents/scripts/lib/templates/decomposer-prompts.js +16 -14
  153. package/.agents/scripts/lib/ticket-body-sections.js +4 -5
  154. package/.agents/scripts/lib/wave-runner/footprint.js +306 -0
  155. package/.agents/scripts/lib/wave-runner/ready-set.js +198 -181
  156. package/.agents/scripts/lib/worktree/lifecycle/merge-reachability.js +13 -45
  157. package/.agents/scripts/lib/worktree/lifecycle/reap.js +4 -5
  158. package/.agents/scripts/lib/worktree-manager.js +2 -3
  159. package/.agents/scripts/lint-label-vocabulary.js +2 -24
  160. package/.agents/scripts/pr-watch-with-update.js +7 -5
  161. package/.agents/scripts/providers/github/blocked-by-add.js +25 -10
  162. package/.agents/scripts/providers/github/cache.js +2 -2
  163. package/.agents/scripts/providers/github/comments.js +6 -28
  164. package/.agents/scripts/providers/github/compose.js +0 -15
  165. package/.agents/scripts/providers/github/errors.js +10 -27
  166. package/.agents/scripts/providers/github/request-helpers.js +1 -2
  167. package/.agents/scripts/providers/github/sub-issues.js +10 -218
  168. package/.agents/scripts/providers/github.js +4 -7
  169. package/.agents/scripts/prune-baseline-orphans.js +181 -0
  170. package/.agents/scripts/resolve-stories.js +21 -7
  171. package/.agents/scripts/run-lint.js +61 -61
  172. package/.agents/scripts/run-test-profile.js +6 -6
  173. package/.agents/scripts/run-verify.js +48 -30
  174. package/.agents/scripts/single-story-close.js +20 -0
  175. package/.agents/scripts/single-story-init.js +12 -35
  176. package/.agents/scripts/stories-wave-tick.js +192 -9
  177. package/.agents/scripts/update-dead-exports-baseline.js +321 -0
  178. package/.agents/skills/core/gates-and-baselines/SKILL.md +2 -2
  179. package/.agents/skills/skills.index.json +1 -11
  180. package/.agents/workflows/audit-documentation.md +5 -6
  181. package/.agents/workflows/audit-to-stories.md +28 -2
  182. package/.agents/workflows/helpers/audit-lens-core.md +11 -12
  183. package/.agents/workflows/helpers/code-quality-guardrails.md +15 -14
  184. package/.agents/workflows/helpers/code-review.md +3 -8
  185. package/.agents/workflows/helpers/deliver-reference.md +30 -2
  186. package/.agents/workflows/helpers/deliver-story-reference.md +84 -16
  187. package/.agents/workflows/helpers/plan-reference.md +76 -0
  188. package/.agents/workflows/helpers/worktree-lifecycle.md +1 -2
  189. package/.agents/workflows/mandrel-update.md +10 -10
  190. package/.agents/workflows/qa-assist.md +15 -20
  191. package/.agents/workflows/qa-explore.md +9 -8
  192. package/README.md +1 -1
  193. package/docs/CHANGELOG.md +58 -0
  194. package/lib/migrations/index.js +2 -0
  195. package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +127 -0
  196. package/package.json +14 -5
  197. package/.agents/schemas/lifecycle/checkpoint.written.schema.json +0 -13
  198. package/.agents/schemas/lifecycle/close-validate.end.schema.json +0 -18
  199. package/.agents/schemas/lifecycle/close-validate.start.schema.json +0 -13
  200. package/.agents/schemas/lifecycle/code-review.end.schema.json +0 -30
  201. package/.agents/schemas/lifecycle/code-review.start.schema.json +0 -12
  202. package/.agents/schemas/lifecycle/intervention.recorded.schema.json +0 -15
  203. package/.agents/schemas/lifecycle/loop.tick.schema.json +0 -20
  204. package/.agents/schemas/lifecycle/notification.emitted.schema.json +0 -18
  205. package/.agents/schemas/lifecycle/pr.created.schema.json +0 -14
  206. package/.agents/schemas/lifecycle/retro.end.schema.json +0 -16
  207. package/.agents/schemas/lifecycle/retro.start.schema.json +0 -12
  208. package/.agents/schemas/lifecycle/story.blocked.schema.json +0 -13
  209. package/.agents/schemas/lifecycle/story.dispatch.end.schema.json +0 -17
  210. package/.agents/schemas/lifecycle/story.dispatch.start.schema.json +0 -15
  211. package/.agents/schemas/lifecycle/story.merged.schema.json +0 -13
  212. package/.agents/scripts/check-gherkin-placeholders.js +0 -663
  213. package/.agents/scripts/check-lifecycle-doc-drift.js +0 -411
  214. package/.agents/scripts/lib/audit-suite/cli.js +0 -64
  215. package/.agents/scripts/lib/bootstrap/baselines-layout-migration.js +0 -202
  216. package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +0 -212
  217. package/.agents/scripts/lib/checks/baseline-drift-main-checkout.js +0 -104
  218. package/.agents/scripts/lib/checks/push-hook-parity.js +0 -106
  219. package/.agents/scripts/lib/checks/windows-coverage-noise-floor.js +0 -92
  220. package/.agents/scripts/lib/checks/worktree-bootstrap-env.js +0 -81
  221. package/.agents/scripts/lib/checks/worktree-residue-biome.js +0 -55
  222. package/.agents/scripts/lib/crap-baseline-index.js +0 -46
  223. package/.agents/scripts/lib/crap-utils-incremental.js +0 -113
  224. package/.agents/scripts/lib/dynamic-workflow/capability.js +0 -396
  225. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -335
  226. package/.agents/scripts/lib/mutation/baseline-snapshot.js +0 -239
  227. package/.agents/scripts/lib/mutation/config-detector.js +0 -119
  228. package/.agents/scripts/lib/mutation/stryker-runner.js +0 -306
  229. package/.agents/scripts/lib/mutation/survivor-report.js +0 -160
  230. package/.agents/scripts/lib/observability/active-story-env.js +0 -170
  231. package/.agents/scripts/lib/observability/tool-trace-hook.js +0 -456
  232. package/.agents/scripts/lib/orchestration/acceptance-clusters.js +0 -111
  233. package/.agents/scripts/lib/orchestration/context-envelope.js +0 -277
  234. package/.agents/scripts/lib/orchestration/detectors-phase.js +0 -194
  235. package/.agents/scripts/lib/orchestration/lifecycle/bus.js +0 -309
  236. package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +0 -181
  237. package/.agents/scripts/lib/orchestration/lifecycle/ledger-writer.js +0 -229
  238. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +0 -54
  239. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -344
  240. package/.agents/scripts/lib/orchestration/lint-baseline-service.js +0 -114
  241. package/.agents/scripts/lib/orchestration/pr-base-guard.js +0 -37
  242. package/.agents/scripts/lib/orchestration/resolves-token.js +0 -127
  243. package/.agents/scripts/lib/orchestration/spec-section-validator.js +0 -130
  244. package/.agents/scripts/lib/orchestration/story-close/emit-blocked.js +0 -55
  245. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +0 -211
  246. package/.agents/scripts/lib/planning-corpus.js +0 -37
  247. package/.agents/scripts/lib/qa/coverage-report.js +0 -181
  248. package/.agents/scripts/lib/qa/propose-missing-test.js +0 -95
  249. package/.agents/scripts/lib/qa/qa-context-hydrator.js +0 -217
  250. package/.agents/scripts/lib/signals/detectors/index.js +0 -14
  251. package/.agents/scripts/lib/signals/detectors/retry.js +0 -253
  252. package/.agents/scripts/lib/signals/detectors/rework.js +0 -167
  253. package/.agents/scripts/lib/signals/read.js +0 -268
  254. package/.agents/scripts/lib/signals/span-tree.js +0 -291
  255. package/.agents/scripts/lib/story-lifecycle.js +0 -194
  256. package/.agents/scripts/lib/story-plan.js +0 -379
  257. package/.agents/scripts/lib/util/phase-timer-state.js +0 -72
  258. package/.agents/scripts/lib/util/phase-timer.js +0 -163
  259. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +0 -169
  260. package/.agents/scripts/lint-baseline.js +0 -507
  261. package/.agents/scripts/providers/github/prs.js +0 -103
  262. package/.agents/scripts/signals-view.js +0 -309
  263. package/.agents/scripts/story-plan.js +0 -370
  264. package/.agents/scripts/sync-branch-from-base.js +0 -149
  265. package/.agents/scripts/validate-docs-freshness.js +0 -314
  266. package/.agents/skills/core/diagnose-friction/SKILL.md +0 -78
  267. package/.agents/workflows/helpers/signals.md +0 -112
@@ -0,0 +1,233 @@
1
+ // .agents/scripts/lib/baselines/orphan-pruner.js
2
+ //
3
+ // Story #5012 — the measurement-free remedy that makes hard-failing on a stale
4
+ // baseline row defensible.
5
+ //
6
+ // The scope gate (`check-baseline-scope.js`) blocks a PR that leaves a row
7
+ // pointing at a file it deleted. A hard gate is only fair while its remedy is
8
+ // cheap, and the existing remedy — re-run the scorer, re-derive the baseline —
9
+ // costs a full coverage run or a full-tree MI re-score for what is arithmetic
10
+ // on a row set. This pruner is that arithmetic.
11
+ //
12
+ // ## Measurement-free by contract
13
+ //
14
+ // Three prohibitions, each guarding a way the file could start lying:
15
+ //
16
+ // 1. **Never add a row.** Adding one means claiming a measurement nobody
17
+ // took. Producing rows stays the producers' exclusive job.
18
+ // 2. **Never restamp `generatedAt`.** A fresh stamp over rows nobody
19
+ // re-measured is precisely the failure an age check exists to catch — the
20
+ // envelope would claim to describe today's tree on the strength of a
21
+ // deletion. The pruner carries the original stamp through untouched.
22
+ // 3. **Never delete a row it cannot prove inert.** Only two classes qualify:
23
+ // the file is absent from disk, or the file is no longer matched by the
24
+ // gate's own `targetDirs` / `ignoreGlobs`. Everything else survives.
25
+ //
26
+ // ## Degrade, never guess
27
+ //
28
+ // A degraded inventory (`files: null` — unreadable `.c8rc.cjs`, absent
29
+ // `targetDirs`) means scope is unknown, not empty. Treating it as empty would
30
+ // make every row look out-of-scope and hand the pruner the whole baseline. So
31
+ // an unreadable scope config falls back to **orphan-only** pruning: the
32
+ // absent-from-disk class still resolves off the filesystem and is still safe,
33
+ // while the out-of-scope class is suspended until scope can be read again.
34
+
35
+ import fs from 'node:fs';
36
+ import path from 'node:path';
37
+
38
+ import { getKindModule } from './kernel.js';
39
+ import { _internals as readerInternals } from './reader.js';
40
+ import { EXTRA_REASONS } from './scope-assert.js';
41
+ import {
42
+ buildScopeInventory,
43
+ isFileKeyed,
44
+ SCOPE_KINDS,
45
+ } from './scope-inventory.js';
46
+ import { writeFile } from './writer.js';
47
+
48
+ /** Every kind whose rows key on a repo-relative file path. */
49
+ export const PRUNABLE_KINDS = Object.freeze(SCOPE_KINDS.filter(isFileKeyed));
50
+
51
+ /**
52
+ * Partition a row set into the rows to keep and the rows to drop.
53
+ *
54
+ * Pure given its injected `existsOnDisk` predicate. `inScope` of `null` is the
55
+ * degraded case: only the absent-from-disk class is prunable.
56
+ *
57
+ * @param {{
58
+ * rows: Array<Record<string, unknown>>,
59
+ * inScope: Set<string> | null,
60
+ * existsOnDisk: (relPath: string) => boolean,
61
+ * }} params
62
+ * @returns {{ keep: Array<object>, removed: Array<{ path: string, reason: string }> }}
63
+ */
64
+ export function planPrune({ rows, inScope, existsOnDisk } = {}) {
65
+ const keep = [];
66
+ const removed = [];
67
+ for (const row of Array.isArray(rows) ? rows : []) {
68
+ const key = row?.path;
69
+ if (typeof key !== 'string' || key.length === 0) {
70
+ keep.push(row);
71
+ continue;
72
+ }
73
+ if (!existsOnDisk(key)) {
74
+ removed.push({ path: key, reason: EXTRA_REASONS.ABSENT });
75
+ continue;
76
+ }
77
+ if (inScope !== null && !inScope.has(key)) {
78
+ removed.push({ path: key, reason: EXTRA_REASONS.OUT_OF_SCOPE });
79
+ continue;
80
+ }
81
+ keep.push(row);
82
+ }
83
+ return { keep, removed };
84
+ }
85
+
86
+ /**
87
+ * Prune one parsed envelope and recompute its `rollup` through the kind's own
88
+ * arithmetic (`kinds/<kind>.js#rollup`), so the pruned file stays internally
89
+ * consistent and still validates against its schema. Recomputing by a private
90
+ * formula here would let the rollup and the rows describe different trees.
91
+ *
92
+ * A rollup carrying component buckets beyond `*` is refused rather than
93
+ * recomputed: the component globs that produced those buckets live in gate
94
+ * config this module is not handed, and emitting a `*`-only rollup would
95
+ * silently delete them.
96
+ *
97
+ * @param {{
98
+ * kind: string,
99
+ * envelope: object,
100
+ * inventory: { files: string[] | null },
101
+ * existsOnDisk?: (relPath: string) => boolean,
102
+ * }} params
103
+ * @returns {{
104
+ * envelope: object | null,
105
+ * removed: Array<{ path: string, reason: string }>,
106
+ * skipped: boolean,
107
+ * reason: string | null,
108
+ * }}
109
+ */
110
+ export function pruneEnvelope({
111
+ kind,
112
+ envelope,
113
+ inventory,
114
+ existsOnDisk,
115
+ } = {}) {
116
+ const rollupKeys = Object.keys(envelope?.rollup ?? {});
117
+ if (rollupKeys.some((key) => key !== '*')) {
118
+ return {
119
+ envelope: null,
120
+ removed: [],
121
+ skipped: true,
122
+ reason: `rollup carries component buckets (${rollupKeys.join(', ')}); prune them with the producer`,
123
+ };
124
+ }
125
+ const inScope = inventory?.files === null ? null : new Set(inventory.files);
126
+ const { keep, removed } = planPrune({
127
+ rows: envelope?.rows ?? [],
128
+ inScope,
129
+ existsOnDisk: existsOnDisk ?? (() => true),
130
+ });
131
+ if (removed.length === 0) {
132
+ return { envelope: null, removed: [], skipped: false, reason: null };
133
+ }
134
+ return {
135
+ envelope: {
136
+ ...envelope,
137
+ // `generatedAt` is carried by the spread, deliberately unmodified.
138
+ rollup: getKindModule(kind).rollup(keep, []),
139
+ rows: keep,
140
+ },
141
+ removed,
142
+ skipped: false,
143
+ reason: null,
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Read and JSON-parse a baseline file. Returns `null` when the file is absent
149
+ * (a kind this repository does not use) and throws only on malformed JSON,
150
+ * which is a real defect the caller should surface rather than swallow.
151
+ *
152
+ * @param {string} absPath
153
+ * @param {typeof fs} fsImpl
154
+ * @returns {object | null}
155
+ */
156
+ function readEnvelope(absPath, fsImpl) {
157
+ let raw;
158
+ try {
159
+ raw = fsImpl.readFileSync(absPath, 'utf8');
160
+ } catch {
161
+ return null;
162
+ }
163
+ return JSON.parse(raw);
164
+ }
165
+
166
+ /**
167
+ * Prune one kind end to end: resolve its baseline path, read it, build the
168
+ * scope inventory, plan, and (unless `check`) write.
169
+ *
170
+ * @param {object} params
171
+ * @returns {object} One entry of the report's `kinds` array.
172
+ */
173
+ function pruneKind({ kind, cwd, quality, check, fsImpl, requireFn }) {
174
+ const absPath = readerInternals.resolveBaselinePath(kind, { cwd });
175
+ const relPath = path.relative(cwd, absPath).split(path.sep).join('/');
176
+ const envelope = readEnvelope(absPath, fsImpl);
177
+ if (envelope === null) {
178
+ return { kind, path: relPath, present: false, removed: [], written: false };
179
+ }
180
+ const inventory = buildScopeInventory({ kind, cwd, quality, requireFn });
181
+ const result = pruneEnvelope({
182
+ kind,
183
+ envelope,
184
+ inventory,
185
+ existsOnDisk: (rel) => fsImpl.existsSync(path.resolve(cwd, rel)),
186
+ });
187
+ const base = {
188
+ kind,
189
+ path: relPath,
190
+ present: true,
191
+ degraded: inventory.degraded,
192
+ degradedReason: inventory.degraded ? inventory.reason : null,
193
+ skipped: result.skipped,
194
+ skipReason: result.reason,
195
+ removed: result.removed,
196
+ written: false,
197
+ };
198
+ if (result.envelope === null || check) return base;
199
+ writeFile(absPath, result.envelope, { fsImpl });
200
+ return { ...base, written: true };
201
+ }
202
+
203
+ /**
204
+ * Prune every file-keyed baseline under `cwd`.
205
+ *
206
+ * @param {{
207
+ * cwd?: string,
208
+ * kinds?: string[],
209
+ * check?: boolean,
210
+ * quality?: object,
211
+ * fsImpl?: typeof fs,
212
+ * requireFn?: (id: string) => object,
213
+ * }} params
214
+ * @returns {{ kinds: Array<object>, removedCount: number, writtenCount: number, check: boolean }}
215
+ */
216
+ export function runPrune({
217
+ cwd = process.cwd(),
218
+ kinds = PRUNABLE_KINDS,
219
+ check = false,
220
+ quality,
221
+ fsImpl = fs,
222
+ requireFn,
223
+ } = {}) {
224
+ const results = kinds.map((kind) =>
225
+ pruneKind({ kind, cwd, quality, check, fsImpl, requireFn }),
226
+ );
227
+ return {
228
+ check,
229
+ kinds: results,
230
+ removedCount: results.reduce((sum, r) => sum + r.removed.length, 0),
231
+ writtenCount: results.filter((r) => r.written).length,
232
+ };
233
+ }
@@ -76,11 +76,10 @@
76
76
  * @module .agents/scripts/lib/baselines/refresh-service
77
77
  */
78
78
 
79
- import { execFile as nodeExecFile } from 'node:child_process';
80
79
  import nodeFs from 'node:fs';
81
80
  import { createRequire } from 'node:module';
82
81
  import path from 'node:path';
83
- import { promisify } from 'node:util';
82
+ import { execFileCaptureAsync } from '../child-exec.js';
84
83
  import { getQuality, resolveConfig } from '../config-resolver.js';
85
84
  import {
86
85
  buildScopePredicate,
@@ -107,8 +106,6 @@ import {
107
106
 
108
107
  const nodeRequire = createRequire(import.meta.url);
109
108
 
110
- const execFileAsync = promisify(nodeExecFile);
111
-
112
109
  /**
113
110
  * Kinds the refresh service knows how to dispatch. Stays in lockstep with
114
111
  * the per-kind modules under `.agents/scripts/lib/baselines/kinds/`.
@@ -574,8 +571,9 @@ function assertRequiredScopeRows({
574
571
  }
575
572
 
576
573
  /**
577
- * Default git-diff derivation for the diff-scope path. Uses `execFile`
578
- * (no shell) and only the canonical two-dot range `baseRef..headRef` —
574
+ * Default git-diff derivation for the diff-scope path. Runs through the shared
575
+ * child-process surface ([`child-exec.js`](../child-exec.js)) `execFile`,
576
+ * never a shell — and only the canonical two-dot range `baseRef..headRef`:
579
577
  * triple-dot is intentionally avoided so the result reflects exactly the
580
578
  * files that differ between the two refs at the time of the call.
581
579
  *
@@ -585,10 +583,10 @@ function assertRequiredScopeRows({
585
583
  async function defaultGitDiff({ baseRef, headRef, cwd }) {
586
584
  const range = `${baseRef}..${headRef}`;
587
585
  try {
588
- const { stdout } = await execFileAsync(
586
+ const { stdout } = await execFileCaptureAsync(
589
587
  'git',
590
588
  ['diff', '--name-only', range],
591
- { cwd, maxBuffer: 16 * 1024 * 1024 },
589
+ { cwd },
592
590
  );
593
591
  return stdout
594
592
  .split(/\r?\n/)
@@ -0,0 +1,223 @@
1
+ // .agents/scripts/lib/baselines/scope-assert.js
2
+ //
3
+ // Story #5012 — the pure both-directions row-set assertion, plus the
4
+ // merge-base attribution that decides which half of a divergence is this
5
+ // change set's fault.
6
+ //
7
+ // `check-baselines.js` answers "did a measured value regress?". Nothing
8
+ // answered "does this baseline still describe the tree?" — so a row could
9
+ // point at a file deleted six months ago, or an in-scope file could carry no
10
+ // row at all, and every gate stayed green. Both directions are checked here:
11
+ //
12
+ // - **missing** — an in-scope file with no row. Only asserted for kinds
13
+ // whose producer emits one row per in-scope file
14
+ // (`scope-inventory.js#KIND_SCOPE_POLICY`).
15
+ // - **extra** — a row whose file is gone from disk (`absent`) or no longer
16
+ // matched by the gate's own `targetDirs` / `ignoreGlobs` (`out-of-scope`).
17
+ //
18
+ // ## Why attribution exists
19
+ //
20
+ // Whole-tree equality on the PR path is unusable: the moment anyone lands an
21
+ // in-scope file, every open PR reds on divergence its author did not create
22
+ // and cannot fix from their branch. So the gate blocks only on divergence
23
+ // attributable to `merge-base(base, HEAD)..HEAD`, and reports the inherited
24
+ // remainder as a warning that costs nothing to carry.
25
+ //
26
+ // Attribution can be wrong in one safe direction only, so the resolution
27
+ // **fails towards strict**: no resolvable base, a HEAD not ahead of its base,
28
+ // or a change set that edits a baseline or the config defining its scope all
29
+ // promote every finding to fatal. The last case is the load-bearing one — once
30
+ // a branch has rewritten the scope rules themselves, "which side of the
31
+ // merge-base introduced this row" is no longer a question the diff can answer.
32
+ //
33
+ // Pure module: no filesystem, no git, no config resolution. Every input is
34
+ // handed in already resolved, which is what lets the whole attribution matrix
35
+ // be unit-tested without a fixture repository.
36
+
37
+ /** A row whose keyed file no longer exists on disk. */
38
+ const REASON_ABSENT = 'absent';
39
+ /** A row whose file exists but is no longer inside the gate's scope. */
40
+ const REASON_OUT_OF_SCOPE = 'out-of-scope';
41
+
42
+ export const EXTRA_REASONS = Object.freeze({
43
+ ABSENT: REASON_ABSENT,
44
+ OUT_OF_SCOPE: REASON_OUT_OF_SCOPE,
45
+ });
46
+
47
+ /**
48
+ * Strictness reasons, in resolution order. Exported so the CLI can render the
49
+ * cause without restating the strings.
50
+ */
51
+ export const STRICT_REASONS = Object.freeze({
52
+ NO_BASE: 'no-resolvable-base',
53
+ NOT_AHEAD: 'head-not-ahead-of-base',
54
+ BASELINE_EDITED: 'change-set-edits-a-baseline',
55
+ SCOPE_CONFIG_EDITED: 'change-set-edits-a-scope-config',
56
+ ATTRIBUTABLE: 'attributable-to-merge-base-range',
57
+ });
58
+
59
+ /**
60
+ * Collect the distinct key values a row set carries, skipping malformed rows.
61
+ *
62
+ * @param {Array<Record<string, unknown>>} rows
63
+ * @param {string} keyField
64
+ * @returns {Set<string>}
65
+ */
66
+ function rowKeys(rows, keyField) {
67
+ const keys = new Set();
68
+ for (const row of Array.isArray(rows) ? rows : []) {
69
+ const value = row?.[keyField];
70
+ if (typeof value === 'string' && value.length > 0) keys.add(value);
71
+ }
72
+ return keys;
73
+ }
74
+
75
+ /**
76
+ * Compare a baseline's row set against its in-scope inventory.
77
+ *
78
+ * Direction gating is the inventory's call, not this function's: a kind whose
79
+ * policy omits `missing` produces no missing findings even when its row set is
80
+ * far sparser than the tree, because for that kind sparseness is correct.
81
+ *
82
+ * @param {{
83
+ * inventory: { kind: string, keyField: string, directions: readonly string[],
84
+ * files: string[] | null, degraded: boolean, reason: string | null },
85
+ * rows: Array<Record<string, unknown>>,
86
+ * existsOnDisk?: (relPath: string) => boolean,
87
+ * }} params
88
+ * @returns {{
89
+ * kind: string,
90
+ * skipped: boolean,
91
+ * reason: string | null,
92
+ * missing: string[],
93
+ * extra: Array<{ path: string, reason: string }>,
94
+ * }}
95
+ */
96
+ export function assertScope({ inventory, rows, existsOnDisk } = {}) {
97
+ const kind = inventory?.kind ?? 'unknown';
98
+ const directions = inventory?.directions ?? [];
99
+ if (directions.length === 0 || inventory?.files === null) {
100
+ return {
101
+ kind,
102
+ skipped: true,
103
+ reason: inventory?.reason ?? `${kind} declares no assertable direction`,
104
+ missing: [],
105
+ extra: [],
106
+ };
107
+ }
108
+
109
+ const keyField = inventory.keyField;
110
+ const inScope = new Set(inventory.files);
111
+ const keyed = rowKeys(rows, keyField);
112
+ const onDisk = typeof existsOnDisk === 'function' ? existsOnDisk : () => true;
113
+
114
+ const missing = directions.includes('missing')
115
+ ? inventory.files.filter((file) => !keyed.has(file)).sort()
116
+ : [];
117
+
118
+ const extra = [];
119
+ if (directions.includes('extra')) {
120
+ for (const key of [...keyed].sort()) {
121
+ if (inScope.has(key)) continue;
122
+ extra.push({
123
+ path: key,
124
+ reason: onDisk(key) ? REASON_OUT_OF_SCOPE : REASON_ABSENT,
125
+ });
126
+ }
127
+ }
128
+
129
+ return { kind, skipped: false, reason: null, missing, extra };
130
+ }
131
+
132
+ /**
133
+ * Decide whether this run blocks on every finding or only on the attributable
134
+ * ones. Fails towards strict — each early return below is a case where the
135
+ * merge-base range cannot be trusted to explain the divergence.
136
+ *
137
+ * @param {{
138
+ * base?: string | null,
139
+ * aheadOfBase?: boolean,
140
+ * changedFiles?: string[],
141
+ * baselinePaths?: string[],
142
+ * scopeConfigPaths?: string[],
143
+ * }} params
144
+ * @returns {{ strict: boolean, reason: string }}
145
+ */
146
+ export function resolveStrictness({
147
+ base,
148
+ aheadOfBase,
149
+ changedFiles = [],
150
+ baselinePaths = [],
151
+ scopeConfigPaths = [],
152
+ } = {}) {
153
+ if (typeof base !== 'string' || base.length === 0) {
154
+ return { strict: true, reason: STRICT_REASONS.NO_BASE };
155
+ }
156
+ if (aheadOfBase !== true) {
157
+ return { strict: true, reason: STRICT_REASONS.NOT_AHEAD };
158
+ }
159
+ const changed = new Set(changedFiles);
160
+ if (baselinePaths.some((file) => changed.has(file))) {
161
+ return { strict: true, reason: STRICT_REASONS.BASELINE_EDITED };
162
+ }
163
+ if (scopeConfigPaths.some((file) => changed.has(file))) {
164
+ return { strict: true, reason: STRICT_REASONS.SCOPE_CONFIG_EDITED };
165
+ }
166
+ return { strict: false, reason: STRICT_REASONS.ATTRIBUTABLE };
167
+ }
168
+
169
+ /**
170
+ * Split a kind's findings into the fatal set and the warning set.
171
+ *
172
+ * Under `strict`, everything is fatal. Otherwise a finding is fatal only when
173
+ * this change set created it:
174
+ *
175
+ * - a **missing** row for a file the range ADDED — the branch introduced an
176
+ * in-scope file and left it unmeasured;
177
+ * - an **extra** row for a file the range DELETED or renamed away — the
178
+ * branch removed the file and left its row behind.
179
+ *
180
+ * Everything else is inherited: real, worth reporting, and not this author's
181
+ * to fix from this branch.
182
+ *
183
+ * @param {{
184
+ * missing?: string[],
185
+ * extra?: Array<{ path: string, reason: string }>,
186
+ * added?: string[],
187
+ * removed?: string[],
188
+ * strict?: boolean,
189
+ * }} params
190
+ * @returns {{
191
+ * fatal: { missing: string[], extra: Array<{ path: string, reason: string }> },
192
+ * warning: { missing: string[], extra: Array<{ path: string, reason: string }> },
193
+ * fatalCount: number,
194
+ * warningCount: number,
195
+ * }}
196
+ */
197
+ export function attributeDivergence({
198
+ missing = [],
199
+ extra = [],
200
+ added = [],
201
+ removed = [],
202
+ strict = false,
203
+ } = {}) {
204
+ const addedSet = new Set(added);
205
+ const removedSet = new Set(removed);
206
+ const isFatalMissing = (file) => strict || addedSet.has(file);
207
+ const isFatalExtra = (finding) => strict || removedSet.has(finding.path);
208
+
209
+ const fatal = {
210
+ missing: missing.filter(isFatalMissing),
211
+ extra: extra.filter(isFatalExtra),
212
+ };
213
+ const warning = {
214
+ missing: missing.filter((file) => !isFatalMissing(file)),
215
+ extra: extra.filter((finding) => !isFatalExtra(finding)),
216
+ };
217
+ return {
218
+ fatal,
219
+ warning,
220
+ fatalCount: fatal.missing.length + fatal.extra.length,
221
+ warningCount: warning.missing.length + warning.extra.length,
222
+ };
223
+ }