@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,554 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Type + runtime tests for the not-block onUnknown semantics +
6
+ * per-predicate typing introduced alongside the trinary
7
+ * `PredicateHandler` widening.
8
+ *
9
+ * Two surfaces under test:
10
+ *
11
+ * 1. Reserved-key registration error \u2014 plugin-merger throws at
12
+ * config-resolve time when a plugin attempts to register a
13
+ * predicate with a name reserved by the schema (currently
14
+ * `"not"`, `"onUnknown"` \u2014 the runtime mirror of
15
+ * {@link ReservedPredicateKey}).
16
+ *
17
+ * 2. Type-vs-runtime sync \u2014 the runtime constant
18
+ * `RESERVED_PREDICATE_KEYS` and the type-level
19
+ * `ReservedPredicateKey` derive lockstep from
20
+ * `OperatorField | keyof PredicateModifiers`. A future maintainer
21
+ * who adds a modifier key (or operator field) without updating
22
+ * both surfaces is caught by the type-level
23
+ * `_RESERVED_PREDICATE_KEYS_COVERS_TYPE` assertion in
24
+ * `evaluator-internals/predicates.ts`. This test additionally
25
+ * pins the runtime constant's contents so a typo or accidental
26
+ * reorder gets a fixture-level diff.
27
+ */
28
+
29
+ import assert from "node:assert/strict";
30
+ import { describe, it } from "node:test";
31
+ import { makeTrackedHost } from "./__test-helpers__.ts";
32
+ import { buildEvaluator } from "./evaluator.ts";
33
+ import {
34
+ isReservedPredicateKey,
35
+ MODIFIER_KEYS,
36
+ RESERVED_PREDICATE_KEYS,
37
+ validateWhenClauseShape,
38
+ } from "./evaluator-internals/predicates.ts";
39
+ import { resolvePlugins } from "./plugin-merger.ts";
40
+ import type {
41
+ BuiltInWhenLeaves,
42
+ BuiltInWhenLeavesInner,
43
+ BuiltInWhenLeavesOuter,
44
+ Plugin,
45
+ PredicateModifiers,
46
+ PredicateShape,
47
+ ReservedPredicateKey,
48
+ Rule,
49
+ TopLevelWhenClause,
50
+ TopLevelWhenClauseNoRecurse,
51
+ } from "./schema.ts";
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Reserved-key registration check (plugin-merger records error-class diagnostic)
55
+ // ---------------------------------------------------------------------------
56
+
57
+ describe("plugin-merger: reserved predicate key registration check", () => {
58
+ it("records an error-class diagnostic when a plugin registers `not` as a predicate name", () => {
59
+ const plugin: Plugin = {
60
+ name: "evil",
61
+ predicates: {
62
+ // Direct collision with the `not?:` operator field on
63
+ // TopLevelWhenClause.
64
+ not: () => true,
65
+ },
66
+ };
67
+ const state = resolvePlugins([plugin], {});
68
+ const hit = state.diagnostics.find(
69
+ (d) => d.kind === "reserved-predicate-key",
70
+ );
71
+ assert.ok(
72
+ hit,
73
+ `expected a reserved-predicate-key diagnostic; got: ${JSON.stringify(state.diagnostics)}`,
74
+ );
75
+ assert.equal(hit.type, "error");
76
+ assert.match(hit.message, /reserved predicate key "not"/);
77
+ });
78
+
79
+ it("records an error-class diagnostic when a plugin registers `onUnknown` as a predicate name", () => {
80
+ const plugin: Plugin = {
81
+ name: "evil",
82
+ predicates: {
83
+ // Direct collision with the `onUnknown?:` modifier on
84
+ // PredicateModifiers (consumed by the leaf adapter at the
85
+ // outer level and by the not-block evaluator inside `not:`).
86
+ onUnknown: () => true,
87
+ },
88
+ };
89
+ const state = resolvePlugins([plugin], {});
90
+ const hit = state.diagnostics.find(
91
+ (d) => d.kind === "reserved-predicate-key",
92
+ );
93
+ assert.ok(hit);
94
+ assert.equal(hit.type, "error");
95
+ assert.match(hit.message, /reserved predicate key "onUnknown"/);
96
+ });
97
+
98
+ it("`onUnknown` collision suggests `unknownPolicy` (modifier-collision suggestion convention)", () => {
99
+ // Pins the modifier-domain-flavored suggestion for `onUnknown:`
100
+ // collisions (per the convention documented on plugin-merger.ts:
101
+ // modifier collisions prefer alternatives including the modifier's
102
+ // domain). The generic `"isNot", "negate"` operator-collision
103
+ // suggestion isn't relevant for an `onUnknown:` collision; the
104
+ // suggestion text must point at `unknownPolicy` /
105
+ // `walkerUnknownPolicy` so a plugin author lands at a semantically
106
+ // related alternative.
107
+ const plugin: Plugin = {
108
+ name: "evil",
109
+ predicates: { onUnknown: () => true },
110
+ };
111
+ const state = resolvePlugins([plugin], {});
112
+ const hit = state.diagnostics.find(
113
+ (d) => d.kind === "reserved-predicate-key",
114
+ );
115
+ assert.ok(hit);
116
+ assert.match(hit.message, /unknownPolicy/);
117
+ });
118
+
119
+ it("diagnostic message includes the offending plugin name and a suggested alternative", () => {
120
+ const plugin: Plugin = {
121
+ name: "my-plugin",
122
+ predicates: { not: () => true },
123
+ };
124
+ const state = resolvePlugins([plugin], {});
125
+ const hit = state.diagnostics.find(
126
+ (d) => d.kind === "reserved-predicate-key",
127
+ );
128
+ assert.ok(hit);
129
+ assert.match(hit.message, /Plugin "my-plugin"/);
130
+ assert.match(hit.message, /isNot/); // suggested alternative
131
+ assert.match(hit.message, /not, onUnknown/); // full reserved set listed
132
+ });
133
+
134
+ it("accepts non-reserved names \u2014 sanity check that the throw is targeted", () => {
135
+ const plugin: Plugin = {
136
+ name: "good",
137
+ predicates: {
138
+ myCustomPredicate: () => true,
139
+ isClean: () => true, // built-in name is fine \u2014 it's a normal collision
140
+ },
141
+ };
142
+ assert.doesNotThrow(() => resolvePlugins([plugin], {}));
143
+ });
144
+ });
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Type \u2194 runtime sync pin
148
+ // ---------------------------------------------------------------------------
149
+
150
+ describe("RESERVED_PREDICATE_KEYS: type \u2194 runtime sync pin", () => {
151
+ it("matches the {OperatorField, modifier keys} fixture so additions get a loud diff", () => {
152
+ // Pinned fixture so adding a new modifier or operator without
153
+ // updating the runtime list (or the matching type union) gets a
154
+ // loud test-level diff. The type-level assertion in
155
+ // `evaluator-internals/predicates.ts` already catches the
156
+ // drift in the OPPOSITE direction (runtime list missing a type
157
+ // member); this test catches the direction the type-level
158
+ // assertion can't (fixture pinning the literal contents).
159
+ assert.deepEqual(
160
+ [...RESERVED_PREDICATE_KEYS].sort(),
161
+ ["not", "onUnknown"].sort(),
162
+ );
163
+ });
164
+
165
+ it("isReservedPredicateKey covers every entry in RESERVED_PREDICATE_KEYS", () => {
166
+ for (const key of RESERVED_PREDICATE_KEYS) {
167
+ assert.equal(
168
+ isReservedPredicateKey(key),
169
+ true,
170
+ `isReservedPredicateKey("${key}") should be true`,
171
+ );
172
+ }
173
+ });
174
+
175
+ it("isReservedPredicateKey returns false for non-reserved keys", () => {
176
+ assert.equal(isReservedPredicateKey("cwd"), false);
177
+ assert.equal(isReservedPredicateKey("branch"), false);
178
+ assert.equal(isReservedPredicateKey("commitsAhead"), false);
179
+ assert.equal(isReservedPredicateKey("happened"), false);
180
+ assert.equal(isReservedPredicateKey("condition"), false);
181
+ });
182
+
183
+ it("type assignment: runtime entries are assignable to ReservedPredicateKey", () => {
184
+ // Compile-time pin via assignment. If a future maintainer
185
+ // adds a runtime entry that drifts from the type union, this
186
+ // assignment fails to compile.
187
+ const _checked: readonly ReservedPredicateKey[] = RESERVED_PREDICATE_KEYS;
188
+ assert.ok(_checked.length > 0);
189
+ });
190
+ });
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // MODIFIER_KEYS ↔ keyof PredicateModifiers sync pin
194
+ // ---------------------------------------------------------------------------
195
+
196
+ describe("MODIFIER_KEYS: type ↔ runtime sync pin", () => {
197
+ it("validateWhenClauseShape strips `onUnknown` when counting leaves (modifier-only outer block throws)", () => {
198
+ // If `onUnknown` ever drops out of MODIFIER_KEYS, an outer
199
+ // block with only `onUnknown:` would count it as a leaf and
200
+ // the validator would PASS — masking the empty-clause foot-gun.
201
+ // This tests the runtime side of the sync pin.
202
+ assert.throws(
203
+ () =>
204
+ validateWhenClauseShape(
205
+ { onUnknown: "block" } as unknown as TopLevelWhenClause<string>,
206
+ 'rule "r".when',
207
+ ),
208
+ /contains no predicate leaves/,
209
+ );
210
+ });
211
+
212
+ it("MODIFIER_KEYS runtime constant matches `keyof PredicateModifiers` exactly", () => {
213
+ // Runtime + compile-time pin: the runtime list and the type-level
214
+ // modifier surface must agree. The type-level
215
+ // `_MODIFIER_KEYS_COVERS_TYPE` constant in
216
+ // `evaluator-internals/predicates.ts` fails compilation when
217
+ // `keyof PredicateModifiers` extends to a key the runtime list
218
+ // missed; this fixture pins the literal contents so a typo or
219
+ // reorder gets a test-level diff. Adding a future modifier
220
+ // (e.g., v0.2 `priority?:`) requires updating both surfaces.
221
+ const expected: readonly (keyof PredicateModifiers)[] = ["onUnknown"];
222
+ assert.deepEqual([...MODIFIER_KEYS].sort(), [...expected].sort());
223
+ });
224
+ });
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Rule.when wired to TopLevelWhenClause<Writes>
228
+ // ---------------------------------------------------------------------------
229
+
230
+ describe("Rule.when: registry-driven mapped type wireup", () => {
231
+ it("NonNullable<Rule['when']> is structurally identical to TopLevelWhenClause<string>", () => {
232
+ // Compile-only assertion. If `BaseRule.when` ever regresses to
233
+ // the legacy `WhenClause` (loose index signature), this fails to
234
+ // typecheck — the registry-driven mapped type's narrow domain is
235
+ // not assignable to a wider permissive interface.
236
+ type RuleWhenIsTopLevel =
237
+ NonNullable<Rule["when"]> extends TopLevelWhenClause<string>
238
+ ? TopLevelWhenClause<string> extends NonNullable<Rule["when"]>
239
+ ? true
240
+ : false
241
+ : false;
242
+ const _identity: RuleWhenIsTopLevel = true;
243
+ void _identity;
244
+ assert.ok(true);
245
+ });
246
+
247
+ it("rule-level onUnknown: is forbidden against the actual Rule type", () => {
248
+ const _r: Rule = {
249
+ name: "r",
250
+ tool: "bash",
251
+ field: "command",
252
+ pattern: "^git",
253
+ reason: "r",
254
+ when: {
255
+ cwd: /work/,
256
+ // @ts-expect-error — rule-level onUnknown: is not on TopLevelWhenClause.
257
+ onUnknown: "block",
258
+ },
259
+ };
260
+ assert.ok(_r.name === "r");
261
+ });
262
+
263
+ it("leaf-level onUnknown: inside not: is forbidden against the actual Rule type", () => {
264
+ const _r: Rule = {
265
+ name: "r",
266
+ tool: "bash",
267
+ field: "command",
268
+ pattern: "^git",
269
+ reason: "r",
270
+ when: {
271
+ not: {
272
+ // @ts-expect-error — InnerValue<"branch"> excludes leaf-level modifiers.
273
+ branch: { pattern: /main/, onUnknown: "allow" },
274
+ },
275
+ },
276
+ };
277
+ assert.ok(_r.name === "r");
278
+ });
279
+
280
+ it("not: not: recursion is forbidden against the actual Rule type", () => {
281
+ const _r: Rule = {
282
+ name: "r",
283
+ tool: "bash",
284
+ field: "command",
285
+ pattern: "^git",
286
+ reason: "r",
287
+ when: {
288
+ not: {
289
+ // @ts-expect-error — TopLevelWhenClauseNoRecurse has no `not?:` field.
290
+ not: { cwd: /work/ },
291
+ },
292
+ },
293
+ };
294
+ assert.ok(_r.name === "r");
295
+ });
296
+ });
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Runtime nested-not: guard (catches JSON / `as any` escape hatches)
300
+ // ---------------------------------------------------------------------------
301
+
302
+ describe("validateWhenClauseShape: nested-not: rejection", () => {
303
+ it("throws on `when: { not: { not: { cwd: P } } }` directly", () => {
304
+ assert.throws(
305
+ () =>
306
+ validateWhenClauseShape(
307
+ {
308
+ not: { not: { cwd: /work/ } },
309
+ } as unknown as TopLevelWhenClause<string>,
310
+ 'rule "r".when',
311
+ ),
312
+ /contains a nested 'not:' key/,
313
+ );
314
+ });
315
+
316
+ it("throws at buildEvaluator config-resolve time when a rule slips a nested not: through `as any`", () => {
317
+ const rule: Rule = {
318
+ name: "nested-not-via-cast",
319
+ tool: "bash",
320
+ field: "command",
321
+ pattern: "^git",
322
+ reason: "r",
323
+ when: { not: { not: { cwd: /work/ } } } as unknown as NonNullable<
324
+ Rule["when"]
325
+ >,
326
+ };
327
+ assert.throws(
328
+ () =>
329
+ buildEvaluator(
330
+ { rules: [rule] },
331
+ resolvePlugins([], {}),
332
+ makeTrackedHost(),
333
+ ),
334
+ /contains a nested 'not:' key/,
335
+ );
336
+ });
337
+
338
+ it("the error message names the path and explains why nested not: is forbidden", () => {
339
+ assert.throws(
340
+ () =>
341
+ validateWhenClauseShape(
342
+ {
343
+ not: { not: { cwd: /work/ } },
344
+ } as unknown as TopLevelWhenClause<string>,
345
+ 'rule "my-rule".when',
346
+ ),
347
+ (err: Error) => {
348
+ assert.match(err.message, /'rule "my-rule"\.when\.not'/);
349
+ assert.match(err.message, /Use a single 'not:' wrapper/);
350
+ return true;
351
+ },
352
+ );
353
+ });
354
+
355
+ it("throws on a JSON-deserialized nested-not (the JSON / `as any` escape hatch the type-level ban can't catch)", () => {
356
+ // Pins the runtime-guard's primary justification: a config
357
+ // loaded from JSON or hand-typed via an `as any` cast bypasses
358
+ // the type-level ban on `TopLevelWhenClauseNoRecurse.not`. The
359
+ // runtime check in validateWhenClauseShape is the only line of
360
+ // defense for that path.
361
+ const fromJson = JSON.parse(
362
+ '{"not": {"not": {"cwd": "/work/"}}}',
363
+ ) as unknown;
364
+ assert.throws(
365
+ () =>
366
+ validateWhenClauseShape(
367
+ fromJson as TopLevelWhenClause<string>,
368
+ 'rule "r".when',
369
+ ),
370
+ /contains a nested 'not:' key/,
371
+ );
372
+ });
373
+
374
+ it("throws on `when: { not: { not: { not: P } } }` (depth-3 recursion stays rejected)", () => {
375
+ // Pins the recursion contract beyond depth-2. The validator
376
+ // recurses into the not-block and runs the same nested-not
377
+ // check; depth-3 must reject at the second level (the inner
378
+ // `not.not` of the outer block).
379
+ assert.throws(
380
+ () =>
381
+ validateWhenClauseShape(
382
+ {
383
+ not: { not: { not: { cwd: /work/ } } },
384
+ } as unknown as TopLevelWhenClause<string>,
385
+ 'rule "r".when',
386
+ ),
387
+ /contains a nested 'not:' key/,
388
+ );
389
+ });
390
+ });
391
+
392
+ // ---------------------------------------------------------------------------
393
+ // BuiltInWhenLeaves shape pin
394
+ // ---------------------------------------------------------------------------
395
+
396
+ describe("BuiltInWhenLeaves: shape pin", () => {
397
+ it("BuiltInWhenLeaves contains exactly { happened, condition, cwd }", () => {
398
+ // Compile-only assertion. If a future change adds a new built-in
399
+ // non-registry leaf (e.g., `tool?:`) without updating the pin, this
400
+ // fails to typecheck — forces a deliberate decision rather than
401
+ // silently widening the surface that ships with the engine itself.
402
+ type _BuiltInShape = keyof BuiltInWhenLeaves extends
403
+ | "happened"
404
+ | "condition"
405
+ | "cwd"
406
+ ? true
407
+ : false;
408
+ const _b: _BuiltInShape = true;
409
+ void _b;
410
+ assert.ok(true);
411
+ });
412
+
413
+ it("BuiltInWhenLeavesOuter and BuiltInWhenLeavesInner share the same key set", () => {
414
+ // Outer/Inner split formalizes the leaf-level `onUnknown:` ban
415
+ // inside `not:` (parity with registry-driven inner predicates).
416
+ // Both flavors carry `happened?:`, `condition?:`, `cwd?:`; only
417
+ // `cwd:`'s spread shape differs.
418
+ type _OuterKeys = keyof BuiltInWhenLeavesOuter extends
419
+ | "happened"
420
+ | "condition"
421
+ | "cwd"
422
+ ? true
423
+ : false;
424
+ type _InnerKeys = keyof BuiltInWhenLeavesInner extends
425
+ | "happened"
426
+ | "condition"
427
+ | "cwd"
428
+ ? true
429
+ : false;
430
+ const _outer: _OuterKeys = true;
431
+ const _inner: _InnerKeys = true;
432
+ void _outer;
433
+ void _inner;
434
+ assert.ok(true);
435
+ });
436
+
437
+ it("BuiltInWhenLeavesInner.cwd spread shape forbids leaf-level onUnknown", () => {
438
+ // The asymmetry: outer `cwd:` allows `{ pattern, onUnknown? }`;
439
+ // inner `cwd:` (inside `not:`) drops `onUnknown?:` because the
440
+ // engine reads the block-level modifier inside `not:` (default
441
+ // `"block"` = fail-CLOSED). A leaf-level `onUnknown:` inside
442
+ // `not:` would silently lose, masking a fail-OPEN authoring
443
+ // error. Pin the constraint with a positive Inner spread (no
444
+ // `onUnknown:`) and an Outer spread (with `onUnknown:`).
445
+ const innerSpread: NonNullable<BuiltInWhenLeavesInner["cwd"]> = {
446
+ pattern: /work/,
447
+ };
448
+ const outerSpread: NonNullable<BuiltInWhenLeavesOuter["cwd"]> = {
449
+ pattern: /work/,
450
+ onUnknown: "allow",
451
+ };
452
+ assert.ok(innerSpread !== undefined);
453
+ assert.ok(outerSpread !== undefined);
454
+ });
455
+
456
+ it("Rule.when rejects leaf-level onUnknown on cwd inside not:", () => {
457
+ // Empirical type-pin: the silent fail-OPEN shape
458
+ // `not: { cwd: { pattern, onUnknown: "allow" } }` must NOT
459
+ // typecheck under the new Outer/Inner split. The `not:` body
460
+ // resolves to `TopLevelWhenClauseNoRecurse` which intersects with
461
+ // `BuiltInWhenLeavesInner` (where `cwd:`'s spread drops the
462
+ // `onUnknown?:` field). Block-level `onUnknown:` lives on the
463
+ // outer `not:` block via `& PredicateModifiers` and is the
464
+ // canonical placement.
465
+ const _ban: Rule = {
466
+ name: "x",
467
+ tool: "bash",
468
+ field: "command",
469
+ pattern: "^x",
470
+ reason: "x",
471
+ when: {
472
+ not: {
473
+ cwd: {
474
+ pattern: /work/,
475
+ // @ts-expect-error: leaf-level onUnknown forbidden inside not: (parity with registry predicates)
476
+ onUnknown: "allow",
477
+ },
478
+ },
479
+ },
480
+ };
481
+ void _ban;
482
+
483
+ // Sibling positive cases: bare cwd inside not:, and block-level
484
+ // onUnknown on the not: block, both must typecheck cleanly.
485
+ const _bareInsideNot: Rule = {
486
+ name: "x",
487
+ tool: "bash",
488
+ field: "command",
489
+ pattern: "^x",
490
+ reason: "x",
491
+ when: { not: { cwd: /work/ } },
492
+ };
493
+ void _bareInsideNot;
494
+
495
+ const _blockLevel: Rule = {
496
+ name: "x",
497
+ tool: "bash",
498
+ field: "command",
499
+ pattern: "^x",
500
+ reason: "x",
501
+ when: { not: { cwd: /work/, onUnknown: "block" } },
502
+ };
503
+ void _blockLevel;
504
+
505
+ assert.ok(true);
506
+ });
507
+
508
+ it("Rule.when rejects spread shape on outer condition: (bare PredicateFn only)", () => {
509
+ // Negative type-pin: `condition?:` is bare-`PredicateFn`-typed at
510
+ // every placement (outer + inner). Future widening to a
511
+ // {@link PredicateShape} that admits a leaf-level `onUnknown:`
512
+ // modifier would silently bypass the engine's outer
513
+ // `condition:` contract (default `"block"` policy hard-coded;
514
+ // authors needing fail-OPEN wrap inside `not: { condition: fn,
515
+ // onUnknown: "allow" }`). Pin the constraint at the authoring
516
+ // surface so any future shape-widening trips the typecheck gate
517
+ // instead of regressing the contract silently.
518
+ const _banSpread: Rule = {
519
+ name: "x",
520
+ tool: "bash",
521
+ field: "command",
522
+ pattern: "^x",
523
+ reason: "x",
524
+ when: {
525
+ // @ts-expect-error: condition?: is bare PredicateFn — spread shape forbidden
526
+ condition: { value: () => true, onUnknown: "allow" },
527
+ },
528
+ };
529
+ void _banSpread;
530
+
531
+ // Sibling positive cases: bare callback at outer + inside `not:`.
532
+ const _bareOuter: Rule = {
533
+ name: "x",
534
+ tool: "bash",
535
+ field: "command",
536
+ pattern: "^x",
537
+ reason: "x",
538
+ when: { condition: () => true },
539
+ };
540
+ void _bareOuter;
541
+
542
+ const _bareInner: Rule = {
543
+ name: "x",
544
+ tool: "bash",
545
+ field: "command",
546
+ pattern: "^x",
547
+ reason: "x",
548
+ when: { not: { condition: () => true } },
549
+ };
550
+ void _bareInner;
551
+
552
+ assert.ok(true);
553
+ });
554
+ });