@tenkara/afm-linux-arm64 0.0.0-stage → 4.0.0-alpha.10

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 (277) hide show
  1. package/bin/afm +4 -0
  2. package/content/.coupling-baseline.tsv +1 -0
  3. package/content/.pending-commands.txt +2 -0
  4. package/content/CHANGELOG-archive.md +806 -0
  5. package/content/CHANGELOG.md +4320 -0
  6. package/content/PRINCIPLES.md +119 -0
  7. package/content/README-light.md +158 -0
  8. package/content/README.md +150 -0
  9. package/content/code-style/_any/file-size.md +62 -0
  10. package/content/code-style/_any/imports.md +68 -0
  11. package/content/code-style/_any/naming.md +72 -0
  12. package/content/code-style/_any/test-placement.md +57 -0
  13. package/content/code-style/nextjs-app-router/imports.md +50 -0
  14. package/content/code-style/nextjs-app-router/naming.md +50 -0
  15. package/content/code-style/prisma-postgres/error-boundary.md +55 -0
  16. package/content/code-style/prisma-postgres/imports.md +61 -0
  17. package/content/code-style/shadcn-ui/naming.md +48 -0
  18. package/content/code-style/stripe-billing/naming.md +76 -0
  19. package/content/code-style/trigger-dev-v4/imports.md +58 -0
  20. package/content/code-style/trigger-dev-v4/naming.md +49 -0
  21. package/content/code-style/trigger-dev-v4/test-placement.md +61 -0
  22. package/content/code-style/trpc-v11/error-boundary.md +51 -0
  23. package/content/code-style/trpc-v11/file-size.md +44 -0
  24. package/content/code-style/trpc-v11/naming.md +60 -0
  25. package/content/code-style/trpc-v11/test-placement.md +52 -0
  26. package/content/core/ach.md +260 -0
  27. package/content/core/afm.md +514 -0
  28. package/content/core/gotchas.md +18 -0
  29. package/content/core/prd.md +141 -0
  30. package/content/core/protocol.md +158 -0
  31. package/content/core/ust.md +65 -0
  32. package/content/guardrails/README.md +55 -0
  33. package/content/guardrails/dsl-com-ordem-obrigatoria.md +47 -0
  34. package/content/guardrails/exaustividade-de-union.md +48 -0
  35. package/content/guardrails/fronteira-de-import.md +57 -0
  36. package/content/hooks/README.md +70 -0
  37. package/content/interview/bootstrap.md +417 -0
  38. package/content/interview/promotion.md +115 -0
  39. package/content/interview/refactor.md +379 -0
  40. package/content/interview/triggers.md +107 -0
  41. package/content/modules/nextjs-app-router/ach.patch.md +44 -0
  42. package/content/modules/nextjs-app-router/gotchas.seed.md +78 -0
  43. package/content/modules/nextjs-app-router/rules.patch.md +35 -0
  44. package/content/modules/prisma-postgres/ach.patch.md +65 -0
  45. package/content/modules/prisma-postgres/rules.patch.md +28 -0
  46. package/content/modules/shadcn-ui/ach.patch.md +42 -0
  47. package/content/modules/stripe-billing/ach.patch.md +46 -0
  48. package/content/modules/stripe-billing/gotchas.seed.md +73 -0
  49. package/content/modules/stripe-billing/rules.patch.md +30 -0
  50. package/content/modules/trigger-dev-v4/ach.patch.md +64 -0
  51. package/content/modules/trigger-dev-v4/gotchas.seed.md +53 -0
  52. package/content/modules/trigger-dev-v4/rules.patch.md +35 -0
  53. package/content/modules/trpc-v11/ach.patch.md +56 -0
  54. package/content/modules/trpc-v11/rules.patch.md +34 -0
  55. package/content/modules/vireya-ui/ach.patch.md +85 -0
  56. package/content/modules/vireya-ui/gotchas.seed.md +98 -0
  57. package/content/modules/vireya-ui/rules.patch.md +36 -0
  58. package/content/ops/adr.md +54 -0
  59. package/content/ops/analyze.md +130 -0
  60. package/content/ops/clarify.md +86 -0
  61. package/content/ops/deliver.md +209 -0
  62. package/content/ops/diagnose.md +143 -0
  63. package/content/ops/evolve.md +366 -0
  64. package/content/ops/export-template.md +97 -0
  65. package/content/ops/generalize.md +117 -0
  66. package/content/ops/gotcha.md +46 -0
  67. package/content/ops/plan.md +61 -0
  68. package/content/ops/policy.md +61 -0
  69. package/content/ops/promote.md +72 -0
  70. package/content/ops/protocol.md +51 -0
  71. package/content/ops/reconcile.md +44 -0
  72. package/content/ops/reflect.md +182 -0
  73. package/content/ops/remediate.md +143 -0
  74. package/content/ops/restructure.md +142 -0
  75. package/content/ops/rule.md +170 -0
  76. package/content/ops/specify.md +62 -0
  77. package/content/ops/tasks.md +67 -0
  78. package/content/ops/update.md +252 -0
  79. package/content/ops/verify.md +67 -0
  80. package/content/patterns/adapter.md +46 -0
  81. package/content/patterns/facade.md +47 -0
  82. package/content/patterns/factory.md +46 -0
  83. package/content/patterns/null-object.md +55 -0
  84. package/content/patterns/result-vs-exception.md +52 -0
  85. package/content/patterns/strategy.md +45 -0
  86. package/content/patterns/template-method-vs-composition.md +50 -0
  87. package/content/questions/adr.questions.yaml +14 -0
  88. package/content/questions/analyze.questions.yaml +12 -0
  89. package/content/questions/bootstrap-mapping.questions.yaml +14 -0
  90. package/content/questions/bootstrap-rules.questions.yaml +10 -0
  91. package/content/questions/bootstrap-stack.questions.yaml +23 -0
  92. package/content/questions/clarify.questions.yaml +10 -0
  93. package/content/questions/code-style.questions.yaml +12 -0
  94. package/content/questions/confirm.questions.yaml +12 -0
  95. package/content/questions/deliver.questions.yaml +23 -0
  96. package/content/questions/evolve.questions.yaml +15 -0
  97. package/content/questions/export-template-apply.questions.yaml +11 -0
  98. package/content/questions/export-template.questions.yaml +23 -0
  99. package/content/questions/gates.questions.yaml +27 -0
  100. package/content/questions/generalize.questions.yaml +13 -0
  101. package/content/questions/gotcha.questions.yaml +11 -0
  102. package/content/questions/init-template.questions.yaml +10 -0
  103. package/content/questions/init.questions.yaml +41 -0
  104. package/content/questions/plan.questions.yaml +14 -0
  105. package/content/questions/refactor.questions.yaml +23 -0
  106. package/content/questions/reflect.questions.yaml +13 -0
  107. package/content/questions/remediate.questions.yaml +27 -0
  108. package/content/questions/restructure.questions.yaml +15 -0
  109. package/content/questions/rf-mapping.questions.yaml +12 -0
  110. package/content/questions/rule-scope.questions.yaml +12 -0
  111. package/content/questions/rule.questions.yaml +12 -0
  112. package/content/questions/specify.questions.yaml +23 -0
  113. package/content/questions/update-artifacts.questions.yaml +10 -0
  114. package/content/questions/update-strings.questions.yaml +14 -0
  115. package/content/questions/update.questions.yaml +10 -0
  116. package/content/questions/verify.questions.yaml +9 -0
  117. package/content/roles/executor.md +18 -0
  118. package/content/roles/explorer.md +22 -0
  119. package/content/roles/reviewer.md +23 -0
  120. package/content/rubrics/enum-vs-union-vs-branded.md +40 -0
  121. package/content/rubrics/episodic-vs-semantic-boundary.md +37 -0
  122. package/content/rubrics/error-classification.md +98 -0
  123. package/content/rubrics/failure-classification.md +111 -0
  124. package/content/rubrics/negative-filters.md +38 -0
  125. package/content/rubrics/solid-triggers.md +119 -0
  126. package/content/rubrics/template-vs-streaming-precedence.md +34 -0
  127. package/content/rubrics/validation-boundary.md +94 -0
  128. package/content/rubrics/when-to-create-dsl.md +65 -0
  129. package/content/rubrics/when-to-create-lib.md +49 -0
  130. package/content/rubrics/when-to-create-module.md +39 -0
  131. package/content/rubrics/when-to-evolve-methodology.md +62 -0
  132. package/content/skills/adr.md +42 -0
  133. package/content/skills/analyze.md +28 -0
  134. package/content/skills/clarify.md +28 -0
  135. package/content/skills/deliver.md +26 -0
  136. package/content/skills/diagnose.md +42 -0
  137. package/content/skills/evolve.md +43 -0
  138. package/content/skills/export-template.md +37 -0
  139. package/content/skills/generalize.md +31 -0
  140. package/content/skills/gotcha.md +40 -0
  141. package/content/skills/init.md +77 -0
  142. package/content/skills/plan.md +28 -0
  143. package/content/skills/policy.md +25 -0
  144. package/content/skills/promote.md +39 -0
  145. package/content/skills/protocol.md +41 -0
  146. package/content/skills/reconcile.md +45 -0
  147. package/content/skills/refactor.md +79 -0
  148. package/content/skills/reflect.md +43 -0
  149. package/content/skills/remediate.md +33 -0
  150. package/content/skills/restructure.md +46 -0
  151. package/content/skills/rule.md +43 -0
  152. package/content/skills/specify.md +28 -0
  153. package/content/skills/tasks.md +24 -0
  154. package/content/skills/update.md +58 -0
  155. package/content/skills/verify.md +29 -0
  156. package/content/templates/adr.template.md +51 -0
  157. package/content/templates/evolution-report.template.md +132 -0
  158. package/content/templates/feature-plan.template.md +84 -0
  159. package/content/templates/feature-research.template.md +56 -0
  160. package/content/templates/feature-spec.template.md +86 -0
  161. package/content/templates/feature-tasks.induced.template.md +91 -0
  162. package/content/templates/feature-tasks.template.md +82 -0
  163. package/content/templates/focus.template.md +30 -0
  164. package/content/templates/gotcha.template.md +31 -0
  165. package/content/templates/guardrail-runner.template.md +97 -0
  166. package/content/templates/guardrail.template.md +63 -0
  167. package/content/templates/learning.template.md +40 -0
  168. package/content/templates/procedure.template.md +47 -0
  169. package/content/templates/protocols/p1.md +29 -0
  170. package/content/templates/protocols/p2.md +28 -0
  171. package/content/templates/protocols/p3.md +47 -0
  172. package/content/templates/research.template.md +50 -0
  173. package/content/templates/session-handoff.template.md +58 -0
  174. package/content/templates/us.template.md +39 -0
  175. package/content/tools/gate/core-rules/PLUGIN-13-secrets.sh +36 -0
  176. package/content/tools/gate/core-rules/PLUGIN-21-code-language.sh +27 -0
  177. package/content/tools/gate/core-rules/PLUGIN-35-gate-complete.sh +19 -0
  178. package/content/tools/gate/core-rules/PLUGIN-36-absolute-path.sh +38 -0
  179. package/content/tools/gate/core-rules/PLUGIN-37-prose-language.sh +56 -0
  180. package/content/tools/gate/core-rules/PLUGIN-38-parser-marker.sh +82 -0
  181. package/content/tools/gate/core-rules/PLUGIN-39-frontmatter-ops.sh +325 -0
  182. package/content/tools/gate/core-rules/PLUGIN-40-dead-reference.sh +74 -0
  183. package/content/tools/gate/core-rules/README.md +55 -0
  184. package/content/tools/gate/guardrails.sh +740 -0
  185. package/content/tools/gate/lib/ratchet.sh +119 -0
  186. package/content/tools/gate/lib/scope.sh +85 -0
  187. package/content/tools/gate/ratchet.seed.tsv +1 -0
  188. package/content/tools/gate/rules/README.md +50 -0
  189. package/package.json +19 -5
  190. package/plugin/agy/hooks.json +29 -0
  191. package/plugin/agy/mcp_config.json +7 -0
  192. package/plugin/agy/plugin.json +6 -0
  193. package/plugin/agy/skills/afm-adr/SKILL.md +48 -0
  194. package/plugin/agy/skills/afm-analyze/SKILL.md +34 -0
  195. package/plugin/agy/skills/afm-clarify/SKILL.md +34 -0
  196. package/plugin/agy/skills/afm-deliver/SKILL.md +30 -0
  197. package/plugin/agy/skills/afm-diagnose/SKILL.md +46 -0
  198. package/plugin/agy/skills/afm-evolve/SKILL.md +49 -0
  199. package/plugin/agy/skills/afm-export-template/SKILL.md +43 -0
  200. package/plugin/agy/skills/afm-generalize/SKILL.md +37 -0
  201. package/plugin/agy/skills/afm-gotcha/SKILL.md +46 -0
  202. package/plugin/agy/skills/afm-init/SKILL.md +81 -0
  203. package/plugin/agy/skills/afm-plan/SKILL.md +34 -0
  204. package/plugin/agy/skills/afm-policy/SKILL.md +29 -0
  205. package/plugin/agy/skills/afm-promote/SKILL.md +43 -0
  206. package/plugin/agy/skills/afm-protocol/SKILL.md +45 -0
  207. package/plugin/agy/skills/afm-reconcile/SKILL.md +49 -0
  208. package/plugin/agy/skills/afm-refactor/SKILL.md +83 -0
  209. package/plugin/agy/skills/afm-reflect/SKILL.md +49 -0
  210. package/plugin/agy/skills/afm-remediate/SKILL.md +39 -0
  211. package/plugin/agy/skills/afm-restructure/SKILL.md +52 -0
  212. package/plugin/agy/skills/afm-rule/SKILL.md +49 -0
  213. package/plugin/agy/skills/afm-specify/SKILL.md +34 -0
  214. package/plugin/agy/skills/afm-tasks/SKILL.md +28 -0
  215. package/plugin/agy/skills/afm-update/SKILL.md +64 -0
  216. package/plugin/agy/skills/afm-verify/SKILL.md +35 -0
  217. package/plugin/claude-code/.claude-plugin/marketplace.json +15 -0
  218. package/plugin/claude-code/.claude-plugin/plugin.json +9 -0
  219. package/plugin/claude-code/.mcp.json +7 -0
  220. package/plugin/claude-code/agents/afm-executor.md +18 -0
  221. package/plugin/claude-code/agents/afm-explorer.md +20 -0
  222. package/plugin/claude-code/agents/afm-reviewer.md +21 -0
  223. package/plugin/claude-code/hooks/hooks.json +85 -0
  224. package/plugin/claude-code/skills/adr/SKILL.md +48 -0
  225. package/plugin/claude-code/skills/analyze/SKILL.md +34 -0
  226. package/plugin/claude-code/skills/clarify/SKILL.md +34 -0
  227. package/plugin/claude-code/skills/deliver/SKILL.md +30 -0
  228. package/plugin/claude-code/skills/diagnose/SKILL.md +46 -0
  229. package/plugin/claude-code/skills/evolve/SKILL.md +49 -0
  230. package/plugin/claude-code/skills/export-template/SKILL.md +43 -0
  231. package/plugin/claude-code/skills/generalize/SKILL.md +37 -0
  232. package/plugin/claude-code/skills/gotcha/SKILL.md +46 -0
  233. package/plugin/claude-code/skills/init/SKILL.md +81 -0
  234. package/plugin/claude-code/skills/plan/SKILL.md +34 -0
  235. package/plugin/claude-code/skills/policy/SKILL.md +29 -0
  236. package/plugin/claude-code/skills/promote/SKILL.md +43 -0
  237. package/plugin/claude-code/skills/protocol/SKILL.md +45 -0
  238. package/plugin/claude-code/skills/reconcile/SKILL.md +49 -0
  239. package/plugin/claude-code/skills/refactor/SKILL.md +83 -0
  240. package/plugin/claude-code/skills/reflect/SKILL.md +49 -0
  241. package/plugin/claude-code/skills/remediate/SKILL.md +39 -0
  242. package/plugin/claude-code/skills/restructure/SKILL.md +52 -0
  243. package/plugin/claude-code/skills/rule/SKILL.md +49 -0
  244. package/plugin/claude-code/skills/specify/SKILL.md +34 -0
  245. package/plugin/claude-code/skills/tasks/SKILL.md +28 -0
  246. package/plugin/claude-code/skills/update/SKILL.md +64 -0
  247. package/plugin/claude-code/skills/verify/SKILL.md +35 -0
  248. package/plugin/opencode/afm/index.js +245 -0
  249. package/plugin/opencode/afm/tui.js +21 -0
  250. package/plugin/opencode/agents/afm-executor.md +39 -0
  251. package/plugin/opencode/agents/afm-explorer.md +92 -0
  252. package/plugin/opencode/agents/afm-reviewer.md +93 -0
  253. package/plugin/opencode/skills/afm-adr/SKILL.md +48 -0
  254. package/plugin/opencode/skills/afm-analyze/SKILL.md +34 -0
  255. package/plugin/opencode/skills/afm-clarify/SKILL.md +34 -0
  256. package/plugin/opencode/skills/afm-deliver/SKILL.md +30 -0
  257. package/plugin/opencode/skills/afm-diagnose/SKILL.md +46 -0
  258. package/plugin/opencode/skills/afm-evolve/SKILL.md +49 -0
  259. package/plugin/opencode/skills/afm-export-template/SKILL.md +43 -0
  260. package/plugin/opencode/skills/afm-generalize/SKILL.md +37 -0
  261. package/plugin/opencode/skills/afm-gotcha/SKILL.md +46 -0
  262. package/plugin/opencode/skills/afm-init/SKILL.md +81 -0
  263. package/plugin/opencode/skills/afm-plan/SKILL.md +34 -0
  264. package/plugin/opencode/skills/afm-policy/SKILL.md +29 -0
  265. package/plugin/opencode/skills/afm-promote/SKILL.md +43 -0
  266. package/plugin/opencode/skills/afm-protocol/SKILL.md +45 -0
  267. package/plugin/opencode/skills/afm-reconcile/SKILL.md +49 -0
  268. package/plugin/opencode/skills/afm-refactor/SKILL.md +83 -0
  269. package/plugin/opencode/skills/afm-reflect/SKILL.md +49 -0
  270. package/plugin/opencode/skills/afm-remediate/SKILL.md +39 -0
  271. package/plugin/opencode/skills/afm-restructure/SKILL.md +52 -0
  272. package/plugin/opencode/skills/afm-rule/SKILL.md +49 -0
  273. package/plugin/opencode/skills/afm-specify/SKILL.md +34 -0
  274. package/plugin/opencode/skills/afm-tasks/SKILL.md +28 -0
  275. package/plugin/opencode/skills/afm-update/SKILL.md +64 -0
  276. package/plugin/opencode/skills/afm-verify/SKILL.md +35 -0
  277. package/README.md +0 -3
