@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,17 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Canonical stringification of a bash {@link CommandRef} for observer
6
+ * watch matching. Every call site must use this one implementation to
7
+ * avoid drift across observer-dispatch + speculative-entry synthesis.
8
+ *
9
+ * @internal — not part of the public pi-steering surface.
10
+ */
11
+
12
+ import { type CommandRef, getBasename, getCommandArgs } from "@cad0p/unbash-walker";
13
+
14
+ /** Render a ref as `"{basename} {args joined by space}"`, trimmed. */
15
+ export function refToText(ref: CommandRef): string {
16
+ return `${getBasename(ref)} ${getCommandArgs(ref).join(" ")}`.trim();
17
+ }
@@ -0,0 +1,629 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Direct coverage for the strict-mode contract owned by
6
+ * `buildSessionRuntime`. Tests:
7
+ *
8
+ * - error-class diagnostics always escalate to a thrown error;
9
+ * - warning-class diagnostics escalate when `failOnWarnings !==
10
+ * false`;
11
+ * - with `failOnWarnings: false`, warnings fall back to
12
+ * `console.warn` and the runtime returns normally;
13
+ * - the aggregated error message follows the rule-based spec
14
+ * (header + bullets, errors first, optional path prefix).
15
+ *
16
+ * Uses a tmp `$HOME` so the walk-up loader's `$HOME` ceiling is
17
+ * scoped to the test directory.
18
+ */
19
+
20
+ import assert from "node:assert/strict";
21
+ import { afterEach, beforeEach, describe, it } from "node:test";
22
+ import {
23
+ useIsolatedHome,
24
+ writeSteeringSingleFileConfig,
25
+ } from "../__test-helpers__.ts";
26
+ import type { SteeringDiagnostic } from "../schema.ts";
27
+ import {
28
+ buildSessionRuntime,
29
+ formatAggregatedDiagnostics,
30
+ formatSingleLineDiagnostic,
31
+ } from "./session-runtime.ts";
32
+
33
+ /** Minimal evaluator host; the strict-mode tests don't drive evaluation. */
34
+ const noopHost = {
35
+ exec: async () => ({
36
+ stdout: "",
37
+ stderr: "",
38
+ code: 0,
39
+ killed: false,
40
+ }),
41
+ appendEntry: () => {},
42
+ };
43
+
44
+ describe("buildSessionRuntime: strict-mode contract", () => {
45
+ let tmpHome: string;
46
+ let warnings: string[];
47
+ let origWarn: typeof console.warn;
48
+ let origInfo: typeof console.info;
49
+ useIsolatedHome("pi-steering-runtime-", (t) => {
50
+ tmpHome = t;
51
+ });
52
+
53
+ beforeEach(() => {
54
+ warnings = [];
55
+ origWarn = console.warn;
56
+ origInfo = console.info;
57
+ console.warn = (msg: unknown) => {
58
+ warnings.push(String(msg));
59
+ };
60
+ // Silence the "observer dropped" info-level chatter; only the
61
+ // strict-mode warn channel is under assertion here.
62
+ console.info = () => {};
63
+ });
64
+
65
+ afterEach(() => {
66
+ console.warn = origWarn;
67
+ console.info = origInfo;
68
+ });
69
+
70
+ it("returns evaluator + dispatcher when there are no diagnostics", async () => {
71
+ // No config layers, no plugins, no diagnostics. Defaults inject
72
+ // rules + plugins; both are clean.
73
+ const result = await buildSessionRuntime(tmpHome, noopHost);
74
+ assert.ok(result.evaluator);
75
+ assert.ok(result.dispatcher);
76
+ assert.deepEqual(warnings, []);
77
+ });
78
+
79
+ it("throws on a warning-class diagnostic by default (failOnWarnings undefined)", async () => {
80
+ // A plugin-shipped predicate collision via direct buildConfig
81
+ // would be hard to stage from a written config; instead, write
82
+ // a config layer that declares two within-layer duplicate rules
83
+ // (rule-name-collision, type:'warning').
84
+ writeSteeringSingleFileConfig(
85
+ tmpHome,
86
+ `export default {
87
+ disableDefaults: true,
88
+ rules: [
89
+ { name: "dup", tool: "bash", field: "command", pattern: /^A/, reason: "first" },
90
+ { name: "dup", tool: "bash", field: "command", pattern: /^B/, reason: "second" },
91
+ ],
92
+ };`,
93
+ );
94
+ await assert.rejects(
95
+ () => buildSessionRuntime(tmpHome, noopHost),
96
+ (err: Error) => {
97
+ assert.match(err.message, /^1 config issue:/);
98
+ assert.match(err.message, /\[warning\]/);
99
+ assert.match(err.message, /duplicate rule "dup"/);
100
+ return true;
101
+ },
102
+ );
103
+ });
104
+
105
+ it("does NOT throw on a warning-class diagnostic when failOnWarnings: false; emits to console.warn", async () => {
106
+ writeSteeringSingleFileConfig(
107
+ tmpHome,
108
+ `export default {
109
+ disableDefaults: true,
110
+ failOnWarnings: false,
111
+ rules: [
112
+ { name: "dup", tool: "bash", field: "command", pattern: /^A/, reason: "first" },
113
+ { name: "dup", tool: "bash", field: "command", pattern: /^B/, reason: "second" },
114
+ ],
115
+ };`,
116
+ );
117
+ const result = await buildSessionRuntime(tmpHome, noopHost);
118
+ assert.ok(result.evaluator);
119
+ assert.ok(result.dispatcher);
120
+ assert.ok(
121
+ warnings.some(
122
+ (w) =>
123
+ w.startsWith("[pi-steering] ") && w.includes('duplicate rule "dup"'),
124
+ ),
125
+ `expected a legacy console.warn for the rule collision; got: ${JSON.stringify(warnings)}`,
126
+ );
127
+ });
128
+
129
+ it("throws on an error-class diagnostic regardless of failOnWarnings", async () => {
130
+ // Two plugins claim the tracker `branch`. The loader's
131
+ // buildConfig pushes an error-class tracker-name-collision
132
+ // diagnostic; the strict-mode opt-out applies only to warnings,
133
+ // not errors. Setting `failOnWarnings: false` does NOT change
134
+ // the throw — errors always escalate.
135
+ //
136
+ // Single-emission lock: both `buildConfig` and `resolvePlugins`
137
+ // independently detect tracker-name collisions. The runtime's
138
+ // short-circuit between the two passes (when merge-side has any
139
+ // error-class diagnostic) drops the second detection so the
140
+ // aggregated message lists the collision exactly once — the
141
+ // header reads `1 config issue:` (singular), and a regex count
142
+ // of the bullet line confirms there's no duplicate.
143
+ writeSteeringSingleFileConfig(
144
+ tmpHome,
145
+ `const t = { initial: "?", unknown: "unknown", modifiers: {}, subshellSemantics: "isolated" };
146
+ export default {
147
+ disableDefaults: true,
148
+ failOnWarnings: false,
149
+ plugins: [
150
+ { name: "pa", trackers: { branch: t } },
151
+ { name: "pb", trackers: { branch: t } },
152
+ ],
153
+ };`,
154
+ );
155
+ await assert.rejects(
156
+ () => buildSessionRuntime(tmpHome, noopHost),
157
+ (err: Error) => {
158
+ assert.match(err.message, /^1 config issue:/);
159
+ assert.match(err.message, /\[error\]/);
160
+ assert.match(err.message, /tracker name collision/);
161
+ const collisionLines = err.message.match(/tracker name collision/g);
162
+ assert.equal(
163
+ collisionLines?.length,
164
+ 1,
165
+ `expected exactly one tracker-name-collision bullet (short-circuit drops the second emission); got ${collisionLines?.length}: ${err.message}`,
166
+ );
167
+ return true;
168
+ },
169
+ );
170
+ });
171
+
172
+ it("throws on an invalid-name diagnostic for a malformed plugin name", async () => {
173
+ // `validateName` flows through the diagnostic stream rather than
174
+ // throwing a plain Error, so the strict-mode aggregation surfaces
175
+ // the malformed name in the same `N config issue:` shape as other
176
+ // error-class diagnostics. The malformed plugin must use a name
177
+ // shape the loader's plugin-name validation accepts — here we
178
+ // trigger the merger via a malformed RULE name shipped by an
179
+ // otherwise-valid plugin.
180
+ writeSteeringSingleFileConfig(
181
+ tmpHome,
182
+ `export default {
183
+ disableDefaults: true,
184
+ plugins: [
185
+ {
186
+ name: "forge-plugin",
187
+ rules: [
188
+ {
189
+ name: "bad name",
190
+ tool: "bash",
191
+ field: "command",
192
+ pattern: /^never$/,
193
+ reason: "r",
194
+ },
195
+ ],
196
+ },
197
+ ],
198
+ };`,
199
+ );
200
+ await assert.rejects(
201
+ () => buildSessionRuntime(tmpHome, noopHost),
202
+ (err: Error) => {
203
+ assert.match(err.message, /^1 config issue:/);
204
+ assert.match(err.message, /\[error\]/);
205
+ assert.match(err.message, /^.*rule name "bad name".*disallowed/m);
206
+ return true;
207
+ },
208
+ );
209
+ });
210
+
211
+ it("aggregates multiple diagnostics with errors first", async () => {
212
+ // One error (tracker collision) + one warning (rule
213
+ // collision). Aggregated message lists the error before the
214
+ // warning.
215
+ writeSteeringSingleFileConfig(
216
+ tmpHome,
217
+ `const t = { initial: "?", unknown: "unknown", modifiers: {}, subshellSemantics: "isolated" };
218
+ export default {
219
+ disableDefaults: true,
220
+ plugins: [
221
+ { name: "pa", trackers: { branch: t } },
222
+ { name: "pb", trackers: { branch: t } },
223
+ ],
224
+ rules: [
225
+ { name: "dup", tool: "bash", field: "command", pattern: /^A/, reason: "first" },
226
+ { name: "dup", tool: "bash", field: "command", pattern: /^B/, reason: "second" },
227
+ ],
228
+ };`,
229
+ );
230
+ await assert.rejects(
231
+ () => buildSessionRuntime(tmpHome, noopHost),
232
+ (err: Error) => {
233
+ const message = err.message;
234
+ assert.match(message, /^2 config issues:/);
235
+ const errorIdx = message.indexOf("[error]");
236
+ const warningIdx = message.indexOf("[warning]");
237
+ assert.ok(errorIdx > -1, "expected an [error] line");
238
+ assert.ok(warningIdx > -1, "expected a [warning] line");
239
+ assert.ok(
240
+ errorIdx < warningIdx,
241
+ "errors should be ordered before warnings",
242
+ );
243
+ return true;
244
+ },
245
+ );
246
+ });
247
+
248
+ it("throws on a malformed user-config rule name (aggregated, not plain Error)", async () => {
249
+ // User-config rule + observer names route through the same
250
+ // `invalid-name` diagnostic stream as plugin-shipped names. Before
251
+ // the unification, this case threw a plain `pi-steering: rule name
252
+ // "..."` Error from `buildEvaluator` rather than the aggregated
253
+ // `N config issue:` shape; production callers and tests then had
254
+ // to handle two distinct error formats for what is structurally
255
+ // the same kind of issue.
256
+ writeSteeringSingleFileConfig(
257
+ tmpHome,
258
+ `export default {
259
+ disableDefaults: true,
260
+ rules: [
261
+ {
262
+ name: "phony] BAD",
263
+ tool: "bash",
264
+ field: "command",
265
+ pattern: /^never$/,
266
+ reason: "r",
267
+ },
268
+ ],
269
+ };`,
270
+ );
271
+ await assert.rejects(
272
+ () => buildSessionRuntime(tmpHome, noopHost),
273
+ (err: Error) => {
274
+ assert.match(err.message, /^1 config issue:/);
275
+ assert.match(err.message, /\[error\]/);
276
+ assert.match(
277
+ err.message,
278
+ /rule name "phony\] BAD" \(user config\) contains disallowed/,
279
+ );
280
+ // Confirm the legacy plain-Error shape is GONE.
281
+ assert.doesNotMatch(err.message, /^pi-steering: rule name/);
282
+ return true;
283
+ },
284
+ );
285
+ });
286
+
287
+ it("aggregates a malformed user-config rule name with a warning-class diagnostic into one throw", async () => {
288
+ // Combined error (user-config invalid name) + warning (observer
289
+ // dropped). Confirms the user-config name diagnostic flows through
290
+ // the same aggregation path as everything else — single throw,
291
+ // errors-first ordering.
292
+ writeSteeringSingleFileConfig(
293
+ tmpHome,
294
+ `export default {
295
+ disableDefaults: true,
296
+ rules: [
297
+ {
298
+ name: "phony] BAD",
299
+ tool: "bash",
300
+ field: "command",
301
+ pattern: /^never$/,
302
+ reason: "r",
303
+ },
304
+ {
305
+ name: "dup",
306
+ tool: "bash",
307
+ field: "command",
308
+ pattern: /^A/,
309
+ reason: "first",
310
+ },
311
+ {
312
+ name: "dup",
313
+ tool: "bash",
314
+ field: "command",
315
+ pattern: /^B/,
316
+ reason: "second",
317
+ },
318
+ ],
319
+ };`,
320
+ );
321
+ await assert.rejects(
322
+ () => buildSessionRuntime(tmpHome, noopHost),
323
+ (err: Error) => {
324
+ assert.match(err.message, /^2 config issues:/);
325
+ const errorIdx = err.message.indexOf("[error]");
326
+ const warningIdx = err.message.indexOf("[warning]");
327
+ assert.ok(errorIdx > -1, "expected an [error] line");
328
+ assert.ok(warningIdx > -1, "expected a [warning] line");
329
+ assert.ok(
330
+ errorIdx < warningIdx,
331
+ "errors should be ordered before warnings",
332
+ );
333
+ assert.match(err.message, /\(user config\)/);
334
+ return true;
335
+ },
336
+ );
337
+ });
338
+
339
+ it("aggregates a tracker-name-collision merge error AND a malformed user-config rule name into one throw", async () => {
340
+ // Combined error: tracker-name-collision (merge-side) plus a
341
+ // malformed user-config rule name. Pins that user-config name
342
+ // validation runs unconditionally so both surface together
343
+ // rather than the user seeing them on separate runs.
344
+ writeSteeringSingleFileConfig(
345
+ tmpHome,
346
+ `const t = { initial: "?", unknown: "unknown", modifiers: {}, subshellSemantics: "isolated" };
347
+ export default {
348
+ disableDefaults: true,
349
+ plugins: [
350
+ { name: "pa", trackers: { branch: t } },
351
+ { name: "pb", trackers: { branch: t } },
352
+ ],
353
+ rules: [
354
+ {
355
+ name: "phony] BAD",
356
+ tool: "bash",
357
+ field: "command",
358
+ pattern: /^never$/,
359
+ reason: "r",
360
+ },
361
+ ],
362
+ };`,
363
+ );
364
+ await assert.rejects(
365
+ () => buildSessionRuntime(tmpHome, noopHost),
366
+ (err: Error) => {
367
+ assert.match(err.message, /^2 config issues:/);
368
+ assert.match(err.message, /tracker name collision/);
369
+ assert.match(
370
+ err.message,
371
+ /rule name "phony\] BAD" \(user config\) contains disallowed/,
372
+ );
373
+ // Both errors render under [error] tags — errors-first
374
+ // ordering is trivially satisfied (no warnings in this case).
375
+ const errorTags = err.message.match(/\[error\]/g) ?? [];
376
+ assert.equal(
377
+ errorTags.length,
378
+ 2,
379
+ `expected two [error] tags; got: ${err.message}`,
380
+ );
381
+ return true;
382
+ },
383
+ );
384
+ });
385
+ });
386
+
387
+ describe("buildSessionRuntime: observer-drop breadcrumbs", () => {
388
+ let tmpHome: string;
389
+ let infos: string[];
390
+ let origInfo: typeof console.info;
391
+ let origWarn: typeof console.warn;
392
+ useIsolatedHome("pi-steering-runtime-", (t) => {
393
+ tmpHome = t;
394
+ });
395
+
396
+ beforeEach(() => {
397
+ infos = [];
398
+ origInfo = console.info;
399
+ origWarn = console.warn;
400
+ console.info = (msg: unknown) => {
401
+ infos.push(String(msg));
402
+ };
403
+ // Silence the strict-mode warn channel; only the info-level
404
+ // observer-drop breadcrumb is under assertion here.
405
+ console.warn = () => {};
406
+ });
407
+
408
+ afterEach(() => {
409
+ console.info = origInfo;
410
+ console.warn = origWarn;
411
+ });
412
+
413
+ it("emits an `observer dropped` breadcrumb when the observer's only consumer rule is disabled via `disabledRules` (parity with CLI)", async () => {
414
+ // Symmetric to the CLI regression test in
415
+ // `bin/pi-steering.test.ts` ("surfaces an `observer dropped`
416
+ // breadcrumb when the consumer rule is disabled via
417
+ // `disabledRules`"). The runtime filters `merged.rules` against
418
+ // `disabledRules` BEFORE handing the union to
419
+ // `dropUnusedObservers`, so an observer whose only consumer is
420
+ // disabled gets dropped at extension factory time. This test pins the
421
+ // production code path — a future refactor that swaps the
422
+ // runtime's filter order (filtering after observer-drop instead
423
+ // of before, or skipping the filter entirely) would let
424
+ // `pi-steering list` continue to surface the breadcrumb
425
+ // correctly while production silently fails to drop the
426
+ // observer.
427
+ writeSteeringSingleFileConfig(
428
+ tmpHome,
429
+ `export default {
430
+ disableDefaults: true,
431
+ disabledRules: ["consumer"],
432
+ observers: [
433
+ {
434
+ name: "obs-x",
435
+ writes: ["X"],
436
+ onResult: () => {},
437
+ },
438
+ ],
439
+ rules: [
440
+ {
441
+ name: "consumer",
442
+ tool: "bash",
443
+ field: "command",
444
+ pattern: /^never$/,
445
+ reason: "r",
446
+ when: { happened: { event: "X" } },
447
+ },
448
+ ],
449
+ };`,
450
+ );
451
+ const result = await buildSessionRuntime(tmpHome, noopHost);
452
+ assert.ok(result.evaluator);
453
+ assert.ok(result.dispatcher);
454
+ const breadcrumb = infos.find((m) =>
455
+ /\[pi-steering\] observer 'obs-x' dropped; its writes \(X\) are not consumed by any rule/.test(
456
+ m,
457
+ ),
458
+ );
459
+ assert.ok(
460
+ breadcrumb !== undefined,
461
+ `expected observer-drop breadcrumb on console.info (consumer rule was disabled, observer should be reported as dropped); got: ${JSON.stringify(infos)}`,
462
+ );
463
+ });
464
+
465
+ it("does NOT drop the observer when the consumer rule is enabled (inverse parity)", async () => {
466
+ // Cross-surface symmetry with the inverse-parity test in
467
+ // `bin/pi-steering.test.ts` (O1 in INVARIANTS.md): both surfaces
468
+ // must agree that an enabled consumer keeps its observer alive.
469
+
470
+ writeSteeringSingleFileConfig(
471
+ tmpHome,
472
+ `export default {
473
+ disableDefaults: true,
474
+ observers: [
475
+ {
476
+ name: "obs-x",
477
+ writes: ["X"],
478
+ onResult: () => {},
479
+ },
480
+ ],
481
+ rules: [
482
+ {
483
+ name: "consumer",
484
+ tool: "bash",
485
+ field: "command",
486
+ pattern: /^never$/,
487
+ reason: "r",
488
+ when: { happened: { event: "X" } },
489
+ },
490
+ ],
491
+ };`,
492
+ );
493
+ const result = await buildSessionRuntime(tmpHome, noopHost);
494
+ assert.ok(result.evaluator);
495
+ assert.ok(result.dispatcher);
496
+ const breadcrumb = infos.find((m) =>
497
+ /\[pi-steering\] observer 'obs-x' dropped/.test(m),
498
+ );
499
+ assert.equal(
500
+ breadcrumb,
501
+ undefined,
502
+ `expected NO observer-drop breadcrumb (consumer rule is enabled, observer is consumed); got: ${JSON.stringify(infos)}`,
503
+ );
504
+ });
505
+ });
506
+
507
+ describe("formatAggregatedDiagnostics: rule-based spec", () => {
508
+ it("renders a single warning with no path prefix", () => {
509
+ const diagnostics: SteeringDiagnostic[] = [
510
+ {
511
+ type: "warning",
512
+ kind: "plugin-name-collision",
513
+ message: 'duplicate plugin "git"; keeping first-registered entry.',
514
+ },
515
+ ];
516
+ const out = formatAggregatedDiagnostics(diagnostics);
517
+ assert.equal(
518
+ out,
519
+ "1 config issue:\n" +
520
+ ' - [warning] duplicate plugin "git"; keeping first-registered entry.',
521
+ );
522
+ });
523
+
524
+ it("renders a single error with the singular header noun", () => {
525
+ const diagnostics: SteeringDiagnostic[] = [
526
+ {
527
+ type: "error",
528
+ kind: "tracker-name-collision",
529
+ message: 'tracker name collision: plugins "a" and "b"',
530
+ },
531
+ ];
532
+ const out = formatAggregatedDiagnostics(diagnostics);
533
+ assert.match(out, /^1 config issue:/);
534
+ assert.match(out, /\[error\] tracker name collision/);
535
+ });
536
+
537
+ it("renders multiple diagnostics with errors-first ordering and the plural header noun", () => {
538
+ const diagnostics: SteeringDiagnostic[] = [
539
+ {
540
+ type: "warning",
541
+ kind: "layer-import-failed",
542
+ path: "/u/.pi/steering.ts",
543
+ message: "failed to import: SyntaxError",
544
+ },
545
+ {
546
+ type: "error",
547
+ kind: "tracker-name-collision",
548
+ message: 'tracker name collision: plugins "a" and "b"',
549
+ },
550
+ {
551
+ type: "warning",
552
+ kind: "plugin-name-collision",
553
+ message: 'duplicate plugin "git"',
554
+ },
555
+ ];
556
+ const out = formatAggregatedDiagnostics(diagnostics);
557
+ const lines = out.split("\n");
558
+ assert.equal(lines[0], "3 config issues:");
559
+ // Error-class lines appear before warning-class.
560
+ assert.match(lines[1] ?? "", /\[error\] tracker name collision/);
561
+ assert.match(lines[2] ?? "", /\[warning\] /);
562
+ assert.match(lines[3] ?? "", /\[warning\] /);
563
+ });
564
+
565
+ it("includes a path prefix when SteeringDiagnostic.path is set", () => {
566
+ const diagnostics: SteeringDiagnostic[] = [
567
+ {
568
+ type: "warning",
569
+ kind: "layer-stray-file",
570
+ path: "/u/.pi/steering/rules.json",
571
+ message: "ignoring non-.ts file under .pi/steering/",
572
+ },
573
+ ];
574
+ const out = formatAggregatedDiagnostics(diagnostics);
575
+ assert.match(out, /\[warning\] \/u\/\.pi\/steering\/rules\.json:/);
576
+ });
577
+ });
578
+
579
+ describe("formatSingleLineDiagnostic: rule-based spec", () => {
580
+ it("renders a warning with a [warning] severity tag and a path prefix", () => {
581
+ const d: SteeringDiagnostic = {
582
+ type: "warning",
583
+ kind: "layer-import-failed",
584
+ path: "/u/.pi/steering.ts",
585
+ message: "failed to import: SyntaxError",
586
+ };
587
+ assert.equal(
588
+ formatSingleLineDiagnostic(d),
589
+ "[pi-steering] [warning] /u/.pi/steering.ts: failed to import: SyntaxError",
590
+ );
591
+ });
592
+
593
+ it("renders a warning with a [warning] severity tag and no path prefix", () => {
594
+ const d: SteeringDiagnostic = {
595
+ type: "warning",
596
+ kind: "plugin-name-collision",
597
+ message: 'duplicate plugin "git"; keeping first-registered entry.',
598
+ };
599
+ assert.equal(
600
+ formatSingleLineDiagnostic(d),
601
+ '[pi-steering] [warning] duplicate plugin "git"; keeping first-registered entry.',
602
+ );
603
+ });
604
+
605
+ it("renders an error with an [error] severity tag and a path prefix", () => {
606
+ const d: SteeringDiagnostic = {
607
+ type: "error",
608
+ kind: "layer-import-failed",
609
+ path: "/u/.pi/steering.ts",
610
+ message: "failed to import: SyntaxError",
611
+ };
612
+ assert.equal(
613
+ formatSingleLineDiagnostic(d),
614
+ "[pi-steering] [error] /u/.pi/steering.ts: failed to import: SyntaxError",
615
+ );
616
+ });
617
+
618
+ it("renders an error with an [error] severity tag and no path prefix", () => {
619
+ const d: SteeringDiagnostic = {
620
+ type: "error",
621
+ kind: "tracker-name-collision",
622
+ message: 'tracker name collision: plugins "a" and "b"',
623
+ };
624
+ assert.equal(
625
+ formatSingleLineDiagnostic(d),
626
+ '[pi-steering] [error] tracker name collision: plugins "a" and "b"',
627
+ );
628
+ });
629
+ });