@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,431 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Tests for {@link fromJSON} — v1 JSON → v2 {@link SteeringConfig}
6
+ * import helper.
7
+ *
8
+ * Covers:
9
+ * - Happy paths: top-level fields, rule fields, `when.cwd`.
10
+ * - Golden conversions: the v1 JSON fixtures in
11
+ * `../loader.test.ts`'s source pass through with the expected
12
+ * output.
13
+ * - Rejection paths: every v2-only construct (plugins, observers,
14
+ * function predicates, custom `when` keys).
15
+ */
16
+
17
+ import assert from "node:assert/strict";
18
+ import { describe, it } from "node:test";
19
+ import { FromJSONError, fromJSON } from "./compat.ts";
20
+
21
+ describe("compat: fromJSON happy paths", () => {
22
+ it("empty object produces empty config", () => {
23
+ assert.deepEqual(fromJSON({}), {});
24
+ });
25
+
26
+ it("preserves `disable` list (renames to `disabledRules` in v2)", () => {
27
+ const out = fromJSON({ disable: ["rule-a", "rule-b"] });
28
+ assert.deepEqual(out.disabledRules, ["rule-a", "rule-b"]);
29
+ });
30
+
31
+ it("preserves `defaultNoOverride`", () => {
32
+ assert.equal(fromJSON({ defaultNoOverride: true }).defaultNoOverride, true);
33
+ assert.equal(
34
+ fromJSON({ defaultNoOverride: false }).defaultNoOverride,
35
+ false,
36
+ );
37
+ });
38
+
39
+ it("converts a minimal rule", () => {
40
+ const out = fromJSON({
41
+ rules: [
42
+ {
43
+ name: "no-foo",
44
+ tool: "bash",
45
+ field: "command",
46
+ pattern: "^foo",
47
+ reason: "foo forbidden",
48
+ },
49
+ ],
50
+ });
51
+ assert.deepEqual(out.rules, [
52
+ {
53
+ name: "no-foo",
54
+ tool: "bash",
55
+ field: "command",
56
+ pattern: "^foo",
57
+ reason: "foo forbidden",
58
+ },
59
+ ]);
60
+ });
61
+
62
+ it("carries forward `requires` / `unless` / `noOverride` / `when.cwd`", () => {
63
+ const out = fromJSON({
64
+ rules: [
65
+ {
66
+ name: "rich",
67
+ tool: "write",
68
+ field: "path",
69
+ pattern: "secret",
70
+ requires: "\\.env$",
71
+ unless: "\\.env\\.example$",
72
+ noOverride: true,
73
+ reason: "no secrets in tracked files",
74
+ when: { cwd: "^/workplace" },
75
+ },
76
+ ],
77
+ });
78
+ const rule = out.rules?.[0];
79
+ assert.ok(rule);
80
+ assert.equal(rule.requires, "\\.env$");
81
+ assert.equal(rule.unless, "\\.env\\.example$");
82
+ assert.equal(rule.noOverride, true);
83
+ assert.equal(rule.when?.cwd, "^/workplace");
84
+ });
85
+
86
+ it("golden: example fixture used by the v1 JSON loader tests round-trips", () => {
87
+ // Mirrors the shape of the fixture at
88
+ // `src/loader.test.ts` — using a
89
+ // generic name so this test doesn't grow an import dependency
90
+ // on the legacy fixture file.
91
+ const fixture = {
92
+ disable: ["noisy-rule"],
93
+ defaultNoOverride: true,
94
+ rules: [
95
+ {
96
+ name: "block-dangerous",
97
+ tool: "bash",
98
+ field: "command",
99
+ pattern: "^rm\\s+-rf",
100
+ reason: "rm -rf is catastrophic",
101
+ noOverride: true,
102
+ },
103
+ {
104
+ name: "warn-in-workplace",
105
+ tool: "bash",
106
+ field: "command",
107
+ pattern: "^git\\s+push",
108
+ reason: "don't push from workplace paths",
109
+ when: { cwd: "^/workplace" },
110
+ },
111
+ ],
112
+ };
113
+ const out = fromJSON(fixture);
114
+ assert.deepEqual(out.disabledRules, ["noisy-rule"]);
115
+ assert.equal(out.defaultNoOverride, true);
116
+ assert.equal(out.rules?.length, 2);
117
+ assert.equal(out.rules?.[0]?.noOverride, true);
118
+ assert.equal(out.rules?.[1]?.when?.cwd, "^/workplace");
119
+ });
120
+ });
121
+
122
+ describe("compat: fromJSON rejections", () => {
123
+ it("rejects non-object top-level", () => {
124
+ assert.throws(() => fromJSON(null), FromJSONError);
125
+ assert.throws(() => fromJSON(42), FromJSONError);
126
+ assert.throws(() => fromJSON("string"), FromJSONError);
127
+ assert.throws(() => fromJSON([]), FromJSONError);
128
+ });
129
+
130
+ it("rejects v2-only top-level keys with a clear error", () => {
131
+ for (const key of [
132
+ "plugins",
133
+ "observers",
134
+ "disablePlugins",
135
+ "disabledPlugins",
136
+ "disabledRules",
137
+ "disableDefaults",
138
+ ]) {
139
+ let caught: unknown;
140
+ try {
141
+ fromJSON({ [key]: [] });
142
+ } catch (e) {
143
+ caught = e;
144
+ }
145
+ assert.ok(
146
+ caught instanceof FromJSONError,
147
+ `expected FromJSONError for ${key}`,
148
+ );
149
+ assert.ok(
150
+ caught.message.includes(key),
151
+ `error should mention the forbidden key, got: ${caught.message}`,
152
+ );
153
+ assert.equal(caught.path, `<root>.${key}`);
154
+ }
155
+ });
156
+
157
+ it("rejects malformed `disable`", () => {
158
+ assert.throws(() => fromJSON({ disable: "not-an-array" }), FromJSONError);
159
+ assert.throws(() => fromJSON({ disable: [1, 2] }), FromJSONError);
160
+ });
161
+
162
+ it("rejects malformed `defaultNoOverride`", () => {
163
+ assert.throws(() => fromJSON({ defaultNoOverride: "true" }), FromJSONError);
164
+ });
165
+
166
+ it("rejects missing required rule fields", () => {
167
+ // missing name
168
+ assert.throws(
169
+ () =>
170
+ fromJSON({
171
+ rules: [
172
+ { tool: "bash", field: "command", pattern: "p", reason: "r" },
173
+ ],
174
+ }),
175
+ FromJSONError,
176
+ );
177
+ // missing pattern
178
+ assert.throws(
179
+ () =>
180
+ fromJSON({
181
+ rules: [{ name: "n", tool: "bash", field: "command", reason: "r" }],
182
+ }),
183
+ FromJSONError,
184
+ );
185
+ // wrong tool value
186
+ assert.throws(
187
+ () =>
188
+ fromJSON({
189
+ rules: [
190
+ {
191
+ name: "n",
192
+ tool: "shell", // not one of bash/write/edit
193
+ field: "command",
194
+ pattern: "p",
195
+ reason: "r",
196
+ },
197
+ ],
198
+ }),
199
+ FromJSONError,
200
+ );
201
+ });
202
+
203
+ it("rejects invalid (tool, field) combinations per the Rule union", () => {
204
+ // bash rules must use `field: "command"`; `path` / `content` are
205
+ // silently-wrong combos the discriminated TS Rule union now
206
+ // rejects. compat.ts mirrors the check at JSON parse time.
207
+ assert.throws(
208
+ () =>
209
+ fromJSON({
210
+ rules: [
211
+ {
212
+ name: "n",
213
+ tool: "bash",
214
+ field: "path",
215
+ pattern: "p",
216
+ reason: "r",
217
+ },
218
+ ],
219
+ }),
220
+ FromJSONError,
221
+ );
222
+ assert.throws(
223
+ () =>
224
+ fromJSON({
225
+ rules: [
226
+ {
227
+ name: "n",
228
+ tool: "bash",
229
+ field: "content",
230
+ pattern: "p",
231
+ reason: "r",
232
+ },
233
+ ],
234
+ }),
235
+ FromJSONError,
236
+ );
237
+ // write / edit rules test `path` or `content`, never `command`.
238
+ assert.throws(
239
+ () =>
240
+ fromJSON({
241
+ rules: [
242
+ {
243
+ name: "n",
244
+ tool: "write",
245
+ field: "command",
246
+ pattern: "p",
247
+ reason: "r",
248
+ },
249
+ ],
250
+ }),
251
+ FromJSONError,
252
+ );
253
+ assert.throws(
254
+ () =>
255
+ fromJSON({
256
+ rules: [
257
+ {
258
+ name: "n",
259
+ tool: "edit",
260
+ field: "command",
261
+ pattern: "p",
262
+ reason: "r",
263
+ },
264
+ ],
265
+ }),
266
+ FromJSONError,
267
+ );
268
+ });
269
+
270
+ it("rejects non-string `pattern`", () => {
271
+ assert.throws(
272
+ () =>
273
+ fromJSON({
274
+ rules: [
275
+ {
276
+ name: "n",
277
+ tool: "bash",
278
+ field: "command",
279
+ pattern: 42,
280
+ reason: "r",
281
+ },
282
+ ],
283
+ }),
284
+ FromJSONError,
285
+ );
286
+ });
287
+
288
+ it("rejects function-shaped rule fields (they can't appear in JSON, but errors help hand-editing humans)", () => {
289
+ assert.throws(
290
+ () =>
291
+ fromJSON({
292
+ rules: [
293
+ {
294
+ name: "n",
295
+ tool: "bash",
296
+ field: "command",
297
+ pattern: "p",
298
+ reason: "r",
299
+ requires: 42,
300
+ },
301
+ ],
302
+ }),
303
+ FromJSONError,
304
+ );
305
+ });
306
+
307
+ it("rejects plugin-registered `when` keys", () => {
308
+ let caught: unknown;
309
+ try {
310
+ fromJSON({
311
+ rules: [
312
+ {
313
+ name: "n",
314
+ tool: "bash",
315
+ field: "command",
316
+ pattern: "p",
317
+ reason: "r",
318
+ when: { branch: "^main$" },
319
+ },
320
+ ],
321
+ });
322
+ } catch (e) {
323
+ caught = e;
324
+ }
325
+ assert.ok(caught instanceof FromJSONError);
326
+ assert.ok(caught.message.includes("when.branch"));
327
+ assert.ok(caught.path.endsWith("when.branch"));
328
+ });
329
+
330
+ it("rejects `when.not` / `when.condition` (v2-only)", () => {
331
+ assert.throws(
332
+ () =>
333
+ fromJSON({
334
+ rules: [
335
+ {
336
+ name: "n",
337
+ tool: "bash",
338
+ field: "command",
339
+ pattern: "p",
340
+ reason: "r",
341
+ when: { not: {} },
342
+ },
343
+ ],
344
+ }),
345
+ FromJSONError,
346
+ );
347
+ assert.throws(
348
+ () =>
349
+ fromJSON({
350
+ rules: [
351
+ {
352
+ name: "n",
353
+ tool: "bash",
354
+ field: "command",
355
+ pattern: "p",
356
+ reason: "r",
357
+ when: { condition: "noop" },
358
+ },
359
+ ],
360
+ }),
361
+ FromJSONError,
362
+ );
363
+ });
364
+
365
+ it("rejects `observer` key on a rule", () => {
366
+ assert.throws(
367
+ () =>
368
+ fromJSON({
369
+ rules: [
370
+ {
371
+ name: "n",
372
+ tool: "bash",
373
+ field: "command",
374
+ pattern: "p",
375
+ reason: "r",
376
+ observer: "some-name",
377
+ },
378
+ ],
379
+ }),
380
+ FromJSONError,
381
+ );
382
+ });
383
+
384
+ it("rejects non-string `when.cwd`", () => {
385
+ assert.throws(
386
+ () =>
387
+ fromJSON({
388
+ rules: [
389
+ {
390
+ name: "n",
391
+ tool: "bash",
392
+ field: "command",
393
+ pattern: "p",
394
+ reason: "r",
395
+ when: { cwd: 42 },
396
+ },
397
+ ],
398
+ }),
399
+ FromJSONError,
400
+ );
401
+ });
402
+ });
403
+
404
+ describe("compat: FromJSONError", () => {
405
+ it("carries a path pointing at the offending location", () => {
406
+ try {
407
+ fromJSON({
408
+ rules: [
409
+ {
410
+ name: "ok",
411
+ tool: "bash",
412
+ field: "command",
413
+ pattern: "p",
414
+ reason: "r",
415
+ },
416
+ {
417
+ name: "bad",
418
+ tool: "bash",
419
+ field: "command",
420
+ pattern: 42,
421
+ reason: "r",
422
+ },
423
+ ],
424
+ });
425
+ assert.fail("expected throw");
426
+ } catch (err) {
427
+ assert.ok(err instanceof FromJSONError);
428
+ assert.equal(err.path, "<root>.rules[1].pattern");
429
+ }
430
+ });
431
+ });
package/src/compat.ts ADDED
@@ -0,0 +1,282 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * `fromJSON` — convert the v1 JSON config shape to a v2
6
+ * {@link SteeringConfig}.
7
+ *
8
+ * Per the accepted ADR ("Design → File layout and loader behavior"):
9
+ * JSON is not a first-class config format in v2. `fromJSON` exists so
10
+ * `.pi/steering.json` files authored against the PoC shape can be
11
+ * loaded programmatically — either as a library call (this module)
12
+ * or via the `pi-steering import-json` CLI wrapping it.
13
+ *
14
+ * Scope:
15
+ *
16
+ * - Top-level v1 fields: `disable`, `defaultNoOverride`, `rules`.
17
+ * - Rule fields: `name`, `tool`, `field`, `pattern` (stays a string —
18
+ * v2 accepts `string | RegExp`), `requires`, `unless`, `reason`,
19
+ * `noOverride`, `when.cwd` (string pattern).
20
+ *
21
+ * Rejected (throws):
22
+ *
23
+ * - Plugins (JSON can't express function-typed handlers).
24
+ * - Observers (ditto — `onResult` is a function).
25
+ * - Function-valued fields on rules (ditto).
26
+ * - `when.<customKey>` — plugin-registered predicates have no
27
+ * JSON-expressible binding.
28
+ * - `when.not` or `when.condition` (also function-shaped or recursive).
29
+ *
30
+ * Callers that hit one of the rejection cases should author the
31
+ * offending rule / plugin directly in TypeScript; `fromJSON` is only
32
+ * for the trivial pattern-string path.
33
+ */
34
+
35
+ import type { Rule, SteeringConfig, WhenClause } from "./schema.ts";
36
+
37
+ /**
38
+ * Error thrown when the input JSON uses a feature the v1 → v2 helper
39
+ * can't represent. Carries a `path` pointing at the offending location
40
+ * (e.g. `rules[2].when.branch`) so callers can point the user at the
41
+ * rule to rewrite in TypeScript.
42
+ */
43
+ export class FromJSONError extends Error {
44
+ readonly path: string;
45
+
46
+ constructor(message: string, path: string) {
47
+ super(`${message} (at ${path})`);
48
+ this.name = "FromJSONError";
49
+ this.path = path;
50
+ }
51
+ }
52
+
53
+ /** Narrow `unknown` to a plain (non-array) object. */
54
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
55
+ return typeof v === "object" && v !== null && !Array.isArray(v);
56
+ }
57
+
58
+ /**
59
+ * Convert v1 JSON into a v2 {@link SteeringConfig}.
60
+ *
61
+ * The input is typed `unknown` deliberately — this helper accepts
62
+ * whatever came out of `JSON.parse`, validates the shape, and throws
63
+ * {@link FromJSONError} on anything it can't represent.
64
+ */
65
+ export function fromJSON(json: unknown): SteeringConfig {
66
+ if (!isPlainObject(json)) {
67
+ throw new FromJSONError(
68
+ "expected a JSON object at the top level",
69
+ "<root>",
70
+ );
71
+ }
72
+
73
+ // Pre-flight: loudly reject v2-only keys that shouldn't appear in
74
+ // v1 JSON. If a user lands here by mistake (e.g. calling fromJSON
75
+ // on an already-v2 object), we'd rather tell them than silently
76
+ // drop their plugins / observers.
77
+ for (const forbidden of [
78
+ "plugins",
79
+ "observers",
80
+ "disablePlugins",
81
+ "disabledPlugins",
82
+ "disabledRules",
83
+ "disableDefaults",
84
+ ] as const) {
85
+ if (forbidden in json) {
86
+ throw new FromJSONError(
87
+ `JSON config cannot express \`${forbidden}\`; ` +
88
+ `author that configuration directly in TypeScript instead`,
89
+ `<root>.${forbidden}`,
90
+ );
91
+ }
92
+ }
93
+
94
+ const out: SteeringConfig = {};
95
+
96
+ if ("disable" in json) {
97
+ const disable = json["disable"];
98
+ if (
99
+ !Array.isArray(disable) ||
100
+ !disable.every((x) => typeof x === "string")
101
+ ) {
102
+ throw new FromJSONError(
103
+ "`disable` must be a string array",
104
+ "<root>.disable",
105
+ );
106
+ }
107
+ // v1 JSON's `disable` → v2 TS `disabledRules`. The JSON key is
108
+ // preserved for legacy on-disk configs; the TS shape uses the
109
+ // past-participle form.
110
+ out.disabledRules = [...disable];
111
+ }
112
+
113
+ if ("defaultNoOverride" in json) {
114
+ const v = json["defaultNoOverride"];
115
+ if (typeof v !== "boolean") {
116
+ throw new FromJSONError(
117
+ "`defaultNoOverride` must be a boolean",
118
+ "<root>.defaultNoOverride",
119
+ );
120
+ }
121
+ out.defaultNoOverride = v;
122
+ }
123
+
124
+ if ("rules" in json) {
125
+ const rules = json["rules"];
126
+ if (!Array.isArray(rules)) {
127
+ throw new FromJSONError("`rules` must be an array", "<root>.rules");
128
+ }
129
+ out.rules = rules.map((rule, i) => convertRule(rule, `<root>.rules[${i}]`));
130
+ }
131
+
132
+ return out;
133
+ }
134
+
135
+ /** Convert a single v1 JSON rule, scoping errors with `path`. */
136
+ function convertRule(raw: unknown, path: string): Rule {
137
+ if (!isPlainObject(raw)) {
138
+ throw new FromJSONError("rule must be an object", path);
139
+ }
140
+
141
+ // Required: name, tool, field, pattern, reason.
142
+ const name = raw["name"];
143
+ if (typeof name !== "string" || name.length === 0) {
144
+ throw new FromJSONError(
145
+ "`name` must be a non-empty string",
146
+ `${path}.name`,
147
+ );
148
+ }
149
+ const tool = raw["tool"];
150
+ if (tool !== "bash" && tool !== "write" && tool !== "edit") {
151
+ throw new FromJSONError(
152
+ "`tool` must be one of 'bash' | 'write' | 'edit'",
153
+ `${path}.tool`,
154
+ );
155
+ }
156
+ const field = raw["field"];
157
+ if (field !== "command" && field !== "path" && field !== "content") {
158
+ throw new FromJSONError(
159
+ "`field` must be one of 'command' | 'path' | 'content'",
160
+ `${path}.field`,
161
+ );
162
+ }
163
+ // Validate the (tool, field) combination per the discriminated
164
+ // Rule union: bash rules test against `command`; write / edit
165
+ // rules test against `path` or `content`.
166
+ if (tool === "bash" && field !== "command") {
167
+ throw new FromJSONError(
168
+ `bash rules must use \`field: "command"\` (got "${field}")`,
169
+ `${path}.field`,
170
+ );
171
+ }
172
+ if ((tool === "write" || tool === "edit") && field === "command") {
173
+ throw new FromJSONError(
174
+ `${tool} rules must use \`field: "path"\` or \`field: "content"\` ` +
175
+ `(got "command")`,
176
+ `${path}.field`,
177
+ );
178
+ }
179
+ const pattern = raw["pattern"];
180
+ if (typeof pattern !== "string") {
181
+ throw new FromJSONError(
182
+ "JSON rules must use a string `pattern` (v2 accepts RegExp in TS)",
183
+ `${path}.pattern`,
184
+ );
185
+ }
186
+ const reason = raw["reason"];
187
+ if (typeof reason !== "string") {
188
+ throw new FromJSONError("`reason` must be a string", `${path}.reason`);
189
+ }
190
+
191
+ const rule: Rule =
192
+ tool === "bash"
193
+ ? { name, tool, field: "command", pattern, reason }
194
+ : {
195
+ name,
196
+ tool,
197
+ // Narrowed by the (tool, field) check above.
198
+ field: field as "path" | "content",
199
+ pattern,
200
+ reason,
201
+ };
202
+
203
+ // Optional: requires / unless (string patterns only).
204
+ if ("requires" in raw) {
205
+ const v = raw["requires"];
206
+ if (typeof v !== "string") {
207
+ throw new FromJSONError(
208
+ "JSON rules must use string `requires` (functions aren't JSON-expressible)",
209
+ `${path}.requires`,
210
+ );
211
+ }
212
+ rule.requires = v;
213
+ }
214
+ if ("unless" in raw) {
215
+ const v = raw["unless"];
216
+ if (typeof v !== "string") {
217
+ throw new FromJSONError(
218
+ "JSON rules must use string `unless` (functions aren't JSON-expressible)",
219
+ `${path}.unless`,
220
+ );
221
+ }
222
+ rule.unless = v;
223
+ }
224
+
225
+ // Optional: noOverride.
226
+ if ("noOverride" in raw) {
227
+ const v = raw["noOverride"];
228
+ if (typeof v !== "boolean") {
229
+ throw new FromJSONError(
230
+ "`noOverride` must be a boolean",
231
+ `${path}.noOverride`,
232
+ );
233
+ }
234
+ rule.noOverride = v;
235
+ }
236
+
237
+ // Optional: when. V1 JSON supports only `when.cwd` (string pattern).
238
+ // Every other key under `when` is plugin-specific and can't be
239
+ // expressed in JSON.
240
+ if ("when" in raw) {
241
+ rule.when = convertWhen(raw["when"], `${path}.when`);
242
+ }
243
+
244
+ // V1 JSON has no `observer` field — observers are v2-only. But if
245
+ // someone hand-edits `observer: "name"` into their JSON, warn loudly.
246
+ if ("observer" in raw) {
247
+ throw new FromJSONError(
248
+ "JSON rules cannot reference observers; observers are v2-only (TS)",
249
+ `${path}.observer`,
250
+ );
251
+ }
252
+
253
+ return rule;
254
+ }
255
+
256
+ /** Convert the v1 `when` object into a v2 {@link WhenClause}. */
257
+ function convertWhen(raw: unknown, path: string): WhenClause {
258
+ if (!isPlainObject(raw)) {
259
+ throw new FromJSONError("`when` must be an object", path);
260
+ }
261
+ const out: WhenClause = {};
262
+ for (const key of Object.keys(raw)) {
263
+ if (key !== "cwd") {
264
+ throw new FromJSONError(
265
+ `JSON config cannot express \`when.${key}\`; ` +
266
+ `plugin-registered predicates and \`when.not\` / \`when.condition\` are v2-only (TS)`,
267
+ `${path}.${key}`,
268
+ );
269
+ }
270
+ }
271
+ if ("cwd" in raw) {
272
+ const v = raw["cwd"];
273
+ if (typeof v !== "string") {
274
+ throw new FromJSONError(
275
+ "JSON `when.cwd` must be a string pattern",
276
+ `${path}.cwd`,
277
+ );
278
+ }
279
+ out.cwd = v;
280
+ }
281
+ return out;
282
+ }