@event4u/agent-config 16.0.0 → 16.1.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 (246) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +297 -137
  5. package/CONTRIBUTING.md +9 -0
  6. package/README.md +2 -2
  7. package/dist/agent-src/commands/analyze/inbox.md +26 -0
  8. package/dist/agent-src/commands/analyze/roadmap-repos.md +27 -0
  9. package/dist/agent-src/commands/challenge-me/closure.md +178 -0
  10. package/dist/agent-src/commands/challenge-me.md +9 -2
  11. package/dist/agent-src/commands/council/default.md +25 -5
  12. package/dist/agent-src/commands/feature/plan.md +28 -1
  13. package/dist/agent-src/commands/feature/roadmap.md +28 -1
  14. package/dist/agent-src/commands/implement-ticket.md +27 -0
  15. package/dist/agent-src/commands/jira-ticket.md +27 -0
  16. package/dist/agent-src/commands/pr/create.md +29 -0
  17. package/dist/agent-src/commands/pr/merge.md +60 -9
  18. package/dist/agent-src/commands/roadmap/ai-council.md +7 -1
  19. package/dist/agent-src/commands/roadmap/create.md +74 -27
  20. package/dist/agent-src/commands/roadmap/materialize.md +27 -0
  21. package/dist/agent-src/commands/roadmap/process-full.md +123 -18
  22. package/dist/agent-src/commands/team.md +22 -0
  23. package/dist/agent-src/contexts/authority/scope-growth-ownership.md +80 -0
  24. package/dist/agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +47 -0
  25. package/dist/agent-src/contexts/execution/auto-orchestration-activation.md +44 -3
  26. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +15 -15
  27. package/dist/agent-src/contexts/execution/decision-closure-execution.md +93 -0
  28. package/dist/agent-src/contexts/execution/fix-loop-ladder.md +108 -0
  29. package/dist/agent-src/contexts/execution/plan-confidence-gate.md +4 -2
  30. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +75 -6
  31. package/dist/agent-src/guidelines/agent-infra/active-remediation-mechanics.md +57 -0
  32. package/dist/agent-src/guidelines/agent-infra/evaluator-independence-mechanics.md +102 -0
  33. package/dist/agent-src/guidelines/design-fidelity-routing.md +29 -0
  34. package/dist/agent-src/rules/autonomous-execution.md +15 -8
  35. package/dist/agent-src/rules/delegation-policy.md +23 -45
  36. package/dist/agent-src/rules/design-fidelity.md +32 -37
  37. package/dist/agent-src/rules/evaluator-independence.md +43 -49
  38. package/dist/agent-src/rules/test-first.md +41 -0
  39. package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +3 -3
  40. package/dist/agent-src/skills/ai-council/SKILL.md +11 -3
  41. package/dist/agent-src/skills/api-design/data/manifest.json +3 -3
  42. package/dist/agent-src/skills/database/data/manifest.json +4 -4
  43. package/dist/agent-src/skills/database/data/query-tuning.csv +3 -3
  44. package/dist/agent-src/skills/design-review/SKILL.md +50 -0
  45. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +14 -4
  46. package/dist/agent-src/skills/experiment-loop/SKILL.md +31 -1
  47. package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
  48. package/dist/agent-src/skills/storybook-workshop/SKILL.md +42 -3
  49. package/dist/agent-src/skills/threat-modeling/SKILL.md +9 -4
  50. package/dist/agent-src/skills/threat-modeling/data/manifest.json +4 -4
  51. package/dist/agent-src/skills/verify-repair-loop/SKILL.md +37 -3
  52. package/dist/agent-src/templates/agent-settings.md +1 -1
  53. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  54. package/dist/agent-src/templates/roadmaps.md +188 -0
  55. package/dist/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +62 -2
  56. package/dist/agent-src/templates/scripts/work_engine/directives/backend/test.ts +1 -0
  57. package/dist/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +1 -0
  58. package/dist/cli-delegate/check_update_banner.js +40 -2
  59. package/dist/cli-delegate/chunk-4E2W63DA.js +335 -0
  60. package/dist/cli-delegate/chunk-5R7HOLAF.js +99 -0
  61. package/dist/cli-delegate/{chunk-IVBPRCQO.js → chunk-6KFEC3G7.js} +1 -1
  62. package/dist/cli-delegate/{chunk-2TAQ2K4V.js → chunk-6KM6ORRY.js} +55 -386
  63. package/dist/cli-delegate/{chunk-2XY7ILBT.js → chunk-FC75PGJE.js} +42 -2
  64. package/dist/cli-delegate/{chunk-GYC3VRYN.js → chunk-FXYUYU4N.js} +57 -13
  65. package/dist/cli-delegate/{chunk-55ACLDUE.js → chunk-GJCBB2UG.js} +168 -10
  66. package/dist/cli-delegate/{chunk-AK6HODNQ.js → chunk-HDVVGPQW.js} +873 -539
  67. package/dist/cli-delegate/{chunk-H32DGUJH.js → chunk-P6ZHP5W2.js} +1 -1
  68. package/dist/cli-delegate/{chunk-57PN4GYJ.js → chunk-RGNIWTEA.js} +1 -1
  69. package/dist/cli-delegate/chunk-ROYYTEZU.js +39 -0
  70. package/dist/cli-delegate/{chunk-3FKR64R5.js → chunk-UNCRJBT3.js} +31 -7
  71. package/dist/cli-delegate/{chunk-2DK2VJWG.js → chunk-WGXTVKCQ.js} +20 -6
  72. package/dist/cli-delegate/chunk-YDPILCOL.js +152 -0
  73. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  74. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  75. package/dist/cli-delegate/cmd_conformance.js +90 -56
  76. package/dist/cli-delegate/cmd_doctor.js +7 -5
  77. package/dist/cli-delegate/cmd_explain.js +2 -2
  78. package/dist/cli-delegate/cmd_export.js +9 -7
  79. package/dist/cli-delegate/cmd_fleet.js +4 -2
  80. package/dist/cli-delegate/cmd_handoff.js +4 -4
  81. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  82. package/dist/cli-delegate/cmd_migrate.js +2 -2
  83. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  84. package/dist/cli-delegate/cmd_preflight.js +4 -2
  85. package/dist/cli-delegate/cmd_prune.js +10 -8
  86. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  87. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  88. package/dist/cli-delegate/cmd_session_recycle.js +2 -2
  89. package/dist/cli-delegate/cmd_settings_get.js +28 -2
  90. package/dist/cli-delegate/cmd_settings_migrate.js +9 -7
  91. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  92. package/dist/cli-delegate/cmd_sync.js +10 -8
  93. package/dist/cli-delegate/cmd_uninstall.js +16 -13
  94. package/dist/cli-delegate/cmd_update.js +1 -1
  95. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  96. package/dist/cli-delegate/cmd_validate.js +12 -10
  97. package/dist/cli-delegate/cmd_versions.js +1 -1
  98. package/dist/discovery/deprecation-report.md +1 -1
  99. package/dist/discovery/discovery-manifest.json +85 -38
  100. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  101. package/dist/discovery/discovery-manifest.summary.md +4 -4
  102. package/dist/discovery/dormancy-report.md +1 -1
  103. package/dist/discovery/orphan-report.md +1 -1
  104. package/dist/discovery/packs.json +10 -8
  105. package/dist/discovery/trust-report.md +3 -3
  106. package/dist/discovery/workspaces.json +8 -6
  107. package/dist/hooks/dispatch.js +1832 -1007
  108. package/dist/install/claudeRuleRewrite.js +14 -3
  109. package/dist/install/conflict.js +60 -10
  110. package/dist/install/conflictTracker.js +218 -0
  111. package/dist/install/fsPrimitives.js +54 -0
  112. package/dist/install/install.mjs +2007 -1481
  113. package/dist/install/manifestFiles.js +259 -0
  114. package/dist/install/preflight.js +14 -2
  115. package/dist/install/preserve.js +157 -0
  116. package/dist/install/recordedOwnership.js +132 -0
  117. package/dist/install/txlog.js +11 -2
  118. package/dist/mcp/registry-manifest.json +3 -3
  119. package/dist/mcp/server.json +2 -2
  120. package/dist/mcp/server.mjs +398 -39
  121. package/dist/router.json +1 -1
  122. package/dist/scripts/_lib/agent_settings.js +3 -2
  123. package/dist/scripts/_lib/fs_atomic.js +167 -0
  124. package/dist/scripts/_lib/installed_tools.js +460 -0
  125. package/dist/scripts/_lib/lean_projection_mode.js +3 -2
  126. package/dist/scripts/_lib/settings_renamed_keys.js +78 -0
  127. package/dist/server/routes/install.js +8 -0
  128. package/dist/server/routes/wizard.js +9 -3
  129. package/dist/server/schemas/settings.js +17 -5
  130. package/dist/shared/missionExecution.js +97 -0
  131. package/dist/ui/assets/{index-D0jaCRPM.js → index-DH1xc62v.js} +17 -17
  132. package/dist/ui/index.html +1 -1
  133. package/docs/MIGRATION.md +103 -0
  134. package/docs/contracts/settings-classes.md +26 -7
  135. package/docs/guidelines/agent-infra/active-remediation-mechanics.md +57 -0
  136. package/docs/guidelines/agent-infra/evaluator-independence-mechanics.md +102 -0
  137. package/docs/guidelines/design-fidelity-routing.md +29 -0
  138. package/package.json +1 -1
  139. package/src/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +62 -2
  140. package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +1 -0
  141. package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +1 -0
  142. package/src/config/agent-settings.template.yml +139 -27
  143. package/src/config/auto-rule-budget.json +16 -0
  144. package/src/config/ci-local-parity.yml +18 -5
  145. package/src/config/continuity-surface.json +21 -0
  146. package/src/config/gate-coverage.yml +146 -13
  147. package/src/config/gate-violation-baselines.json +6 -6
  148. package/src/config/hook-token-budget.json +2 -0
  149. package/src/config/loop-surfaces.yaml +215 -0
  150. package/src/config/platform-anchor.json +3 -2
  151. package/src/config/release-gate-locality.yml +11 -0
  152. package/src/config/routing-coverage-seed.json +18 -4
  153. package/src/config/rule-enforcement-baseline.json +0 -1
  154. package/src/install/claudeRuleRewrite.ts +16 -2
  155. package/src/install/conflict.ts +65 -9
  156. package/src/install/conflictTracker.ts +299 -0
  157. package/src/install/fsPrimitives.ts +54 -0
  158. package/src/install/manifestFiles.ts +312 -0
  159. package/src/install/preflight.ts +16 -2
  160. package/src/install/preserve.ts +205 -0
  161. package/src/install/recordedOwnership.ts +149 -0
  162. package/src/install/txlog.ts +11 -2
  163. package/src/install/types.ts +19 -4
  164. package/src/scripts/_cli/cmd_conformance.ts +88 -15
  165. package/src/scripts/_cli/cmd_doctor.ts +4 -3
  166. package/src/scripts/_cli/cmd_route_explain.ts +9 -2
  167. package/src/scripts/_cli/cmd_settings_get.ts +44 -0
  168. package/src/scripts/_cli/doctor_execution.ts +90 -0
  169. package/src/scripts/_lib/activation_payload.ts +247 -0
  170. package/src/scripts/_lib/agent_settings.ts +4 -2
  171. package/src/scripts/_lib/authority_path.ts +102 -0
  172. package/src/scripts/_lib/cascade_base.ts +94 -0
  173. package/src/scripts/_lib/changelog_eras.ts +7 -2
  174. package/src/scripts/_lib/continuation_ladder.ts +149 -2
  175. package/src/scripts/_lib/council_transport.ts +129 -0
  176. package/src/scripts/_lib/delivery_ready.ts +112 -0
  177. package/src/scripts/_lib/forge_protection.ts +267 -0
  178. package/src/scripts/_lib/hook_platform_keys.ts +27 -0
  179. package/src/scripts/_lib/json_python_parity.ts +127 -0
  180. package/src/scripts/_lib/lean_projection_mode.ts +14 -3
  181. package/src/scripts/_lib/loop_surfaces.ts +382 -0
  182. package/src/scripts/_lib/mission_record.ts +190 -0
  183. package/src/scripts/_lib/obligation_frequency.ts +217 -0
  184. package/src/scripts/_lib/obligations.ts +402 -0
  185. package/src/scripts/_lib/orchestration_record.ts +11 -20
  186. package/src/scripts/_lib/release_highlights.ts +199 -10
  187. package/src/scripts/_lib/release_holds.ts +493 -0
  188. package/src/scripts/_lib/release_migration_gate.ts +184 -0
  189. package/src/scripts/_lib/rides_along.ts +102 -0
  190. package/src/scripts/_lib/settings_renamed_keys.ts +82 -0
  191. package/src/scripts/_lib/test_delta.ts +166 -0
  192. package/src/scripts/_lib/test_provenance.ts +171 -0
  193. package/src/scripts/_lib/typed_op_grant.ts +246 -0
  194. package/src/scripts/_lib/typed_op_watch.ts +185 -0
  195. package/src/scripts/ai_council/config.ts +8 -11
  196. package/src/scripts/ai_council/fallback_config.ts +5 -3
  197. package/src/scripts/ai_council/ownership_classes.ts +118 -0
  198. package/src/scripts/ask_block_census.ts +285 -15
  199. package/src/scripts/check_enforcement_matrix.ts +624 -0
  200. package/src/scripts/check_gate_reachability.ts +173 -10
  201. package/src/scripts/check_generator_sync.ts +954 -0
  202. package/src/scripts/check_preamble_payload_budget.ts +75 -2
  203. package/src/scripts/check_release_highlights.ts +33 -1
  204. package/src/scripts/check_release_holds.ts +567 -0
  205. package/src/scripts/check_rule_projection_integrity.ts +22 -12
  206. package/src/scripts/check_test_delta.ts +294 -0
  207. package/src/scripts/check_test_weakening.ts +299 -0
  208. package/src/scripts/closure_scan.ts +333 -0
  209. package/src/scripts/compile_hook_manifest.ts +43 -3
  210. package/src/scripts/council_record_shape.ts +209 -0
  211. package/src/scripts/hook_manifest.json +1 -1
  212. package/src/scripts/hook_manifest.yaml +88 -9
  213. package/src/scripts/hooks/concern_registry.ts +4 -0
  214. package/src/scripts/hooks/design_pass_hook.ts +128 -3
  215. package/src/scripts/hooks/evidence_independence.ts +96 -0
  216. package/src/scripts/hooks/memory_recall_hook.ts +540 -0
  217. package/src/scripts/hooks/obligation_settle_hook.ts +182 -0
  218. package/src/scripts/hooks/rule_inject_hook.ts +49 -1
  219. package/src/scripts/hooks/run_continuation_hook.ts +17 -20
  220. package/src/scripts/hooks_doctor.ts +85 -0
  221. package/src/scripts/hooks_status.ts +19 -0
  222. package/src/scripts/install.ts +106 -206
  223. package/src/scripts/janitor.ts +7 -0
  224. package/src/scripts/lint_decision_classes.ts +464 -0
  225. package/src/scripts/lint_hook_manifest.ts +11 -0
  226. package/src/scripts/lint_major_migration_sections.ts +450 -0
  227. package/src/scripts/lint_plan_risk_register.ts +47 -3
  228. package/src/scripts/lint_roadmap_blockers.ts +61 -0
  229. package/src/scripts/lint_roadmap_producers.ts +202 -0
  230. package/src/scripts/measure_rule_budget.ts +101 -5
  231. package/src/scripts/new_roadmap.ts +37 -0
  232. package/src/scripts/orchestration_record.ts +0 -5
  233. package/src/scripts/release.ts +14 -98
  234. package/src/scripts/release_drill.ts +267 -1
  235. package/src/scripts/release_publication.ts +51 -0
  236. package/src/scripts/report_enforcement_drift.ts +566 -0
  237. package/src/scripts/report_host_injection_effect.ts +3 -11
  238. package/src/scripts/report_standing_payload_by_host.ts +26 -152
  239. package/src/scripts/schemas/agent-settings.schema.json +23 -0
  240. package/src/scripts/schemas/command.schema.json +4 -0
  241. package/src/scripts/scope_growth.ts +219 -0
  242. package/src/scripts/trigger_eval_grandfather.json +0 -1
  243. package/src/scripts/ui_conformance_probe.ts +622 -0
  244. package/src/server/schemas/settings.ts +31 -7
  245. package/src/shared/missionExecution.ts +126 -0
  246. package/dist/cli-delegate/chunk-523HCFYO.js +0 -90
