@cad0p/pi-steering 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +29 -0
  3. package/README.md +920 -0
  4. package/dist/__test-helpers__.d.ts +95 -0
  5. package/dist/__test-helpers__.d.ts.map +1 -0
  6. package/dist/__test-helpers__.js +174 -0
  7. package/dist/__test-helpers__.js.map +1 -0
  8. package/dist/bin/pi-steering.d.ts +9 -0
  9. package/dist/bin/pi-steering.d.ts.map +1 -0
  10. package/dist/bin/pi-steering.js +506 -0
  11. package/dist/bin/pi-steering.js.map +1 -0
  12. package/dist/compat.d.ts +50 -0
  13. package/dist/compat.d.ts.map +1 -0
  14. package/dist/compat.js +180 -0
  15. package/dist/compat.js.map +1 -0
  16. package/dist/defaults.d.ts +61 -0
  17. package/dist/defaults.d.ts.map +1 -0
  18. package/dist/defaults.js +106 -0
  19. package/dist/defaults.js.map +1 -0
  20. package/dist/define-config.d.ts +235 -0
  21. package/dist/define-config.d.ts.map +1 -0
  22. package/dist/define-config.js +118 -0
  23. package/dist/define-config.js.map +1 -0
  24. package/dist/define-predicate.d.ts +49 -0
  25. package/dist/define-predicate.d.ts.map +1 -0
  26. package/dist/define-predicate.js +35 -0
  27. package/dist/define-predicate.js.map +1 -0
  28. package/dist/evaluator-internals/context.d.ts +186 -0
  29. package/dist/evaluator-internals/context.d.ts.map +1 -0
  30. package/dist/evaluator-internals/context.js +219 -0
  31. package/dist/evaluator-internals/context.js.map +1 -0
  32. package/dist/evaluator-internals/override.d.ts +19 -0
  33. package/dist/evaluator-internals/override.d.ts.map +1 -0
  34. package/dist/evaluator-internals/override.js +79 -0
  35. package/dist/evaluator-internals/override.js.map +1 -0
  36. package/dist/evaluator-internals/predicates.d.ts +152 -0
  37. package/dist/evaluator-internals/predicates.d.ts.map +1 -0
  38. package/dist/evaluator-internals/predicates.js +849 -0
  39. package/dist/evaluator-internals/predicates.js.map +1 -0
  40. package/dist/evaluator-internals/speculative-synthesis.d.ts +102 -0
  41. package/dist/evaluator-internals/speculative-synthesis.d.ts.map +1 -0
  42. package/dist/evaluator-internals/speculative-synthesis.js +101 -0
  43. package/dist/evaluator-internals/speculative-synthesis.js.map +1 -0
  44. package/dist/evaluator.d.ts +101 -0
  45. package/dist/evaluator.d.ts.map +1 -0
  46. package/dist/evaluator.js +714 -0
  47. package/dist/evaluator.js.map +1 -0
  48. package/dist/helpers/walker-unknown-cwd-reason.d.ts +68 -0
  49. package/dist/helpers/walker-unknown-cwd-reason.d.ts.map +1 -0
  50. package/dist/helpers/walker-unknown-cwd-reason.js +73 -0
  51. package/dist/helpers/walker-unknown-cwd-reason.js.map +1 -0
  52. package/dist/index.d.ts +42 -0
  53. package/dist/index.d.ts.map +1 -0
  54. package/dist/index.js +93 -0
  55. package/dist/index.js.map +1 -0
  56. package/dist/internal/drop-unused-observers.d.ts +43 -0
  57. package/dist/internal/drop-unused-observers.d.ts.map +1 -0
  58. package/dist/internal/drop-unused-observers.js +50 -0
  59. package/dist/internal/drop-unused-observers.js.map +1 -0
  60. package/dist/internal/finalize-plugin-state.d.ts +22 -0
  61. package/dist/internal/finalize-plugin-state.d.ts.map +1 -0
  62. package/dist/internal/finalize-plugin-state.js +14 -0
  63. package/dist/internal/finalize-plugin-state.js.map +1 -0
  64. package/dist/internal/merge-observers.d.ts +30 -0
  65. package/dist/internal/merge-observers.d.ts.map +1 -0
  66. package/dist/internal/merge-observers.js +27 -0
  67. package/dist/internal/merge-observers.js.map +1 -0
  68. package/dist/internal/pattern-utils.d.ts +33 -0
  69. package/dist/internal/pattern-utils.d.ts.map +1 -0
  70. package/dist/internal/pattern-utils.js +20 -0
  71. package/dist/internal/pattern-utils.js.map +1 -0
  72. package/dist/internal/ref-text.d.ts +11 -0
  73. package/dist/internal/ref-text.d.ts.map +1 -0
  74. package/dist/internal/ref-text.js +15 -0
  75. package/dist/internal/ref-text.js.map +1 -0
  76. package/dist/internal/session-runtime.d.ts +44 -0
  77. package/dist/internal/session-runtime.d.ts.map +1 -0
  78. package/dist/internal/session-runtime.js +143 -0
  79. package/dist/internal/session-runtime.js.map +1 -0
  80. package/dist/internal/watch-matcher.d.ts +56 -0
  81. package/dist/internal/watch-matcher.d.ts.map +1 -0
  82. package/dist/internal/watch-matcher.js +179 -0
  83. package/dist/internal/watch-matcher.js.map +1 -0
  84. package/dist/loader.d.ts +97 -0
  85. package/dist/loader.d.ts.map +1 -0
  86. package/dist/loader.js +527 -0
  87. package/dist/loader.js.map +1 -0
  88. package/dist/observer-dispatcher.d.ts +69 -0
  89. package/dist/observer-dispatcher.d.ts.map +1 -0
  90. package/dist/observer-dispatcher.js +184 -0
  91. package/dist/observer-dispatcher.js.map +1 -0
  92. package/dist/plugin-merger.d.ts +175 -0
  93. package/dist/plugin-merger.d.ts.map +1 -0
  94. package/dist/plugin-merger.js +450 -0
  95. package/dist/plugin-merger.js.map +1 -0
  96. package/dist/plugins/git/branch-tracker.d.ts +85 -0
  97. package/dist/plugins/git/branch-tracker.d.ts.map +1 -0
  98. package/dist/plugins/git/branch-tracker.js +181 -0
  99. package/dist/plugins/git/branch-tracker.js.map +1 -0
  100. package/dist/plugins/git/cwd-extensions.d.ts +10 -0
  101. package/dist/plugins/git/cwd-extensions.d.ts.map +1 -0
  102. package/dist/plugins/git/cwd-extensions.js +155 -0
  103. package/dist/plugins/git/cwd-extensions.js.map +1 -0
  104. package/dist/plugins/git/git-ops.d.ts +89 -0
  105. package/dist/plugins/git/git-ops.d.ts.map +1 -0
  106. package/dist/plugins/git/git-ops.js +107 -0
  107. package/dist/plugins/git/git-ops.js.map +1 -0
  108. package/dist/plugins/git/index.d.ts +225 -0
  109. package/dist/plugins/git/index.d.ts.map +1 -0
  110. package/dist/plugins/git/index.js +52 -0
  111. package/dist/plugins/git/index.js.map +1 -0
  112. package/dist/plugins/git/predicates.d.ts +312 -0
  113. package/dist/plugins/git/predicates.d.ts.map +1 -0
  114. package/dist/plugins/git/predicates.js +580 -0
  115. package/dist/plugins/git/predicates.js.map +1 -0
  116. package/dist/plugins/git/rules.d.ts +209 -0
  117. package/dist/plugins/git/rules.d.ts.map +1 -0
  118. package/dist/plugins/git/rules.js +267 -0
  119. package/dist/plugins/git/rules.js.map +1 -0
  120. package/dist/schema.d.ts +1799 -0
  121. package/dist/schema.d.ts.map +1 -0
  122. package/dist/schema.js +4 -0
  123. package/dist/schema.js.map +1 -0
  124. package/dist/testing/index.d.ts +621 -0
  125. package/dist/testing/index.d.ts.map +1 -0
  126. package/dist/testing/index.js +747 -0
  127. package/dist/testing/index.js.map +1 -0
  128. package/examples/README.md +83 -0
  129. package/examples/combined-git-discipline/README.md +52 -0
  130. package/examples/combined-git-discipline/node_modules/.bin/pi-steering +43 -0
  131. package/examples/combined-git-discipline/package.json +18 -0
  132. package/examples/combined-git-discipline/steering.json +27 -0
  133. package/examples/combined-git-discipline/steering.test.ts +42 -0
  134. package/examples/combined-git-discipline/steering.ts +64 -0
  135. package/examples/combined-git-discipline/tsconfig.json +7 -0
  136. package/examples/draft-prs-only/README.md +42 -0
  137. package/examples/draft-prs-only/node_modules/.bin/pi-steering +43 -0
  138. package/examples/draft-prs-only/package.json +18 -0
  139. package/examples/draft-prs-only/steering.json +12 -0
  140. package/examples/draft-prs-only/steering.test.ts +48 -0
  141. package/examples/draft-prs-only/steering.ts +36 -0
  142. package/examples/draft-prs-only/tsconfig.json +7 -0
  143. package/examples/dynamic-reason-runtime-cwd/README.md +148 -0
  144. package/examples/dynamic-reason-runtime-cwd/node_modules/.bin/pi-steering +43 -0
  145. package/examples/dynamic-reason-runtime-cwd/package.json +18 -0
  146. package/examples/dynamic-reason-runtime-cwd/steering.test.ts +130 -0
  147. package/examples/dynamic-reason-runtime-cwd/steering.ts +82 -0
  148. package/examples/dynamic-reason-runtime-cwd/tsconfig.json +7 -0
  149. package/examples/force-push-strict/README.md +75 -0
  150. package/examples/force-push-strict/node_modules/.bin/pi-steering +43 -0
  151. package/examples/force-push-strict/package.json +18 -0
  152. package/examples/force-push-strict/steering.json +12 -0
  153. package/examples/force-push-strict/steering.test.ts +51 -0
  154. package/examples/force-push-strict/steering.ts +54 -0
  155. package/examples/force-push-strict/tsconfig.json +7 -0
  156. package/examples/no-amend/README.md +52 -0
  157. package/examples/no-amend/node_modules/.bin/pi-steering +43 -0
  158. package/examples/no-amend/package.json +18 -0
  159. package/examples/no-amend/steering.cwd-scoped.json +12 -0
  160. package/examples/no-amend/steering.json +11 -0
  161. package/examples/no-amend/steering.test.ts +49 -0
  162. package/examples/no-amend/steering.ts +38 -0
  163. package/examples/no-amend/tsconfig.json +7 -0
  164. package/examples/work-item-plugin/README.md +103 -0
  165. package/examples/work-item-plugin/node_modules/.bin/pi-steering +43 -0
  166. package/examples/work-item-plugin/package.json +19 -0
  167. package/examples/work-item-plugin/src/index.test.ts +168 -0
  168. package/examples/work-item-plugin/src/index.ts +122 -0
  169. package/examples/work-item-plugin/src/observers/npm-test-tracker.test.ts +95 -0
  170. package/examples/work-item-plugin/src/observers/npm-test-tracker.ts +88 -0
  171. package/examples/work-item-plugin/src/observers/retest-required-tracker.test.ts +81 -0
  172. package/examples/work-item-plugin/src/observers/retest-required-tracker.ts +81 -0
  173. package/examples/work-item-plugin/src/predicates/work-item-format.test.ts +157 -0
  174. package/examples/work-item-plugin/src/predicates/work-item-format.ts +99 -0
  175. package/examples/work-item-plugin/src/rules/commit-description-check.test.ts +107 -0
  176. package/examples/work-item-plugin/src/rules/commit-description-check.ts +83 -0
  177. package/examples/work-item-plugin/src/rules/commit-requires-work-item.test.ts +79 -0
  178. package/examples/work-item-plugin/src/rules/commit-requires-work-item.ts +56 -0
  179. package/examples/work-item-plugin/src/rules/push-requires-tests.test.ts +245 -0
  180. package/examples/work-item-plugin/src/rules/push-requires-tests.ts +62 -0
  181. package/examples/work-item-plugin/tsconfig.json +8 -0
  182. package/package.json +88 -0
  183. package/skills/steering-authoring/SKILL.md +102 -0
  184. package/src/INVARIANTS.md +116 -0
  185. package/src/__test-helpers__.ts +243 -0
  186. package/src/bin/pi-steering.test.ts +920 -0
  187. package/src/bin/pi-steering.ts +584 -0
  188. package/src/compat.test.ts +431 -0
  189. package/src/compat.ts +282 -0
  190. package/src/defaults.test.ts +585 -0
  191. package/src/defaults.ts +148 -0
  192. package/src/define-config.test.ts +888 -0
  193. package/src/define-config.ts +375 -0
  194. package/src/define-predicate.test.ts +70 -0
  195. package/src/define-predicate.ts +57 -0
  196. package/src/evaluator-internals/context.ts +290 -0
  197. package/src/evaluator-internals/override.test.ts +99 -0
  198. package/src/evaluator-internals/override.ts +83 -0
  199. package/src/evaluator-internals/predicates.ts +1037 -0
  200. package/src/evaluator-internals/speculative-synthesis.test.ts +411 -0
  201. package/src/evaluator-internals/speculative-synthesis.ts +199 -0
  202. package/src/evaluator.test.ts +6296 -0
  203. package/src/evaluator.ts +1106 -0
  204. package/src/factory-time-load.test.ts +630 -0
  205. package/src/helpers/walker-unknown-cwd-reason.test.ts +73 -0
  206. package/src/helpers/walker-unknown-cwd-reason.ts +83 -0
  207. package/src/index.test.ts +836 -0
  208. package/src/index.ts +229 -0
  209. package/src/internal/drop-unused-observers.test.ts +141 -0
  210. package/src/internal/drop-unused-observers.ts +81 -0
  211. package/src/internal/finalize-plugin-state.ts +43 -0
  212. package/src/internal/merge-observers.ts +51 -0
  213. package/src/internal/pattern-utils.test.ts +52 -0
  214. package/src/internal/pattern-utils.ts +38 -0
  215. package/src/internal/ref-text.test.ts +54 -0
  216. package/src/internal/ref-text.ts +17 -0
  217. package/src/internal/session-runtime.test.ts +629 -0
  218. package/src/internal/session-runtime.ts +201 -0
  219. package/src/internal/watch-matcher.test.ts +326 -0
  220. package/src/internal/watch-matcher.ts +200 -0
  221. package/src/jsdoc-propagation.test.ts +277 -0
  222. package/src/loader.test.ts +1098 -0
  223. package/src/loader.ts +581 -0
  224. package/src/not-block-onunknown.test.ts +554 -0
  225. package/src/observer-dispatcher.test.ts +939 -0
  226. package/src/observer-dispatcher.ts +290 -0
  227. package/src/per-predicate-typing.test.ts +361 -0
  228. package/src/plugin-merger.test.ts +790 -0
  229. package/src/plugin-merger.ts +598 -0
  230. package/src/plugins/git/README.md +485 -0
  231. package/src/plugins/git/branch-tracker.test.ts +281 -0
  232. package/src/plugins/git/branch-tracker.ts +187 -0
  233. package/src/plugins/git/cwd-extensions.test.ts +230 -0
  234. package/src/plugins/git/cwd-extensions.ts +160 -0
  235. package/src/plugins/git/git-ops.test.ts +397 -0
  236. package/src/plugins/git/git-ops.ts +173 -0
  237. package/src/plugins/git/index.ts +253 -0
  238. package/src/plugins/git/integration.test.ts +705 -0
  239. package/src/plugins/git/predicates.test.ts +1401 -0
  240. package/src/plugins/git/predicates.ts +693 -0
  241. package/src/plugins/git/rules.test.ts +1001 -0
  242. package/src/plugins/git/rules.ts +307 -0
  243. package/src/schema.test.ts +259 -0
  244. package/src/schema.ts +1963 -0
  245. package/src/testing/index.test.ts +1696 -0
  246. package/src/testing/index.ts +1472 -0
