@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,790 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Tests for `resolvePlugins` — the plugin merger.
6
+ *
7
+ * Covers the collision semantics the ADR pins down: first-wins on soft
8
+ * collisions (predicate / observer / rule), hard error on tracker name
9
+ * collision, proper layering of trackerExtensions on top of registered
10
+ * trackers, and the config-level `disabledRules` / `disabledPlugins` filters.
11
+ */
12
+
13
+ import assert from "node:assert/strict";
14
+ import { describe, it } from "node:test";
15
+ import type { Modifier, Tracker } from "@cad0p/unbash-walker";
16
+ import {
17
+ resolvePlugins,
18
+ validateName,
19
+ validateUserConfigNames,
20
+ } from "./plugin-merger.ts";
21
+ import type { Observer, Plugin, Rule } from "./schema.ts";
22
+
23
+ /** Build a minimal observer with a recognizable onResult. */
24
+ function mkObserver(name: string): Observer {
25
+ return { name, onResult: () => {} };
26
+ }
27
+
28
+ /** Build a minimal bash rule. */
29
+ function mkRule(name: string): Rule {
30
+ return {
31
+ name,
32
+ tool: "bash",
33
+ field: "command",
34
+ pattern: "^never$",
35
+ reason: `rule ${name}`,
36
+ };
37
+ }
38
+
39
+ /** Build a minimal tracker with deterministic initial / unknown. */
40
+ function mkTracker(label: string): Tracker<string> {
41
+ return {
42
+ initial: `${label}:init`,
43
+ unknown: `${label}:unknown`,
44
+ modifiers: {},
45
+ subshellSemantics: "isolated",
46
+ };
47
+ }
48
+
49
+ /** A sentinel per-command modifier we can identify by identity. */
50
+ function mkModifier(tag: string): Modifier<string> {
51
+ return {
52
+ scope: "per-command",
53
+ apply: (_args, current) => `${current}+${tag}`,
54
+ };
55
+ }
56
+
57
+ describe("resolvePlugins: empty input", () => {
58
+ it("returns an empty ResolvedPluginState for no plugins", () => {
59
+ const state = resolvePlugins([], {});
60
+ assert.deepEqual(state.predicates, {});
61
+ assert.deepEqual(state.observers, []);
62
+ assert.deepEqual(state.trackers, {});
63
+ assert.deepEqual(state.trackerModifiers, {});
64
+ assert.deepEqual(state.composedTrackers, {});
65
+ assert.deepEqual(state.rules, []);
66
+ assert.deepEqual(state.diagnostics, []);
67
+ });
68
+ });
69
+
70
+ describe("resolvePlugins: single plugin surface", () => {
71
+ it("propagates predicates / observers / trackers / rules unchanged", () => {
72
+ const tracker = mkTracker("t");
73
+ const obs = mkObserver("obs");
74
+ const rule = mkRule("r");
75
+ const predicate = () => true;
76
+
77
+ const plugin: Plugin = {
78
+ name: "p",
79
+ predicates: { foo: predicate },
80
+ observers: [obs],
81
+ trackers: { t: tracker as Tracker<unknown> },
82
+ rules: [rule],
83
+ };
84
+
85
+ const state = resolvePlugins([plugin], {});
86
+ assert.equal(state.predicates["foo"], predicate);
87
+ assert.deepEqual(state.observers, [obs]);
88
+ assert.equal(state.trackers["t"], tracker);
89
+ // No extensions → composed tracker is identity-equal when no extras
90
+ // were layered on.
91
+ assert.equal(state.composedTrackers["t"], tracker);
92
+ assert.deepEqual(state.rules, [rule]);
93
+ assert.deepEqual(state.rulePluginOwners, { r: "p" });
94
+ assert.deepEqual(state.diagnostics, []);
95
+ });
96
+ });
97
+
98
+ describe("resolvePlugins: rulePluginOwners", () => {
99
+ it("maps each plugin rule name to its originating plugin", () => {
100
+ const p1: Plugin = { name: "plugin-a", rules: [mkRule("rule-a")] };
101
+ const p2: Plugin = { name: "plugin-b", rules: [mkRule("rule-b")] };
102
+ const state = resolvePlugins([p1, p2], {});
103
+ assert.deepEqual(state.rulePluginOwners, {
104
+ "rule-a": "plugin-a",
105
+ "rule-b": "plugin-b",
106
+ });
107
+ });
108
+
109
+ it("first-wins collision keeps the first owner", () => {
110
+ const p1: Plugin = { name: "first", rules: [mkRule("dup")] };
111
+ const p2: Plugin = { name: "second", rules: [mkRule("dup")] };
112
+ const state = resolvePlugins([p1, p2], {});
113
+ assert.deepEqual(state.rulePluginOwners, { dup: "first" });
114
+ });
115
+ });
116
+
117
+ describe("resolvePlugins: predicate collision (soft)", () => {
118
+ it("keeps first-registered predicate, emits warning", () => {
119
+ const keptHandler = () => true;
120
+ const droppedHandler = () => false;
121
+ const p1: Plugin = { name: "first", predicates: { branch: keptHandler } };
122
+ const p2: Plugin = {
123
+ name: "second",
124
+ predicates: { branch: droppedHandler },
125
+ };
126
+
127
+ const state = resolvePlugins([p1, p2], {});
128
+ assert.equal(state.predicates["branch"], keptHandler);
129
+ assert.equal(state.diagnostics.length, 1);
130
+ assert.equal(state.diagnostics[0]?.kind, "predicate-collision");
131
+ assert.match(state.diagnostics[0]?.message ?? "", /when\.branch/);
132
+ assert.match(state.diagnostics[0]?.message ?? "", /first/);
133
+ assert.match(state.diagnostics[0]?.message ?? "", /second/);
134
+ });
135
+ });
136
+
137
+ describe("resolvePlugins: observer collision (soft)", () => {
138
+ it("keeps first-registered observer, emits warning", () => {
139
+ const kept = mkObserver("sync-done");
140
+ const dropped = mkObserver("sync-done");
141
+ const p1: Plugin = { name: "first", observers: [kept] };
142
+ const p2: Plugin = { name: "second", observers: [dropped] };
143
+
144
+ const state = resolvePlugins([p1, p2], {});
145
+ assert.deepEqual(state.observers, [kept]);
146
+ assert.equal(state.observers[0], kept, "first-registered instance kept");
147
+ assert.equal(state.diagnostics.length, 1);
148
+ assert.equal(state.diagnostics[0]?.kind, "observer-collision");
149
+ });
150
+ });
151
+
152
+ describe("resolvePlugins: rule collision (soft)", () => {
153
+ it("keeps first-registered rule, emits warning", () => {
154
+ const kept = mkRule("shared-name");
155
+ const dropped = mkRule("shared-name");
156
+ const p1: Plugin = { name: "first", rules: [kept] };
157
+ const p2: Plugin = { name: "second", rules: [dropped] };
158
+
159
+ const state = resolvePlugins([p1, p2], {});
160
+ assert.equal(state.rules.length, 1);
161
+ assert.equal(state.rules[0], kept);
162
+ assert.equal(state.diagnostics.length, 1);
163
+ assert.equal(state.diagnostics[0]?.kind, "rule-collision");
164
+ });
165
+ });
166
+
167
+ describe("resolvePlugins: tracker collision (error-class diagnostic)", () => {
168
+ it("records an error-class tracker-name-collision diagnostic when two plugins register the same tracker name", () => {
169
+ const p1: Plugin = {
170
+ name: "first",
171
+ trackers: { branch: mkTracker("one") as Tracker<unknown> },
172
+ };
173
+ const p2: Plugin = {
174
+ name: "second",
175
+ trackers: { branch: mkTracker("two") as Tracker<unknown> },
176
+ };
177
+
178
+ const state = resolvePlugins([p1, p2], {});
179
+ const hit = state.diagnostics.find(
180
+ (d) => d.kind === "tracker-name-collision",
181
+ );
182
+ assert.ok(
183
+ hit,
184
+ `expected a tracker-name-collision diagnostic; got: ${JSON.stringify(state.diagnostics)}`,
185
+ );
186
+ assert.equal(hit.type, "error");
187
+ assert.match(hit.message, /tracker name collision/);
188
+ assert.match(hit.message, /"first".*"second"/);
189
+ // Direct callers (those that bypass `buildConfig`) check
190
+ // `result.diagnostics.some(d => d.type === "error")` before using
191
+ // the resolved state — same contract as `loadHarness`.
192
+ assert.ok(
193
+ state.diagnostics.some((d) => d.type === "error"),
194
+ "expected at least one error-class diagnostic",
195
+ );
196
+ // The colliding tracker is dropped from the second plugin; the first
197
+ // plugin's registration wins (matches the loader's first-wins ordering).
198
+ assert.equal(
199
+ (state.trackers.branch as Tracker<string>).initial,
200
+ "one:init",
201
+ );
202
+ });
203
+
204
+ it("preserves warning-class diagnostics from earlier plugins alongside the tracker-name-collision error", () => {
205
+ // Convert-to-diagnostic guarantees: a tracker-name collision no
206
+ // longer aborts the merger. Earlier plugins' warning-class
207
+ // diagnostics (e.g. duplicate predicate names) survive on the
208
+ // returned `diagnostics` array, so direct callers of
209
+ // `resolvePlugins` see the full picture in one read instead of
210
+ // catching a throw and losing visibility into prior issues.
211
+ const p1: Plugin = {
212
+ name: "first",
213
+ predicates: { dup: () => true },
214
+ trackers: { branch: mkTracker("one") as Tracker<unknown> },
215
+ };
216
+ const p2: Plugin = {
217
+ name: "second",
218
+ predicates: { dup: () => false },
219
+ trackers: { branch: mkTracker("two") as Tracker<unknown> },
220
+ };
221
+ const state = resolvePlugins([p1, p2], {});
222
+ assert.ok(
223
+ state.diagnostics.some((d) => d.kind === "predicate-collision"),
224
+ "expected the predicate-collision warning to survive alongside the tracker error",
225
+ );
226
+ assert.ok(
227
+ state.diagnostics.some((d) => d.kind === "tracker-name-collision"),
228
+ "expected the tracker-name-collision error",
229
+ );
230
+ });
231
+
232
+ it('records an error-class diagnostic when a plugin registers the reserved tracker name "events"', () => {
233
+ // `walkerState.events` is written by the evaluator's speculative-
234
+ // entry synthesis pass (see evaluator.ts `prepareBashState`). A
235
+ // plugin-registered `events` tracker would be silently clobbered
236
+ // when the evaluator merges synthesized entries in, breaking
237
+ // `when.happened` with `in: "tool_call"`. Schema JSDoc promises
238
+ // rejection; this test holds the promise honest.
239
+ const p: Plugin = {
240
+ name: "broken",
241
+ trackers: { events: mkTracker("x") as Tracker<unknown> },
242
+ };
243
+ const state = resolvePlugins([p], {});
244
+ const hit = state.diagnostics.find(
245
+ (d) => d.kind === "reserved-tracker-name",
246
+ );
247
+ assert.ok(
248
+ hit,
249
+ `expected a reserved-tracker-name diagnostic; got: ${JSON.stringify(state.diagnostics)}`,
250
+ );
251
+ assert.equal(hit.type, "error");
252
+ assert.match(hit.message, /tracker name "events" is reserved/);
253
+ // The reserved tracker is dropped, not added to the trackers map.
254
+ assert.ok(!("events" in state.trackers));
255
+ });
256
+ });
257
+
258
+ describe("resolvePlugins: tracker extensions", () => {
259
+ it("layers extension modifiers on top of the declaring tracker", () => {
260
+ const origMod = mkModifier("orig");
261
+ const extraMod = mkModifier("extra");
262
+ const tracker: Tracker<unknown> = {
263
+ initial: "init",
264
+ unknown: "unknown",
265
+ modifiers: { git: origMod as Modifier<unknown> },
266
+ subshellSemantics: "isolated",
267
+ };
268
+ const owner: Plugin = { name: "owner", trackers: { cwd: tracker } };
269
+ const extender: Plugin = {
270
+ name: "extender",
271
+ trackerExtensions: {
272
+ cwd: { git: extraMod as Modifier<unknown> },
273
+ },
274
+ };
275
+
276
+ const state = resolvePlugins([owner, extender], {});
277
+ assert.deepEqual(state.diagnostics, []);
278
+ assert.equal(state.trackers["cwd"], tracker, "raw tracker preserved");
279
+ const composed = state.composedTrackers["cwd"]!;
280
+ assert.notEqual(
281
+ composed,
282
+ tracker,
283
+ "composed tracker is a new object (non-mutating)",
284
+ );
285
+ const composedMods = composed.modifiers["git"];
286
+ assert.ok(Array.isArray(composedMods), "composed git entry is array");
287
+ const list = composedMods as Modifier<unknown>[];
288
+ assert.equal(list.length, 2);
289
+ assert.equal(list[0], origMod, "original modifier retained first");
290
+ assert.equal(list[1], extraMod, "extension modifier appended");
291
+ });
292
+
293
+ it("warns and drops extensions targeting an unregistered tracker", () => {
294
+ const extender: Plugin = {
295
+ name: "extender",
296
+ trackerExtensions: {
297
+ nosuch: { git: mkModifier("x") as Modifier<unknown> },
298
+ },
299
+ };
300
+ const state = resolvePlugins([extender], {});
301
+ assert.equal(state.diagnostics.length, 1);
302
+ assert.equal(state.diagnostics[0]?.kind, "extension-orphan");
303
+ assert.match(state.diagnostics[0]?.message ?? "", /"nosuch"/);
304
+ assert.deepEqual(state.trackerModifiers, {});
305
+ assert.deepEqual(state.composedTrackers, {});
306
+ });
307
+
308
+ it("keeps extensions targeting knownBuiltinTrackers (no warning, modifiers preserved)", () => {
309
+ // The caller declares `"cwd"` as a built-in tracker name. The
310
+ // merger still doesn't OWN the tracker (cwd isn't in any
311
+ // plugin's `trackers` map, so `composedTrackers.cwd` stays
312
+ // undefined), but the extension modifiers are preserved in
313
+ // `trackerModifiers.cwd` for the caller to layer onto its own
314
+ // built-in tracker. The evaluator uses this path for the
315
+ // walker's built-in `cwdTracker`.
316
+ const extender: Plugin = {
317
+ name: "extender",
318
+ trackerExtensions: {
319
+ cwd: { git: mkModifier("x") as Modifier<unknown> },
320
+ },
321
+ };
322
+ const state = resolvePlugins([extender], {}, ["cwd"]);
323
+ assert.equal(
324
+ state.diagnostics.filter((w) => w.kind === "extension-orphan").length,
325
+ 0,
326
+ "no orphan warning when the tracker name is declared built-in",
327
+ );
328
+ assert.ok("cwd" in state.trackerModifiers);
329
+ assert.ok(state.trackerModifiers["cwd"]?.["git"] !== undefined);
330
+ // Still NOT composed - the caller is responsible for composing
331
+ // these modifiers onto its own built-in tracker.
332
+ assert.ok(!("cwd" in state.composedTrackers));
333
+ });
334
+
335
+ it("accepts the array form on trackerExtensions values", () => {
336
+ const tracker: Tracker<unknown> = {
337
+ initial: "x",
338
+ unknown: "?",
339
+ modifiers: {},
340
+ subshellSemantics: "isolated",
341
+ };
342
+ const m1 = mkModifier("a");
343
+ const m2 = mkModifier("b");
344
+ const owner: Plugin = { name: "owner", trackers: { cwd: tracker } };
345
+ const extender: Plugin = {
346
+ name: "extender",
347
+ trackerExtensions: {
348
+ cwd: {
349
+ git: [m1, m2] as readonly Modifier<unknown>[],
350
+ },
351
+ },
352
+ };
353
+
354
+ const state = resolvePlugins([owner, extender], {});
355
+ const composed = state.composedTrackers["cwd"]!;
356
+ const list = composed.modifiers["git"] as Modifier<unknown>[];
357
+ assert.ok(Array.isArray(list));
358
+ assert.deepEqual(list, [m1, m2]);
359
+ });
360
+
361
+ it("appends modifiers from multiple plugins in registration order", () => {
362
+ const tracker: Tracker<unknown> = {
363
+ initial: "x",
364
+ unknown: "?",
365
+ modifiers: {},
366
+ subshellSemantics: "isolated",
367
+ };
368
+ const m1 = mkModifier("one");
369
+ const m2 = mkModifier("two");
370
+ const owner: Plugin = { name: "owner", trackers: { cwd: tracker } };
371
+ const ext1: Plugin = {
372
+ name: "ext1",
373
+ trackerExtensions: { cwd: { git: m1 as Modifier<unknown> } },
374
+ };
375
+ const ext2: Plugin = {
376
+ name: "ext2",
377
+ trackerExtensions: { cwd: { git: m2 as Modifier<unknown> } },
378
+ };
379
+
380
+ const state = resolvePlugins([owner, ext1, ext2], {});
381
+ const list = state.composedTrackers["cwd"]!.modifiers[
382
+ "git"
383
+ ] as Modifier<unknown>[];
384
+ assert.deepEqual(list, [m1, m2]);
385
+ });
386
+
387
+ it("leaves trackers unchanged when no extensions are registered", () => {
388
+ const tracker = mkTracker("cwd");
389
+ const plugin: Plugin = {
390
+ name: "only",
391
+ trackers: { cwd: tracker as Tracker<unknown> },
392
+ };
393
+ const state = resolvePlugins([plugin], {});
394
+ assert.equal(state.composedTrackers["cwd"], tracker);
395
+ });
396
+ });
397
+
398
+ describe("resolvePlugins: config filters", () => {
399
+ let origInfo: typeof console.info;
400
+ let infos: string[];
401
+
402
+ function captureInfos(): void {
403
+ origInfo = console.info;
404
+ infos = [];
405
+ console.info = (msg: unknown) => {
406
+ infos.push(String(msg));
407
+ };
408
+ }
409
+
410
+ function restoreInfos(): void {
411
+ console.info = origInfo;
412
+ }
413
+
414
+ it("applies config.disabledRules to plugin-shipped rules", () => {
415
+ const kept = mkRule("keep-me");
416
+ const dropped = mkRule("drop-me");
417
+ const plugin: Plugin = {
418
+ name: "p",
419
+ rules: [kept, dropped],
420
+ };
421
+ captureInfos();
422
+ try {
423
+ const state = resolvePlugins([plugin], { disabledRules: ["drop-me"] });
424
+ assert.equal(state.rules.length, 1);
425
+ assert.equal(state.rules[0]?.name, "keep-me");
426
+ // Disabling a plugin-shipped rule is by-design behavior, not a
427
+ // diagnostic-stream entry. Surfaced via console.info so authors
428
+ // debugging "why isn't my rule firing?" still get a breadcrumb.
429
+ assert.deepEqual(
430
+ state.diagnostics.filter((d) => d.message.includes("disabled")),
431
+ [],
432
+ );
433
+ assert.ok(
434
+ infos.some(
435
+ (m) =>
436
+ m.includes("[pi-steering]") &&
437
+ m.includes('rule "drop-me"') &&
438
+ m.includes("disabled via config.disabledRules"),
439
+ ),
440
+ `expected a console.info breadcrumb for the disabled rule; got: ${JSON.stringify(infos)}`,
441
+ );
442
+ } finally {
443
+ restoreInfos();
444
+ }
445
+ });
446
+
447
+ it("applies config.disabledPlugins to skip an entire plugin", () => {
448
+ const p1: Plugin = {
449
+ name: "git",
450
+ predicates: { branch: () => true },
451
+ rules: [mkRule("no-main-commit")],
452
+ observers: [mkObserver("sync-done")],
453
+ };
454
+ const p2: Plugin = {
455
+ name: "kept",
456
+ predicates: { other: () => true },
457
+ };
458
+ captureInfos();
459
+ try {
460
+ const state = resolvePlugins([p1, p2], { disabledPlugins: ["git"] });
461
+
462
+ assert.deepEqual(state.observers, []);
463
+ assert.deepEqual(state.rules, []);
464
+ assert.ok(!("branch" in state.predicates));
465
+ assert.ok("other" in state.predicates);
466
+ // No diagnostic for the disabled plugin — by-design behavior.
467
+ assert.deepEqual(
468
+ state.diagnostics.filter((d) => d.message.includes("disabled")),
469
+ [],
470
+ );
471
+ assert.ok(
472
+ infos.some(
473
+ (m) =>
474
+ m.includes("[pi-steering]") &&
475
+ m.includes('plugin "git"') &&
476
+ m.includes("disabled via config.disabledPlugins"),
477
+ ),
478
+ `expected a console.info breadcrumb for the disabled plugin; got: ${JSON.stringify(infos)}`,
479
+ );
480
+ } finally {
481
+ restoreInfos();
482
+ }
483
+ });
484
+
485
+ it("disabledPlugins covering a default plugin does NOT throw under strict default", () => {
486
+ // Regression for the case where `disabledPlugins: ["git"]`
487
+ // previously emitted a `plugin-disabled` warning that strict
488
+ // mode (the new default) escalated to a thrown error, breaking
489
+ // the most common opt-out path on first activation.
490
+ captureInfos();
491
+ try {
492
+ const state = resolvePlugins(
493
+ [{ name: "git", predicates: { branch: () => true } }],
494
+ { disabledPlugins: ["git"] },
495
+ );
496
+ assert.deepEqual(state.diagnostics, []);
497
+ } finally {
498
+ restoreInfos();
499
+ }
500
+ });
501
+ });
502
+
503
+ describe("resolvePlugins: result immutability", () => {
504
+ it("doesn't mutate the input plugin array or its trackers", () => {
505
+ const origMods = { cd: mkModifier("cd") as Modifier<unknown> };
506
+ const tracker: Tracker<unknown> = {
507
+ initial: "/",
508
+ unknown: "unknown",
509
+ modifiers: origMods,
510
+ subshellSemantics: "isolated",
511
+ };
512
+ const owner: Plugin = { name: "owner", trackers: { cwd: tracker } };
513
+ const extender: Plugin = {
514
+ name: "extender",
515
+ trackerExtensions: {
516
+ cwd: { git: mkModifier("git") as Modifier<unknown> },
517
+ },
518
+ };
519
+ const plugins = [owner, extender];
520
+
521
+ const snapshot = JSON.stringify({
522
+ pluginsLen: plugins.length,
523
+ modifierKeys: Object.keys(tracker.modifiers),
524
+ });
525
+ resolvePlugins(plugins, {});
526
+ assert.equal(
527
+ JSON.stringify({
528
+ pluginsLen: plugins.length,
529
+ modifierKeys: Object.keys(tracker.modifiers),
530
+ }),
531
+ snapshot,
532
+ "plugins array / tracker.modifiers not mutated",
533
+ );
534
+ assert.equal(tracker.modifiers["cd"], origMods["cd"]);
535
+ assert.ok(
536
+ !("git" in tracker.modifiers),
537
+ "extension modifier did not bleed into original tracker",
538
+ );
539
+ });
540
+ });
541
+
542
+ // ---------------------------------------------------------------------------
543
+ // S3: name validation (rules, plugins, observers)
544
+ // ---------------------------------------------------------------------------
545
+
546
+ describe("S3: validateName", () => {
547
+ const okNames = [
548
+ "no-force-push",
549
+ "must_read_docs",
550
+ "rule1",
551
+ "1-critical",
552
+ "2026-release",
553
+ "A",
554
+ "pi-steering_git",
555
+ ];
556
+ for (const n of okNames) {
557
+ it(`accepts ${JSON.stringify(n)}`, () => {
558
+ assert.equal(validateName("rule", n), undefined);
559
+ });
560
+ }
561
+
562
+ const badNames: Array<[string, unknown]> = [
563
+ ["empty string", ""],
564
+ ["leading dash", "-bad"],
565
+ ["leading underscore", "_bad"],
566
+ ["contains space", "bad name"],
567
+ ["contains tab", "bad\tname"],
568
+ ["contains newline", "bad\nname"],
569
+ ["contains ] (block-reason tag forge)", "phony] ALL CLEAR [real"],
570
+ ["contains @", "rule@source"],
571
+ ["contains .", "ns.rule"],
572
+ ["contains /", "a/b"],
573
+ ["contains colon", "steering:rule"],
574
+ ["non-string (number)", 42],
575
+ ["non-string (undefined)", undefined],
576
+ ["non-string (null)", null],
577
+ ];
578
+ for (const [label, value] of badNames) {
579
+ it(`rejects ${label}`, () => {
580
+ const d = validateName("rule", value);
581
+ assert.ok(d, `expected diagnostic for ${label}`);
582
+ assert.equal(d?.type, "error");
583
+ assert.equal(d?.kind, "invalid-name");
584
+ assert.match(d!.message, /contains disallowed characters/);
585
+ });
586
+ }
587
+
588
+ it("diagnostic message names the kind (rule / plugin / observer)", () => {
589
+ assert.match(
590
+ validateName("rule", "bad name")!.message,
591
+ /^rule name "bad name".*disallowed/,
592
+ );
593
+ assert.match(
594
+ validateName("plugin", "bad name")!.message,
595
+ /^plugin name "bad name".*disallowed/,
596
+ );
597
+ assert.match(
598
+ validateName("observer", "bad name")!.message,
599
+ /^observer name "bad name".*disallowed/,
600
+ );
601
+ });
602
+
603
+ it("diagnostic message includes the context hint when provided", () => {
604
+ assert.match(
605
+ validateName("rule", "bad name", 'plugin "git"')!.message,
606
+ /^rule name "bad name" \(plugin "git"\)/,
607
+ );
608
+ });
609
+ });
610
+
611
+ describe("S3: validateUserConfigNames", () => {
612
+ it("returns no diagnostics for a clean user-config", () => {
613
+ const out = validateUserConfigNames([
614
+ {
615
+ rules: [
616
+ {
617
+ name: "clean-rule",
618
+ tool: "bash",
619
+ field: "command",
620
+ pattern: /a/,
621
+ reason: "r",
622
+ },
623
+ ],
624
+ observers: [{ name: "clean_obs", onResult: async () => {} }],
625
+ },
626
+ ]);
627
+ assert.equal(out.length, 0);
628
+ });
629
+
630
+ it("flags a malformed user-config rule name as an invalid-name diagnostic", () => {
631
+ const out = validateUserConfigNames([
632
+ {
633
+ rules: [
634
+ {
635
+ name: "phony] ALL CLEAR [real",
636
+ tool: "bash",
637
+ field: "command",
638
+ pattern: /a/,
639
+ reason: "r",
640
+ },
641
+ ],
642
+ },
643
+ ]);
644
+ assert.equal(out.length, 1);
645
+ assert.equal(out[0]?.kind, "invalid-name");
646
+ assert.equal(out[0]?.type, "error");
647
+ assert.match(
648
+ out[0]!.message,
649
+ /^rule name "phony\] ALL CLEAR \[real" \(user config\).*disallowed/,
650
+ );
651
+ });
652
+
653
+ it("flags a malformed user-config observer name as an invalid-name diagnostic", () => {
654
+ const out = validateUserConfigNames([
655
+ {
656
+ observers: [{ name: "evil] obs", onResult: async () => {} }],
657
+ },
658
+ ]);
659
+ assert.equal(out.length, 1);
660
+ assert.equal(out[0]?.kind, "invalid-name");
661
+ assert.equal(out[0]?.type, "error");
662
+ assert.match(
663
+ out[0]!.message,
664
+ /^observer name "evil\] obs" \(user config\).*disallowed/,
665
+ );
666
+ });
667
+
668
+ it("surfaces both rule and observer diagnostics in declaration order", () => {
669
+ const out = validateUserConfigNames([
670
+ {
671
+ rules: [
672
+ {
673
+ name: "bad rule",
674
+ tool: "bash",
675
+ field: "command",
676
+ pattern: /a/,
677
+ reason: "r",
678
+ },
679
+ ],
680
+ observers: [{ name: "bad obs", onResult: async () => {} }],
681
+ },
682
+ ]);
683
+ assert.equal(out.length, 2);
684
+ assert.match(out[0]!.message, /^rule name/);
685
+ assert.match(out[1]!.message, /^observer name/);
686
+ });
687
+
688
+ it("does NOT flag default rule names as `(user config)` — validator iterates over input layers, not the post-merge config", () => {
689
+ // Verify by passing an EMPTY layer array. The validator's single
690
+ // argument is `layers` (raw, never default-injected), so the
691
+ // shape itself proves default rules cannot leak in.
692
+ const out = validateUserConfigNames([]);
693
+ assert.equal(out.length, 0);
694
+ });
695
+
696
+ it("validates names across multiple layers", () => {
697
+ // Sanity check that the new layer-array shape works as
698
+ // expected: malformed names in any layer surface, with each
699
+ // layer contributing its own diagnostics in order.
700
+ const out = validateUserConfigNames([
701
+ {
702
+ rules: [
703
+ {
704
+ name: "bad rule a",
705
+ tool: "bash",
706
+ field: "command",
707
+ pattern: /a/,
708
+ reason: "r",
709
+ },
710
+ ],
711
+ },
712
+ {
713
+ observers: [{ name: "bad obs b", onResult: async () => {} }],
714
+ },
715
+ ]);
716
+ assert.equal(out.length, 2);
717
+ assert.match(out[0]!.message, /"bad rule a"/);
718
+ assert.match(out[1]!.message, /"bad obs b"/);
719
+ });
720
+ });
721
+
722
+ describe("S3: resolvePlugins records invalid plugin / rule / observer names as error-class diagnostics", () => {
723
+ it("records an invalid-name diagnostic for a malformed plugin name", () => {
724
+ const plugin: Plugin = {
725
+ name: "bad name",
726
+ };
727
+ const result = resolvePlugins([plugin], {});
728
+ const d = result.diagnostics.find((d) => d.kind === "invalid-name");
729
+ assert.ok(d, "expected invalid-name diagnostic");
730
+ assert.equal(d?.type, "error");
731
+ assert.match(d!.message, /^plugin name "bad name".*disallowed/);
732
+ });
733
+
734
+ it("records an invalid-name diagnostic for a malformed rule name inside a plugin", () => {
735
+ const plugin: Plugin = {
736
+ name: "git",
737
+ rules: [mkRule("phony] ALL CLEAR [real")],
738
+ };
739
+ const result = resolvePlugins([plugin], {});
740
+ const d = result.diagnostics.find((d) => d.kind === "invalid-name");
741
+ assert.ok(d, "expected invalid-name diagnostic");
742
+ assert.equal(d?.type, "error");
743
+ assert.match(
744
+ d!.message,
745
+ /^rule name "phony\] ALL CLEAR \[real" \(plugin "git"\).*disallowed/,
746
+ );
747
+ });
748
+
749
+ it("records an invalid-name diagnostic for a malformed observer name inside a plugin", () => {
750
+ const plugin: Plugin = {
751
+ name: "git",
752
+ observers: [mkObserver("bad name")],
753
+ };
754
+ const result = resolvePlugins([plugin], {});
755
+ const d = result.diagnostics.find((d) => d.kind === "invalid-name");
756
+ assert.ok(d, "expected invalid-name diagnostic");
757
+ assert.equal(d?.type, "error");
758
+ assert.match(
759
+ d!.message,
760
+ /^observer name "bad name" \(plugin "git"\).*disallowed/,
761
+ );
762
+ });
763
+
764
+ it("validates BEFORE applying disabledPlugins filter (malformed names still record a diagnostic)", () => {
765
+ // A malformed-named plugin still records a diagnostic even if the
766
+ // user tries to disable it. This matches the S3 intent: names are
767
+ // written to disk, and a malformed one is a config-author bug we
768
+ // want to surface loudly regardless of runtime opt-outs.
769
+ const plugin: Plugin = {
770
+ name: "bad name",
771
+ };
772
+ const result = resolvePlugins([plugin], { disabledPlugins: ["bad name"] });
773
+ const d = result.diagnostics.find((d) => d.kind === "invalid-name");
774
+ assert.ok(d, "expected invalid-name diagnostic");
775
+ });
776
+
777
+ it("skips a malformed-named plugin from downstream merger work", () => {
778
+ // A plugin with a malformed name records the diagnostic and is
779
+ // excluded from tracker / rule / observer registration so the bad
780
+ // name doesn't leak into downstream collision keys.
781
+ const plugin: Plugin = {
782
+ name: "bad name",
783
+ trackers: { branch: mkTracker("branch") },
784
+ rules: [mkRule("valid-rule")],
785
+ };
786
+ const result = resolvePlugins([plugin], {});
787
+ assert.equal(result.rules.length, 0);
788
+ assert.deepEqual(result.trackers, {});
789
+ });
790
+ });