@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.
- package/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +297 -137
- package/CONTRIBUTING.md +9 -0
- package/README.md +2 -2
- package/dist/agent-src/commands/analyze/inbox.md +26 -0
- package/dist/agent-src/commands/analyze/roadmap-repos.md +27 -0
- package/dist/agent-src/commands/challenge-me/closure.md +178 -0
- package/dist/agent-src/commands/challenge-me.md +9 -2
- package/dist/agent-src/commands/council/default.md +25 -5
- package/dist/agent-src/commands/feature/plan.md +28 -1
- package/dist/agent-src/commands/feature/roadmap.md +28 -1
- package/dist/agent-src/commands/implement-ticket.md +27 -0
- package/dist/agent-src/commands/jira-ticket.md +27 -0
- package/dist/agent-src/commands/pr/create.md +29 -0
- package/dist/agent-src/commands/pr/merge.md +60 -9
- package/dist/agent-src/commands/roadmap/ai-council.md +7 -1
- package/dist/agent-src/commands/roadmap/create.md +74 -27
- package/dist/agent-src/commands/roadmap/materialize.md +27 -0
- package/dist/agent-src/commands/roadmap/process-full.md +123 -18
- package/dist/agent-src/commands/team.md +22 -0
- package/dist/agent-src/contexts/authority/scope-growth-ownership.md +80 -0
- package/dist/agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +47 -0
- package/dist/agent-src/contexts/execution/auto-orchestration-activation.md +44 -3
- package/dist/agent-src/contexts/execution/autonomy-mechanics.md +15 -15
- package/dist/agent-src/contexts/execution/decision-closure-execution.md +93 -0
- package/dist/agent-src/contexts/execution/fix-loop-ladder.md +108 -0
- package/dist/agent-src/contexts/execution/plan-confidence-gate.md +4 -2
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +75 -6
- package/dist/agent-src/guidelines/agent-infra/active-remediation-mechanics.md +57 -0
- package/dist/agent-src/guidelines/agent-infra/evaluator-independence-mechanics.md +102 -0
- package/dist/agent-src/guidelines/design-fidelity-routing.md +29 -0
- package/dist/agent-src/rules/autonomous-execution.md +15 -8
- package/dist/agent-src/rules/delegation-policy.md +23 -45
- package/dist/agent-src/rules/design-fidelity.md +32 -37
- package/dist/agent-src/rules/evaluator-independence.md +43 -49
- package/dist/agent-src/rules/test-first.md +41 -0
- package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +3 -3
- package/dist/agent-src/skills/ai-council/SKILL.md +11 -3
- package/dist/agent-src/skills/api-design/data/manifest.json +3 -3
- package/dist/agent-src/skills/database/data/manifest.json +4 -4
- package/dist/agent-src/skills/database/data/query-tuning.csv +3 -3
- package/dist/agent-src/skills/design-review/SKILL.md +50 -0
- package/dist/agent-src/skills/existing-ui-audit/SKILL.md +14 -4
- package/dist/agent-src/skills/experiment-loop/SKILL.md +31 -1
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
- package/dist/agent-src/skills/storybook-workshop/SKILL.md +42 -3
- package/dist/agent-src/skills/threat-modeling/SKILL.md +9 -4
- package/dist/agent-src/skills/threat-modeling/data/manifest.json +4 -4
- package/dist/agent-src/skills/verify-repair-loop/SKILL.md +37 -3
- package/dist/agent-src/templates/agent-settings.md +1 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +188 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +62 -2
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/test.ts +1 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +1 -0
- package/dist/cli-delegate/check_update_banner.js +40 -2
- package/dist/cli-delegate/chunk-4E2W63DA.js +335 -0
- package/dist/cli-delegate/chunk-5R7HOLAF.js +99 -0
- package/dist/cli-delegate/{chunk-IVBPRCQO.js → chunk-6KFEC3G7.js} +1 -1
- package/dist/cli-delegate/{chunk-2TAQ2K4V.js → chunk-6KM6ORRY.js} +55 -386
- package/dist/cli-delegate/{chunk-2XY7ILBT.js → chunk-FC75PGJE.js} +42 -2
- package/dist/cli-delegate/{chunk-GYC3VRYN.js → chunk-FXYUYU4N.js} +57 -13
- package/dist/cli-delegate/{chunk-55ACLDUE.js → chunk-GJCBB2UG.js} +168 -10
- package/dist/cli-delegate/{chunk-AK6HODNQ.js → chunk-HDVVGPQW.js} +873 -539
- package/dist/cli-delegate/{chunk-H32DGUJH.js → chunk-P6ZHP5W2.js} +1 -1
- package/dist/cli-delegate/{chunk-57PN4GYJ.js → chunk-RGNIWTEA.js} +1 -1
- package/dist/cli-delegate/chunk-ROYYTEZU.js +39 -0
- package/dist/cli-delegate/{chunk-3FKR64R5.js → chunk-UNCRJBT3.js} +31 -7
- package/dist/cli-delegate/{chunk-2DK2VJWG.js → chunk-WGXTVKCQ.js} +20 -6
- package/dist/cli-delegate/chunk-YDPILCOL.js +152 -0
- package/dist/cli-delegate/cmd_analyze_session.js +1 -1
- package/dist/cli-delegate/cmd_brand_status.js +1 -1
- package/dist/cli-delegate/cmd_conformance.js +90 -56
- package/dist/cli-delegate/cmd_doctor.js +7 -5
- package/dist/cli-delegate/cmd_explain.js +2 -2
- package/dist/cli-delegate/cmd_export.js +9 -7
- package/dist/cli-delegate/cmd_fleet.js +4 -2
- package/dist/cli-delegate/cmd_handoff.js +4 -4
- package/dist/cli-delegate/cmd_mcp_available.js +1 -1
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_packs_active.js +2 -2
- package/dist/cli-delegate/cmd_preflight.js +4 -2
- package/dist/cli-delegate/cmd_prune.js +10 -8
- package/dist/cli-delegate/cmd_route_audit.js +2 -2
- package/dist/cli-delegate/cmd_route_explain.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +2 -2
- package/dist/cli-delegate/cmd_settings_get.js +28 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +9 -7
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +10 -8
- package/dist/cli-delegate/cmd_uninstall.js +16 -13
- package/dist/cli-delegate/cmd_update.js +1 -1
- package/dist/cli-delegate/cmd_upgrade.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +12 -10
- package/dist/cli-delegate/cmd_versions.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +85 -38
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -4
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +10 -8
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +8 -6
- package/dist/hooks/dispatch.js +1832 -1007
- package/dist/install/claudeRuleRewrite.js +14 -3
- package/dist/install/conflict.js +60 -10
- package/dist/install/conflictTracker.js +218 -0
- package/dist/install/fsPrimitives.js +54 -0
- package/dist/install/install.mjs +2007 -1481
- package/dist/install/manifestFiles.js +259 -0
- package/dist/install/preflight.js +14 -2
- package/dist/install/preserve.js +157 -0
- package/dist/install/recordedOwnership.js +132 -0
- package/dist/install/txlog.js +11 -2
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +398 -39
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/agent_settings.js +3 -2
- package/dist/scripts/_lib/fs_atomic.js +167 -0
- package/dist/scripts/_lib/installed_tools.js +460 -0
- package/dist/scripts/_lib/lean_projection_mode.js +3 -2
- package/dist/scripts/_lib/settings_renamed_keys.js +78 -0
- package/dist/server/routes/install.js +8 -0
- package/dist/server/routes/wizard.js +9 -3
- package/dist/server/schemas/settings.js +17 -5
- package/dist/shared/missionExecution.js +97 -0
- package/dist/ui/assets/{index-D0jaCRPM.js → index-DH1xc62v.js} +17 -17
- package/dist/ui/index.html +1 -1
- package/docs/MIGRATION.md +103 -0
- package/docs/contracts/settings-classes.md +26 -7
- package/docs/guidelines/agent-infra/active-remediation-mechanics.md +57 -0
- package/docs/guidelines/agent-infra/evaluator-independence-mechanics.md +102 -0
- package/docs/guidelines/design-fidelity-routing.md +29 -0
- package/package.json +1 -1
- package/src/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +62 -2
- package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +1 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +1 -0
- package/src/config/agent-settings.template.yml +139 -27
- package/src/config/auto-rule-budget.json +16 -0
- package/src/config/ci-local-parity.yml +18 -5
- package/src/config/continuity-surface.json +21 -0
- package/src/config/gate-coverage.yml +146 -13
- package/src/config/gate-violation-baselines.json +6 -6
- package/src/config/hook-token-budget.json +2 -0
- package/src/config/loop-surfaces.yaml +215 -0
- package/src/config/platform-anchor.json +3 -2
- package/src/config/release-gate-locality.yml +11 -0
- package/src/config/routing-coverage-seed.json +18 -4
- package/src/config/rule-enforcement-baseline.json +0 -1
- package/src/install/claudeRuleRewrite.ts +16 -2
- package/src/install/conflict.ts +65 -9
- package/src/install/conflictTracker.ts +299 -0
- package/src/install/fsPrimitives.ts +54 -0
- package/src/install/manifestFiles.ts +312 -0
- package/src/install/preflight.ts +16 -2
- package/src/install/preserve.ts +205 -0
- package/src/install/recordedOwnership.ts +149 -0
- package/src/install/txlog.ts +11 -2
- package/src/install/types.ts +19 -4
- package/src/scripts/_cli/cmd_conformance.ts +88 -15
- package/src/scripts/_cli/cmd_doctor.ts +4 -3
- package/src/scripts/_cli/cmd_route_explain.ts +9 -2
- package/src/scripts/_cli/cmd_settings_get.ts +44 -0
- package/src/scripts/_cli/doctor_execution.ts +90 -0
- package/src/scripts/_lib/activation_payload.ts +247 -0
- package/src/scripts/_lib/agent_settings.ts +4 -2
- package/src/scripts/_lib/authority_path.ts +102 -0
- package/src/scripts/_lib/cascade_base.ts +94 -0
- package/src/scripts/_lib/changelog_eras.ts +7 -2
- package/src/scripts/_lib/continuation_ladder.ts +149 -2
- package/src/scripts/_lib/council_transport.ts +129 -0
- package/src/scripts/_lib/delivery_ready.ts +112 -0
- package/src/scripts/_lib/forge_protection.ts +267 -0
- package/src/scripts/_lib/hook_platform_keys.ts +27 -0
- package/src/scripts/_lib/json_python_parity.ts +127 -0
- package/src/scripts/_lib/lean_projection_mode.ts +14 -3
- package/src/scripts/_lib/loop_surfaces.ts +382 -0
- package/src/scripts/_lib/mission_record.ts +190 -0
- package/src/scripts/_lib/obligation_frequency.ts +217 -0
- package/src/scripts/_lib/obligations.ts +402 -0
- package/src/scripts/_lib/orchestration_record.ts +11 -20
- package/src/scripts/_lib/release_highlights.ts +199 -10
- package/src/scripts/_lib/release_holds.ts +493 -0
- package/src/scripts/_lib/release_migration_gate.ts +184 -0
- package/src/scripts/_lib/rides_along.ts +102 -0
- package/src/scripts/_lib/settings_renamed_keys.ts +82 -0
- package/src/scripts/_lib/test_delta.ts +166 -0
- package/src/scripts/_lib/test_provenance.ts +171 -0
- package/src/scripts/_lib/typed_op_grant.ts +246 -0
- package/src/scripts/_lib/typed_op_watch.ts +185 -0
- package/src/scripts/ai_council/config.ts +8 -11
- package/src/scripts/ai_council/fallback_config.ts +5 -3
- package/src/scripts/ai_council/ownership_classes.ts +118 -0
- package/src/scripts/ask_block_census.ts +285 -15
- package/src/scripts/check_enforcement_matrix.ts +624 -0
- package/src/scripts/check_gate_reachability.ts +173 -10
- package/src/scripts/check_generator_sync.ts +954 -0
- package/src/scripts/check_preamble_payload_budget.ts +75 -2
- package/src/scripts/check_release_highlights.ts +33 -1
- package/src/scripts/check_release_holds.ts +567 -0
- package/src/scripts/check_rule_projection_integrity.ts +22 -12
- package/src/scripts/check_test_delta.ts +294 -0
- package/src/scripts/check_test_weakening.ts +299 -0
- package/src/scripts/closure_scan.ts +333 -0
- package/src/scripts/compile_hook_manifest.ts +43 -3
- package/src/scripts/council_record_shape.ts +209 -0
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +88 -9
- package/src/scripts/hooks/concern_registry.ts +4 -0
- package/src/scripts/hooks/design_pass_hook.ts +128 -3
- package/src/scripts/hooks/evidence_independence.ts +96 -0
- package/src/scripts/hooks/memory_recall_hook.ts +540 -0
- package/src/scripts/hooks/obligation_settle_hook.ts +182 -0
- package/src/scripts/hooks/rule_inject_hook.ts +49 -1
- package/src/scripts/hooks/run_continuation_hook.ts +17 -20
- package/src/scripts/hooks_doctor.ts +85 -0
- package/src/scripts/hooks_status.ts +19 -0
- package/src/scripts/install.ts +106 -206
- package/src/scripts/janitor.ts +7 -0
- package/src/scripts/lint_decision_classes.ts +464 -0
- package/src/scripts/lint_hook_manifest.ts +11 -0
- package/src/scripts/lint_major_migration_sections.ts +450 -0
- package/src/scripts/lint_plan_risk_register.ts +47 -3
- package/src/scripts/lint_roadmap_blockers.ts +61 -0
- package/src/scripts/lint_roadmap_producers.ts +202 -0
- package/src/scripts/measure_rule_budget.ts +101 -5
- package/src/scripts/new_roadmap.ts +37 -0
- package/src/scripts/orchestration_record.ts +0 -5
- package/src/scripts/release.ts +14 -98
- package/src/scripts/release_drill.ts +267 -1
- package/src/scripts/release_publication.ts +51 -0
- package/src/scripts/report_enforcement_drift.ts +566 -0
- package/src/scripts/report_host_injection_effect.ts +3 -11
- package/src/scripts/report_standing_payload_by_host.ts +26 -152
- package/src/scripts/schemas/agent-settings.schema.json +23 -0
- package/src/scripts/schemas/command.schema.json +4 -0
- package/src/scripts/scope_growth.ts +219 -0
- package/src/scripts/trigger_eval_grandfather.json +0 -1
- package/src/scripts/ui_conformance_probe.ts +622 -0
- package/src/server/schemas/settings.ts +31 -7
- package/src/shared/missionExecution.ts +126 -0
- 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
|
+
}
|