@@ -0,0 +1,888 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Type-inference + runtime tests for {@link defineConfig}.
6
+ *
7
+ * The main value of `defineConfig` is compile-time — typos in
8
+ * `observer: "description-read"` (when the plugin actually registers
9
+ * `description-reads`) should be REJECTED at type-check. `@ts-expect-error`
10
+ * in this file serves as the assertion: if the type machinery stops
11
+ * catching the typo, the `@ts-expect-error` directive itself errors at
12
+ * type-check and the file fails to compile.
13
+ */
14
+
15
+ import assert from "node:assert/strict";
16
+ import { describe, it } from "node:test";
17
+ import type { DefaultPluginName, DefaultRuleName } from "./define-config.ts";
18
+ import { defineConfig } from "./define-config.ts";
19
+ import shippedGitPlugin from "./plugins/git/index.ts";
20
+ import type { Observer, Plugin, PredicateContext } from "./schema.ts";
21
+
22
+ // Type-equality helper for direct type-identity assertions. Pinning
23
+ // `Equal<X, Y>` to `true` forces a same-CR test update if `X` widens
24
+ // or narrows — catches partial regressions the behavioral typo-check
25
+ // can miss (e.g. dropping one literal from a union of four would leave
26
+ // the other three positive cases passing while silently weakening the
27
+ // fence).
28
+ type Equal<A, B> =
29
+ (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
30
+ ? true
31
+ : false;
32
+
33
+ const readObserver = {
34
+ name: "description-reads",
35
+ onResult: () => {},
36
+ } as const satisfies Observer;
37
+
38
+ const syncObserver = {
39
+ name: "sync-done",
40
+ onResult: () => {},
41
+ } as const satisfies Observer;
42
+
43
+ const gitPlugin = {
44
+ name: "git",
45
+ observers: [{ name: "branch-changed", onResult: () => {} }],
46
+ } as const satisfies Plugin;
47
+
48
+ describe("defineConfig: runtime behavior", () => {
49
+ it("returns a SteeringConfig with the fields the caller passed", () => {
50
+ const cfg = defineConfig({
51
+ defaultNoOverride: true,
52
+ plugins: [gitPlugin],
53
+ observers: [readObserver],
54
+ rules: [
55
+ {
56
+ name: "some-rule",
57
+ tool: "bash",
58
+ field: "command",
59
+ pattern: /./,
60
+ reason: "r",
61
+ observer: "description-reads",
62
+ },
63
+ ],
64
+ });
65
+ assert.equal(cfg.defaultNoOverride, true);
66
+ assert.equal(cfg.plugins?.[0]?.name, "git");
67
+ assert.equal(cfg.observers?.[0]?.name, "description-reads");
68
+ assert.equal(cfg.rules?.[0]?.observer, "description-reads");
69
+ });
70
+
71
+ it("normalizes readonly inputs to mutable arrays", () => {
72
+ // Tuple literals from `const`-generics land as readonly; output
73
+ // should be a plain array (SteeringConfig fields aren't readonly).
74
+ // Register rules matching the disabledRules entries so the generic
75
+ // AllRuleNames constraint (ADR §8) accepts them.
76
+ const disables = ["x", "y"] as const;
77
+ const cfg = defineConfig({
78
+ rules: [
79
+ {
80
+ name: "x",
81
+ tool: "bash",
82
+ field: "command",
83
+ pattern: /./,
84
+ reason: "r",
85
+ },
86
+ {
87
+ name: "y",
88
+ tool: "bash",
89
+ field: "command",
90
+ pattern: /./,
91
+ reason: "r",
92
+ },
93
+ ],
94
+ disabledRules: disables,
95
+ });
96
+ assert.deepEqual(cfg.disabledRules, ["x", "y"]);
97
+ // Sanity: the returned array is detached from the input —
98
+ // different reference, so module-scoped sources can't be
99
+ // poisoned by a later mutation downstream of `defineConfig`.
100
+ // (`disabledRules` is `readonly` on the public type, so we can't
101
+ // push to it without a cast — reference inequality is the
102
+ // underlying invariant.)
103
+ assert.notStrictEqual(cfg.disabledRules, disables);
104
+ });
105
+
106
+ it("omits keys the caller didn't pass (no undefined leakage)", () => {
107
+ const cfg = defineConfig({});
108
+ assert.deepEqual(Object.keys(cfg), []);
109
+ });
110
+ });
111
+
112
+ describe("defineConfig: type-level checks", () => {
113
+ it("allows observer name references drawn from inline observers", () => {
114
+ const cfg = defineConfig({
115
+ observers: [readObserver, syncObserver],
116
+ rules: [
117
+ {
118
+ name: "r1",
119
+ tool: "bash",
120
+ field: "command",
121
+ pattern: /./,
122
+ reason: "r",
123
+ observer: "description-reads",
124
+ },
125
+ {
126
+ name: "r2",
127
+ tool: "bash",
128
+ field: "command",
129
+ pattern: /./,
130
+ reason: "r",
131
+ observer: "sync-done",
132
+ },
133
+ ],
134
+ });
135
+ assert.equal(cfg.rules?.length, 2);
136
+ });
137
+
138
+ it("allows observer name references drawn from plugin observers", () => {
139
+ const cfg = defineConfig({
140
+ plugins: [gitPlugin],
141
+ rules: [
142
+ {
143
+ name: "r-plug",
144
+ tool: "bash",
145
+ field: "command",
146
+ pattern: /./,
147
+ reason: "r",
148
+ observer: "branch-changed",
149
+ },
150
+ ],
151
+ });
152
+ assert.equal(cfg.rules?.[0]?.observer, "branch-changed");
153
+ });
154
+
155
+ it("rejects unknown observer names at type-check", () => {
156
+ const cfg = defineConfig({
157
+ observers: [readObserver] as const,
158
+ rules: [
159
+ {
160
+ name: "r-typo",
161
+ tool: "bash",
162
+ field: "command",
163
+ pattern: /./,
164
+ reason: "r",
165
+ // @ts-expect-error — typo: "description-read" vs. "description-reads".
166
+ observer: "description-read",
167
+ },
168
+ ],
169
+ });
170
+ assert.equal(cfg.rules?.length, 1);
171
+ });
172
+
173
+ it("rejects unknown observer names with no observers registered", () => {
174
+ const cfg = defineConfig({
175
+ rules: [
176
+ {
177
+ name: "r-no-obs",
178
+ tool: "bash",
179
+ field: "command",
180
+ pattern: /./,
181
+ reason: "r",
182
+ // @ts-expect-error — nothing registered, so any string is invalid.
183
+ observer: "sync-done",
184
+ },
185
+ ],
186
+ });
187
+ assert.equal(cfg.rules?.length, 1);
188
+ });
189
+
190
+ it("accepts inline Observer objects regardless of name registry", () => {
191
+ const cfg = defineConfig({
192
+ rules: [
193
+ {
194
+ name: "r-inline",
195
+ tool: "bash",
196
+ field: "command",
197
+ pattern: /./,
198
+ reason: "r",
199
+ observer: { name: "ad-hoc", onResult: () => {} },
200
+ },
201
+ ],
202
+ });
203
+ assert.equal(
204
+ typeof cfg.rules?.[0]?.observer === "object"
205
+ ? cfg.rules?.[0]?.observer?.name
206
+ : undefined,
207
+ "ad-hoc",
208
+ );
209
+ });
210
+
211
+ it("allows const-asserted tuples of plugins (tuple literal type flows)", () => {
212
+ const pluginTuple = [gitPlugin] as const;
213
+ const cfg = defineConfig({
214
+ plugins: pluginTuple,
215
+ rules: [
216
+ {
217
+ name: "r-tuple",
218
+ tool: "bash",
219
+ field: "command",
220
+ pattern: /./,
221
+ reason: "r",
222
+ observer: "branch-changed",
223
+ },
224
+ ],
225
+ });
226
+ assert.equal(cfg.plugins?.length, 1);
227
+ });
228
+
229
+ it("rule-name omitted `observer` field compiles regardless", () => {
230
+ const cfg = defineConfig({
231
+ rules: [
232
+ {
233
+ name: "r-no-observer",
234
+ tool: "bash",
235
+ field: "command",
236
+ pattern: /./,
237
+ reason: "r",
238
+ },
239
+ ],
240
+ });
241
+ assert.equal(cfg.rules?.[0]?.observer, undefined);
242
+ });
243
+ });
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // ADR §8 generic constraints — disabledRules / disabledPlugins / writes ↔ happened.
247
+ // ---------------------------------------------------------------------------
248
+ //
249
+ // These tests pin the ADR §8 compile-time contract:
250
+ //
251
+ // - `disabledRules` typed against union of registered rule names
252
+ // - `disabledPlugins` typed against union of registered plugin names
253
+ // - `when.happened.event` typed against union of declared `writes`
254
+ //
255
+ // Same `@ts-expect-error` strategy as above: if the type machinery
256
+ // stops enforcing the constraint, the directive itself errors at
257
+ // type-check and this file fails to compile.
258
+
259
+ describe("defineConfig: type constraints (ADR §8)", () => {
260
+ it("disabledRules accepts registered rule names (plugin + user)", () => {
261
+ const plugin = {
262
+ name: "p",
263
+ rules: [
264
+ {
265
+ name: "plugin-rule",
266
+ tool: "bash",
267
+ field: "command",
268
+ pattern: /./,
269
+ reason: "r",
270
+ },
271
+ ],
272
+ } as const satisfies Plugin;
273
+ const cfg = defineConfig({
274
+ plugins: [plugin],
275
+ rules: [
276
+ {
277
+ name: "user-rule",
278
+ tool: "bash",
279
+ field: "command",
280
+ pattern: /./,
281
+ reason: "r",
282
+ },
283
+ ],
284
+ disabledRules: ["plugin-rule", "user-rule"],
285
+ });
286
+ assert.deepEqual(cfg.disabledRules, ["plugin-rule", "user-rule"]);
287
+ });
288
+
289
+ it("disabledRules rejects unknown rule names at type-check", () => {
290
+ const plugin = {
291
+ name: "p",
292
+ rules: [
293
+ {
294
+ name: "known-rule",
295
+ tool: "bash",
296
+ field: "command",
297
+ pattern: /./,
298
+ reason: "r",
299
+ },
300
+ ],
301
+ } as const satisfies Plugin;
302
+ const cfg = defineConfig({
303
+ plugins: [plugin],
304
+ // @ts-expect-error — "unknown-rule" is not a registered rule name.
305
+ disabledRules: ["unknown-rule"],
306
+ });
307
+ assert.equal(cfg.disabledRules?.length, 1);
308
+ });
309
+
310
+ it("disabledPlugins accepts registered plugin names", () => {
311
+ const p1 = { name: "p1" } as const satisfies Plugin;
312
+ const p2 = { name: "p2" } as const satisfies Plugin;
313
+ const cfg = defineConfig({
314
+ plugins: [p1, p2],
315
+ disabledPlugins: ["p1"],
316
+ });
317
+ assert.deepEqual(cfg.disabledPlugins, ["p1"]);
318
+ });
319
+
320
+ it("disabledPlugins rejects unknown plugin names at type-check", () => {
321
+ const p1 = { name: "p1" } as const satisfies Plugin;
322
+ const p2 = { name: "p2" } as const satisfies Plugin;
323
+ const cfg = defineConfig({
324
+ plugins: [p1, p2],
325
+ // @ts-expect-error — "p3" is not a registered plugin.
326
+ disabledPlugins: ["p3"],
327
+ });
328
+ assert.equal(cfg.disabledPlugins?.length, 1);
329
+ });
330
+
331
+ it("when.happened.event rejects strings outside the writes union", () => {
332
+ const cfg = defineConfig({
333
+ rules: [
334
+ {
335
+ name: "r",
336
+ tool: "bash",
337
+ field: "command",
338
+ pattern: /./,
339
+ reason: "r",
340
+ writes: ["allowed-type"],
341
+ when: {
342
+ happened: {
343
+ // @ts-expect-error — "forbidden-type" not in writes union.
344
+ event: "forbidden-type",
345
+ in: "agent_loop",
346
+ },
347
+ },
348
+ },
349
+ ],
350
+ });
351
+ assert.equal(cfg.rules?.length, 1);
352
+ });
353
+
354
+ it("when.happened.event accepts strings in the rule's own writes union", () => {
355
+ const cfg = defineConfig({
356
+ rules: [
357
+ {
358
+ name: "r",
359
+ tool: "bash",
360
+ field: "command",
361
+ pattern: /./,
362
+ reason: "r",
363
+ writes: ["self-type"],
364
+ when: { happened: { event: "self-type", in: "agent_loop" } },
365
+ },
366
+ ],
367
+ });
368
+ assert.equal(cfg.rules?.[0]?.name, "r");
369
+ });
370
+
371
+ it("when.happened.event accepts strings from an inline observer's writes", () => {
372
+ const observer = {
373
+ name: "obs",
374
+ writes: ["sync-done"],
375
+ onResult: () => {},
376
+ } as const satisfies Observer;
377
+ const cfg = defineConfig({
378
+ observers: [observer],
379
+ rules: [
380
+ {
381
+ name: "r",
382
+ tool: "bash",
383
+ field: "command",
384
+ pattern: /./,
385
+ reason: "r",
386
+ when: { happened: { event: "sync-done", in: "agent_loop" } },
387
+ },
388
+ ],
389
+ });
390
+ assert.equal(cfg.rules?.[0]?.when?.happened?.event, "sync-done");
391
+ });
392
+
393
+ it("when.happened.event accepts strings from a plugin rule's writes", () => {
394
+ const plugin = {
395
+ name: "p",
396
+ rules: [
397
+ {
398
+ name: "pr",
399
+ tool: "bash",
400
+ field: "command",
401
+ pattern: /./,
402
+ reason: "r",
403
+ writes: ["plugin-type"],
404
+ },
405
+ ],
406
+ } as const satisfies Plugin;
407
+ const cfg = defineConfig({
408
+ plugins: [plugin],
409
+ rules: [
410
+ {
411
+ name: "r",
412
+ tool: "bash",
413
+ field: "command",
414
+ pattern: /./,
415
+ reason: "r",
416
+ when: { happened: { event: "plugin-type", in: "agent_loop" } },
417
+ },
418
+ ],
419
+ });
420
+ assert.equal(cfg.rules?.[0]?.when?.happened?.event, "plugin-type");
421
+ });
422
+
423
+ it("when.happened.event accepts strings from a plugin observer's writes", () => {
424
+ const plugin = {
425
+ name: "p",
426
+ observers: [
427
+ {
428
+ name: "obs",
429
+ writes: ["plugin-obs-type"],
430
+ onResult: () => {},
431
+ },
432
+ ],
433
+ } as const satisfies Plugin;
434
+ const cfg = defineConfig({
435
+ plugins: [plugin],
436
+ rules: [
437
+ {
438
+ name: "r",
439
+ tool: "bash",
440
+ field: "command",
441
+ pattern: /./,
442
+ reason: "r",
443
+ when: {
444
+ happened: { event: "plugin-obs-type", in: "agent_loop" },
445
+ },
446
+ },
447
+ ],
448
+ });
449
+ assert.equal(cfg.rules?.[0]?.when?.happened?.event, "plugin-obs-type");
450
+ });
451
+
452
+ it("G10 — Plugin.predicates accepts typed-arg handlers without a cast", () => {
453
+ // Pins Item 2 of PR #5: `Plugin.predicates` is now
454
+ // `Record<string, AnyPredicateHandler>` (where
455
+ // `AnyPredicateHandler = PredicateHandler<any>`), which leverages
456
+ // TS's bivariance fallback so a specifically-typed handler
457
+ // assigns into the registry slot directly — no cast, no
458
+ // `definePredicate` wrapper needed at the plug-in site.
459
+ //
460
+ // Pre-Item-2 this test pinned the OPPOSITE behavior (typed-arg
461
+ // handlers were rejected by the `PredicateHandler<unknown>` slot,
462
+ // motivating `definePredicate`). If TS's variance rules change
463
+ // under us the `@ts-expect-error` version of this test would
464
+ // reappear in git history — `definePredicate` is still exported
465
+ // for handler AUTHORS who want the generic narrowing sugar on
466
+ // their handler declaration.
467
+ const plugin: Plugin = {
468
+ name: "p",
469
+ predicates: {
470
+ commitFormat: (_args: { pattern: RegExp }, _ctx: PredicateContext) =>
471
+ true,
472
+ },
473
+ };
474
+ assert.equal(plugin.name, "p");
475
+ });
476
+ });
477
+
478
+ describe("defineConfig: bare-annotation footgun (ADR §8 authoring pattern)", () => {
479
+ // These tests pin the ASYMMETRIC failure modes of bare type annotations
480
+ // vs. `as const satisfies` so the JSDoc authoring-pattern guidance in
481
+ // `schema.ts` (Rule.writes / Observer.writes / Plugin.name) stays
482
+ // truthful. If TS's literal-inference behavior changes under us, or a
483
+ // refactor narrows/widens one of the helper types, these tests will
484
+ // surface the change loud enough to update the docs.
485
+ //
486
+ // The two failure modes:
487
+ // - NAME fields (plugin.name, rule.name): bare annotation widens to
488
+ // `string`, which makes `AllPluginNames` / `AllRuleNames` = `string`.
489
+ // Typos in `disabledRules` / `disabledPlugins` then compile silently.
490
+ // This is the "no typo detection" footgun.
491
+ // - `writes` arrays: bare annotation widens `readonly ["x"]` to
492
+ // `readonly string[]`, which can't project string literals, so
493
+ // `AllWrites` collapses to `never`. EVERY `when.happened.event`
494
+ // reference is rejected. This is the "can't use writes at all"
495
+ // failure — louder, but still a footgun if you don't know why.
496
+
497
+ it("bare `: Plugin` annotation widens `name` — typos in disabledPlugins compile silently", () => {
498
+ const widePlugin: Plugin = { name: "known-plugin" };
499
+ // No @ts-expect-error: this COMPILES cleanly because `widePlugin.name`
500
+ // has type `string`, so `AllPluginNames` = `string`, so any string
501
+ // satisfies `disabledPlugins`. If TS future-fixes literal inference
502
+ // on bare annotations OR a refactor narrows `AllPluginNames`, this
503
+ // line starts erroring — signal to update the JSDoc footgun note.
504
+ const cfg = defineConfig({
505
+ plugins: [widePlugin],
506
+ disabledPlugins: ["typo-name"],
507
+ });
508
+ assert.equal(cfg.disabledPlugins?.[0], "typo-name");
509
+ });
510
+
511
+ it("`as const satisfies Plugin` preserves `name` — typos caught at type-check", () => {
512
+ const narrowPlugin = { name: "known-plugin" } as const satisfies Plugin;
513
+ const cfg = defineConfig({
514
+ plugins: [narrowPlugin],
515
+ // @ts-expect-error — "typo-name" not in AllPluginNames union
516
+ // (which is the literal "known-plugin" thanks to `as const`).
517
+ disabledPlugins: ["typo-name"],
518
+ });
519
+ assert.equal(cfg.disabledPlugins?.length, 1);
520
+ });
521
+
522
+ it("bare `: Observer` annotation widens `writes` — collapses AllWrites to never", () => {
523
+ const wideObserver: Observer = {
524
+ name: "obs",
525
+ writes: ["sync-done"],
526
+ onResult: () => {},
527
+ };
528
+ const cfg = defineConfig({
529
+ observers: [wideObserver],
530
+ rules: [
531
+ {
532
+ name: "r",
533
+ tool: "bash",
534
+ field: "command",
535
+ pattern: /./,
536
+ reason: "r",
537
+ // @ts-expect-error — bare-annotated observer widens
538
+ // `writes` to `readonly string[]`, which collapses
539
+ // `AllWrites` to `never`. "sync-done" is rejected even
540
+ // though it IS in the runtime value — type info was lost
541
+ // at annotation time.
542
+ when: { happened: { event: "sync-done", in: "agent_loop" } },
543
+ },
544
+ ],
545
+ });
546
+ assert.equal(cfg.rules?.length, 1);
547
+ });
548
+
549
+ it("`as const satisfies Observer` preserves `writes` — type is referenceable", () => {
550
+ const narrowObserver = {
551
+ name: "obs",
552
+ writes: ["sync-done"],
553
+ onResult: () => {},
554
+ } as const satisfies Observer;
555
+ const cfg = defineConfig({
556
+ observers: [narrowObserver],
557
+ rules: [
558
+ {
559
+ name: "r",
560
+ tool: "bash",
561
+ field: "command",
562
+ pattern: /./,
563
+ reason: "r",
564
+ when: { happened: { event: "sync-done", in: "agent_loop" } },
565
+ },
566
+ ],
567
+ });
568
+ assert.equal(cfg.rules?.[0]?.when?.happened?.event, "sync-done");
569
+ });
570
+ });
571
+
572
+ describe("defineConfig: default rule + plugin names in typo-check union", () => {
573
+ // `AllRuleNames` and `AllPluginNames` include the names of
574
+ // `DEFAULT_RULES` / `DEFAULT_PLUGINS` directly, so disabling an
575
+ // engine-shipped default typechecks without a cast — the same as
576
+ // disabling a plugin or user rule. Pins the contract that
577
+ // `defaults.ts` keeps `as const satisfies readonly Rule[]` /
578
+ // `readonly Plugin[]` so literal `name` values survive into the
579
+ // unions; if either annotation regresses to a bare `Rule[]` /
580
+ // `Plugin[]`, these `@ts-expect-error` directives stop firing and
581
+ // the file fails to compile.
582
+
583
+ it("disabledRules accepts a single default-rule name without a cast", () => {
584
+ const cfg = defineConfig({
585
+ disabledRules: ["no-force-push"],
586
+ });
587
+ assert.deepEqual(cfg.disabledRules, ["no-force-push"]);
588
+ });
589
+
590
+ it("disabledRules accepts every other shipped default name", () => {
591
+ const cfg = defineConfig({
592
+ disabledRules: [
593
+ "no-hard-reset",
594
+ "no-rm-rf-slash",
595
+ "no-long-running-commands",
596
+ ],
597
+ });
598
+ assert.equal(cfg.disabledRules?.length, 3);
599
+ });
600
+
601
+ it("disabledRules accepts a mix of default + plugin + user rule names", () => {
602
+ const plugin = {
603
+ name: "p",
604
+ rules: [
605
+ {
606
+ name: "plugin-rule",
607
+ tool: "bash",
608
+ field: "command",
609
+ pattern: /./,
610
+ reason: "r",
611
+ },
612
+ ],
613
+ } as const satisfies Plugin;
614
+ const cfg = defineConfig({
615
+ plugins: [plugin],
616
+ rules: [
617
+ {
618
+ name: "user-rule",
619
+ tool: "bash",
620
+ field: "command",
621
+ pattern: /./,
622
+ reason: "r",
623
+ },
624
+ ],
625
+ disabledRules: ["no-force-push", "plugin-rule", "user-rule"],
626
+ });
627
+ assert.equal(cfg.disabledRules?.length, 3);
628
+ });
629
+
630
+ it("disabledRules rejects misspellings of default-rule names", () => {
631
+ const cfg = defineConfig({
632
+ // @ts-expect-error — "no-force-pushh" is a typo of the default
633
+ // rule "no-force-push". Default rule names are part of the
634
+ // typo-check union; misspellings are rejected at compile time.
635
+ disabledRules: ["no-force-pushh"],
636
+ });
637
+ assert.equal(cfg.disabledRules?.length, 1);
638
+ });
639
+
640
+ it("disabledPlugins rejects a plugin name that was never declared", () => {
641
+ const cfg = defineConfig({
642
+ // @ts-expect-error — "git" is NOT a default plugin since the
643
+ // monorepo split: DEFAULT_PLUGINS is empty and the git plugin
644
+ // is opt-in. Disabling an undeclared plugin is a typo or a
645
+ // misunderstanding — rejected at compile time (this is the
646
+ // divergence fix: runtime defaults and type-level visibility
647
+ // can no longer drift apart).
648
+ disabledPlugins: ["git"],
649
+ });
650
+ assert.equal(cfg.disabledPlugins?.length, 1);
651
+ });
652
+
653
+ it("disabledPlugins accepts a mix of declared + user plugin names", () => {
654
+ const userPlugin = { name: "my-plugin" } as const satisfies Plugin;
655
+ const cfg = defineConfig({
656
+ plugins: [shippedGitPlugin, userPlugin],
657
+ disabledPlugins: ["git", "my-plugin"],
658
+ });
659
+ assert.equal(cfg.disabledPlugins?.length, 2);
660
+ });
661
+
662
+ it("disabledPlugins rejects misspellings of a declared plugin name", () => {
663
+ const cfg = defineConfig({
664
+ plugins: [shippedGitPlugin],
665
+ // @ts-expect-error — "gti" is a typo of the DECLARED plugin
666
+ // "git". Declared plugin names are part of the typo-check
667
+ // union; misspellings are rejected at compile time.
668
+ disabledPlugins: ["gti"],
669
+ });
670
+ assert.equal(cfg.disabledPlugins?.length, 1);
671
+ });
672
+
673
+ // Direct type-identity assertions. The behavioral typo tests above
674
+ // pin the unions transitively (a `string` regression would stop the
675
+ // `@ts-expect-error` directives from firing). The assertions below
676
+ // pin the exact literal set: dropping one default name or hand-rolling
677
+ // `DefaultRuleName` to a partial enumeration is a typecheck failure
678
+ // here even when the positive tests above happen to cover the
679
+ // remaining literals.
680
+ type _RuleUnionCheck = Equal<
681
+ DefaultRuleName,
682
+ | "no-force-push"
683
+ | "no-hard-reset"
684
+ | "no-rm-rf-slash"
685
+ | "no-long-running-commands"
686
+ >;
687
+ const _ruleUnionCheck: _RuleUnionCheck = true;
688
+ void _ruleUnionCheck;
689
+
690
+ // DEFAULT_PLUGINS is empty since the monorepo split (git plugin is
691
+ // opt-in), so the default plugin-name union is `never` — any
692
+ // `disabledPlugins` entry without a matching declared plugin is a
693
+ // compile error.
694
+ type _PluginUnionCheck = Equal<DefaultPluginName, never>;
695
+ const _pluginUnionCheck: _PluginUnionCheck = true;
696
+ void _pluginUnionCheck;
697
+ });
698
+
699
+ describe("defineConfig: cross-module plugin typo detection (F2 regression fence)", () => {
700
+ // The F2 finding: gitPlugin's emitted .d.ts was widening rule names
701
+ // to `Rule<string, string>[]`, which silently disabled typo detection
702
+ // on `disabledRules: [...]` for consumers importing the shipped plugin.
703
+ // `as const satisfies Rule` at the source and `as const satisfies
704
+ // readonly Rule[]` on the collection preserve the tuple + literals.
705
+ //
706
+ // These tests pin that the SHIPPED `gitPlugin` (not an inline copy)
707
+ // carries literal rule names through to `defineConfig` generics.
708
+ // If the widening regresses, the @ts-expect-error directives stop
709
+ // firing and these tests fail to compile — loud signal.
710
+
711
+ it("disabledRules accepts rule names from the imported gitPlugin", () => {
712
+ const cfg = defineConfig({
713
+ plugins: [shippedGitPlugin],
714
+ disabledRules: ["no-main-commit"],
715
+ });
716
+ assert.equal(cfg.disabledRules?.[0], "no-main-commit");
717
+ });
718
+
719
+ it("disabledRules rejects typos in imported-plugin rule names", () => {
720
+ const cfg = defineConfig({
721
+ plugins: [shippedGitPlugin],
722
+ // @ts-expect-error — "no-main-commit-typo" is not a registered
723
+ // rule name on the imported gitPlugin. If this directive stops
724
+ // firing, the .d.ts widening regression documented as F2 in
725
+ // the phase-a3b review has returned.
726
+ disabledRules: ["no-main-commit-typo"],
727
+ });
728
+ assert.equal(cfg.disabledRules?.length, 1);
729
+ });
730
+
731
+ it("disabledPlugins accepts the imported gitPlugin's name", () => {
732
+ const cfg = defineConfig({
733
+ plugins: [shippedGitPlugin],
734
+ disabledPlugins: ["git"],
735
+ });
736
+ assert.equal(cfg.disabledPlugins?.[0], "git");
737
+ });
738
+
739
+ it("disabledPlugins rejects a typo on the imported gitPlugin's name", () => {
740
+ const cfg = defineConfig({
741
+ plugins: [shippedGitPlugin],
742
+ // @ts-expect-error — "gti" is not a registered plugin name.
743
+ disabledPlugins: ["gti"],
744
+ });
745
+ assert.equal(cfg.disabledPlugins?.length, 1);
746
+ });
747
+ });
748
+
749
+ // ---------------------------------------------------------------------------
750
+ // Rule discriminated union — `tool` gates the legal `field` values (R4).
751
+ //
752
+ // These tests pin the (tool, field) combinations that TS accepts and the
753
+ // ones it rejects. The discriminated Rule union (BashRule | WriteRule |
754
+ // EditRule) replaced a flat `Rule` that accepted any `field` value
755
+ // regardless of `tool` — an author could write
756
+ // `{ tool: "bash", field: "path", ... }` and the evaluator would
757
+ // silently run the rule against the command text (bash always uses the
758
+ // extracted command). The union surfaces those typos at compile time.
759
+ // ---------------------------------------------------------------------------
760
+
761
+ describe("Rule discriminated union: tool gates field", () => {
762
+ it("bash + command typechecks", () => {
763
+ const cfg = defineConfig({
764
+ rules: [
765
+ {
766
+ name: "bash-ok",
767
+ tool: "bash",
768
+ field: "command",
769
+ pattern: /^rm\b/,
770
+ reason: "r",
771
+ },
772
+ ],
773
+ });
774
+ assert.equal(cfg.rules?.length, 1);
775
+ });
776
+
777
+ it("write + path / content typecheck", () => {
778
+ const cfg = defineConfig({
779
+ rules: [
780
+ {
781
+ name: "write-path",
782
+ tool: "write",
783
+ field: "path",
784
+ pattern: /^\/etc\//,
785
+ reason: "r",
786
+ },
787
+ {
788
+ name: "write-content",
789
+ tool: "write",
790
+ field: "content",
791
+ pattern: /SECRET/,
792
+ reason: "r",
793
+ },
794
+ ],
795
+ });
796
+ assert.equal(cfg.rules?.length, 2);
797
+ });
798
+
799
+ it("edit + path / content typecheck", () => {
800
+ const cfg = defineConfig({
801
+ rules: [
802
+ {
803
+ name: "edit-path",
804
+ tool: "edit",
805
+ field: "path",
806
+ pattern: /^\/etc\//,
807
+ reason: "r",
808
+ },
809
+ {
810
+ name: "edit-content",
811
+ tool: "edit",
812
+ field: "content",
813
+ pattern: /SECRET/,
814
+ reason: "r",
815
+ },
816
+ ],
817
+ });
818
+ assert.equal(cfg.rules?.length, 2);
819
+ });
820
+
821
+ it('bash rules reject `field: "path"` (type error)', () => {
822
+ const cfg = defineConfig({
823
+ rules: [
824
+ // @ts-expect-error — bash rules must use `field: "command"`.
825
+ // Previously silently misbehaved (evaluator always tested
826
+ // the extracted command regardless of `field`).
827
+ {
828
+ name: "bash-path-bad",
829
+ tool: "bash",
830
+ field: "path",
831
+ pattern: /^x/,
832
+ reason: "r",
833
+ },
834
+ ],
835
+ });
836
+ assert.equal(cfg.rules?.length, 1);
837
+ });
838
+
839
+ it('bash rules reject `field: "content"` (type error)', () => {
840
+ const cfg = defineConfig({
841
+ rules: [
842
+ // @ts-expect-error — bash rules must use `field: "command"`.
843
+ {
844
+ name: "bash-content-bad",
845
+ tool: "bash",
846
+ field: "content",
847
+ pattern: /^x/,
848
+ reason: "r",
849
+ },
850
+ ],
851
+ });
852
+ assert.equal(cfg.rules?.length, 1);
853
+ });
854
+
855
+ it('write rules reject `field: "command"` (type error)', () => {
856
+ const cfg = defineConfig({
857
+ rules: [
858
+ // @ts-expect-error — write rules test `path` / `content`,
859
+ // never `command` (write has no command).
860
+ {
861
+ name: "write-command-bad",
862
+ tool: "write",
863
+ field: "command",
864
+ pattern: /^x/,
865
+ reason: "r",
866
+ },
867
+ ],
868
+ });
869
+ assert.equal(cfg.rules?.length, 1);
870
+ });
871
+
872
+ it('edit rules reject `field: "command"` (type error)', () => {
873
+ const cfg = defineConfig({
874
+ rules: [
875
+ // @ts-expect-error — edit rules test `path` / `content`,
876
+ // never `command`.
877
+ {
878
+ name: "edit-command-bad",
879
+ tool: "edit",
880
+ field: "command",
881
+ pattern: /^x/,
882
+ reason: "r",
883
+ },
884
+ ],
885
+ });
886
+ assert.equal(cfg.rules?.length, 1);
887
+ });
888
+ });