@@ -0,0 +1,740 @@
1
+ #!/usr/bin/env bash
2
+ # Mechanical gate for the hard rules of `docs/afm.md` § 3.
3
+ #
4
+ # guardrails.sh # look at the INDEX (what becomes the commit); exit 1 on a violation
5
+ # guardrails.sh --worktree # look at the working tree
6
+ # guardrails.sh --audit # prove that rule and runner are in sync, both ways
7
+ # guardrails.sh --self-test # prove the aggregator catches what it claims to catch
8
+ # guardrails.sh --baseline # measure HEAD and record the debt, so the gate starts green
9
+ # guardrails.sh --install # arm the pre-commit hook (opt-in, never automatic)
10
+ #
11
+ # Why it exists: hard rules with an executable trigger written in PROSE never run. A rule that
12
+ # holds only when the agent remembers to run the grep fails exactly when context gets tight.
13
+ #
14
+ # Runner contract (`<rules>/<NN>-<slug>.sh`):
15
+ # argv[1] : "staged" | "worktree"
16
+ # env RATCHET : path of the ratchet TSV
17
+ # env AFM_GATE_LIB : directory holding scope.sh and ratchet.sh
18
+ # stdout : one line per finding — "<NN>\t<path>[:<line>]\t<message>"
19
+ # exit 0 : compliant · exit 1 : violated · exit 2 : COULD NOT decide
20
+ #
21
+ # Exit 2 is deliberately distinct from 1: "I could not verify" must never become "it failed".
22
+ # A runner can depend on a tool the machine does not have. The first machine without it would
23
+ # turn the gate into a permanent block, and a gate like that gets disabled — together with
24
+ # every other rule in the same aggregator.
25
+ set -u
26
+
27
+ V="${V:-}"
28
+ MODE=staged
29
+ ACTION=run
30
+ REPO=""
31
+ RULES_DIR=""
32
+ CORE_RULES_DIR=""
33
+ AFM_DOC=""
34
+ RATCHET_ARG=""
35
+
36
+ usage() {
37
+ printf 'usage: %s [-v] [--staged|--worktree] [--audit|--self-test|--install|--baseline]\n' "$0" >&2
38
+ printf ' [--repo <dir>] [--rules <dir>] [--core-rules <dir>]\n' >&2
39
+ printf ' [--ratchet <tsv>] [--afm-doc <md>]\n' >&2
40
+ exit 2
41
+ }
42
+
43
+ while [ $# -gt 0 ]; do
44
+ case "$1" in
45
+ -v|--verbose) V=1 ;;
46
+ --staged) MODE=staged ;;
47
+ --worktree) MODE=worktree ;;
48
+ --audit) ACTION=audit ;;
49
+ --self-test) ACTION=selftest ;;
50
+ --install) ACTION=install ;;
51
+ --baseline) ACTION=baseline ;;
52
+ --repo) shift; [ $# -gt 0 ] || usage; REPO="$1" ;;
53
+ --rules) shift; [ $# -gt 0 ] || usage; RULES_DIR="$1" ;;
54
+ --core-rules) shift; [ $# -gt 0 ] || usage; CORE_RULES_DIR="$1" ;;
55
+ --ratchet) shift; [ $# -gt 0 ] || usage; RATCHET_ARG="$1" ;;
56
+ --afm-doc) shift; [ $# -gt 0 ] || usage; AFM_DOC="$1" ;;
57
+ *) usage ;;
58
+ esac
59
+ shift
60
+ done
61
+
62
+ # --- Where things are -------------------------------------------------------
63
+ # The aggregator is canonical in the plugin and materialized in the consumer, so it cannot
64
+ # derive the repository from its own location. It asks git, and falls back to the working
65
+ # directory when git has nothing to say.
66
+ if [ -z "$REPO" ]; then
67
+ REPO="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
68
+ fi
69
+
70
+ # The library travels next to this script, whichever copy is running.
71
+ AFM_GATE_LIB="${AFM_GATE_LIB:-$(cd "$(dirname "$0")" && pwd)/lib}"
72
+ export AFM_GATE_LIB
73
+ # The mode reaches a runner as $1, but `scope_all` takes no mode argument and cannot grow one
74
+ # without breaking every runner a consumer already wrote. It reads this instead.
75
+ export AFM_GATE_MODE="$MODE"
76
+ # shellcheck source=lib/ratchet.sh
77
+ . "$AFM_GATE_LIB/ratchet.sh"
78
+
79
+ # Two candidates, in order. A consumer that took the materialized layout needs no flag; a
80
+ # project that already keeps its gates somewhere else passes --rules and --ratchet.
81
+ first_existing() {
82
+ local c
83
+ for c in "$@"; do [ -e "$c" ] && { printf '%s' "$c"; return 0; }; done
84
+ printf '%s' "$1"
85
+ }
86
+
87
+ [ -n "$RULES_DIR" ] || RULES_DIR="${AFM_GATE_RULES:-$(first_existing "$REPO/docs/tools/gate/rules" "$REPO/test/rules")}"
88
+
89
+ # The gate has TWO classes of runner, and conflating them was a real defect (ADR 0021).
90
+ #
91
+ # PROJECT rules — `$RULES_DIR`. One project's rules about its own CODE. The plugin ships
92
+ # none of these, and the README in `gate/rules/` explains why: a consumer
93
+ # that inherits a rule nobody chose bypasses it first.
94
+ # ARTIFACT rules — `$CORE_RULES_DIR`. The contract the plugin already imposes on the `docs/`
95
+ # it materializes: English prose, no absolute path, the OKF frontmatter, a
96
+ # versioned runner behind every 0/1 trigger. Nobody "chooses" these — they
97
+ # are what an AFM instance IS. Until 3.20.0 they lived in one repository and
98
+ # every consumer got an `afm.md` that stopped at rule 21.
99
+ #
100
+ # A project runner with the same NN WINS over the shipped one, so a consumer can override
101
+ # without forking the plugin.
102
+ [ -n "$CORE_RULES_DIR" ] || CORE_RULES_DIR="${AFM_GATE_CORE_RULES:-$(first_existing "$REPO/docs/tools/gate/core-rules" "$REPO/test/core-rules")}"
103
+ [ -n "$AFM_DOC" ] || AFM_DOC="${AFM_GATE_DOC:-$REPO/docs/afm.md}"
104
+ if [ -n "$RATCHET_ARG" ]; then
105
+ RATCHET="$RATCHET_ARG"
106
+ else
107
+ RATCHET="${RATCHET:-$(first_existing "$REPO/docs/tools/gate/ratchet.tsv" "$REPO/test/baseline/ratchet.tsv")}"
108
+ fi
109
+ export RATCHET
110
+
111
+ # Absolute before the `cd` below, because a relative path passed on the command line is
112
+ # relative to where the operator stood, not to the repository.
113
+ abspath() {
114
+ case "$1" in
115
+ /*) printf '%s' "$1" ;;
116
+ *) printf '%s/%s' "$(pwd)" "$1" ;;
117
+ esac
118
+ }
119
+ RULES_DIR="$(abspath "$RULES_DIR")"
120
+ CORE_RULES_DIR="$(abspath "$CORE_RULES_DIR")"
121
+ # A runner may invoke the aggregator again (rule PLUGIN-35 does exactly that). Without these,
122
+ # the nested call re-resolves the directories from the DEFAULTS and reports every shipped rule
123
+ # as a phantom gate — a failure invented by the check itself.
124
+ export AFM_GATE_RULES="$RULES_DIR"
125
+ export AFM_GATE_CORE_RULES="$CORE_RULES_DIR"
126
+ AFM_DOC="$(abspath "$AFM_DOC")"
127
+ RATCHET="$(abspath "$RATCHET")"
128
+ export RATCHET
129
+
130
+ # Stand in the repository before running anything. `scope_all` and `scope_files` ask git, and
131
+ # git answers about the CURRENT DIRECTORY: from a subdirectory it lists only that subdirectory,
132
+ # and from another repository it lists that other repository. Measured 2026-08-26 while testing
133
+ # the refutation of ADR 0017 — the gate was invoked with `--repo <other project>` from this
134
+ # repository and reported findings in `plugins/afm/`, under the other project's name. A count
135
+ # rule that measures the wrong tree does not fail; it reports a number that looks right.
136
+ cd "$REPO" || { printf 'cannot enter %s\n' "$REPO" >&2; exit 2; }
137
+
138
+ # A runner must NOT derive the repository from its own path. Until 3.20.0 every one of them ran
139
+ # `REPO="$(cd "$(dirname "$0")/../.." && pwd)"`, which is only correct while the runner sits
140
+ # exactly two levels below the root. The moment a runner shipped with the plugin — five levels
141
+ # down — that expression pointed at `tools/`, and rule 37 reported "cannot be decided" against a
142
+ # file that was right there. The aggregator already stands in the repository, so it hands the
143
+ # answer down instead of letting each runner guess.
144
+ export AFM_GATE_REPO="$REPO"
145
+
146
+ # --- Scoreboard -------------------------------------------------------------
147
+ # Silent when green, loud when red. A gate that talks while everything is fine trains the
148
+ # operator to ignore its output, and then the red line disappears in the noise.
149
+ PASS=0; FAIL=0; NOTE=0
150
+ sec() { [ -n "$V" ] && printf '%s\n' "$1"; return 0; }
151
+ ok() { PASS=$((PASS+1)); [ -n "$V" ] && printf ' ok — %s\n' "$1"; return 0; }
152
+ bad() { FAIL=$((FAIL+1)); printf ' FAIL — %s\n' "$1"; }
153
+ # A note is KNOWN DEBT or "could not decide". Neither is actionable at commit time, so it only
154
+ # shows under -v, or under --audit, which is where you look at debt on purpose.
155
+ note() { NOTE=$((NOTE+1)); [ -n "$V" ] && printf ' note — %s\n' "$1"; return 0; }
156
+
157
+ summary() {
158
+ if [ "$FAIL" -gt 0 ]; then
159
+ printf '\n%d ok, %d fail, %d note (known debt: %s --audit)\n' "$PASS" "$FAIL" "$NOTE" "$0"
160
+ elif [ -n "$V" ]; then
161
+ printf '\n%d ok, %d note\n' "$PASS" "$NOTE"
162
+ fi
163
+ [ "$FAIL" -eq 0 ]
164
+ }
165
+
166
+ # A rule ID has TWO namespaces, and that is what removed the number reservation (ADR 0021).
167
+ #
168
+ # PLUGIN-<n> a rule the plugin ships. It is the contract of the `docs/` AFM materializes.
169
+ # <n> a rule this project wrote about its own code.
170
+ #
171
+ # Before 3.20.0 the template reserved 18-29 for "universal rules, which grow with each release"
172
+ # and told a project to start at 30. A reservation is a promise about numbers nobody can keep:
173
+ # this repository had already spent 35-39 on rules that turned out to be universal, and the
174
+ # moment they shipped, any consumer's own rule 35 collided with them. A namespace cannot
175
+ # collide, and it also says where a rule came from, which the number alone never did.
176
+ #
177
+ # An organisation policy rule stays numbered 100 or higher — a third namespace, already
178
+ # distinct, and left alone here.
179
+ rule_number() { basename "$1" | sed -E 's/^((PLUGIN-)?[0-9]+)-.*/\1/'; }
180
+
181
+ # --- Core: run one runner and decide against the ratchet --------------------
182
+ # A runner only knows how to find a violation. The budget belongs to the aggregator. That keeps
183
+ # the ratchet rule in one place and keeps runners dumb, which is what makes them cheap to write
184
+ # and cheap to audit.
185
+ run_rule() {
186
+ local f="$1" nn out rc line path region
187
+ nn="$(rule_number "$f")"
188
+
189
+ out="$("$f" "$MODE" 2>/dev/null)"
190
+ rc=$?
191
+
192
+ if [ "$rc" = "2" ]; then
193
+ note "rule ${nn}: the runner could not decide (missing dependency) — this is not a violation"
194
+ return 0
195
+ fi
196
+
197
+ if [ -z "$out" ]; then
198
+ ok "rule ${nn}"
199
+ return 0
200
+ fi
201
+
202
+ # Group the findings by the most specific configured region.
203
+ local -A tally=()
204
+ while IFS= read -r line; do
205
+ [ -n "$line" ] || continue
206
+ path="$(printf '%s' "$line" | cut -f2 | cut -d: -f1)"
207
+ region="$(ratchet_region "$nn" "$path")"
208
+ tally["$region"]=$(( ${tally["$region"]:-0} + 1 ))
209
+ done <<< "$out"
210
+
211
+ local budget r n
212
+ for r in "${!tally[@]}"; do
213
+ n="${tally[$r]}"
214
+ budget="$(ratchet_budget "$nn" "$r")"
215
+ if [ "$n" -le "$budget" ]; then
216
+ note "rule ${nn} in '${r}': ${n}/${budget} within the ratchet$( [ -n "$(ratchet_note "$nn" "$r")" ] && printf ' (%s)' "$(ratchet_note "$nn" "$r")" )"
217
+ else
218
+ bad "rule ${nn} in '${r}': ${n} violation(s), budget ${budget}"
219
+ printf '%s\n' "$out" | awk -F'\t' '{ printf " %s — %s\n", $2, $3 }'
220
+ fi
221
+ done
222
+ }
223
+
224
+ # The one place that decides WHICH runners exist. Project rules win over shipped ones on a
225
+ # number collision, so a consumer overrides by dropping a same-numbered file into its own dir.
226
+ # Every caller that used to glob a single directory goes through here — a second glob left
227
+ # behind is a rule that runs in `run_all` and is invisible to `--audit`, which is the shape of
228
+ # defect this whole release is about.
229
+ rule_files() {
230
+ local d f nn
231
+ local -A seen=()
232
+ for d in "$RULES_DIR" "$CORE_RULES_DIR"; do
233
+ [ -d "$d" ] || continue
234
+ # BOTH shapes. `[0-9]*.sh` alone silently skipped every shipped runner, because a namespaced
235
+ # name starts with `P`. The glob matched nothing, and a glob that matches nothing reports
236
+ # nothing — the audit went green while the entire core-rules directory was invisible.
237
+ for f in "$d"/[0-9]*.sh "$d"/PLUGIN-[0-9]*.sh; do
238
+ [ -f "$f" ] || continue
239
+ nn="$(rule_number "$f")"
240
+ [ -n "${seen[$nn]:-}" ] && continue
241
+ seen[$nn]="$f"
242
+ done
243
+ done
244
+ for nn in $(printf '%s\n' "${!seen[@]}" | sort -n); do printf '%s\n' "${seen[$nn]}"; done
245
+ }
246
+
247
+ run_all() {
248
+ local f found=0
249
+ while IFS= read -r f; do
250
+ [ -n "$f" ] || continue
251
+ [ -x "$f" ] || { bad "runner is not executable: $f"; continue; }
252
+ found=1
253
+ run_rule "$f"
254
+ done < <(rule_files)
255
+ [ "$found" = 1 ] || note "no runner in ${RULES_DIR} or ${CORE_RULES_DIR} — the gate is empty"
256
+ }
257
+
258
+ # --- Baseline: measure HEAD so the gate can start green ---------------------
259
+ # `ops/rule.md` step 3 already requires this measurement before a rule is turned on. Doing it by
260
+ # hand means it does not get done, and the rule is then turned on in a repository that already
261
+ # breaks it. A gate that starts red is disabled the next day.
262
+ #
263
+ # It only ever APPENDS, and only for a rule that has no row yet. Overwriting would erase the one
264
+ # thing in the ratchet that cost real work: a region boundary somebody decided on.
265
+ baseline() {
266
+ sec "== baseline: measuring HEAD =="
267
+ local f nn out rc n added=0
268
+
269
+ if [ ! -f "$RATCHET" ]; then
270
+ mkdir -p "$(dirname "$RATCHET")" || { printf 'cannot create %s\n' "$RATCHET" >&2; return 1; }
271
+ printf 'rule\tregion\tcount\tnote\n' > "$RATCHET"
272
+ printf 'created %s\n' "$RATCHET"
273
+ fi
274
+
275
+ local stamp
276
+ stamp="$(date +%Y-%m-%d)"
277
+
278
+ while IFS= read -r f; do
279
+ [ -n "$f" ] && [ -x "$f" ] || continue
280
+ nn="$(rule_number "$f")"
281
+
282
+ if ratchet_has_rule "$nn"; then
283
+ printf ' skip — rule %s already has a row; a measured decision is never overwritten\n' "$nn"
284
+ continue
285
+ fi
286
+
287
+ out="$("$f" worktree 2>/dev/null)"
288
+ rc=$?
289
+ if [ "$rc" = "2" ]; then
290
+ printf ' skip — rule %s could not decide; nothing to record\n' "$nn"
291
+ continue
292
+ fi
293
+
294
+ n="$(printf '%s' "$out" | grep -c . || true)"
295
+ printf '%s\t.\t%s\tmeasured at baseline on %s\n' "$nn" "$n" "$stamp" >> "$RATCHET"
296
+ added=$((added+1))
297
+ if [ "$n" -gt 0 ]; then
298
+ printf ' debt — rule %s: %s finding(s) recorded as pre-existing\n' "$nn" "$n"
299
+ else
300
+ printf ' clean — rule %s: born at 0, and growth now fails\n' "$nn"
301
+ fi
302
+ done < <(rule_files)
303
+
304
+ printf '\n%d rule(s) recorded in %s\n' "$added" "$RATCHET"
305
+ [ "$added" -gt 0 ] && printf 'The debt is a starting point, not a target. It only decreases.\n'
306
+ return 0
307
+ }
308
+
309
+ # --- Audit: rule and runner, both ways --------------------------------------
310
+ # A rule with a 0/1 trigger and no runner is a PHANTOM GATE: the doc promises a check nobody
311
+ # runs, which is worse than promising nothing — it buys trust it cannot back up.
312
+ rules_declared() {
313
+ [ -f "$AFM_DOC" ] || return 0
314
+ awk '/^## 3\./{f=1} /^## [0-9]/&&!/^## 3\./{if(f)f=0} f' "$AFM_DOC" \
315
+ | sed -nE 's/^((PLUGIN-)?[0-9]+)\. \*\*.*/\1/p'
316
+ }
317
+
318
+ # Which surface enforces a rule: `pre-commit`, `ci`, or `agent` (judgment, no runner).
319
+ #
320
+ # It reads the *Verification* line of the rule. That line used to live inside § 3 of `afm.md`,
321
+ # and since 3.9.1 the rules are one file each under `rules/` while § 3 is only an index. This
322
+ # function kept reading `afm.md` alone, found no *Verification* line for any rule, and answered
323
+ # `agent` for all of them — so the phantom-gate branch below could never fire. Measured on
324
+ # 2026-08-30: deleting rule 37's runner produced no finding at all, from the very check whose
325
+ # whole purpose is to catch a rule that promises a gate nobody runs.
326
+ #
327
+ # Both layouts are read, inline first, because an instance that has not migrated still has the
328
+ # line in `afm.md` and must not lose the check on the way to the split layout.
329
+ rule_surface() {
330
+ local id="$1" body out
331
+ # The ID can hold a `-`, which is not a regex metacharacter, so a literal anchor is enough.
332
+ out="$(awk -v r="^${id}\\. " '
333
+ $0 ~ r {f=1}
334
+ f && /\*Verification/ {
335
+ if (match($0, /\*Verification \(([a-z-]+)\)/, m)) { print m[1]; exit }
336
+ print "agent"; exit
337
+ }
338
+ ' "$AFM_DOC" 2>/dev/null)"
339
+ [ -n "$out" ] && { printf '%s' "$out"; return 0; }
340
+
341
+ for body in "$(dirname "$AFM_DOC")/rules/${id}-"*.md; do
342
+ [ -f "$body" ] || continue
343
+ out="$(awk '
344
+ /\*Verification/ {
345
+ if (match($0, /\*Verification \(([a-z-]+)\)/, m)) { print m[1]; exit }
346
+ print "agent"; exit
347
+ }' "$body" 2>/dev/null)"
348
+ [ -n "$out" ] && { printf '%s' "$out"; return 0; }
349
+ done
350
+ printf 'agent'
351
+ }
352
+
353
+ audit_debt() {
354
+ [ -f "$RATCHET" ] || return 0
355
+ # A note that opens with `PERMANENT:` is a DECLARATION, not debt. Some regions never decrease
356
+ # by their nature — episodic history that only grows, and fixtures whose assertions match their
357
+ # own content — and listing them as debt forever makes the debt list mean less every time it is
358
+ # read. The count still holds the ceiling, so a real increase is still caught.
359
+ awk -F'\t' 'NR>1 && $3+0 > 0 {
360
+ if ($4 ~ /^PERMANENT:/) { sub(/^PERMANENT:[[:space:]]*/, "", $4)
361
+ printf " declared — rule %s in %s: %s, permanent (%s)\n", $1, $2, $3, $4 }
362
+ else printf " debt — rule %s in %s: %s (%s)\n", $1, $2, $3, $4
363
+ }' "$RATCHET"
364
+ }
365
+
366
+ audit() {
367
+ local nn f have
368
+ for nn in $(rules_declared); do
369
+ have=""
370
+ while IFS= read -r f; do
371
+ [ "$(rule_number "$f")" = "$nn" ] && have="$f"
372
+ done < <(rule_files)
373
+ if [ -n "$have" ]; then
374
+ ok "rule ${nn} has a runner ($(basename "$have"))"
375
+ else
376
+ case "$(rule_surface "$nn")" in
377
+ pre-commit|ci) bad "rule ${nn} declares a gate but has no runner in ${RULES_DIR} or ${CORE_RULES_DIR} — phantom gate" ;;
378
+ *) note "rule ${nn} is an 'agent' surface (no runner, by design)" ;;
379
+ esac
380
+ fi
381
+ done
382
+ while IFS= read -r f; do
383
+ [ -n "$f" ] || continue
384
+ nn="$(rule_number "$f")"
385
+ rules_declared | grep -qx "$nn" \
386
+ && ok "runner ${nn} matches a declared rule" \
387
+ || bad "orphan runner: $(basename "$f") matches no rule in § 3 of ${AFM_DOC}"
388
+ done < <(rule_files)
389
+ }
390
+
391
+ # --- Self-test --------------------------------------------------------------
392
+ mk_runner() { # $1=dir $2=NN $3=exit $4=finding lines (separated by §)
393
+ local p="$1/$2-fixture.sh"
394
+ { printf '#!/usr/bin/env bash\n'
395
+ [ -n "$4" ] && printf '%s\n' "$4" | tr '§' '\n' | while IFS= read -r l; do
396
+ [ -n "$l" ] && printf 'printf "%%b\\n" "%s"\n' "$l"
397
+ done
398
+ printf 'exit %s\n' "$3"
399
+ } > "$p"
400
+ chmod +x "$p"
401
+ printf '%s' "$p"
402
+ }
403
+
404
+ SPASS=0; SFAIL=0
405
+ sok() { SPASS=$((SPASS+1)); [ -n "$V" ] && printf ' ok — %s\n' "$1"; return 0; }
406
+ snote() { [ -n "$V" ] && printf " note — %s\n" "$1"; return 0; }
407
+ sbad() { SFAIL=$((SFAIL+1)); printf ' FAIL — %s\n' "$1"; }
408
+
409
+ selftest() {
410
+ sec "== aggregator self-test (synthetic fixtures) =="
411
+ TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
412
+ mkdir -p "$TMP/rules"
413
+ local saved_dir="$RULES_DIR" saved_core="$CORE_RULES_DIR" saved_ratchet="$RATCHET" saved_doc="$AFM_DOC"
414
+
415
+ RULES_DIR="$TMP/rules"
416
+ # The self-test builds its own fixtures. A real shipped runner leaking in would make the
417
+ # aggregator prove itself against rules the fixture never declared.
418
+ CORE_RULES_DIR="$TMP/core-rules"; mkdir -p "$CORE_RULES_DIR"
419
+ RATCHET="$TMP/ratchet.tsv"; export RATCHET
420
+ printf 'rule\tregion\tcount\tnote\n' > "$RATCHET"
421
+
422
+ # 1. a runner that exits 1 fails the aggregator, and the rule number reaches the output.
423
+ mk_runner "$TMP/rules" 99 1 "99\tsrc/a.ts:3\tviolated" >/dev/null
424
+ PASS=0; FAIL=0; NOTE=0
425
+ run_all > "$TMP/out" 2>&1
426
+ [ "$FAIL" -gt 0 ] && sok "a runner that exits 1 fails the aggregator" || sbad "the aggregator ignored a red runner"
427
+ grep -q '99' "$TMP/out" \
428
+ && sok "the output names the violated rule" \
429
+ || sbad "the output does not name the rule"
430
+
431
+ # 2. a compliant repository: exit 0 AND silence.
432
+ rm -f "$TMP/rules"/*.sh
433
+ mk_runner "$TMP/rules" 98 0 "" >/dev/null
434
+ PASS=0; FAIL=0; NOTE=0
435
+ local keepv="$V"; V=""
436
+ run_all > "$TMP/out" 2>&1
437
+ V="$keepv"
438
+ [ "$FAIL" -eq 0 ] && sok "a compliant repository does not fail" || sbad "the aggregator failed a compliant repository"
439
+ [ ! -s "$TMP/out" ] && sok "a compliant repository prints nothing" || sbad "the aggregator spoke while green: $(cat "$TMP/out")"
440
+
441
+ # 3. exit 2 becomes a note, never a failure.
442
+ rm -f "$TMP/rules"/*.sh
443
+ mk_runner "$TMP/rules" 97 2 "" >/dev/null
444
+ PASS=0; FAIL=0; NOTE=0
445
+ run_all >/dev/null 2>&1
446
+ [ "$FAIL" -eq 0 ] && [ "$NOTE" -gt 0 ] \
447
+ && sok "exit 2 becomes a note, not a failure" \
448
+ || sbad "exit 2 was treated as a violation (FAIL=$FAIL NOTE=$NOTE)"
449
+
450
+ # 4. ratchet: inside the budget passes, growth fails.
451
+ rm -f "$TMP/rules"/*.sh
452
+ printf 'rule\tregion\tcount\tnote\n96\t.\t3\tmeasured debt\n' > "$RATCHET"
453
+ mk_runner "$TMP/rules" 96 1 "96\ta.ts:1\tx§96\tb.ts:1\tx§96\tc.ts:1\tx" >/dev/null
454
+ PASS=0; FAIL=0; NOTE=0
455
+ run_all >/dev/null 2>&1
456
+ [ "$FAIL" -eq 0 ] && sok "3 findings against a budget of 3 pass (ratchet)" || sbad "the ratchet failed inside its budget"
457
+ rm -f "$TMP/rules"/*.sh
458
+ mk_runner "$TMP/rules" 96 1 "96\ta.ts:1\tx§96\tb.ts:1\tx§96\tc.ts:1\tx§96\td.ts:1\tx" >/dev/null
459
+ PASS=0; FAIL=0; NOTE=0
460
+ run_all >/dev/null 2>&1
461
+ [ "$FAIL" -gt 0 ] && sok "4 findings against a budget of 3 fail (the ratchet only decreases)" || sbad "the ratchet let the debt grow"
462
+
463
+ # 5. the most specific configured region wins over the generic one.
464
+ rm -f "$TMP/rules"/*.sh
465
+ printf 'rule\tregion\tcount\tnote\n95\t.\t0\t-\n95\tsrc/legacy\t2\tlegacy\n' > "$RATCHET"
466
+ mk_runner "$TMP/rules" 95 1 "95\tsrc/legacy/a.ts:1\tx§95\tsrc/legacy/b.ts:1\tx" >/dev/null
467
+ PASS=0; FAIL=0; NOTE=0
468
+ run_all >/dev/null 2>&1
469
+ [ "$FAIL" -eq 0 ] && sok "a finding lands in the most specific configured region" || sbad "the specific region was ignored"
470
+
471
+ # 5b. a region is a PATH, so it only matches at a path boundary. `index(p,$2)==1` is a literal
472
+ # character prefix, and `docs/research-extra/x.md` starts with the characters of
473
+ # `docs/research`. The neighbour's debt therefore landed in this region's budget: found by an
474
+ # external probe (3.9.0-rc.7). A budget that absorbs a directory nobody measured is a budget
475
+ # that stops meaning anything, and it fails toward green.
476
+ rm -f "$TMP/rules"/*.sh
477
+ printf 'rule\tregion\tcount\tnote\n94\t.\t0\t-\n94\tdocs/research\t2\tpre-existing\n' > "$RATCHET"
478
+ mk_runner "$TMP/rules" 94 1 "94\tdocs/research-extra/a.md:1\tx§94\tdocs/research-extra/b.md:1\tx" >/dev/null
479
+ PASS=0; FAIL=0; NOTE=0
480
+ run_all >/dev/null 2>&1
481
+ [ "$FAIL" -gt 0 ] \
482
+ && sok "a sibling directory does not spend the neighbouring region's budget" \
483
+ || sbad "docs/research-extra was absorbed by the docs/research budget (prefix without a boundary)"
484
+
485
+ # ...and the region must still cover its own subtree, and itself.
486
+ rm -f "$TMP/rules"/*.sh
487
+ mk_runner "$TMP/rules" 94 1 "94\tdocs/research/a.md:1\tx§94\tdocs/research/deep/b.md:1\tx" >/dev/null
488
+ PASS=0; FAIL=0; NOTE=0
489
+ run_all >/dev/null 2>&1
490
+ [ "$FAIL" -eq 0 ] \
491
+ && sok "the region covers its own subtree" \
492
+ || sbad "the boundary broke the legitimate match inside the region"
493
+
494
+ # A budget bash cannot compare is malformed, not a budget. The ratchet TSV is written by hand,
495
+ # and an all-digit value beyond the 64-bit range survives awk (which prints it back as
496
+ # 100000000000000000000) and then breaks `[ -le ]` itself with "integer expression expected" —
497
+ # which evaluates falsy, so the finding counts as over budget AND stderr fills up. Same defect
498
+ # class the Stop hook already paid for on the cadence threshold.
499
+ printf 'rule\tregion\tcount\tnote\n93\t.\t99999999999999999999\tuncomparable\n' > "$RATCHET"
500
+ B="$(ratchet_budget 93 .)"
501
+ ERR="$( { [ 1 -le "$B" ]; } 2>&1 )"
502
+ [ -z "$(printf '%s' "$ERR" | grep -iE 'integer|inteiro')" ] \
503
+ && sok "an uncomparable budget never reaches [ -le ] raw" \
504
+ || sbad "ratchet_budget returned a value bash cannot compare: [$B]"
505
+ printf 'rule\tregion\tcount\tnote\n93\t.\t7\tnormal\n' > "$RATCHET"
506
+ [ "$(ratchet_budget 93 .)" = "7" ] \
507
+ && sok "a sane budget is unaffected by the clamp" \
508
+ || sbad "the clamp broke a normal budget"
509
+
510
+ # 6. --audit catches a rule with no runner and a runner with no rule.
511
+ rm -f "$TMP/rules"/*.sh
512
+ AFM_DOC="$TMP/afm.md"
513
+ printf '## 3. Rules\n\n77. **A rule with a gate.**\n\n *Verification (pre-commit):* rules/77-x.sh\n\n78. **A rule of judgment.**\n\n *Verification:* eye to eye\n\n## 4. Other\n' > "$AFM_DOC"
514
+ PASS=0; FAIL=0; NOTE=0
515
+ audit >/dev/null 2>&1
516
+ [ "$FAIL" -gt 0 ] && sok "--audit reports a pre-commit rule with no runner (phantom gate)" || sbad "--audit let a phantom gate through"
517
+ [ "$NOTE" -gt 0 ] && sok "--audit does not demand a runner for an 'agent' rule" || sbad "--audit demanded a runner for a judgment rule"
518
+ mk_runner "$TMP/rules" 77 0 "" >/dev/null
519
+ mk_runner "$TMP/rules" 66 0 "" >/dev/null
520
+ PASS=0; FAIL=0; NOTE=0
521
+ audit >/dev/null 2>&1
522
+ [ "$FAIL" -gt 0 ] && sok "--audit reports an orphan runner" || sbad "--audit let a runner with no rule through"
523
+
524
+ # 7. --baseline turns a red gate green, and only by recording what it measured.
525
+ rm -f "$TMP/rules"/*.sh
526
+ printf 'rule\tregion\tcount\tnote\n' > "$RATCHET"
527
+ mk_runner "$TMP/rules" 94 1 "94\ta.ts:1\tx§94\tb.ts:1\tx§94\tc.ts:1\tx" >/dev/null
528
+ PASS=0; FAIL=0; NOTE=0
529
+ run_all >/dev/null 2>&1
530
+ [ "$FAIL" -gt 0 ] && sok "a rule with no row starts red" || sbad "a rule with no row did not start red"
531
+ baseline >/dev/null 2>&1
532
+ awk -F'\t' 'NR>1 && $1=="94" && $3=="3"{f=1} END{exit !f}' "$RATCHET" \
533
+ && sok "--baseline records the measured count" \
534
+ || sbad "--baseline did not record the measured count: $(cat "$RATCHET")"
535
+ PASS=0; FAIL=0; NOTE=0
536
+ run_all >/dev/null 2>&1
537
+ [ "$FAIL" -eq 0 ] && sok "after --baseline the gate starts green" || sbad "the gate stayed red after --baseline"
538
+ rm -f "$TMP/rules"/*.sh
539
+ mk_runner "$TMP/rules" 94 1 "94\ta.ts:1\tx§94\tb.ts:1\tx§94\tc.ts:1\tx§94\td.ts:1\tx" >/dev/null
540
+ PASS=0; FAIL=0; NOTE=0
541
+ run_all >/dev/null 2>&1
542
+ [ "$FAIL" -gt 0 ] && sok "growth past the baseline still fails" || sbad "the baseline became a licence to grow"
543
+
544
+ # 8. --baseline never overwrites a row a human decided on.
545
+ printf 'rule\tregion\tcount\tnote\n93\tsrc/legacy\t2\thuman decision\n' > "$RATCHET"
546
+ rm -f "$TMP/rules"/*.sh
547
+ mk_runner "$TMP/rules" 93 1 "93\ta.ts:1\tx§93\tb.ts:1\tx§93\tc.ts:1\tx" >/dev/null
548
+ baseline > "$TMP/out" 2>&1
549
+ awk -F'\t' 'NR>1 && $1=="93" && $2=="src/legacy" && $4=="human decision"{f=1} END{exit !f}' "$RATCHET" \
550
+ && sok "--baseline leaves a registered row untouched" \
551
+ || sbad "--baseline overwrote measured debt"
552
+ [ "$(awk -F'\t' 'NR>1 && $1=="93"' "$RATCHET" | wc -l)" = "1" ] \
553
+ && sok "--baseline adds no second row for a registered rule" \
554
+ || sbad "--baseline duplicated a registered rule"
555
+ grep -q 'skip' "$TMP/out" \
556
+ && sok "--baseline says out loud which rule it skipped" \
557
+ || sbad "--baseline skipped in silence"
558
+
559
+ # 9. an empty gate is empty, not failed.
560
+ rm -f "$TMP/rules"/*.sh
561
+ PASS=0; FAIL=0; NOTE=0
562
+ run_all >/dev/null 2>&1
563
+ [ "$FAIL" -eq 0 ] && [ "$NOTE" -gt 0 ] \
564
+ && sok "an empty gate reports empty, and does not fail" \
565
+ || sbad "an empty gate did not behave as empty (FAIL=$FAIL NOTE=$NOTE)"
566
+
567
+ # 10. the runner receives AFM_GATE_LIB, and the library is really there.
568
+ printf '#!/usr/bin/env bash\n. "${AFM_GATE_LIB:?}/scope.sh"\nexit 0\n' > "$TMP/rules/92-lib.sh"
569
+ chmod +x "$TMP/rules/92-lib.sh"
570
+ PASS=0; FAIL=0; NOTE=0
571
+ run_all >/dev/null 2>&1
572
+ [ "$FAIL" -eq 0 ] \
573
+ && sok "a runner resolves its library through AFM_GATE_LIB" \
574
+ || sbad "AFM_GATE_LIB did not reach the runner"
575
+
576
+ # --- 3.20.0: the two-class gate. Each block below is one defect that reported GREEN ---
577
+ sec "== self-test: PLUGIN- namespace and exemptions =="
578
+
579
+ mkdir -p "$TMP/core-rules"
580
+ CORE_RULES_DIR="$TMP/core-rules"
581
+ printf '#!/usr/bin/env bash\nprintf "PLUGIN-81\\tsrc/a.ts\\tfinding\\n"\nexit 1\n' > "$TMP/core-rules/PLUGIN-81-x.sh"
582
+ chmod +x "$TMP/core-rules/PLUGIN-81-x.sh"
583
+
584
+ # 1. the glob. `[0-9]*.sh` alone never matches a name that starts with `P`, and a glob that
585
+ # matches nothing reports nothing — the whole shipped directory was invisible.
586
+ if rule_files | grep -q 'PLUGIN-81-x.sh'; then
587
+ sok "a PLUGIN- runner is found by rule_files"
588
+ else
589
+ sbad "rule_files skipped the namespaced runner — the core-rules directory is invisible"
590
+ fi
591
+ [ "$(rule_number "$TMP/core-rules/PLUGIN-81-x.sh")" = "PLUGIN-81" ] \
592
+ && sok "rule_number keeps the namespace" \
593
+ || sbad "rule_number stripped the namespace (got $(rule_number "$TMP/core-rules/PLUGIN-81-x.sh"))"
594
+
595
+ # 2. a project runner with the same number wins, so a consumer overrides without a fork.
596
+ printf '#!/usr/bin/env bash\nexit 0\n' > "$TMP/rules/82-mine.sh"; chmod +x "$TMP/rules/82-mine.sh"
597
+ printf '#!/usr/bin/env bash\nexit 1\n' > "$TMP/core-rules/82-theirs.sh"; chmod +x "$TMP/core-rules/82-theirs.sh"
598
+ rule_files | grep -q '82-mine.sh' && ! rule_files | grep -q '82-theirs.sh' \
599
+ && sok "a project runner overrides a shipped one on the same number" \
600
+ || sbad "the shipped runner won over the project's — an override is impossible"
601
+ rm -f "$TMP/rules/82-mine.sh" "$TMP/core-rules/82-theirs.sh"
602
+
603
+ # 3. an exempt row is a SCOPE statement, not a region with a budget of zero. Treating it as a
604
+ # region made it absorb every finding under the path and fail there — the exemption became
605
+ # its own opposite.
606
+ printf 'rule\tregion\tcount\tnote\nPLUGIN-83\tsrc/vendor\texempt\tnot ours\n' > "$TMP/ratchet.tsv"
607
+ RATCHET="$TMP/ratchet.tsv"; export RATCHET
608
+ ratchet_exempt PLUGIN-83 src/vendor/lib.ts \
609
+ && sok "an exempt row takes a path out of scope" \
610
+ || sbad "ratchet_exempt did not match its own row"
611
+ ratchet_exempt PLUGIN-83 src/vendor-extra/lib.ts \
612
+ && sbad "the exemption leaked past the path boundary" \
613
+ || sok "an exemption stops at the path boundary"
614
+ [ "$(ratchet_region PLUGIN-83 src/vendor/lib.ts)" = "." ] \
615
+ && sok "an exempt row is not a region" \
616
+ || sbad "an exempt row became a region with budget 0 — the exemption inverted"
617
+
618
+ # 4. rule_surface must read the SPLIT layout. Since 3.9.1 the *Verification* line lives in
619
+ # rules/<id>-*.md and § 3 is an index; reading afm.md alone answered `agent` for every rule,
620
+ # which silently disabled the phantom-gate branch.
621
+ mkdir -p "$TMP/rules-doc"
622
+ AFM_DOC="$TMP/afm.md"
623
+ printf '## 3. Rules\n\nPLUGIN-84. **Split layout.** — [`rules/PLUGIN-84-x.md`](./rules/PLUGIN-84-x.md)\n\n## 4. Other\n' > "$AFM_DOC"
624
+ mkdir -p "$TMP/rules"; mkdir -p "$(dirname "$AFM_DOC")/rules"
625
+ printf -- '---\ntype: rule\n---\n\n# Hard rule PLUGIN-84\n\n*Verification (pre-commit):* rules/PLUGIN-84-x.sh\n' \
626
+ > "$(dirname "$AFM_DOC")/rules/PLUGIN-84-x.md"
627
+ [ "$(rule_surface PLUGIN-84)" = "pre-commit" ] \
628
+ && sok "rule_surface reads the split layout" \
629
+ || sbad "rule_surface answered '$(rule_surface PLUGIN-84)' — the phantom-gate branch is dead"
630
+ rules_declared | grep -qx 'PLUGIN-84' \
631
+ && sok "rules_declared reads a namespaced index entry" \
632
+ || sbad "rules_declared skipped the namespaced rule"
633
+
634
+ # 5. PLUGIN-40 resolves a cited path from the repo root OR from the citing file's directory.
635
+ # A checker that knows only one convention invents findings, and a gate that invents
636
+ # findings is switched off — the same end as a gate that finds nothing.
637
+ mkdir -p "$TMP/repo/docs/rules" "$TMP/repo/docs/adr" "$TMP/repo/src"
638
+ : > "$TMP/repo/src/real.ts"
639
+ printf -- '*Verification:* [`tools/gate/ghost.sh`](x)\n' > "$TMP/repo/docs/rules/PLUGIN-99-x.md"
640
+ printf -- 'The file `src/real.ts` is fine.\n' > "$TMP/repo/docs/adr/0001-y.md"
641
+ # Resolves ONLY from the citing file's own directory. Without this case the assertion above
642
+ # passes with the directory branch deleted, which is a test of nothing.
643
+ printf -- 'See `notes/sibling.md` next to me.\n' > "$TMP/repo/docs/adr/0002-z.md"
644
+ # `scope_all` asks git, and git answers about the CURRENT directory — a fixture that is not a
645
+ # repository yields an empty file list, and the runner then finds nothing for the wrong reason.
646
+ ( cd "$TMP/repo" && git init -q . && git add -A ) >/dev/null 2>&1
647
+ # Created AFTER `git add`, so it is UNTRACKED. That is the case where the directory-relative
648
+ # branch is the only thing that saves it: the `git ls-files` fallback matches by suffix and
649
+ # would cover a tracked sibling on its own, which made the first version of this assertion
650
+ # pass with the branch deleted — a test of nothing.
651
+ mkdir -p "$TMP/repo/docs/adr/notes"; : > "$TMP/repo/docs/adr/notes/sibling.md"
652
+ P40="$(dirname "$0")/core-rules/PLUGIN-40-dead-reference.sh"
653
+ if [ -x "$P40" ]; then
654
+ P40OUT="$(cd "$TMP/repo" && AFM_GATE_REPO="$TMP/repo" AFM_GATE_LIB="$AFM_GATE_LIB" AFM_GATE_MODE=worktree RATCHET=/dev/null bash "$P40" worktree 2>/dev/null)"
655
+ printf '%s' "$P40OUT" | grep -q 'ghost.sh' \
656
+ && sok "PLUGIN-40 finds a path that exists under neither convention" \
657
+ || sbad "PLUGIN-40 missed a dead reference"
658
+ printf '%s' "$P40OUT" | grep -q 'real.ts' \
659
+ && sbad "PLUGIN-40 flagged a path that resolves from the repository root" \
660
+ || sok "PLUGIN-40 accepts a path that resolves from the repository root"
661
+ printf '%s' "$P40OUT" | grep -q 'notes/sibling.md' \
662
+ && sbad "PLUGIN-40 flagged a path that resolves from the citing file's own directory" \
663
+ || sok "PLUGIN-40 accepts a path relative to the citing file"
664
+ else
665
+ snote "PLUGIN-40 runner absent — skipped"
666
+ fi
667
+
668
+ rm -rf "$TMP/core-rules"
669
+ CORE_RULES_DIR=""
670
+
671
+ RULES_DIR="$saved_dir"; CORE_RULES_DIR="$saved_core"; RATCHET="$saved_ratchet"; AFM_DOC="$saved_doc"; export RATCHET
672
+ printf '\n%d ok, %d fail (self-test)\n' "$SPASS" "$SFAIL"
673
+ [ "$SFAIL" -eq 0 ]
674
+ }
675
+
676
+ # --- Install ----------------------------------------------------------------
677
+ install_gate() {
678
+ # A repository CANNOT declare its own hooks. If it could, `git clone` would be remote code
679
+ # execution. That is why every hook manager (husky, lefthook, pre-commit) charges a one-time
680
+ # opt-in step. This is ours.
681
+ #
682
+ # Running a command called `--install` IS the authorization. What this mode never does is
683
+ # install itself from a SessionStart or from another hook. An opt-in that installs itself is
684
+ # not an opt-in, and the config is local, so every clone decides again.
685
+ local hooks_dir="$REPO/.githooks"
686
+ local gate_cmd
687
+ gate_cmd="$(python3 - "$REPO" "$0" 2>/dev/null <<'PY' || printf '%s' "$0"
688
+ import os, sys
689
+ print(os.path.relpath(os.path.abspath(sys.argv[2]), os.path.abspath(sys.argv[1])))
690
+ PY
691
+ )"
692
+
693
+ if [ ! -f "$hooks_dir/pre-commit" ]; then
694
+ mkdir -p "$hooks_dir"
695
+ { printf '#!/usr/bin/env sh\n'
696
+ printf '# Gate for the hard rules — the surface that makes docs/afm.md § 3 executable.\n'
697
+ printf '#\n'
698
+ printf '# Deliberate escape: `git commit --no-verify`. That is not a failure mode. Friction is the\n'
699
+ printf '# intended effect, and a gate with no escape becomes a gate that gets uninstalled.\n'
700
+ printf 'exec ./%s --staged\n' "$gate_cmd"
701
+ } > "$hooks_dir/pre-commit"
702
+ chmod +x "$hooks_dir/pre-commit"
703
+ printf 'wrote %s\n' ".githooks/pre-commit"
704
+ fi
705
+
706
+ local cur
707
+ cur="$(git config --get core.hooksPath 2>/dev/null || true)"
708
+ if [ "$cur" = ".githooks" ]; then
709
+ printf 'The gate is already armed (core.hooksPath = .githooks).\n'
710
+ return 0
711
+ fi
712
+ if [ -n "$cur" ]; then
713
+ # Overwriting another manager's config in silence would disarm THEIR gate with nobody
714
+ # noticing — the exact failure mode this gate exists to prevent.
715
+ printf 'core.hooksPath already points at "%s" (another hook manager?).\n' "$cur" >&2
716
+ printf 'I do not overwrite that in silence. Resolve the conflict and run again, or point\n' >&2
717
+ printf 'that pre-commit at: ./%s --staged\n' "$gate_cmd" >&2
718
+ return 1
719
+ fi
720
+ git config core.hooksPath .githooks || { printf 'failed to write core.hooksPath\n' >&2; return 1; }
721
+ printf 'Gate armed: core.hooksPath = .githooks\n'
722
+ printf 'Every `git commit` now goes through ./%s --staged.\n' "$gate_cmd"
723
+ printf 'Deliberate escape: `git commit --no-verify`. Disarm: `git config --unset core.hooksPath`.\n'
724
+ return 0
725
+ }
726
+
727
+ case "$ACTION" in
728
+ selftest) selftest; exit ;;
729
+ baseline) baseline; exit ;;
730
+ install) install_gate; exit ;;
731
+ audit)
732
+ sec "== audit: hard rule and runner =="
733
+ audit
734
+ printf '\n== debt recorded in the ratchet ==\n'
735
+ audit_debt
736
+ summary; exit ;;
737
+ run)
738
+ sec "== hard rule gate (mode: $MODE) =="
739
+ run_all; summary; exit ;;
740
+ esac