@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,1098 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Walk-up discovery + merge tests for {@link loadConfigs},
6
+ * {@link buildConfig}, and {@link loadSteeringConfig}.
7
+ *
8
+ * Uses the same scratch-HOME + `mkdtempSync` pattern as the v1 JSON
9
+ * loader tests (`../loader.test.ts`) to keep global config leakage
10
+ * out of the test run. Fixtures are written fresh per test so runs
11
+ * are reproducible without repo-committed scratch files.
12
+ */
13
+
14
+ import assert from "node:assert/strict";
15
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { afterEach, beforeEach, describe, it } from "node:test";
19
+ import { useIsolatedHome } from "./__test-helpers__.ts";
20
+ import {
21
+ ancestorChain,
22
+ buildConfig,
23
+ configCandidates,
24
+ findConfigFile,
25
+ loadConfigs,
26
+ loadSteeringConfig,
27
+ } from "./loader.ts";
28
+ import type { Plugin, SteeringConfig } from "./schema.ts";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Helpers
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /** Emit a minimal `.ts` config module at `file`. */
35
+ function writeConfig(file: string, body: string): void {
36
+ mkdirSync(join(file, ".."), { recursive: true });
37
+ writeFileSync(file, body, "utf8");
38
+ }
39
+
40
+ /**
41
+ * Body template: default-exports an object literal with the given
42
+ * identifiers / patterns. Kept as raw source so the real dynamic-import
43
+ * codepath runs (we're testing the native-TS-loading behavior, not a
44
+ * synthetic hook).
45
+ */
46
+ function configModule(body: string): string {
47
+ return `// Generated by loader.test.ts\nexport default ${body};\n`;
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Unit bits
52
+ // ---------------------------------------------------------------------------
53
+
54
+ describe("loader: configCandidates", () => {
55
+ it("returns index.ts before steering.ts", () => {
56
+ const [a, b] = configCandidates("/tmp/x");
57
+ assert.equal(a, "/tmp/x/.pi/steering/index.ts");
58
+ assert.equal(b, "/tmp/x/.pi/steering.ts");
59
+ });
60
+ });
61
+
62
+ describe("loader: ancestorChain", () => {
63
+ it("returns innermost first", () => {
64
+ const prior = process.env["HOME"];
65
+ process.env["HOME"] = "/home/user";
66
+ try {
67
+ const chain = ancestorChain("/home/user/projects/foo/bar");
68
+ assert.deepEqual(chain, [
69
+ "/home/user/projects/foo/bar",
70
+ "/home/user/projects/foo",
71
+ "/home/user/projects",
72
+ "/home/user",
73
+ ]);
74
+ } finally {
75
+ if (prior === undefined) delete process.env["HOME"];
76
+ else process.env["HOME"] = prior;
77
+ }
78
+ });
79
+
80
+ it("stops at filesystem root if $HOME is unset", () => {
81
+ const prior = process.env["HOME"];
82
+ delete process.env["HOME"];
83
+ try {
84
+ const chain = ancestorChain("/a/b");
85
+ assert.deepEqual(chain, ["/a/b", "/a", "/"]);
86
+ } finally {
87
+ if (prior !== undefined) process.env["HOME"] = prior;
88
+ }
89
+ });
90
+
91
+ it("cwd === $HOME returns [$HOME] only", () => {
92
+ const prior = process.env["HOME"];
93
+ const home = mkdtempSync(join(tmpdir(), "pi-steering-v2-chain-home-"));
94
+ process.env["HOME"] = home;
95
+ try {
96
+ const chain = ancestorChain(home);
97
+ assert.deepEqual(chain, [home]);
98
+ } finally {
99
+ if (prior === undefined) delete process.env["HOME"];
100
+ else process.env["HOME"] = prior;
101
+ rmSync(home, { recursive: true, force: true });
102
+ }
103
+ });
104
+
105
+ it("cwd OUTSIDE $HOME tree walks to filesystem root", () => {
106
+ const prior = process.env["HOME"];
107
+ process.env["HOME"] = "/home/user";
108
+ try {
109
+ // cwd is in /other/path — no shared prefix with $HOME, so the
110
+ // walk should run to filesystem root rather than terminating
111
+ // at the (never-reached) $HOME sentinel.
112
+ const chain = ancestorChain("/other/path");
113
+ assert.deepEqual(chain, ["/other/path", "/other", "/"]);
114
+ } finally {
115
+ if (prior === undefined) delete process.env["HOME"];
116
+ else process.env["HOME"] = prior;
117
+ }
118
+ });
119
+ });
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // findConfigFile
123
+ // ---------------------------------------------------------------------------
124
+
125
+ describe("loader: findConfigFile", () => {
126
+ let tmp: string;
127
+ beforeEach(() => {
128
+ tmp = mkdtempSync(join(tmpdir(), "pi-steering-v2-loader-"));
129
+ });
130
+ afterEach(() => {
131
+ rmSync(tmp, { recursive: true, force: true });
132
+ });
133
+
134
+ it("prefers .pi/steering/index.ts over .pi/steering.ts", () => {
135
+ writeConfig(join(tmp, ".pi", "steering.ts"), configModule("{}"));
136
+ writeConfig(join(tmp, ".pi", "steering", "index.ts"), configModule("{}"));
137
+ const { file } = findConfigFile(tmp);
138
+ assert.equal(file, join(tmp, ".pi", "steering", "index.ts"));
139
+ });
140
+
141
+ it("falls back to .pi/steering.ts when index.ts is absent", () => {
142
+ writeConfig(join(tmp, ".pi", "steering.ts"), configModule("{}"));
143
+ const { file, diagnostic } = findConfigFile(tmp);
144
+ assert.equal(file, join(tmp, ".pi", "steering.ts"));
145
+ assert.equal(diagnostic, null);
146
+ });
147
+
148
+ it("returns null when neither candidate exists", () => {
149
+ assert.deepEqual(findConfigFile(tmp), { file: null, diagnostic: null });
150
+ });
151
+
152
+ it("reports a coexistence diagnostic when both forms exist", () => {
153
+ writeConfig(join(tmp, ".pi", "steering.ts"), configModule("{}"));
154
+ writeConfig(join(tmp, ".pi", "steering", "index.ts"), configModule("{}"));
155
+ const { file, diagnostic } = findConfigFile(tmp);
156
+ assert.equal(file, join(tmp, ".pi", "steering", "index.ts"));
157
+ assert.ok(diagnostic, "expected a diagnostic when both forms coexist");
158
+ assert.equal(diagnostic.kind, "layer-form-coexistence");
159
+ assert.equal(diagnostic.type, "warning");
160
+ // `path` points at the parent directory — the renderer's `${path}:`
161
+ // prefix surfaces the dir once and the message names the conflict.
162
+ assert.equal(diagnostic.path, tmp);
163
+ assert.match(
164
+ diagnostic.message,
165
+ /both .pi\/steering.ts and .pi\/steering\/index.ts/,
166
+ );
167
+ });
168
+ });
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // loadConfigs — walk-up, stray-file diagnostic, bad layer handling
172
+ // ---------------------------------------------------------------------------
173
+
174
+ describe("loader: loadConfigs", () => {
175
+ let tmp: string;
176
+ useIsolatedHome("pi-steering-v2-loadcfgs-", (t) => {
177
+ tmp = t;
178
+ });
179
+
180
+ it("returns empty when no layer has a config", async () => {
181
+ const cwd = join(tmp, "a", "b");
182
+ mkdirSync(cwd, { recursive: true });
183
+ const { layers, diagnostics } = await loadConfigs(cwd);
184
+ assert.deepEqual(layers, []);
185
+ assert.deepEqual(diagnostics, []);
186
+ });
187
+
188
+ it("collects ancestor configs inner-first", async () => {
189
+ const outer = join(tmp, "a");
190
+ const inner = join(tmp, "a", "b");
191
+ mkdirSync(inner, { recursive: true });
192
+ writeConfig(
193
+ join(outer, ".pi", "steering.ts"),
194
+ configModule("{ disabledRules: ['outer-only'] }"),
195
+ );
196
+ writeConfig(
197
+ join(inner, ".pi", "steering.ts"),
198
+ configModule("{ disabledRules: ['inner-only'] }"),
199
+ );
200
+ const { layers } = await loadConfigs(inner);
201
+ // Inner (b) → outer (a) order.
202
+ assert.deepEqual(
203
+ layers.map((l) => l.disabledRules?.[0]),
204
+ ["inner-only", "outer-only"],
205
+ );
206
+ });
207
+
208
+ it("prefers index.ts over steering.ts at the same layer", async () => {
209
+ const cwd = join(tmp, "project");
210
+ mkdirSync(cwd, { recursive: true });
211
+ writeConfig(
212
+ join(cwd, ".pi", "steering.ts"),
213
+ configModule("{ disabledRules: ['flat-file'] }"),
214
+ );
215
+ writeConfig(
216
+ join(cwd, ".pi", "steering", "index.ts"),
217
+ configModule("{ disabledRules: ['directory'] }"),
218
+ );
219
+ const { layers } = await loadConfigs(cwd);
220
+ assert.equal(layers.length, 1);
221
+ assert.deepEqual(layers[0]?.disabledRules, ["directory"]);
222
+ });
223
+
224
+ it("reports a layer-form-coexistence diagnostic when both forms exist, uses directory form", async () => {
225
+ const cwd = join(tmp, "project");
226
+ mkdirSync(cwd, { recursive: true });
227
+ writeConfig(
228
+ join(cwd, ".pi", "steering.ts"),
229
+ configModule("{ disabledRules: ['flat-form'] }"),
230
+ );
231
+ writeConfig(
232
+ join(cwd, ".pi", "steering", "index.ts"),
233
+ configModule("{ disabledRules: ['dir-form'] }"),
234
+ );
235
+ const { layers, diagnostics } = await loadConfigs(cwd);
236
+ assert.equal(layers.length, 1);
237
+ assert.deepEqual(
238
+ layers[0]?.disabledRules,
239
+ ["dir-form"],
240
+ "directory form should win on ambiguous coexistence",
241
+ );
242
+ const hit = diagnostics.find((d) => d.kind === "layer-form-coexistence");
243
+ assert.ok(
244
+ hit,
245
+ `expected a layer-form-coexistence diagnostic; got: ${JSON.stringify(diagnostics)}`,
246
+ );
247
+ assert.equal(hit.type, "warning");
248
+ assert.match(
249
+ hit.message,
250
+ /both .pi\/steering.ts and .pi\/steering\/index.ts/,
251
+ );
252
+ });
253
+
254
+ it("emits a layer-stray-file diagnostic per non-.ts file under .pi/steering/", async () => {
255
+ const cwd = join(tmp, "project");
256
+ mkdirSync(cwd, { recursive: true });
257
+ // Create the steering/ dir with stray non-.ts files AND no
258
+ // index.ts, so the loader has a reason to walk the dir. Cover
259
+ // all four file extensions users are likely to fat-finger into
260
+ // the directory (.mjs / .json per the original test, plus .mts
261
+ // and .js to catch the TS-lookalike + bare-JS cases).
262
+ mkdirSync(join(cwd, ".pi", "steering"), { recursive: true });
263
+ writeFileSync(
264
+ join(cwd, ".pi", "steering", "rules.mjs"),
265
+ "// not ts",
266
+ "utf8",
267
+ );
268
+ writeFileSync(join(cwd, ".pi", "steering", "rules.json"), "{}", "utf8");
269
+ writeFileSync(
270
+ join(cwd, ".pi", "steering", "rules.mts"),
271
+ "// looks like ts but isn't .ts",
272
+ "utf8",
273
+ );
274
+ writeFileSync(
275
+ join(cwd, ".pi", "steering", "rules.js"),
276
+ "// plain js",
277
+ "utf8",
278
+ );
279
+ const { diagnostics } = await loadConfigs(cwd);
280
+ const stray = diagnostics.filter((d) => d.kind === "layer-stray-file");
281
+ assert.equal(stray.length, 4);
282
+ assert.ok(stray.every((d) => d.type === "warning"));
283
+ const paths = stray.map((d) => d.path ?? "");
284
+ assert.ok(paths.some((p) => p.endsWith("rules.mjs")));
285
+ assert.ok(paths.some((p) => p.endsWith("rules.json")));
286
+ assert.ok(paths.some((p) => p.endsWith("rules.mts")));
287
+ assert.ok(paths.some((p) => p.endsWith("rules.js")));
288
+ });
289
+
290
+ it("does NOT report stray-file diagnostics for .ts helpers under .pi/steering/", async () => {
291
+ const cwd = join(tmp, "project");
292
+ mkdirSync(cwd, { recursive: true });
293
+ writeConfig(join(cwd, ".pi", "steering", "index.ts"), configModule("{}"));
294
+ writeConfig(
295
+ join(cwd, ".pi", "steering", "helpers.ts"),
296
+ "export const x = 1;",
297
+ );
298
+ const { diagnostics } = await loadConfigs(cwd);
299
+ assert.deepEqual(
300
+ diagnostics.filter((d) => d.kind === "layer-stray-file"),
301
+ [],
302
+ );
303
+ });
304
+
305
+ it("records a layer-import-failed diagnostic and skips a layer whose module fails to import", async () => {
306
+ const cwd = join(tmp, "project");
307
+ mkdirSync(cwd, { recursive: true });
308
+ writeConfig(
309
+ join(cwd, ".pi", "steering.ts"),
310
+ "export default { rules: {{ not valid ts }} };",
311
+ );
312
+ const { layers, diagnostics } = await loadConfigs(cwd);
313
+ assert.deepEqual(layers, []);
314
+ const hit = diagnostics.find((d) => d.kind === "layer-import-failed");
315
+ assert.ok(
316
+ hit,
317
+ `expected a layer-import-failed diagnostic; got: ${JSON.stringify(diagnostics)}`,
318
+ );
319
+ assert.equal(hit.type, "warning");
320
+ assert.equal(hit.path, join(cwd, ".pi", "steering.ts"));
321
+ assert.match(hit.message, /failed to import/);
322
+ });
323
+
324
+ it("records a layer-import-failed diagnostic when the default export is not an object", async () => {
325
+ const cwd = join(tmp, "project");
326
+ mkdirSync(cwd, { recursive: true });
327
+ writeConfig(join(cwd, ".pi", "steering.ts"), "export default 42;");
328
+ const { layers, diagnostics } = await loadConfigs(cwd);
329
+ assert.deepEqual(layers, []);
330
+ assert.ok(
331
+ diagnostics.some(
332
+ (d) =>
333
+ d.kind === "layer-import-failed" &&
334
+ d.message.includes("must be a SteeringConfig object"),
335
+ ),
336
+ );
337
+ });
338
+
339
+ it("records a layer-import-failed diagnostic when the module has no default export", async () => {
340
+ const cwd = join(tmp, "project");
341
+ mkdirSync(cwd, { recursive: true });
342
+ // Named exports only — no `export default`. This used to silently
343
+ // fall back to treating the module namespace itself as the
344
+ // config (Fix 2 removed that fallback).
345
+ writeConfig(
346
+ join(cwd, ".pi", "steering.ts"),
347
+ "export const rules = [];\nexport const plugins = [];\n",
348
+ );
349
+ const { layers, diagnostics } = await loadConfigs(cwd);
350
+ assert.deepEqual(layers, []);
351
+ assert.ok(
352
+ diagnostics.some(
353
+ (d) =>
354
+ d.kind === "layer-import-failed" &&
355
+ d.message.includes("must have a default export"),
356
+ ),
357
+ `expected 'must have a default export' diagnostic; got: ${JSON.stringify(
358
+ diagnostics,
359
+ )}`,
360
+ );
361
+ });
362
+
363
+ it("records a layer-import-failed diagnostic when the default export is an array", async () => {
364
+ const cwd = join(tmp, "project");
365
+ mkdirSync(cwd, { recursive: true });
366
+ // Arrays pass `typeof === 'object'`; the hardened guard (Fix 3)
367
+ // rejects them explicitly with an "array" tag in the message.
368
+ writeConfig(join(cwd, ".pi", "steering.ts"), "export default [];");
369
+ const { layers, diagnostics } = await loadConfigs(cwd);
370
+ assert.deepEqual(layers, []);
371
+ assert.ok(
372
+ diagnostics.some(
373
+ (d) =>
374
+ d.kind === "layer-import-failed" &&
375
+ d.message.includes("must be a SteeringConfig object") &&
376
+ d.message.includes("got array"),
377
+ ),
378
+ `expected array-rejection diagnostic; got: ${JSON.stringify(diagnostics)}`,
379
+ );
380
+ });
381
+
382
+ it("layer-import-failed message does not duplicate the path that the diagnostic's `path` field already carries", async () => {
383
+ const cwd = join(tmp, "project");
384
+ mkdirSync(cwd, { recursive: true });
385
+ const configPath = join(cwd, ".pi", "steering.ts");
386
+ writeConfig(configPath, "export const rules = [];\n");
387
+ const { diagnostics } = await loadConfigs(cwd);
388
+ const hit = diagnostics.find((d) => d.kind === "layer-import-failed");
389
+ assert.ok(hit, "expected a layer-import-failed diagnostic");
390
+ assert.equal(hit.path, configPath);
391
+ // The path is on the diagnostic's `path` field; the message body
392
+ // must not embed the same absolute path. Locked here so a future
393
+ // edit that reintroduces the prefix fails the test.
394
+ assert.ok(
395
+ !hit.message.includes(configPath),
396
+ `message should not embed the path; got: ${hit.message}`,
397
+ );
398
+ assert.equal(
399
+ hit.message,
400
+ "failed to import: config file must have a default export. " +
401
+ "Use `export default { ... } satisfies SteeringConfig` or " +
402
+ "`export default defineConfig({ ... })`.",
403
+ );
404
+ });
405
+
406
+ it("handles heterogeneous config forms across layers (inner flat + outer dir)", async () => {
407
+ // Inner (session cwd) uses the single-file form .pi/steering.ts;
408
+ // outer ancestor uses the directory form .pi/steering/index.ts.
409
+ // Both layers should be collected inner-first without the
410
+ // loader tripping on the form mismatch.
411
+ const outer = join(tmp, "a");
412
+ const inner = join(tmp, "a", "b");
413
+ mkdirSync(inner, { recursive: true });
414
+ writeConfig(
415
+ join(inner, ".pi", "steering.ts"),
416
+ configModule("{ disabledRules: ['inner-flat'] }"),
417
+ );
418
+ writeConfig(
419
+ join(outer, ".pi", "steering", "index.ts"),
420
+ configModule("{ disabledRules: ['outer-dir'] }"),
421
+ );
422
+ const { layers } = await loadConfigs(inner);
423
+ assert.deepEqual(
424
+ layers.map((l) => l.disabledRules?.[0]),
425
+ ["inner-flat", "outer-dir"],
426
+ "expected inner-first ordering regardless of per-layer form",
427
+ );
428
+ });
429
+
430
+ it("re-imports config when file content changes between calls", async () => {
431
+ // Regression: Node's ESM module map is keyed on URL and caches
432
+ // indefinitely within a process. Without cache-busting, an edit
433
+ // to `.pi/steering/index.ts` between two `loadConfigs` calls is
434
+ // invisible — `/reload` looks like it does nothing. The loader
435
+ // appends a `?t=<timestamp>` query string to defeat the cache.
436
+ const dir = join(tmp, "reimport");
437
+ mkdirSync(dir, { recursive: true });
438
+ const configFile = join(dir, ".pi", "steering", "index.ts");
439
+
440
+ writeConfig(configFile, configModule("{ disabledRules: ['v1'] }"));
441
+ const first = await loadConfigs(dir);
442
+ assert.equal(first.layers[0]?.disabledRules?.[0], "v1");
443
+
444
+ writeConfig(configFile, configModule("{ disabledRules: ['v2'] }"));
445
+ const second = await loadConfigs(dir);
446
+ assert.equal(
447
+ second.layers[0]?.disabledRules?.[0],
448
+ "v2",
449
+ "expected fresh-fetch on second call after file edit; got cached " +
450
+ "version, which means the cache-bust in importConfigFile is broken",
451
+ );
452
+ });
453
+
454
+ it("recovers from initial-load failure on next call", async () => {
455
+ // Node's ESM cache also caches FAILED imports — once a URL has
456
+ // thrown during evaluation, every subsequent `import(url)` of
457
+ // that URL throws the same error, even after the file is fixed.
458
+ // Cache-busting fixes this for free: a unique URL each call gets
459
+ // a fresh evaluation attempt.
460
+ //
461
+ // We use a runtime throw (`throw new Error(...)`) rather than a
462
+ // syntax error — syntax errors are surfaced by the TS stripper
463
+ // before the module reaches Node's ESM cache, so they don't
464
+ // poison subsequent loads. The interesting case for cache-bust
465
+ // is the runtime-throw path.
466
+ const dir = join(tmp, "recover");
467
+ mkdirSync(dir, { recursive: true });
468
+ const configFile = join(dir, ".pi", "steering", "index.ts");
469
+
470
+ // First load: file evaluates but throws.
471
+ writeConfig(configFile, "throw new Error('first-load-boom');\n");
472
+ const first = await loadConfigs(dir);
473
+ assert.equal(first.layers.length, 0);
474
+ assert.equal(first.diagnostics[0]?.kind, "layer-import-failed");
475
+
476
+ // Second load: file fixed.
477
+ writeConfig(configFile, configModule("{ disabledRules: ['fixed'] }"));
478
+ const second = await loadConfigs(dir);
479
+ assert.equal(
480
+ second.layers[0]?.disabledRules?.[0],
481
+ "fixed",
482
+ "expected fixed file to load on second call; got cached failure, " +
483
+ "which means the cache-bust in importConfigFile is broken",
484
+ );
485
+ assert.deepEqual(second.diagnostics, []);
486
+ });
487
+ });
488
+
489
+ // ---------------------------------------------------------------------------
490
+ // buildConfig — merge semantics
491
+ // ---------------------------------------------------------------------------
492
+
493
+ describe("loader: buildConfig", () => {
494
+ it("concatenates rules from all layers (inner-first)", () => {
495
+ const inner: SteeringConfig = {
496
+ rules: [
497
+ {
498
+ name: "inner",
499
+ tool: "bash",
500
+ field: "command",
501
+ pattern: /^x/,
502
+ reason: "r",
503
+ },
504
+ ],
505
+ };
506
+ const outer: SteeringConfig = {
507
+ rules: [
508
+ {
509
+ name: "outer",
510
+ tool: "bash",
511
+ field: "command",
512
+ pattern: /^y/,
513
+ reason: "r",
514
+ },
515
+ ],
516
+ };
517
+ const { config: merged, diagnostics } = buildConfig([inner, outer]);
518
+ assert.deepEqual(
519
+ merged.rules?.map((r) => r.name),
520
+ ["inner", "outer"],
521
+ );
522
+ assert.deepEqual(diagnostics, []);
523
+ });
524
+
525
+ it("inner rule by same name overrides outer (and stays silent)", () => {
526
+ const inner: SteeringConfig = {
527
+ rules: [
528
+ {
529
+ name: "dup",
530
+ tool: "bash",
531
+ field: "command",
532
+ pattern: /^INNER/,
533
+ reason: "inner reason",
534
+ },
535
+ ],
536
+ };
537
+ const outer: SteeringConfig = {
538
+ rules: [
539
+ {
540
+ name: "dup",
541
+ tool: "bash",
542
+ field: "command",
543
+ pattern: /^OUTER/,
544
+ reason: "outer reason",
545
+ },
546
+ ],
547
+ };
548
+ const { config: merged, diagnostics } = buildConfig([inner, outer]);
549
+ assert.equal(merged.rules?.length, 1);
550
+ assert.equal(merged.rules?.[0]?.reason, "inner reason");
551
+ // Cross-layer rule overrides are intentional — no diagnostic.
552
+ assert.deepEqual(diagnostics, []);
553
+ });
554
+
555
+ it("records a rule-name-collision diagnostic for within-layer duplicate rules (keeps first)", () => {
556
+ const { config: merged, diagnostics } = buildConfig([
557
+ {
558
+ rules: [
559
+ {
560
+ name: "dup",
561
+ tool: "bash",
562
+ field: "command",
563
+ pattern: /^FIRST/,
564
+ reason: "first-wins",
565
+ },
566
+ {
567
+ name: "dup",
568
+ tool: "bash",
569
+ field: "command",
570
+ pattern: /^SECOND/,
571
+ reason: "dropped",
572
+ },
573
+ ],
574
+ },
575
+ ]);
576
+ assert.equal(merged.rules?.length, 1);
577
+ assert.equal(
578
+ merged.rules?.[0]?.reason,
579
+ "first-wins",
580
+ "first-registered rule should survive within a layer",
581
+ );
582
+ const hit = diagnostics.find((d) => d.kind === "rule-name-collision");
583
+ assert.ok(
584
+ hit,
585
+ `expected a rule-name-collision diagnostic; got: ${JSON.stringify(diagnostics)}`,
586
+ );
587
+ assert.equal(hit.type, "warning");
588
+ assert.match(hit.message, /duplicate rule "dup"/);
589
+ assert.match(hit.message, /within single config layer/);
590
+ });
591
+
592
+ it("unions disabledRules / disabledPlugins across layers", () => {
593
+ const inner: SteeringConfig = {
594
+ disabledRules: ["a"],
595
+ disabledPlugins: ["pA"],
596
+ };
597
+ const outer: SteeringConfig = {
598
+ disabledRules: ["b", "a"], // dup with inner — should coalesce
599
+ disabledPlugins: ["pB"],
600
+ };
601
+ const { config: merged } = buildConfig([inner, outer]);
602
+ assert.deepEqual(
603
+ merged.disabledRules ? [...merged.disabledRules].sort() : undefined,
604
+ ["a", "b"],
605
+ );
606
+ assert.deepEqual(
607
+ merged.disabledPlugins ? [...merged.disabledPlugins].sort() : undefined,
608
+ ["pA", "pB"],
609
+ );
610
+ });
611
+
612
+ it("inner `defaultNoOverride` wins; missing layer leaves outer in place", () => {
613
+ assert.equal(
614
+ buildConfig([{}, { defaultNoOverride: true }]).config.defaultNoOverride,
615
+ true,
616
+ "outer sets it, inner doesn't — outer wins",
617
+ );
618
+ assert.equal(
619
+ buildConfig([{ defaultNoOverride: false }, { defaultNoOverride: true }])
620
+ .config.defaultNoOverride,
621
+ false,
622
+ "inner explicitly false beats outer true",
623
+ );
624
+ assert.equal(buildConfig([]).config.defaultNoOverride, undefined);
625
+ });
626
+
627
+ it("inner `disableDefaults` wins", () => {
628
+ assert.equal(
629
+ buildConfig([{ disableDefaults: true }, { disableDefaults: false }])
630
+ .config.disableDefaults,
631
+ true,
632
+ );
633
+ assert.equal(buildConfig([]).config.disableDefaults, undefined);
634
+ });
635
+
636
+ it("inner `failOnWarnings` wins; default left undefined when no layer specifies", () => {
637
+ // Inner-wins precedence is identical to `disableDefaults` /
638
+ // `defaultNoOverride` since all three flow through `mergeBool`.
639
+ assert.equal(
640
+ buildConfig([{ failOnWarnings: false }, { failOnWarnings: true }]).config
641
+ .failOnWarnings,
642
+ false,
643
+ "inner explicitly false beats outer true",
644
+ );
645
+ assert.equal(
646
+ buildConfig([{}, { failOnWarnings: true }]).config.failOnWarnings,
647
+ true,
648
+ "missing inner layer leaves outer's explicit true in place",
649
+ );
650
+ assert.equal(
651
+ buildConfig([]).config.failOnWarnings,
652
+ undefined,
653
+ "buildConfig leaves the field undefined when no layer specifies it; runtime applies the !== false default",
654
+ );
655
+ });
656
+
657
+ it("records a plugin-name-collision diagnostic for cross-layer duplicate plugin names; first-wins", () => {
658
+ const pInner: Plugin = { name: "p", rules: [] };
659
+ const pOuter: Plugin = { name: "p", rules: [] };
660
+ const { config: merged, diagnostics } = buildConfig([
661
+ { plugins: [pInner] },
662
+ { plugins: [pOuter] },
663
+ ]);
664
+ assert.equal(merged.plugins?.length, 1);
665
+ const hit = diagnostics.find((d) => d.kind === "plugin-name-collision");
666
+ assert.ok(
667
+ hit,
668
+ `expected a plugin-name-collision diagnostic; got: ${JSON.stringify(diagnostics)}`,
669
+ );
670
+ assert.equal(hit.type, "warning");
671
+ assert.match(hit.message, /duplicate plugin "p"/);
672
+ });
673
+
674
+ it("records an observer-name-collision diagnostic for within-layer duplicates", () => {
675
+ const { config: merged, diagnostics } = buildConfig([
676
+ {
677
+ observers: [
678
+ { name: "o", onResult: () => {} },
679
+ { name: "o", onResult: () => {} },
680
+ ],
681
+ },
682
+ ]);
683
+ assert.equal(merged.observers?.length, 1);
684
+ const hit = diagnostics.find((d) => d.kind === "observer-name-collision");
685
+ assert.ok(
686
+ hit,
687
+ `expected an observer-name-collision diagnostic; got: ${JSON.stringify(diagnostics)}`,
688
+ );
689
+ assert.equal(hit.type, "warning");
690
+ assert.match(hit.message, /duplicate observer "o"/);
691
+ });
692
+
693
+ it("inner observer by same name overrides outer (and stays silent)", () => {
694
+ const innerFn = () => {};
695
+ const outerFn = () => {};
696
+ const { config: merged, diagnostics } = buildConfig([
697
+ { observers: [{ name: "shared", onResult: innerFn }] },
698
+ { observers: [{ name: "shared", onResult: outerFn }] },
699
+ ]);
700
+ assert.equal(merged.observers?.length, 1);
701
+ assert.strictEqual(
702
+ merged.observers?.[0]?.onResult,
703
+ innerFn,
704
+ "inner-layer observer should win on cross-layer name collision",
705
+ );
706
+ // Cross-layer observer overrides are the intended customization
707
+ // path — mirror the cross-layer rule-override test and assert no
708
+ // diagnostic fires. Only within-layer duplicates record one.
709
+ assert.deepEqual(diagnostics, []);
710
+ });
711
+
712
+ it("records an error-class tracker-name-collision diagnostic when two plugins claim the same tracker", () => {
713
+ const t = { initial: 0, unknown: -1, modifiers: {} } as const;
714
+ const a: Plugin = { name: "pa", trackers: { branch: t as never } };
715
+ const b: Plugin = { name: "pb", trackers: { branch: t as never } };
716
+ const { diagnostics } = buildConfig([{ plugins: [a] }, { plugins: [b] }]);
717
+ const hit = diagnostics.find((d) => d.kind === "tracker-name-collision");
718
+ assert.ok(
719
+ hit,
720
+ `expected a tracker-name-collision diagnostic; got: ${JSON.stringify(diagnostics)}`,
721
+ );
722
+ assert.equal(hit.type, "error");
723
+ assert.match(hit.message, /tracker name collision/);
724
+ assert.match(hit.message, /branch/);
725
+ });
726
+
727
+ it("suppresses the tracker-name-collision diagnostic when disabledPlugins covers one of the participants", () => {
728
+ // The diagnostic message itself directs the user to "rename one
729
+ // tracker or disable one plugin". Following that remedy must
730
+ // resolve the diagnostic in the same edit — same disable-then-
731
+ // detect ordering as plugin-name-collision and rule-name-collision.
732
+ const t = { initial: 0, unknown: -1, modifiers: {} } as const;
733
+ const a: Plugin = { name: "pa", trackers: { branch: t as never } };
734
+ const b: Plugin = { name: "pb", trackers: { branch: t as never } };
735
+ const { diagnostics } = buildConfig([
736
+ { plugins: [a, b], disabledPlugins: ["pa"] },
737
+ ]);
738
+ assert.equal(
739
+ diagnostics.filter((d) => d.kind === "tracker-name-collision").length,
740
+ 0,
741
+ `disabling one participant should suppress the diagnostic; got: ${JSON.stringify(diagnostics)}`,
742
+ );
743
+ });
744
+
745
+ it("still emits a tracker-name-collision diagnostic without the disable", () => {
746
+ // Inverse of the previous test — same colliding plugins, no
747
+ // disabledPlugins, the diagnostic still fires.
748
+ const t = { initial: 0, unknown: -1, modifiers: {} } as const;
749
+ const a: Plugin = { name: "pa", trackers: { branch: t as never } };
750
+ const b: Plugin = { name: "pb", trackers: { branch: t as never } };
751
+ const { diagnostics } = buildConfig([{ plugins: [a, b] }]);
752
+ assert.equal(
753
+ diagnostics.filter((d) => d.kind === "tracker-name-collision").length,
754
+ 1,
755
+ );
756
+ });
757
+
758
+ it("drops a colliding plugin from collision detection when disabledPlugins covers it", () => {
759
+ // Disabling 'git' in any layer should suppress the cross-layer
760
+ // duplicate-plugin diagnostic for 'git'. The user's natural
761
+ // workflow on seeing the warning is to add the plugin to
762
+ // disabledPlugins; that edit alone should resolve the warning.
763
+ // The plugin still appears in the merged output (downstream
764
+ // surfaces tag it as disabled); collision detection is the only
765
+ // thing that gets suppressed.
766
+ const gitInner: Plugin = { name: "git", rules: [] };
767
+ const gitOuter: Plugin = { name: "git", rules: [] };
768
+ const { config: merged, diagnostics } = buildConfig([
769
+ { plugins: [gitInner], disabledPlugins: ["git"] },
770
+ { plugins: [gitOuter] },
771
+ ]);
772
+ assert.equal(
773
+ merged.plugins?.length,
774
+ 1,
775
+ "first-seen plugin should still survive into the merged plugin list",
776
+ );
777
+ assert.equal(merged.plugins?.[0]?.name, "git");
778
+ assert.deepEqual(merged.disabledPlugins, ["git"]);
779
+ assert.equal(
780
+ diagnostics.filter((d) => d.kind === "plugin-name-collision").length,
781
+ 0,
782
+ `expected no plugin-name-collision diagnostic; got: ${JSON.stringify(diagnostics)}`,
783
+ );
784
+ });
785
+
786
+ it("still emits a plugin-name-collision diagnostic without the disable", () => {
787
+ // Inverse of the previous test — same colliding plugins, no
788
+ // disabledPlugins, the cross-layer diagnostic still fires.
789
+ const gitInner: Plugin = { name: "git", rules: [] };
790
+ const gitOuter: Plugin = { name: "git", rules: [] };
791
+ const { diagnostics } = buildConfig([
792
+ { plugins: [gitInner] },
793
+ { plugins: [gitOuter] },
794
+ ]);
795
+ assert.equal(
796
+ diagnostics.filter((d) => d.kind === "plugin-name-collision").length,
797
+ 1,
798
+ );
799
+ });
800
+
801
+ it("drops a within-layer duplicate rule from collision detection when disabledRules covers it", () => {
802
+ const { config: merged, diagnostics } = buildConfig([
803
+ {
804
+ disabledRules: ["dup"],
805
+ rules: [
806
+ {
807
+ name: "dup",
808
+ tool: "bash",
809
+ field: "command",
810
+ pattern: /^FIRST/,
811
+ reason: "first",
812
+ },
813
+ {
814
+ name: "dup",
815
+ tool: "bash",
816
+ field: "command",
817
+ pattern: /^SECOND/,
818
+ reason: "second",
819
+ },
820
+ ],
821
+ },
822
+ ]);
823
+ assert.equal(
824
+ merged.rules?.length,
825
+ 1,
826
+ "first-seen rule should still survive into the merged rule list",
827
+ );
828
+ assert.equal(merged.rules?.[0]?.name, "dup");
829
+ assert.equal(
830
+ diagnostics.filter((d) => d.kind === "rule-name-collision").length,
831
+ 0,
832
+ `expected no rule-name-collision diagnostic; got: ${JSON.stringify(diagnostics)}`,
833
+ );
834
+ });
835
+
836
+ it("applies `defaults` as the outermost layer", () => {
837
+ const { config: merged } = buildConfig(
838
+ [
839
+ {
840
+ rules: [
841
+ {
842
+ name: "user",
843
+ tool: "bash",
844
+ field: "command",
845
+ pattern: /u/,
846
+ reason: "u",
847
+ },
848
+ ],
849
+ },
850
+ ],
851
+ {
852
+ rules: [
853
+ {
854
+ name: "built-in",
855
+ tool: "bash",
856
+ field: "command",
857
+ pattern: /b/,
858
+ reason: "b",
859
+ },
860
+ ],
861
+ defaultNoOverride: true,
862
+ },
863
+ );
864
+ assert.deepEqual(merged.rules?.map((r) => r.name).sort(), [
865
+ "built-in",
866
+ "user",
867
+ ]);
868
+ assert.equal(merged.defaultNoOverride, true);
869
+ });
870
+
871
+ it("user rule shadows a defaults rule of the same name", () => {
872
+ const { config: merged } = buildConfig(
873
+ [
874
+ {
875
+ rules: [
876
+ {
877
+ name: "shared",
878
+ tool: "bash",
879
+ field: "command",
880
+ pattern: /USER/,
881
+ reason: "user",
882
+ },
883
+ ],
884
+ },
885
+ ],
886
+ {
887
+ rules: [
888
+ {
889
+ name: "shared",
890
+ tool: "bash",
891
+ field: "command",
892
+ pattern: /DEFAULT/,
893
+ reason: "default",
894
+ },
895
+ ],
896
+ },
897
+ );
898
+ assert.equal(merged.rules?.length, 1);
899
+ assert.equal(merged.rules?.[0]?.reason, "user");
900
+ });
901
+ });
902
+
903
+ // ---------------------------------------------------------------------------
904
+ // loadSteeringConfig — end-to-end
905
+ // ---------------------------------------------------------------------------
906
+
907
+ describe("loader: loadSteeringConfig", () => {
908
+ let tmp: string;
909
+ let origWarn: typeof console.warn;
910
+ useIsolatedHome("pi-steering-v2-end2end-", (t) => {
911
+ tmp = t;
912
+ });
913
+
914
+ beforeEach(() => {
915
+ origWarn = console.warn;
916
+ console.warn = () => {};
917
+ });
918
+ afterEach(() => {
919
+ console.warn = origWarn;
920
+ });
921
+
922
+ it("loads + merges a single-layer project", async () => {
923
+ const cwd = join(tmp, "p");
924
+ mkdirSync(cwd, { recursive: true });
925
+ writeConfig(
926
+ join(cwd, ".pi", "steering.ts"),
927
+ configModule(
928
+ `{ rules: [{ name: "r", tool: "bash", field: "command", pattern: "^git", reason: "r" }] }`,
929
+ ),
930
+ );
931
+ const { config: merged, diagnostics } = await loadSteeringConfig(cwd);
932
+ assert.equal(merged.rules?.length, 1);
933
+ assert.equal(merged.rules?.[0]?.name, "r");
934
+ assert.deepEqual(diagnostics, []);
935
+ });
936
+
937
+ it("applies caller-supplied defaults when no layer sets a field", async () => {
938
+ const cwd = join(tmp, "p");
939
+ mkdirSync(cwd, { recursive: true });
940
+ const { config: merged, diagnostics } = await loadSteeringConfig(cwd, {
941
+ defaultNoOverride: true,
942
+ rules: [
943
+ {
944
+ name: "built-in",
945
+ tool: "bash",
946
+ field: "command",
947
+ pattern: /b/,
948
+ reason: "b",
949
+ },
950
+ ],
951
+ });
952
+ assert.equal(merged.defaultNoOverride, true);
953
+ assert.equal(merged.rules?.[0]?.name, "built-in");
954
+ assert.deepEqual(diagnostics, []);
955
+ });
956
+
957
+ it("surfaces both loader-side and merge-side diagnostics in a single array", async () => {
958
+ // Stage a dual-form coexistence (loader-side warning) plus a
959
+ // within-layer rule-name collision (merge-side warning) so we
960
+ // can confirm both streams flow through the wrapper.
961
+ const cwd = join(tmp, "p");
962
+ mkdirSync(cwd, { recursive: true });
963
+ writeConfig(join(cwd, ".pi", "steering.ts"), configModule("{}"));
964
+ writeConfig(
965
+ join(cwd, ".pi", "steering", "index.ts"),
966
+ configModule(
967
+ `{ rules: [
968
+ { name: "dup", tool: "bash", field: "command", pattern: /^A/, reason: "first" },
969
+ { name: "dup", tool: "bash", field: "command", pattern: /^B/, reason: "second" },
970
+ ] }`,
971
+ ),
972
+ );
973
+ const { diagnostics } = await loadSteeringConfig(cwd);
974
+ assert.ok(
975
+ diagnostics.some((d) => d.kind === "layer-form-coexistence"),
976
+ `expected a layer-form-coexistence diagnostic; got: ${JSON.stringify(diagnostics)}`,
977
+ );
978
+ assert.ok(
979
+ diagnostics.some((d) => d.kind === "rule-name-collision"),
980
+ `expected a rule-name-collision diagnostic; got: ${JSON.stringify(diagnostics)}`,
981
+ );
982
+ });
983
+
984
+ it("surfaces plugin-merger-side warnings (predicate-collision)", async () => {
985
+ // External embedders calling `loadSteeringConfig` for their own
986
+ // pre-flight check or bridge wiring need to see merger-side
987
+ // diagnostics, not just loader-side ones — otherwise their lint
988
+ // pass false-greens on configs that production refuses to start.
989
+ const cwd = join(tmp, "p");
990
+ mkdirSync(cwd, { recursive: true });
991
+ writeConfig(
992
+ join(cwd, ".pi", "steering.ts"),
993
+ configModule(
994
+ `{ plugins: [
995
+ { name: "p1", predicates: { branch: () => true } },
996
+ { name: "p2", predicates: { branch: () => false } },
997
+ ] }`,
998
+ ),
999
+ );
1000
+ const { diagnostics } = await loadSteeringConfig(cwd);
1001
+ const hit = diagnostics.find((d) => d.kind === "predicate-collision");
1002
+ assert.ok(
1003
+ hit,
1004
+ `expected a predicate-collision diagnostic; got: ${JSON.stringify(diagnostics)}`,
1005
+ );
1006
+ assert.equal(hit.type, "warning");
1007
+ });
1008
+
1009
+ it("surfaces plugin-merger-side errors (reserved-tracker-name)", async () => {
1010
+ // reserved-tracker-name is an error-class diagnostic produced
1011
+ // inside `resolvePlugins`. Without the plugin merger wired in, an
1012
+ // embedder using `loadSteeringConfig` as a pre-flight would never
1013
+ // see it — production's `buildSessionRuntime` would refuse to
1014
+ // start on the same config.
1015
+ const cwd = join(tmp, "p");
1016
+ mkdirSync(cwd, { recursive: true });
1017
+ writeConfig(
1018
+ join(cwd, ".pi", "steering.ts"),
1019
+ configModule(
1020
+ `{ plugins: [
1021
+ {
1022
+ name: "reserved-name-plugin",
1023
+ trackers: { events: { initial: "?", unknown: "unknown", modifiers: {}, subshellSemantics: "isolated" } },
1024
+ },
1025
+ ] }`,
1026
+ ),
1027
+ );
1028
+ const { diagnostics } = await loadSteeringConfig(cwd);
1029
+ const hit = diagnostics.find((d) => d.kind === "reserved-tracker-name");
1030
+ assert.ok(
1031
+ hit,
1032
+ `expected a reserved-tracker-name diagnostic; got: ${JSON.stringify(diagnostics)}`,
1033
+ );
1034
+ assert.equal(hit.type, "error");
1035
+ });
1036
+
1037
+ it("surfaces malformed user-config rule names as invalid-name diagnostics (does NOT throw)", async () => {
1038
+ // User-config name validation runs inside the shared merge-pipeline
1039
+ // helper between `buildConfig` and `resolvePlugins`, so external
1040
+ // embedders calling `loadSteeringConfig` get the same `invalid-name`
1041
+ // diagnostic stream as the runtime / harness / CLI surfaces.
1042
+ const cwd = join(tmp, "p");
1043
+ mkdirSync(cwd, { recursive: true });
1044
+ writeConfig(
1045
+ join(cwd, ".pi", "steering.ts"),
1046
+ configModule(
1047
+ `{ rules: [
1048
+ { name: "phony] BAD", tool: "bash", field: "command", pattern: /^never$/, reason: "r" },
1049
+ ] }`,
1050
+ ),
1051
+ );
1052
+ const { diagnostics } = await loadSteeringConfig(cwd);
1053
+ const hit = diagnostics.find((d) => d.kind === "invalid-name");
1054
+ assert.ok(
1055
+ hit,
1056
+ `expected an invalid-name diagnostic; got: ${JSON.stringify(diagnostics)}`,
1057
+ );
1058
+ assert.equal(hit.type, "error");
1059
+ assert.match(hit.message, /\(user config\)/);
1060
+ });
1061
+
1062
+ it("surfaces BOTH a tracker-name-collision AND a malformed user-config rule name in one load", async () => {
1063
+ // Combined error: tracker-name-collision (merge-side) plus a
1064
+ // malformed user-config rule name. Pins that user-config name
1065
+ // validation runs unconditionally so embedders see both in one
1066
+ // `loadSteeringConfig` call.
1067
+ const cwd = join(tmp, "p");
1068
+ mkdirSync(cwd, { recursive: true });
1069
+ writeConfig(
1070
+ join(cwd, ".pi", "steering.ts"),
1071
+ configModule(
1072
+ `{
1073
+ plugins: [
1074
+ { name: "pa", trackers: { branch: { initial: "?", unknown: "unknown", modifiers: {}, subshellSemantics: "isolated" } } },
1075
+ { name: "pb", trackers: { branch: { initial: "?", unknown: "unknown", modifiers: {}, subshellSemantics: "isolated" } } },
1076
+ ],
1077
+ rules: [
1078
+ { name: "phony] BAD", tool: "bash", field: "command", pattern: /^never$/, reason: "r" },
1079
+ ],
1080
+ }`,
1081
+ ),
1082
+ );
1083
+ const { diagnostics } = await loadSteeringConfig(cwd);
1084
+ assert.ok(
1085
+ diagnostics.some((d) => d.kind === "tracker-name-collision"),
1086
+ `expected a tracker-name-collision diagnostic; got: ${JSON.stringify(diagnostics)}`,
1087
+ );
1088
+ assert.ok(
1089
+ diagnostics.some(
1090
+ (d) =>
1091
+ d.kind === "invalid-name" &&
1092
+ /phony\] BAD/.test(d.message) &&
1093
+ /\(user config\)/.test(d.message),
1094
+ ),
1095
+ `expected an invalid-name diagnostic for the malformed user-config rule; got: ${JSON.stringify(diagnostics)}`,
1096
+ );
1097
+ });
1098
+ });