@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,375 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * `defineConfig` — compile-time-typed config builder.
6
+ *
7
+ * Two supported authoring styles per the accepted ADR ("Design →
8
+ * `defineConfig` and compile-time inference"):
9
+ *
10
+ * 1. **`defineConfig`** — uses `const`-generics on plugins / observers
11
+ * to infer the union of observer names, then constrains
12
+ * {@link Rule.observer} string references to that union. Typos in
13
+ * `observer: "description-read"` (when the plugin registers
14
+ * `description-reads`) produce a compile error.
15
+ *
16
+ * 2. **`satisfies SteeringConfig`** — plain TypeScript construct users
17
+ * can fall back to when they don't want the generic inference
18
+ * complexity. Gets shape validation but no cross-reference name
19
+ * checking.
20
+ *
21
+ * The function itself does minimal runtime work — it just returns the
22
+ * config unchanged. All the value is in the types.
23
+ *
24
+ * Generics threaded through (ADR §8):
25
+ * - `AllObserverNames<P, Inline>` — for `Rule.observer` string refs.
26
+ * - `AllWrites<P, R, Inline>` — for `Rule.when.happened.event`.
27
+ * - `AllRuleNames<P, R>` — for `config.disabledRules`.
28
+ * - `AllPluginNames<P>` — for `config.disabledPlugins`.
29
+ *
30
+ * All four helpers are exported from this module but NOT re-exported
31
+ * from the package root; they're internal plumbing, not user-facing
32
+ * API. Stable enough that plugin authors who import them directly can
33
+ * rely on their shape within a single minor version, but the contract
34
+ * is "use via defineConfig".
35
+ */
36
+
37
+ import type { DEFAULT_PLUGINS, DEFAULT_RULES } from "./defaults.ts";
38
+ import type {
39
+ BuiltInWhenLeavesOuter,
40
+ Observer,
41
+ Plugin,
42
+ Rule,
43
+ SteeringConfig,
44
+ } from "./schema.ts";
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Type-level plumbing: project `name` / `writes` literals off tuples of
48
+ // rules, observers, or plugins.
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /**
52
+ * Pull a single projection off every element of an array type.
53
+ *
54
+ * - `K = "name"` — value is the element's `name` literal
55
+ * (`{ name: N }` → `N`).
56
+ * - `K = "writes"` — value is each element of the element's
57
+ * `writes` tuple (`{ writes: readonly [..., S] }`
58
+ * → `S`).
59
+ *
60
+ * Elements missing the field (optional `writes`, widened `name`)
61
+ * contribute `never`. Non-tuple `T` inputs short-circuit to `never`.
62
+ */
63
+ type ProjectField<
64
+ T,
65
+ K extends "name" | "writes",
66
+ > = T extends readonly (infer E)[]
67
+ ? E extends Record<K, infer V>
68
+ ? K extends "writes"
69
+ ? V extends readonly (infer S extends string)[]
70
+ ? S
71
+ : never
72
+ : V extends string
73
+ ? V
74
+ : never
75
+ : never
76
+ : never;
77
+
78
+ /**
79
+ * Walk a tuple of plugins and union a {@link ProjectField} projection
80
+ * across every plugin's `Source` array (`"rules"` or `"observers"`).
81
+ *
82
+ * Replaces four near-identical recursive walkers that differed only in
83
+ * `(Source, K)` pair — see git blame for the pre-R2 shape.
84
+ */
85
+ type FromPluginField<
86
+ P extends readonly Plugin[],
87
+ Source extends "rules" | "observers",
88
+ K extends "name" | "writes",
89
+ > = P extends readonly [infer First, ...infer Rest]
90
+ ?
91
+ | (First extends Plugin
92
+ ? First[Source] extends infer X
93
+ ? X extends readonly (Rule | Observer)[]
94
+ ? ProjectField<X, K>
95
+ : never
96
+ : never
97
+ : never)
98
+ | (Rest extends readonly Plugin[]
99
+ ? FromPluginField<Rest, Source, K>
100
+ : never)
101
+ : never;
102
+
103
+ /**
104
+ * Extract the union of observer names registered across:
105
+ * - every plugin's `observers: Observer[]` array, AND
106
+ * - the top-level inline `observers: Observer[]` array.
107
+ *
108
+ * Used to constrain string references in {@link Rule.observer} so typos
109
+ * surface as compile errors in `defineConfig` call sites.
110
+ *
111
+ * Falls back to `never` when no observers are registered (correct:
112
+ * string references should be rejected entirely when there's nothing
113
+ * to reference).
114
+ */
115
+ export type AllObserverNames<
116
+ P extends readonly Plugin[],
117
+ Inline extends readonly Observer[],
118
+ > = FromPluginField<P, "observers", "name"> | ProjectField<Inline, "name">;
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Default-rule + default-plugin name unions
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /**
125
+ * Union of {@link DEFAULT_RULES} `name` literals — the names of rules
126
+ * shipped by the engine itself. Folded into {@link AllRuleNames}; the
127
+ * live list is the `name` field of each entry in {@link DEFAULT_RULES}.
128
+ *
129
+ * Relies on {@link DEFAULT_RULES} being authored as
130
+ * `as const satisfies readonly Rule[]` so the literal `name` values
131
+ * survive through the array — a bare `Rule[]` annotation widens to
132
+ * `string` and collapses this union.
133
+ */
134
+ export type DefaultRuleName = (typeof DEFAULT_RULES)[number]["name"];
135
+
136
+ /**
137
+ * Union of {@link DEFAULT_PLUGINS} `name` literals. Folded into
138
+ * {@link AllPluginNames} so `disabledPlugins` typechecks against
139
+ * shipped defaults without a cast.
140
+ */
141
+ export type DefaultPluginName = (typeof DEFAULT_PLUGINS)[number]["name"];
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // AllPluginNames — union of plugin `.name` literals across loaded plugins.
145
+ // ---------------------------------------------------------------------------
146
+
147
+ /**
148
+ * Extract the union of plugin names registered in the `plugins` tuple,
149
+ * **plus** the names of {@link DEFAULT_PLUGINS} (those plugins ship
150
+ * pre-loaded by the engine and `disabledPlugins` honors them at
151
+ * runtime).
152
+ *
153
+ * Used to constrain {@link SteeringConfig.disabledPlugins} so typos
154
+ * surface as compile errors. Default-plugin names are always part of
155
+ * this union, even when the user passes no `plugins` themselves.
156
+ *
157
+ * Falls back to just {@link DefaultPluginName} when no user plugins
158
+ * are registered — typing `disabledPlugins` against an empty
159
+ * user-tuple still accepts the engine defaults.
160
+ */
161
+ export type AllPluginNames<P extends readonly Plugin[]> =
162
+ | DefaultPluginName
163
+ | ProjectField<P, "name">;
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // AllRuleNames — union of rule `.name` literals across plugins + user rules.
167
+ // ---------------------------------------------------------------------------
168
+
169
+ /**
170
+ * Extract the union of rule names across:
171
+ * - every {@link DEFAULT_RULES} entry (engine-shipped defaults),
172
+ * - every plugin's `rules: Rule[]` array, AND
173
+ * - the top-level inline `rules: Rule[]` array.
174
+ *
175
+ * Used to constrain {@link SteeringConfig.disabledRules} so typos
176
+ * surface as compile errors. Default rule names are always part of
177
+ * this union — disabling a default (`disabledRules: ["no-force-push"]`)
178
+ * typechecks the same as disabling a user or plugin rule.
179
+ *
180
+ * Falls back to just {@link DefaultRuleName} when no plugin or user
181
+ * rules are registered.
182
+ */
183
+ export type AllRuleNames<
184
+ P extends readonly Plugin[],
185
+ R extends readonly Rule[],
186
+ > =
187
+ | DefaultRuleName
188
+ | FromPluginField<P, "rules", "name">
189
+ | ProjectField<R, "name">;
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // AllWrites — union of `writes[]` literals across rules + observers.
193
+ // ---------------------------------------------------------------------------
194
+
195
+ /**
196
+ * Extract the union of session-entry custom types declared via `writes`
197
+ * arrays across:
198
+ * - every plugin's `rules: Rule[]` (rule-side writes via `onFire`),
199
+ * - every plugin's `observers: Observer[]` (observer-side writes),
200
+ * - the top-level inline `rules`, AND
201
+ * - the top-level inline `observers`.
202
+ *
203
+ * Used to constrain {@link BuiltInWhenLeavesOuter.happened} `event` so typos
204
+ * (e.g., `happened: { event: "sync-don" }` when the observer writes
205
+ * `"sync-done"`) surface as compile errors.
206
+ *
207
+ * Authors who omit `writes` on a rule/observer don't contribute to the
208
+ * union — the rule's write is undeclared, and any downstream
209
+ * `when.happened.event` referencing it will be rejected. Matches the
210
+ * "declare your writes" discipline that `writes[]` encourages.
211
+ */
212
+ export type AllWrites<
213
+ P extends readonly Plugin[],
214
+ R extends readonly Rule[],
215
+ Inline extends readonly Observer[] = readonly [],
216
+ > =
217
+ | FromPluginField<P, "rules", "writes">
218
+ | FromPluginField<P, "observers", "writes">
219
+ | ProjectField<R, "writes">
220
+ | ProjectField<Inline, "writes">;
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // DefineConfigInput
224
+ // ---------------------------------------------------------------------------
225
+
226
+ /**
227
+ * Config author surface — the shape `defineConfig` accepts. Matches
228
+ * {@link SteeringConfig} but with `const`-generic tuple slots on
229
+ * `plugins` / `rules` / `observers` so tuple literal types survive
230
+ * through the call and drive name inference.
231
+ *
232
+ * Generic constraints:
233
+ * - `disabledRules` / `disabledPlugins` typed against the rule / plugin
234
+ * name unions — typos rejected at compile time.
235
+ * - `rules[].when.happened.event` and `rules[].when.happened.since`
236
+ * are both typed against `AllWrites` — typos rejected at compile
237
+ * time.
238
+ */
239
+ export interface DefineConfigInput<
240
+ P extends readonly Plugin[],
241
+ Inline extends readonly Observer[],
242
+ R extends readonly Rule<
243
+ AllObserverNames<P, Inline>,
244
+ AllWrites<P, R, Inline>
245
+ >[],
246
+ > extends SteeringConfig {
247
+ disabledRules?: readonly AllRuleNames<P, R>[];
248
+ disabledPlugins?: readonly AllPluginNames<P>[];
249
+ plugins?: P;
250
+ rules?: R;
251
+ observers?: Inline;
252
+ }
253
+
254
+ /**
255
+ * Build a {@link SteeringConfig} with cross-reference name checking.
256
+ *
257
+ * Observer references in {@link Rule.observer} are typed against the
258
+ * union of observer names gathered from `plugins[*].observers` AND the
259
+ * top-level `observers` array — a typo produces a compile error.
260
+ *
261
+ * The `disabledRules` / `disabledPlugins` arrays are typed against the unions
262
+ * of registered rule / plugin names — typos rejected.
263
+ *
264
+ * `rules[].when.happened.event` and `rules[].when.happened.since` are
265
+ * both typed against the union of all `writes` declarations across
266
+ * plugin rules, plugin observers, user rules, and user observers —
267
+ * typos rejected. (The `since` field on the `Writes` union enforces
268
+ * the same contract as `event`: the sentinel event must be known to
269
+ * the config, not a free-form string.)
270
+ *
271
+ * Runtime behavior: returns a shallow copy of the input with optional
272
+ * fields normalized from `readonly` arrays to mutable arrays (the
273
+ * {@link SteeringConfig} shape doesn't constrain mutability). The
274
+ * return value is safe to pass to the loader / buildConfig.
275
+ *
276
+ * ## Authoring pattern — preserving observer/plugin names for inference
277
+ *
278
+ * For compile-time typo detection on rule `observer` references, declare
279
+ * your observers and plugins with `as const satisfies` so TypeScript
280
+ * preserves the literal `name` values through to `AllObserverNames`:
281
+ *
282
+ * const myObs = {
283
+ * name: "description-read",
284
+ * onResult: (event, ctx) => { ... },
285
+ * } as const satisfies Observer;
286
+ *
287
+ * const myPlugin = {
288
+ * name: "my-plugin",
289
+ * observers: [{ name: "sync-done", onResult: ... }],
290
+ * } as const satisfies Plugin;
291
+ *
292
+ * Authors who prefer type annotations (`const myObs: Observer = ...`)
293
+ * get widened `name: string`, which collapses `AllObserverNames` to
294
+ * `string` and silently disables typo detection. Use `as const satisfies`
295
+ * to keep the inference.
296
+ *
297
+ * ## Behavior with no observers declared
298
+ *
299
+ * When no plugins contribute observers AND no inline `observers[]` is
300
+ * passed, `AllObserverNames` resolves to `never`, which causes ANY
301
+ * string `observer` reference on a Rule to be a compile error. This is
302
+ * deliberate — fail-closed on unknown observer names. For configs that
303
+ * deliberately reference observers by name without registering them
304
+ * inline (e.g., deferred to runtime), use `satisfies SteeringConfig`
305
+ * as a fallback; you lose typo detection but regain flexibility.
306
+ *
307
+ * ## Hover ergonomics for plugin-predicate JSDoc
308
+ *
309
+ * The `const R extends readonly Rule[]` signature narrows the
310
+ * contextual type of inline rule literals to their `const`-inferred
311
+ * shape, bypassing the homomorphic mapped-type linkage that surfaces
312
+ * source-declared JSDoc on hover (e.g. on `when.isClean:`). Factor
313
+ * rules out into `const myRule = { ... } as const satisfies Rule`
314
+ * bindings before passing them to `defineConfig` to keep the
315
+ * hover-rich shape; see the
316
+ * `examples/dynamic-reason-runtime-cwd/steering.ts` example. The
317
+ * `as const` modifier on the binding (and the `const R` modifier on
318
+ * the signature) preserves each rule's literal `name` so
319
+ * `disabledRules` typo detection fires — the alternatives `: Rule`
320
+ * and bare `satisfies Rule` restore hover but widen the inferred
321
+ * type and collapse typo detection (and `when.happened.event`
322
+ * narrowing across declared `writes`).
323
+ *
324
+ * @example
325
+ * export default defineConfig({
326
+ * plugins: [gitPlugin],
327
+ * observers: [descriptionReadObserver],
328
+ * rules: [
329
+ * { name: "must-read-docs", ..., observer: "description-read" },
330
+ * ],
331
+ * });
332
+ */
333
+ export function defineConfig<
334
+ const P extends readonly Plugin[] = [],
335
+ const Inline extends readonly Observer[] = [],
336
+ const R extends readonly Rule<
337
+ AllObserverNames<P, Inline>,
338
+ AllWrites<P, R, Inline>
339
+ >[] = [],
340
+ >(config: DefineConfigInput<P, Inline, R>): SteeringConfig {
341
+ // Runtime work is minimal: copy the supplied config, widening the
342
+ // `readonly` tuple slots back to plain arrays for downstream
343
+ // consumers (loader, evaluator) that don't care about the tuple
344
+ // literal types. The generic machinery's job is done at the call
345
+ // site — once we return, we return plain SteeringConfig.
346
+ const out: SteeringConfig = {};
347
+ if (config.defaultNoOverride !== undefined) {
348
+ out.defaultNoOverride = config.defaultNoOverride;
349
+ }
350
+ if (config.disabledRules !== undefined) {
351
+ out.disabledRules = [...config.disabledRules];
352
+ }
353
+ if (config.disabledPlugins !== undefined) {
354
+ out.disabledPlugins = [...config.disabledPlugins];
355
+ }
356
+ if (config.disableDefaults !== undefined) {
357
+ out.disableDefaults = config.disableDefaults;
358
+ }
359
+ if (config.failOnWarnings !== undefined) {
360
+ out.failOnWarnings = config.failOnWarnings;
361
+ }
362
+ if (config.plugins !== undefined) {
363
+ // Cast: `readonly Plugin[]` → `Plugin[]` (shape is identical;
364
+ // the loader never mutates the array, but SteeringConfig
365
+ // doesn't require readonly).
366
+ out.plugins = [...config.plugins];
367
+ }
368
+ if (config.rules !== undefined) {
369
+ out.rules = [...config.rules] as Rule[];
370
+ }
371
+ if (config.observers !== undefined) {
372
+ out.observers = [...config.observers];
373
+ }
374
+ return out;
375
+ }
@@ -0,0 +1,70 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Tests for `definePredicate<T>` — shape smoke + typed-argument flow.
6
+ *
7
+ * `definePredicate` is purely type-level sugar; at runtime it returns
8
+ * its handler unchanged. These tests pin the runtime contract (call
9
+ * with typed args gets through unchanged) and the type signature by
10
+ * constructing examples that would fail to compile if the generic
11
+ * were mishandled.
12
+ */
13
+
14
+ import assert from "node:assert/strict";
15
+ import { describe, it } from "node:test";
16
+ import { definePredicate } from "./define-predicate.ts";
17
+ import type { Plugin, PredicateContext, PredicateHandler } from "./schema.ts";
18
+
19
+ describe("definePredicate", () => {
20
+ it("returns the handler unchanged (pure pass-through)", () => {
21
+ const handler = (_args: { pattern: RegExp }, _ctx: PredicateContext) =>
22
+ true;
23
+ const wrapped = definePredicate<{ pattern: RegExp }>(handler);
24
+ assert.equal(wrapped, handler as unknown);
25
+ });
26
+
27
+ it("resulting handler is assignable into Plugin.predicates", () => {
28
+ interface CommitFormatArgs {
29
+ pattern: RegExp;
30
+ onUnknown?: "allow" | "block";
31
+ }
32
+ const commitFormat = definePredicate<CommitFormatArgs>((args, _ctx) =>
33
+ args.pattern.test("conventional: subject"),
34
+ );
35
+ // If definePredicate returned PredicateHandler<CommitFormatArgs>
36
+ // instead of the loose PredicateHandler, this assignment would
37
+ // fail because Plugin.predicates is keyed with the loose
38
+ // handler type.
39
+ const plugin: Plugin = {
40
+ name: "git",
41
+ predicates: { commitFormat },
42
+ };
43
+ assert.equal(plugin.name, "git");
44
+ assert.equal(typeof plugin.predicates?.commitFormat, "function");
45
+ });
46
+
47
+ it("handler still invokes with typed args at runtime", async () => {
48
+ interface Args {
49
+ flag: string;
50
+ }
51
+ let sawArgs: Args | null = null;
52
+ const handler = definePredicate<Args>((args, _ctx) => {
53
+ sawArgs = args;
54
+ return args.flag === "yes";
55
+ });
56
+ const loose = handler as PredicateHandler;
57
+ const ctx: PredicateContext = {
58
+ cwd: "/",
59
+ tool: "bash",
60
+ input: { tool: "bash", command: "" },
61
+ agentLoopIndex: 0,
62
+ exec: async () => ({ stdout: "", stderr: "", exitCode: 0 }),
63
+ appendEntry: () => {},
64
+ findEntries: () => [],
65
+ };
66
+ const r = await loose({ flag: "yes" }, ctx);
67
+ assert.equal(r, true);
68
+ assert.deepEqual(sawArgs, { flag: "yes" });
69
+ });
70
+ });
@@ -0,0 +1,57 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * `definePredicate<T>` — ~5-LOC helper for declaring typed plugin
6
+ * predicate handlers.
7
+ *
8
+ * Parallels {@link defineConfig} — pure pass-through at runtime, all
9
+ * the value is in the type signature. Narrows the handler's first
10
+ * argument to the supplied type parameter `T` so the body reads like
11
+ * a plain function on typed args, without the author needing to
12
+ * write the annotation twice (once on the declaration, once on the
13
+ * handler's `args` parameter). The `PredicateHandler<T>` return
14
+ * preserves that narrowing when the result is assigned to a local
15
+ * variable; the {@link Plugin.predicates} registry slot then accepts
16
+ * it cast-free via {@link AnyPredicateHandler} (= `PredicateHandler<any>`),
17
+ * which uses TS bivariance to admit typed handlers directly.
18
+ *
19
+ * See ADR §10 for the motivating usage.
20
+ */
21
+
22
+ import type { PredicateHandler } from "./schema.ts";
23
+
24
+ /**
25
+ * Sugar for declaring a typed {@link PredicateHandler}. The handler
26
+ * is returned unchanged at runtime; the generic parameter `T`
27
+ * narrows the handler's `args` parameter to the author's intended
28
+ * shape. Return type is `PredicateHandler<T>`, so authors threading
29
+ * the result through local variables keep the narrowed arg type.
30
+ * The registry slot at {@link Plugin.predicates} accepts the result
31
+ * cast-free via {@link AnyPredicateHandler}.
32
+ *
33
+ * @example
34
+ * interface CommitFormatArgs {
35
+ * pattern: RegExp;
36
+ * onUnknown?: "allow" | "block";
37
+ * }
38
+ *
39
+ * export const commitFormat = definePredicate<CommitFormatArgs>(
40
+ * (args, ctx) => {
41
+ * // `args` is narrowed to CommitFormatArgs here.
42
+ * const msg = extractCommitMessage(ctx.input.args ?? []);
43
+ * return args.pattern.test(msg);
44
+ * },
45
+ * );
46
+ *
47
+ * // Plug into a plugin:
48
+ * export const gitPlugin: Plugin = {
49
+ * name: "git",
50
+ * predicates: { commitFormat },
51
+ * };
52
+ */
53
+ export function definePredicate<T>(
54
+ handler: PredicateHandler<T>,
55
+ ): PredicateHandler<T> {
56
+ return handler;
57
+ }