@@ -0,0 +1,954 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Refuse a push whose diff edits the SOURCE of a tracked generated artefact
4
+ * without re-running that artefact's generator.
5
+ *
6
+ * WHY THIS EXISTS — three incidents, one day, one class. A FOURTH followed the
7
+ * same day, after this gate was written and while its registry still carried
8
+ * only two triples; it has its own section below because it turned out to be
9
+ * about a different thing — which local green means.
10
+ *
11
+ * On 2026-09-12, in a single autonomous drain run, the same defect shipped
12
+ * three times:
13
+ *
14
+ * 1. `ADR-118` gained a `review_trigger` in its body. The committed ADR
15
+ * evidence census (the `adr-evidence-census` triple below) pins a line
16
+ * offset into every ADR, so it went stale. CI caught it after the push.
17
+ * 2. The same thing on the next branch — `ADR-134` gained two body blocks,
18
+ * census stale again. The first fix had been written as a one-off rather
19
+ * than as a pattern, so the second occurrence was free.
20
+ * 3. `src/server/schemas/settings.ts` had two description strings rewritten.
21
+ * `dist/install/install.mjs` is a committed esbuild bundle over that
22
+ * module and was not rebuilt. FOUR CI jobs went red on the one cause.
23
+ *
24
+ * Each artefact already had a gate. Both gates run only in CI, so each cost a
25
+ * red run and a fixup push. This gate moves the same question before the push,
26
+ * and generalises it: a registry of {source, generator, output} triples, each
27
+ * checked only when THIS branch touched its source.
28
+ *
29
+ * DIFF-SCOPED, AND THAT IS A DESIGN CONSTRAINT RATHER THAN AN OPTIMISATION.
30
+ * A gate that re-ran every generator on every push would cost more than the
31
+ * failure it prevents, and a pre-push gate people skip is worse than no gate —
32
+ * a fact this repository has recorded more than once. So a triple whose source
33
+ * this branch did not touch is not checked, and the run says so.
34
+ *
35
+ * FAIL CLOSED, ALWAYS. Every input this gate cannot read is a non-zero exit,
36
+ * never a green: an unresolvable base ref, a generator that fails, an output
37
+ * that cannot be read, a source set that cannot be derived. A gate that exits 0
38
+ * on a measurement it could not take is the precise failure mode it exists to
39
+ * prevent.
40
+ *
41
+ * THE POISONED-REGENERATION PROBLEM, AND WHY THIS GATE NORMALISES
42
+ *
43
+ * `node_modules` in a `.claude/worktrees/` checkout of this repository is a
44
+ * SYMLINK to the parent checkout. esbuild resolves each bundled module
45
+ * relative to the outfile and bakes the result into its per-module boundary
46
+ * comments and its CommonJS registry keys, so a bundle built in a worktree
47
+ * carries `../../../node_modules/yaml/...` where a bundle built in the main
48
+ * checkout carries `node_modules/yaml/...`. Measured on this tree: 189 such
49
+ * lines, deterministic across runs.
50
+ *
51
+ * Two consequences, and they pull in opposite directions:
52
+ *
53
+ * - A naive regenerate-and-diff would report the install bundle permanently
54
+ * stale in every worktree — a false red, which teaches people to skip.
55
+ * - Telling someone to run `npm run build:install-bundle` from a worktree
56
+ * would have them COMMIT those 189 lines, breaking module resolution for
57
+ * every consumer installing from the package. That near-miss was real and
58
+ * was caught only by reading the diff before committing.
59
+ *
60
+ * So this gate does both halves. It DETECTS the ascent in its own regenerated
61
+ * output; where detected it cancels it for the comparison (an exact, bounded
62
+ * rewrite of `(../)+node_modules/` to `node_modules/` and nothing else), and
63
+ * it rewrites the remedy it prints so the advice is safe from where the
64
+ * contributor actually is. Proven rather than argued: with the cancellation
65
+ * applied, a worktree build of the current tree is byte-identical to the
66
+ * committed bundle, and a one-character source edit still reds.
67
+ *
68
+ * The cancellation is narrow on purpose. It only ever removes `../` hops
69
+ * immediately preceding `node_modules/`; no real content change has that
70
+ * shape. And it protects only the side this gate BUILDS — the committed
71
+ * bundle is never rewritten, and an ascent inside it stays a finding for
72
+ * `check_bundle_path_leakage`, which owns that invariant and runs in the same
73
+ * preflight chain.
74
+ *
75
+ * THE DISCRIMINATOR — WHEN A GENERATED ARTEFACT IS THE SUSPECT, READ THE
76
+ * COMMITTED BLOB, NEVER THE WORKING TREE.
77
+ *
78
+ * A fourth occurrence, same day, and it is the one that says why the local
79
+ * test suite is not a substitute for this gate. A comment-only edit to
80
+ * `hook_manifest.yaml` left the committed `hook_manifest.json` on the previous
81
+ * fingerprint. `tests/hooks/hook_manifest_compiled.test.ts` — the gate that
82
+ * owns exactly this invariant — was run locally and PASSED, because an earlier
83
+ * `compile_hook_manifest` had already repaired the WORKING TREE. The defect was
84
+ * never that the file was wrong on disk; it was that the repair was never
85
+ * committed. CI checks out the commit, so CI read the stale blob and redded.
86
+ *
87
+ * A local suite reads the working tree. A remote one reads what you pushed.
88
+ * Every generated artefact is therefore two files, and a green local run says
89
+ * nothing about the one the reviewer and CI will see. The command that tells
90
+ * them apart:
91
+ *
92
+ * git show HEAD:<path> | diff - <path> # committed vs working tree
93
+ * git status --porcelain -- <path> # the same question, shorter
94
+ *
95
+ * Non-empty output means the regeneration is real and uncommitted, which is
96
+ * indistinguishable from a green local test and is caught by neither.
97
+ *
98
+ * AND NOT BY THIS GATE EITHER, WHICH IS WORTH SAYING PLAINLY RATHER THAN
99
+ * LEAVING FOR SOMEONE TO DISCOVER. `changedPaths` reads the working tree, the
100
+ * index and the base diff, so an uncommitted SOURCE edit puts a triple in
101
+ * scope — but the comparison then reads the OUTPUT off disk, so an uncommitted
102
+ * repair of the output satisfies it exactly as it satisfies the local test.
103
+ * This gate closes the forgot-to-regenerate case; it does not close the
104
+ * regenerated-but-did-not-commit case. Nothing in this repository does. Until
105
+ * something does, the two commands above are the whole instrument, and the
106
+ * habit they encode — when a generated artefact is the suspect, ask git what
107
+ * you committed, never what you have — is the transferable part.
108
+ *
109
+ * Usage:
110
+ * tsx src/scripts/check_generator_sync.ts
111
+ * tsx src/scripts/check_generator_sync.ts --quiet
112
+ * tsx src/scripts/check_generator_sync.ts --base <ref>
113
+ * tsx src/scripts/check_generator_sync.ts --self-test
114
+ *
115
+ * Exit codes: 0 = every in-scope output matches a fresh run · 1 = a stale
116
+ * output, or an input this gate could not read · 2 = usage error.
117
+ */
118
+ import { execFileSync, spawnSync } from 'node:child_process';
119
+ import * as fs from 'node:fs';
120
+ import * as os from 'node:os';
121
+ import * as path from 'node:path';
122
+ import process from 'node:process';
123
+ import { fileURLToPath, pathToFileURL } from 'node:url';
124
+
125
+ // Single-quoted on purpose: check_gate_completeness detects ledger adoption by this exact import form.
126
+ import { GateLedger } from './_lib/gate_ledger.js';
127
+ import { runGateCli, runSelfTest, type SelfTestCase } from './_lib/gate_self_test.js';
128
+ import { resolveBaseRef } from './_lib/ratchet_base_ref.js';
129
+ import { reportScanned } from './_lib/scan_scope.js';
130
+
131
+ const GATE = 'check_generator_sync';
132
+ const _HERE = fileURLToPath(import.meta.url);
133
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
134
+
135
+ /**
136
+ * A `../`-ascent reaching `node_modules/`, however many hops.
137
+ *
138
+ * The same shape `check_bundle_path_leakage` calls `parent-relative-node-modules`.
139
+ * It is re-expressed here rather than imported because the two gates apply it to
140
+ * different populations — that gate to the COMMITTED artefact, this one to its
141
+ * own freshly built comparison copy — and coupling them would make a scope
142
+ * change to either silently change the other.
143
+ */
144
+ const PARENT_ASCENT_TO_NODE_MODULES = /(?:\.\.\/)+node_modules\//g;
145
+
146
+ /** One source the registry watches, and how a changed path is matched to it. */
147
+ export interface SourceMatcher {
148
+ /** `prefix` — any path under it. `file` — that exact path. */
149
+ readonly kind: 'prefix' | 'file';
150
+ /** Repo-relative, `/`-separated (git's own output format on every platform). */
151
+ readonly value: string;
152
+ }
153
+
154
+ /** A regeneration that could not be performed — never a pass. */
155
+ export interface Unmeasurable {
156
+ readonly ok: false;
157
+ readonly reason: string;
158
+ }
159
+
160
+ export interface Regenerated {
161
+ readonly ok: true;
162
+ /** The freshly generated bytes, before normalisation. */
163
+ readonly text: string;
164
+ }
165
+
166
+ export interface Triple {
167
+ /** Stable id — the ledger target and the failure heading. */
168
+ readonly id: string;
169
+ /** Repo-relative path of the committed generated artefact. */
170
+ readonly output: string;
171
+ /** The exact command a human runs to fix a red. Printed verbatim. */
172
+ readonly remedy: string;
173
+ /** One line naming why the output depends on the sources, for the report. */
174
+ readonly why: string;
175
+ /**
176
+ * Which changed paths put this triple in scope.
177
+ *
178
+ * Returns an {@link Unmeasurable} rather than an empty list when the source
179
+ * set cannot be determined: an empty list would silently put the triple out
180
+ * of scope, which is the one way this gate could pass without measuring.
181
+ */
182
+ sourcesOf(root: string): { ok: true; sources: SourceMatcher[] } | Unmeasurable;
183
+ /** Regenerate into a temp dir this gate owns. Never writes into the tree. */
184
+ regenerate(root: string, workDir: string): Regenerated | Unmeasurable;
185
+ /**
186
+ * Cancel known environment artefacts before comparing, and report whether
187
+ * any were found so the remedy can be adjusted. Applied ONLY to the freshly
188
+ * built side.
189
+ */
190
+ normalise?(text: string): { text: string; environmentArtefacts: number };
191
+ /** Remedy override when {@link normalise} found environment artefacts. */
192
+ poisonedRemedy?: string;
193
+ }
194
+
195
+ function _git(root: string, args: readonly string[]): string {
196
+ return execFileSync('git', [...args], {
197
+ cwd: root,
198
+ encoding: 'utf8',
199
+ maxBuffer: 64 * 1024 * 1024,
200
+ });
201
+ }
202
+
203
+ /**
204
+ * Repo-relative paths this branch changed against `baseRef`, plus anything
205
+ * uncommitted.
206
+ *
207
+ * The working tree is included deliberately. This gate's whole subject is the
208
+ * window before a commit reaches a remote, and an edit that is staged but not
209
+ * yet committed is exactly the state in which incidents 1-3 were made.
210
+ */
211
+ export function changedPaths(root: string, baseRef: string): string[] {
212
+ const out = new Set<string>();
213
+ const add = (raw: string): void => {
214
+ for (const line of raw.split('\n')) {
215
+ const p = line.trim();
216
+ if (p !== '') out.add(p);
217
+ }
218
+ };
219
+ add(_git(root, ['diff', '--name-only', `${baseRef}...HEAD`]));
220
+ add(_git(root, ['diff', '--name-only', 'HEAD']));
221
+ add(_git(root, ['diff', '--name-only', '--cached']));
222
+ add(_git(root, ['ls-files', '--others', '--exclude-standard']));
223
+ return [...out].sort();
224
+ }
225
+
226
+ /** Does any changed path match any of this triple's sources? */
227
+ export function matchedSources(
228
+ changed: readonly string[],
229
+ sources: readonly SourceMatcher[],
230
+ ): string[] {
231
+ const hits: string[] = [];
232
+ for (const c of changed) {
233
+ for (const s of sources) {
234
+ const hit = s.kind === 'file' ? c === s.value : c.startsWith(s.value);
235
+ if (hit) {
236
+ hits.push(c);
237
+ break;
238
+ }
239
+ }
240
+ }
241
+ return hits;
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // The registry
246
+ // ---------------------------------------------------------------------------
247
+
248
+ /**
249
+ * Every bundled ES module esbuild recorded in an install-bundle build.
250
+ *
251
+ * Derived from the committed bundle's own per-module boundary comments rather
252
+ * than from a hand-maintained import list, so the source set cannot drift from
253
+ * what the bundle actually contains. A NEW import is still covered: the module
254
+ * that added it is itself in the list, so its edit puts the triple in scope and
255
+ * the rebuilt bundle then carries the new module.
256
+ */
257
+ export function bundledInstallSources(bundleText: string): string[] {
258
+ const out = new Set<string>();
259
+ for (const line of bundleText.split('\n')) {
260
+ const m = /^\/\/ (src\/[^\s]+)$/.exec(line.trim());
261
+ if (m !== null) out.add(m[1] as string);
262
+ }
263
+ return [...out].sort();
264
+ }
265
+
266
+ const INSTALL_BUNDLE = 'dist/install/install.mjs';
267
+
268
+ const INSTALL_BUNDLE_TRIPLE: Triple = {
269
+ id: 'install-bundle',
270
+ output: INSTALL_BUNDLE,
271
+ remedy: 'npm run build:install-bundle',
272
+ why: 'a committed esbuild bundle over src/scripts/install.ts and everything it imports',
273
+ poisonedRemedy:
274
+ 'run `npm run build:install-bundle` from the MAIN CHECKOUT, not from this worktree — ' +
275
+ "a worktree's node_modules is a symlink and esbuild bakes `../../../node_modules/...` " +
276
+ 'into the bundle (189 lines measured), which breaks module resolution for every consumer',
277
+ sourcesOf(root) {
278
+ const abs = path.join(root, INSTALL_BUNDLE);
279
+ let text: string;
280
+ try {
281
+ text = fs.readFileSync(abs, 'utf8');
282
+ } catch (e) {
283
+ return {
284
+ ok: false,
285
+ reason:
286
+ `cannot read ${INSTALL_BUNDLE} to derive its bundled source set (${String(e)}). ` +
287
+ 'The source set is read from the bundle itself, so an unreadable bundle means ' +
288
+ 'this gate cannot tell whether the branch touched a source — which is a ' +
289
+ 'refusal, not a pass.',
290
+ };
291
+ }
292
+ const mods = bundledInstallSources(text);
293
+ if (mods.length === 0) {
294
+ return {
295
+ ok: false,
296
+ reason:
297
+ `${INSTALL_BUNDLE} carries no \`// src/...\` module boundary comments. Either the ` +
298
+ 'bundle is not an esbuild output any more, or the comment format changed — ' +
299
+ 'either way the derived source set would be empty and every edit would read ' +
300
+ 'as out of scope.',
301
+ };
302
+ }
303
+ return {
304
+ ok: true,
305
+ sources: mods.map((value) => ({ kind: 'file', value }) as const),
306
+ };
307
+ },
308
+ regenerate(root, workDir) {
309
+ const outFile = path.join(workDir, 'install.mjs');
310
+ const esbuild = path.join(
311
+ root,
312
+ 'node_modules',
313
+ '.bin',
314
+ process.platform === 'win32' ? 'esbuild.cmd' : 'esbuild',
315
+ );
316
+ if (!fs.existsSync(esbuild)) {
317
+ return {
318
+ ok: false,
319
+ reason: `no esbuild at ${path.relative(root, esbuild)} — run \`npm ci\` before this gate`,
320
+ };
321
+ }
322
+ // Argv kept in lockstep with package.json `build:install-bundle`; the
323
+ // only difference is --outfile, which points at this gate's temp dir so
324
+ // the tree is never written to.
325
+ const res = spawnSync(
326
+ esbuild,
327
+ [
328
+ 'src/scripts/install.ts',
329
+ '--bundle',
330
+ '--platform=node',
331
+ '--format=esm',
332
+ '--target=node20',
333
+ '--define:__AGENT_CONFIG_BUNDLE__=true',
334
+ `--outfile=${outFile}`,
335
+ "--banner:js=import { createRequire as __acCreateRequire } from 'node:module'; const require = globalThis.require ?? __acCreateRequire(import.meta.url);",
336
+ ],
337
+ { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 },
338
+ );
339
+ if (res.status !== 0) {
340
+ return {
341
+ ok: false,
342
+ reason: `esbuild exited ${String(res.status)}: ${(res.stderr ?? '').trim()}`,
343
+ };
344
+ }
345
+ try {
346
+ return { ok: true, text: fs.readFileSync(outFile, 'utf8') };
347
+ } catch (e) {
348
+ return {
349
+ ok: false,
350
+ reason: `esbuild reported success but wrote no readable outfile (${String(e)})`,
351
+ };
352
+ }
353
+ },
354
+ normalise(text) {
355
+ const environmentArtefacts = (text.match(PARENT_ASCENT_TO_NODE_MODULES) ?? []).length;
356
+ return {
357
+ text: text.replace(PARENT_ASCENT_TO_NODE_MODULES, 'node_modules/'),
358
+ environmentArtefacts,
359
+ };
360
+ },
361
+ };
362
+
363
+ const CENSUS_TRIPLE: Triple = {
364
+ id: 'adr-evidence-census',
365
+ output: 'agents/evidence/analysis/adr-evidence-census-2026-08.md',
366
+ remedy: './scripts-run src/scripts/adr/evidence_census',
367
+ why: 'the census pins a line offset into every ADR, so any body edit moves the offsets below it',
368
+ sourcesOf() {
369
+ return {
370
+ ok: true,
371
+ sources: [
372
+ { kind: 'prefix', value: 'docs/decisions/' },
373
+ { kind: 'prefix', value: 'docs/adrs/' },
374
+ ],
375
+ };
376
+ },
377
+ regenerate(root, workDir) {
378
+ const outFile = path.join(workDir, 'census.md');
379
+ const tsx = path.join(
380
+ root,
381
+ 'node_modules',
382
+ '.bin',
383
+ process.platform === 'win32' ? 'tsx.cmd' : 'tsx',
384
+ );
385
+ if (!fs.existsSync(tsx)) {
386
+ return {
387
+ ok: false,
388
+ reason: `no tsx at ${path.relative(root, tsx)} — run \`npm ci\` before this gate`,
389
+ };
390
+ }
391
+ const res = spawnSync(
392
+ tsx,
393
+ [path.join(root, 'src', 'scripts', 'adr', 'evidence_census.ts'), '--out', outFile],
394
+ { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 },
395
+ );
396
+ if (res.status !== 0) {
397
+ return {
398
+ ok: false,
399
+ reason: `evidence_census exited ${String(res.status)}: ${(res.stderr ?? '').trim()}`,
400
+ };
401
+ }
402
+ try {
403
+ return { ok: true, text: fs.readFileSync(outFile, 'utf8') };
404
+ } catch (e) {
405
+ return {
406
+ ok: false,
407
+ reason: `evidence_census reported success but wrote no readable file (${String(e)})`,
408
+ };
409
+ }
410
+ },
411
+ };
412
+
413
+ const HOOK_MANIFEST_YAML = 'src/scripts/hook_manifest.yaml';
414
+ const HOOK_MANIFEST_JSON = 'src/scripts/hook_manifest.json';
415
+
416
+ /**
417
+ * The triple the registry missed, added the day it was missed.
418
+ *
419
+ * `hook_manifest.json` is a compiled projection of `hook_manifest.yaml` carrying
420
+ * a CONTENT-DERIVED fingerprint, which makes it the one output here that a
421
+ * COMMENT-ONLY source edit invalidates: the fingerprint hashes the YAML text, so
422
+ * a reworded comment moves it while every field in the parsed manifest stays
423
+ * byte-identical. That is exactly what happened on 2026-09-12 — a comment edit
424
+ * left the committed JSON on the previous fingerprint, no regeneration task
425
+ * covers this file (`task sync` and `task generate-tools` both report no drift),
426
+ * and CI caught it after the push.
427
+ */
428
+ const HOOK_MANIFEST_TRIPLE: Triple = {
429
+ id: 'hook-manifest-compiled',
430
+ output: HOOK_MANIFEST_JSON,
431
+ remedy: './scripts-run src/scripts/compile_hook_manifest',
432
+ why:
433
+ 'the compiled manifest carries a fingerprint hashed over the YAML TEXT, so even a ' +
434
+ 'comment-only edit to the source invalidates it — and no `task` target regenerates it',
435
+ sourcesOf() {
436
+ return { ok: true, sources: [{ kind: 'file', value: HOOK_MANIFEST_YAML }] };
437
+ },
438
+ regenerate(root, workDir) {
439
+ const outFile = path.join(workDir, 'hook_manifest.json');
440
+ const tsx = path.join(
441
+ root,
442
+ 'node_modules',
443
+ '.bin',
444
+ process.platform === 'win32' ? 'tsx.cmd' : 'tsx',
445
+ );
446
+ if (!fs.existsSync(tsx)) {
447
+ return {
448
+ ok: false,
449
+ reason: `no tsx at ${path.relative(root, tsx)} — run \`npm ci\` before this gate`,
450
+ };
451
+ }
452
+ // `--out` exists so this runs the SHIPPED generator without writing into
453
+ // the tree. Compiling inline here instead would test this gate's copy of
454
+ // the rule rather than the rule itself, which is how a gate drifts green.
455
+ const res = spawnSync(
456
+ tsx,
457
+ [path.join(root, 'src', 'scripts', 'compile_hook_manifest.ts'), '--out', outFile],
458
+ { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 },
459
+ );
460
+ if (res.status !== 0) {
461
+ return {
462
+ ok: false,
463
+ reason:
464
+ `compile_hook_manifest exited ${String(res.status)}: ` +
465
+ `${(res.stderr ?? '').trim()}`,
466
+ };
467
+ }
468
+ try {
469
+ return { ok: true, text: fs.readFileSync(outFile, 'utf8') };
470
+ } catch (e) {
471
+ return {
472
+ ok: false,
473
+ reason: `compile_hook_manifest reported success but wrote no readable file (${String(e)})`,
474
+ };
475
+ }
476
+ },
477
+ };
478
+
479
+ export const REGISTRY: readonly Triple[] = [
480
+ CENSUS_TRIPLE,
481
+ INSTALL_BUNDLE_TRIPLE,
482
+ HOOK_MANIFEST_TRIPLE,
483
+ ];
484
+
485
+ // ---------------------------------------------------------------------------
486
+ // Run
487
+ // ---------------------------------------------------------------------------
488
+
489
+ export interface RunOptions {
490
+ repoRoot?: string;
491
+ baseRef?: string | null;
492
+ quiet?: boolean;
493
+ registry?: readonly Triple[];
494
+ write?: (chunk: string) => unknown;
495
+ }
496
+
497
+ function firstLineDiff(committed: string, fresh: string): string {
498
+ const a = committed.split('\n');
499
+ const b = fresh.split('\n');
500
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
501
+ if (a[i] !== b[i]) {
502
+ return (
503
+ ` first difference at line ${String(i + 1)}\n` +
504
+ ` committed: ${JSON.stringify((a[i] ?? '<end of file>').slice(0, 160))}\n` +
505
+ ` fresh run: ${JSON.stringify((b[i] ?? '<end of file>').slice(0, 160))}\n`
506
+ );
507
+ }
508
+ }
509
+ return ' (the two differ only in trailing bytes)\n';
510
+ }
511
+
512
+ export function run(opts: RunOptions = {}): number {
513
+ const root = opts.repoRoot ?? REPO_ROOT;
514
+ const quiet = opts.quiet ?? false;
515
+ const registry = opts.registry ?? REGISTRY;
516
+ const write = opts.write ?? process.stdout.write.bind(process.stdout);
517
+
518
+ const baseRef = opts.baseRef === undefined ? resolveBaseRef(root) : opts.baseRef;
519
+ if (baseRef === null || baseRef === '') {
520
+ write(
521
+ `❌ ${GATE}: no base ref resolved — pass --base <ref> or set RATCHET_BASE_REF.\n` +
522
+ ' Comparing against an assumed-empty base would put every triple out of scope ' +
523
+ 'and pass by measuring nothing.\n',
524
+ );
525
+ return 1;
526
+ }
527
+
528
+ let changed: string[];
529
+ try {
530
+ changed = changedPaths(root, baseRef);
531
+ } catch (e) {
532
+ write(`❌ ${GATE}: cannot diff against ${baseRef} — ${String(e)}\n`);
533
+ return 1;
534
+ }
535
+
536
+ const ledger = new GateLedger(GATE);
537
+ ledger.plan(registry.map((t) => t.id));
538
+
539
+ const failures: string[] = [];
540
+ let inScope = 0;
541
+
542
+ for (const triple of registry) {
543
+ const sources = triple.sourcesOf(root);
544
+ if (!sources.ok) {
545
+ // An undeterminable source set is a refusal. Skipping here would be
546
+ // the silent pass this gate exists to make impossible.
547
+ ledger.fail(triple.id, sources.reason);
548
+ failures.push(`${triple.id}: UNMEASURABLE — ${sources.reason}`);
549
+ continue;
550
+ }
551
+
552
+ const hits = matchedSources(changed, sources.sources);
553
+ if (hits.length === 0) {
554
+ ledger.skip(triple.id, 'no_applicable_files');
555
+ continue;
556
+ }
557
+ inScope += 1;
558
+
559
+ const workDir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'gensync-'));
560
+ try {
561
+ const outAbs = path.join(root, triple.output);
562
+ let committed: string;
563
+ try {
564
+ committed = fs.readFileSync(outAbs, 'utf8');
565
+ } catch (e) {
566
+ ledger.fail(triple.id, 'committed output unreadable');
567
+ failures.push(
568
+ `${triple.id}: UNMEASURABLE — cannot read the committed output ${triple.output} ` +
569
+ `(${String(e)}). A generated artefact its own gate cannot open is a refusal, ` +
570
+ 'not an absence.',
571
+ );
572
+ continue;
573
+ }
574
+
575
+ const fresh = triple.regenerate(root, workDir);
576
+ if (!fresh.ok) {
577
+ ledger.fail(triple.id, 'generator failed');
578
+ failures.push(
579
+ `${triple.id}: UNMEASURABLE — the generator did not run: ${fresh.reason}`,
580
+ );
581
+ continue;
582
+ }
583
+
584
+ const norm = triple.normalise?.(fresh.text) ?? {
585
+ text: fresh.text,
586
+ environmentArtefacts: 0,
587
+ };
588
+ const poisoned = norm.environmentArtefacts > 0;
589
+
590
+ if (norm.text === committed) {
591
+ ledger.complete(triple.id);
592
+ if (!quiet) {
593
+ write(
594
+ `✅ ${triple.id}: ${String(hits.length)} source(s) changed, ${triple.output} ` +
595
+ `matches a fresh run` +
596
+ (poisoned
597
+ ? ` (${String(norm.environmentArtefacts)} environment path artefact(s) cancelled)`
598
+ : '') +
599
+ '\n',
600
+ );
601
+ }
602
+ continue;
603
+ }
604
+
605
+ ledger.fail(triple.id, 'output is stale against its source');
606
+ const remedy =
607
+ poisoned && triple.poisonedRemedy !== undefined ? triple.poisonedRemedy : triple.remedy;
608
+ failures.push(
609
+ `${triple.id}: ${triple.output} is STALE.\n` +
610
+ ` ${triple.why}\n` +
611
+ ` ${String(hits.length)} source(s) changed on this branch, e.g. ${hits.slice(0, 3).join(', ')}\n` +
612
+ firstLineDiff(committed, norm.text) +
613
+ ` REGENERATE: ${remedy}\n` +
614
+ (poisoned
615
+ ? ` NOTE: this build environment injected ${String(norm.environmentArtefacts)} ` +
616
+ '`../.../node_modules/` path(s); they were cancelled for the comparison ' +
617
+ 'above, but a rebuild committed from HERE would carry them.\n'
618
+ : ''),
619
+ );
620
+ } finally {
621
+ fs.rmSync(workDir, { recursive: true, force: true });
622
+ }
623
+ }
624
+
625
+ ledger.report();
626
+
627
+ // Diff-scoped: zero in-scope triples is the ordinary case and IS the pass —
628
+ // but it is announced rather than printed as a bare green, because a silent
629
+ // green over an empty set is indistinguishable from a gate that stopped
630
+ // resolving its registry.
631
+ reportScanned({
632
+ gate: GATE,
633
+ scanned: inScope,
634
+ units: 'generated artefact(s) whose source this branch touched',
635
+ roots: registry.map((t) => t.output),
636
+ allowEmpty:
637
+ 'EMPTY_VALID: the scope is the set of registry triples whose SOURCE changed since the ' +
638
+ 'base ref; a branch touching none has no generator to re-run, and zero is the pass. ' +
639
+ 'The registry itself is never empty — an unresolvable triple fails rather than skips.',
640
+ });
641
+
642
+ if (failures.length > 0) {
643
+ write(
644
+ `❌ ${GATE}: ${String(failures.length)} generated artefact(s) out of sync with their source.\n\n`,
645
+ );
646
+ for (const f of failures) write(` - ${f}\n`);
647
+ write(
648
+ '\n Each of these has a CI gate that would catch it after the push. This one is ' +
649
+ 'before it.\n',
650
+ );
651
+ return 1;
652
+ }
653
+
654
+ if (!quiet) {
655
+ if (inScope === 0) {
656
+ write(
657
+ `✅ ${GATE}: no registry source changed since ${baseRef} — ` +
658
+ `${String(registry.length)} triple(s) out of scope, nothing to regenerate\n`,
659
+ );
660
+ } else {
661
+ write(`✅ ${GATE}: ${String(inScope)} in-scope generated artefact(s) match a fresh run\n`);
662
+ }
663
+ }
664
+ return 0;
665
+ }
666
+
667
+ // ---------------------------------------------------------------------------
668
+ // Self-test
669
+ // ---------------------------------------------------------------------------
670
+
671
+ function _mkGitRepo(dir: string): string {
672
+ const g = (args: string[]): void => {
673
+ spawnSync('git', args, { cwd: dir, encoding: 'utf8' });
674
+ };
675
+ g(['init', '-q']);
676
+ g(['config', 'user.email', 'g@example.com']);
677
+ g(['config', 'user.name', 'g']);
678
+ g(['add', '-A']);
679
+ g(['commit', '-qm', 'base', '--allow-empty']);
680
+ return spawnSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).stdout.trim();
681
+ }
682
+
683
+ /**
684
+ * A self-contained triple over a trivial generator: `out.txt` must equal
685
+ * `src/in.txt` uppercased. Small enough that each case isolates exactly one
686
+ * property of the gate rather than a property of esbuild.
687
+ */
688
+ function _fixtureTriple(over: Partial<Triple> = {}): Triple {
689
+ return {
690
+ id: 'fixture',
691
+ output: 'out.txt',
692
+ remedy: 'fixture-regen',
693
+ why: 'out.txt is in.txt uppercased',
694
+ sourcesOf: () => ({ ok: true, sources: [{ kind: 'file', value: 'src/in.txt' }] }),
695
+ regenerate: (root) => {
696
+ try {
697
+ return {
698
+ ok: true,
699
+ text: fs.readFileSync(path.join(root, 'src', 'in.txt'), 'utf8').toUpperCase(),
700
+ };
701
+ } catch (e) {
702
+ return { ok: false, reason: String(e) };
703
+ }
704
+ },
705
+ ...over,
706
+ };
707
+ }
708
+
709
+ /** A repo whose `src/in.txt` is UNTRACKED, so it is inside the changed set. */
710
+ function _fixtureRepo(inText: string, outText: string): { dir: string; base: string } {
711
+ const dir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'gensync-st-'));
712
+ const base = _mkGitRepo(dir);
713
+ fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
714
+ fs.writeFileSync(path.join(dir, 'src', 'in.txt'), inText);
715
+ fs.writeFileSync(path.join(dir, 'out.txt'), outText);
716
+ return { dir, base };
717
+ }
718
+
719
+ const _poisonNormalise = (text: string): { text: string; environmentArtefacts: number } => ({
720
+ text: text.replace(PARENT_ASCENT_TO_NODE_MODULES, 'node_modules/'),
721
+ environmentArtefacts: (text.match(PARENT_ASCENT_TO_NODE_MODULES) ?? []).length,
722
+ });
723
+
724
+ function selfTest(): number {
725
+ const silent = (): void => {};
726
+ const cases: SelfTestCase[] = [
727
+ {
728
+ name: 'source changed, output NOT regenerated → reject',
729
+ expect: 'reject',
730
+ run: () => {
731
+ const { dir, base } = _fixtureRepo('hello\n', 'STALE\n');
732
+ return run({
733
+ repoRoot: dir,
734
+ baseRef: base,
735
+ quiet: true,
736
+ registry: [_fixtureTriple()],
737
+ write: silent,
738
+ });
739
+ },
740
+ },
741
+ {
742
+ name: 'source changed AND output correctly regenerated → accept (the false-positive case)',
743
+ expect: 'accept',
744
+ run: () => {
745
+ const { dir, base } = _fixtureRepo('hello\n', 'HELLO\n');
746
+ return run({
747
+ repoRoot: dir,
748
+ baseRef: base,
749
+ quiet: true,
750
+ registry: [_fixtureTriple()],
751
+ write: silent,
752
+ });
753
+ },
754
+ },
755
+ {
756
+ name: 'output stale but NO source changed → accept (diff-scoping holds)',
757
+ expect: 'accept',
758
+ run: () => {
759
+ // Both files are committed at base, so neither is in the changed
760
+ // set; only an unrelated file moved. The stale `out.txt` must not
761
+ // be reported — a gate that ignored scope would red here.
762
+ const dir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'gensync-st-'));
763
+ fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
764
+ fs.writeFileSync(path.join(dir, 'src', 'in.txt'), 'hello\n');
765
+ fs.writeFileSync(path.join(dir, 'out.txt'), 'STALE\n');
766
+ const base = _mkGitRepo(dir);
767
+ fs.writeFileSync(path.join(dir, 'unrelated.txt'), 'x\n');
768
+ return run({
769
+ repoRoot: dir,
770
+ baseRef: base,
771
+ quiet: true,
772
+ registry: [_fixtureTriple()],
773
+ write: silent,
774
+ });
775
+ },
776
+ },
777
+ {
778
+ name: 'source set undeterminable → reject, never a silent out-of-scope pass',
779
+ expect: 'reject',
780
+ run: () => {
781
+ const { dir, base } = _fixtureRepo('hello\n', 'HELLO\n');
782
+ return run({
783
+ repoRoot: dir,
784
+ baseRef: base,
785
+ quiet: true,
786
+ registry: [
787
+ _fixtureTriple({
788
+ sourcesOf: () => ({ ok: false, reason: 'cannot derive the source set' }),
789
+ }),
790
+ ],
791
+ write: silent,
792
+ });
793
+ },
794
+ },
795
+ {
796
+ name: 'generator fails → reject (an unrunnable generator is not a fresh output)',
797
+ expect: 'reject',
798
+ run: () => {
799
+ const { dir, base } = _fixtureRepo('hello\n', 'HELLO\n');
800
+ return run({
801
+ repoRoot: dir,
802
+ baseRef: base,
803
+ quiet: true,
804
+ registry: [_fixtureTriple({ regenerate: () => ({ ok: false, reason: 'boom' }) })],
805
+ write: silent,
806
+ });
807
+ },
808
+ },
809
+ {
810
+ name: 'committed output missing → reject (unreadable input is never a pass)',
811
+ expect: 'reject',
812
+ run: () => {
813
+ const { dir, base } = _fixtureRepo('hello\n', 'HELLO\n');
814
+ fs.rmSync(path.join(dir, 'out.txt'));
815
+ return run({
816
+ repoRoot: dir,
817
+ baseRef: base,
818
+ quiet: true,
819
+ registry: [_fixtureTriple()],
820
+ write: silent,
821
+ });
822
+ },
823
+ },
824
+ {
825
+ name: 'unresolvable base ref → reject',
826
+ expect: 'reject',
827
+ run: () => {
828
+ const { dir } = _fixtureRepo('hello\n', 'HELLO\n');
829
+ return run({
830
+ repoRoot: dir,
831
+ baseRef: null,
832
+ quiet: true,
833
+ registry: [_fixtureTriple()],
834
+ write: silent,
835
+ });
836
+ },
837
+ },
838
+ {
839
+ name: 'environment-poisoned regeneration is cancelled, not reported stale → accept',
840
+ expect: 'accept',
841
+ run: () => {
842
+ const { dir, base } = _fixtureRepo('x\n', 'node_modules/yaml/x.js\n');
843
+ return run({
844
+ repoRoot: dir,
845
+ baseRef: base,
846
+ quiet: true,
847
+ registry: [
848
+ _fixtureTriple({
849
+ regenerate: () => ({ ok: true, text: '../../../node_modules/yaml/x.js\n' }),
850
+ normalise: _poisonNormalise,
851
+ }),
852
+ ],
853
+ write: silent,
854
+ });
855
+ },
856
+ },
857
+ {
858
+ name: 'a REAL staleness still reds THROUGH the poison cancellation → reject',
859
+ expect: 'reject',
860
+ run: () => {
861
+ // The cancellation must not become a blanket excuse: the fresh
862
+ // side carries both the environment ascent AND a genuine content
863
+ // change, and only the former may be absorbed.
864
+ const { dir, base } = _fixtureRepo('x\n', 'node_modules/yaml/x.js\nOLD\n');
865
+ return run({
866
+ repoRoot: dir,
867
+ baseRef: base,
868
+ quiet: true,
869
+ registry: [
870
+ _fixtureTriple({
871
+ regenerate: () => ({
872
+ ok: true,
873
+ text: '../../../node_modules/yaml/x.js\nNEW\n',
874
+ }),
875
+ normalise: _poisonNormalise,
876
+ }),
877
+ ],
878
+ write: silent,
879
+ });
880
+ },
881
+ },
882
+ {
883
+ name: 'the shipped registry against this tree → accept',
884
+ expect: 'accept',
885
+ run: () => runGateCli(REPO_ROOT, 'src/scripts/check_generator_sync.ts', ['--quiet'], REPO_ROOT),
886
+ },
887
+ ];
888
+ return runSelfTest({ gate: GATE, cases, minCases: 10, minRejectCases: 6 });
889
+ }
890
+
891
+ // ---------------------------------------------------------------------------
892
+ // CLI
893
+ // ---------------------------------------------------------------------------
894
+
895
+ const USAGE =
896
+ `usage: ${GATE} [--base <ref>] [--quiet] [--self-test] [-h|--help]\n` +
897
+ ' Refuses when a tracked generated artefact is stale against a source this\n' +
898
+ ' branch touched. Diff-scoped; fails closed on any input it cannot read.\n';
899
+
900
+ export function main(argv: readonly string[] = process.argv.slice(2)): number {
901
+ let base: string | undefined;
902
+ let quiet = false;
903
+
904
+ for (let i = 0; i < argv.length; i += 1) {
905
+ const a = argv[i] as string;
906
+ if (a === '--self-test') {
907
+ // Guarded so a fixture invocation cannot recurse into its own suite.
908
+ if (process.env['GATE_SELF_TEST_CHILD'] === '1') {
909
+ process.stderr.write(`${GATE}: --self-test is not re-entrant\n`);
910
+ return 2;
911
+ }
912
+ return selfTest();
913
+ } else if (a === '--quiet') {
914
+ quiet = true;
915
+ } else if (a === '--base') {
916
+ const v = argv[i + 1];
917
+ if (v === undefined || v.startsWith('--')) {
918
+ process.stderr.write(`${GATE}: --base needs a ref\n${USAGE}`);
919
+ return 2;
920
+ }
921
+ base = v;
922
+ i += 1;
923
+ } else if (a === '-h' || a === '--help') {
924
+ process.stdout.write(USAGE);
925
+ return 0;
926
+ } else {
927
+ // Refuse rather than ignore. A gate in a required job that silently
928
+ // accepts a mistyped flag hands back a green nobody earned.
929
+ process.stderr.write(`${GATE}: unknown argument ${a}\n${USAGE}`);
930
+ return 2;
931
+ }
932
+ }
933
+
934
+ return run({ quiet, ...(base !== undefined ? { baseRef: base } : {}) });
935
+ }
936
+
937
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
938
+ function _isCliEntry(): boolean {
939
+ if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) return false;
940
+ if (process.argv[1] === undefined) return false;
941
+ if (import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) return true;
942
+ try {
943
+ return (
944
+ fs.realpathSync(fileURLToPath(import.meta.url)) ===
945
+ fs.realpathSync(path.resolve(process.argv[1]))
946
+ );
947
+ } catch {
948
+ return false;
949
+ }
950
+ }
951
+
952
+ if (_isCliEntry()) {
953
+ process.exit(main());
954
+ }