@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,920 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Tests for the `@cad0p/pi-steering` CLI (`./pi-steering.ts`).
6
+ *
7
+ * Runs the CLI as a subprocess via `node --experimental-strip-types
8
+ * src/bin/pi-steering.ts …`. This mirrors real invocation (the built
9
+ * shebang script runs under a fresh node) and sidesteps the
10
+ * `node:test` worker's stdout sharing — patching
11
+ * `process.stdout.write` in-process swallows the worker's TAP frames.
12
+ *
13
+ * File IO fixtures live under `mkdtempSync`; each test cleans up its
14
+ * directory in `afterEach`.
15
+ */
16
+
17
+ import assert from "node:assert/strict";
18
+ import { spawn } from "node:child_process";
19
+ import {
20
+ mkdirSync,
21
+ mkdtempSync,
22
+ readFileSync,
23
+ rmSync,
24
+ writeFileSync,
25
+ } from "node:fs";
26
+ import { tmpdir } from "node:os";
27
+ import { dirname, join, resolve } from "node:path";
28
+ import { afterEach, beforeEach, describe, it } from "node:test";
29
+ import { fileURLToPath } from "node:url";
30
+ import { writeSteeringDirConfig } from "../__test-helpers__.ts";
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // subprocess runner
34
+ // ---------------------------------------------------------------------------
35
+
36
+ const CLI_PATH = resolve(
37
+ dirname(fileURLToPath(import.meta.url)),
38
+ "pi-steering.ts",
39
+ );
40
+
41
+ interface RunResult {
42
+ code: number;
43
+ stdout: string;
44
+ stderr: string;
45
+ }
46
+
47
+ /**
48
+ * Run the CLI as a child process under `node --experimental-strip-types`.
49
+ * Returns the exit code and captured stdout/stderr. Never throws for
50
+ * non-zero exit codes — the caller asserts on `code`.
51
+ *
52
+ * Accepts an optional `cwd` so tests for the `list` subcommand can
53
+ * point the walk-up loader at a scratch directory without polluting
54
+ * the project.
55
+ */
56
+ function runCli(...args: string[]): Promise<RunResult>;
57
+ function runCli(opts: { cwd?: string }, ...args: string[]): Promise<RunResult>;
58
+ function runCli(
59
+ first?: string | { cwd?: string },
60
+ ...rest: string[]
61
+ ): Promise<RunResult> {
62
+ let cwd: string | undefined;
63
+ let args: string[];
64
+ if (typeof first === "object" && first !== null) {
65
+ cwd = first.cwd;
66
+ args = rest;
67
+ } else {
68
+ args = first === undefined ? [...rest] : [first, ...rest];
69
+ }
70
+ return new Promise((resolvePromise, rejectPromise) => {
71
+ const child = spawn(
72
+ process.execPath,
73
+ ["--experimental-strip-types", CLI_PATH, ...args],
74
+ {
75
+ stdio: ["ignore", "pipe", "pipe"],
76
+ ...(cwd !== undefined ? { cwd } : {}),
77
+ },
78
+ );
79
+ let stdout = "";
80
+ let stderr = "";
81
+ child.stdout.setEncoding("utf8");
82
+ child.stderr.setEncoding("utf8");
83
+ child.stdout.on("data", (chunk: string) => {
84
+ stdout += chunk;
85
+ });
86
+ child.stderr.on("data", (chunk: string) => {
87
+ stderr += chunk;
88
+ });
89
+ child.on("error", rejectPromise);
90
+ child.on("close", (code) => {
91
+ // Filter the Node experimental-strip-types warning so tests
92
+ // assert against clean stderr. The warning is
93
+ // version-dependent; drop any line mentioning it.
94
+ const cleanedStderr = stderr
95
+ .split("\n")
96
+ .filter((line) => !/ExperimentalWarning/.test(line))
97
+ .filter((line) => !/Use `node --trace-warnings/.test(line))
98
+ .join("\n");
99
+ resolvePromise({
100
+ code: code ?? -1,
101
+ stdout,
102
+ stderr: cleanedStderr,
103
+ });
104
+ });
105
+ });
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // tmpdir fixture
110
+ // ---------------------------------------------------------------------------
111
+
112
+ let scratch: string;
113
+
114
+ beforeEach(() => {
115
+ scratch = mkdtempSync(join(tmpdir(), "pi-steering-cli-"));
116
+ });
117
+
118
+ afterEach(() => {
119
+ rmSync(scratch, { recursive: true, force: true });
120
+ });
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // top-level
124
+ // ---------------------------------------------------------------------------
125
+
126
+ describe("pi-steering CLI: help + dispatch", () => {
127
+ it("--help prints usage and exits 0", async () => {
128
+ const r = await runCli("--help");
129
+ assert.equal(r.code, 0);
130
+ assert.match(r.stdout, /pi-steering — tools for/);
131
+ assert.match(r.stdout, /import-json/);
132
+ assert.match(r.stdout, /list \[--format=/);
133
+ assert.equal(r.stderr.trim(), "");
134
+ });
135
+
136
+ it("-h prints usage and exits 0", async () => {
137
+ const r = await runCli("-h");
138
+ assert.equal(r.code, 0);
139
+ assert.match(r.stdout, /USAGE/);
140
+ });
141
+
142
+ it("no args prints usage and exits 0", async () => {
143
+ const r = await runCli();
144
+ assert.equal(r.code, 0);
145
+ assert.match(r.stdout, /USAGE/);
146
+ });
147
+
148
+ it("unknown subcommand writes to stderr and exits 1", async () => {
149
+ const r = await runCli("bogus-sub");
150
+ assert.equal(r.code, 1);
151
+ assert.match(r.stderr, /unknown subcommand "bogus-sub"/);
152
+ // Help still gets written to stdout for context.
153
+ assert.match(r.stdout, /USAGE/);
154
+ });
155
+ });
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // import-json: argument parsing
159
+ // ---------------------------------------------------------------------------
160
+
161
+ describe("pi-steering import-json: argument parsing", () => {
162
+ it("requires an input file", async () => {
163
+ const r = await runCli("import-json");
164
+ assert.equal(r.code, 1);
165
+ assert.match(r.stderr, /requires an input file/);
166
+ });
167
+
168
+ it("rejects unknown flags", async () => {
169
+ const r = await runCli("import-json", "--nope");
170
+ assert.equal(r.code, 1);
171
+ assert.match(r.stderr, /unknown flag "--nope"/);
172
+ });
173
+
174
+ it("rejects -o without an argument", async () => {
175
+ const r = await runCli("import-json", "input.json", "-o");
176
+ assert.equal(r.code, 1);
177
+ assert.match(r.stderr, /-o requires an argument/);
178
+ });
179
+
180
+ it("rejects more than one positional argument", async () => {
181
+ const r = await runCli("import-json", "a.json", "b.json");
182
+ assert.equal(r.code, 1);
183
+ assert.match(r.stderr, /too many positional arguments/);
184
+ });
185
+ });
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // import-json: IO error paths
189
+ // ---------------------------------------------------------------------------
190
+
191
+ describe("pi-steering import-json: IO errors", () => {
192
+ it("missing file -> stderr + exit 1", async () => {
193
+ const r = await runCli("import-json", join(scratch, "nope.json"));
194
+ assert.equal(r.code, 1);
195
+ assert.match(r.stderr, /cannot read /);
196
+ });
197
+
198
+ it("invalid JSON -> stderr + exit 1", async () => {
199
+ const path = join(scratch, "bad.json");
200
+ writeFileSync(path, "{ not json", "utf8");
201
+ const r = await runCli("import-json", path);
202
+ assert.equal(r.code, 1);
203
+ assert.match(r.stderr, /not valid JSON/);
204
+ });
205
+ });
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // import-json: happy path
209
+ // ---------------------------------------------------------------------------
210
+
211
+ const VALID_V1 = {
212
+ disable: ["no-force-push"],
213
+ rules: [
214
+ {
215
+ name: "no-amend",
216
+ tool: "bash",
217
+ field: "command",
218
+ pattern: "^git\\s+commit\\b.*--amend",
219
+ reason: "Don't rewrite history.",
220
+ },
221
+ ],
222
+ };
223
+
224
+ describe("pi-steering import-json: conversion", () => {
225
+ it("stdout mode: writes defineConfig output and exits 0", async () => {
226
+ const path = join(scratch, "steering.json");
227
+ writeFileSync(path, JSON.stringify(VALID_V1), "utf8");
228
+
229
+ const r = await runCli("import-json", path);
230
+ assert.equal(r.code, 0);
231
+ assert.equal(r.stderr.trim(), "");
232
+ assert.match(r.stdout, /import \{ defineConfig \} from "@cad0p\/pi-steering"/);
233
+ assert.match(r.stdout, /export default defineConfig\(/);
234
+ assert.match(r.stdout, /"no-amend"/);
235
+ assert.match(r.stdout, /"Don't rewrite history\."/);
236
+ // Preserve the disabled-rules list. Note the rename: v1 JSON's
237
+ // `disable` key becomes v2 TS `disabledRules` on output.
238
+ assert.match(r.stdout, /"disabledRules":\s*\[\s*"no-force-push"\s*\]/);
239
+ });
240
+
241
+ it("-o mode: writes file + reports path, exits 0", async () => {
242
+ const inputPath = join(scratch, "steering.json");
243
+ const outputPath = join(scratch, "steering.ts");
244
+ writeFileSync(inputPath, JSON.stringify(VALID_V1), "utf8");
245
+
246
+ const r = await runCli("import-json", inputPath, "-o", outputPath);
247
+ assert.equal(r.code, 0);
248
+ assert.match(r.stdout, new RegExp(`Wrote ${outputPath}`));
249
+ assert.equal(r.stderr.trim(), "");
250
+
251
+ const written = readFileSync(outputPath, "utf8");
252
+ assert.match(written, /import \{ defineConfig \}/);
253
+ assert.match(written, /"no-amend"/);
254
+ // The generated file should be valid enough that a user can
255
+ // drop it in and `tsc --noEmit` it. Sanity check: trailing
256
+ // newline, no BOM.
257
+ assert.ok(written.endsWith("\n"));
258
+ assert.ok(!written.startsWith("\uFEFF"));
259
+ });
260
+
261
+ it("--output long form works the same as -o", async () => {
262
+ const inputPath = join(scratch, "steering.json");
263
+ const outputPath = join(scratch, "out.ts");
264
+ writeFileSync(inputPath, JSON.stringify(VALID_V1), "utf8");
265
+
266
+ const r = await runCli("import-json", inputPath, "--output", outputPath);
267
+ assert.equal(r.code, 0);
268
+ const written = readFileSync(outputPath, "utf8");
269
+ assert.match(written, /defineConfig/);
270
+ });
271
+
272
+ it("FromJSONError propagates as exit 2 with path info", async () => {
273
+ // `plugins` is a v2-only construct; `fromJSON` rejects it.
274
+ const inputPath = join(scratch, "steering.json");
275
+ writeFileSync(
276
+ inputPath,
277
+ JSON.stringify({ plugins: [{ name: "git" }] }),
278
+ "utf8",
279
+ );
280
+
281
+ const r = await runCli("import-json", inputPath);
282
+ assert.equal(r.code, 2);
283
+ assert.match(r.stderr, /conversion failed at <root>\.plugins/);
284
+ assert.equal(r.stdout, "");
285
+ });
286
+ });
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // `list` subcommand
290
+ // ---------------------------------------------------------------------------
291
+
292
+ describe("pi-steering list", () => {
293
+ it("prints 'no config' when no .pi/steering exists", async () => {
294
+ const r = await runCli({ cwd: scratch }, "list");
295
+ assert.equal(r.code, 0);
296
+ assert.match(r.stdout, /No steering config found\./);
297
+ });
298
+
299
+ it("--format=json with no config returns empty structure", async () => {
300
+ const r = await runCli({ cwd: scratch }, "list", "--format=json");
301
+ assert.equal(r.code, 0);
302
+ const parsed = JSON.parse(r.stdout) as {
303
+ plugins: unknown[];
304
+ userRules: unknown[];
305
+ disabled: { rules: unknown[] };
306
+ };
307
+ assert.deepEqual(parsed.plugins, []);
308
+ assert.deepEqual(parsed.userRules, []);
309
+ assert.deepEqual(parsed.disabled.rules, []);
310
+ });
311
+
312
+ it("text format groups plugin + user rules and lists disables", async () => {
313
+ writeSteeringDirConfig(
314
+ scratch,
315
+ `export default {
316
+ plugins: [
317
+ {
318
+ name: "git",
319
+ rules: [
320
+ {
321
+ name: "no-main-commit",
322
+ tool: "bash",
323
+ field: "command",
324
+ pattern: /^git\\s+commit/,
325
+ when: { branch: /^main$/ },
326
+ reason: "no",
327
+ },
328
+ ],
329
+ },
330
+ ],
331
+ rules: [
332
+ {
333
+ name: "my-rule",
334
+ tool: "bash",
335
+ field: "command",
336
+ pattern: /^echo/,
337
+ reason: "no",
338
+ },
339
+ ],
340
+ disabledRules: ["some-disabled-rule"],
341
+ };`,
342
+ );
343
+ const r = await runCli({ cwd: scratch }, "list");
344
+ assert.equal(r.code, 0);
345
+ assert.match(r.stdout, /Resolved config: 1 plugin, 2 rules, 0 observers\./);
346
+ assert.match(r.stdout, /git\s+\[pi-steering\/plugins\/git\]/);
347
+ assert.match(r.stdout, /no-main-commit\s+bash\s+when: branch/);
348
+ assert.match(r.stdout, /User \(\.pi\/steering\/index\.ts\):/);
349
+ assert.match(r.stdout, /my-rule\s+bash/);
350
+ assert.match(r.stdout, /Disabled rules: some-disabled-rule/);
351
+ });
352
+
353
+ it("--format=json emits a parseable structure with all sections", async () => {
354
+ writeSteeringDirConfig(
355
+ scratch,
356
+ `export default {
357
+ plugins: [{ name: "git", rules: [] }],
358
+ rules: [
359
+ {
360
+ name: "u1",
361
+ tool: "bash",
362
+ field: "command",
363
+ pattern: /^ls/,
364
+ reason: "no",
365
+ },
366
+ ],
367
+ observers: [
368
+ {
369
+ name: "obs1",
370
+ writes: ["thing-happened"],
371
+ onResult: () => {},
372
+ },
373
+ ],
374
+ };`,
375
+ );
376
+ const r = await runCli({ cwd: scratch }, "list", "--format=json");
377
+ assert.equal(r.code, 0);
378
+ const parsed = JSON.parse(r.stdout) as {
379
+ plugins: Array<{ name: string; source?: string; rules: unknown[] }>;
380
+ userRules: Array<{ name: string; tool: string }>;
381
+ userObservers: Array<{ name: string; writes: string[] }>;
382
+ disabled: { rules: unknown[]; plugins: unknown[] };
383
+ };
384
+ assert.equal(parsed.plugins[0]?.name, "git");
385
+ assert.equal(parsed.plugins[0]?.source, "pi-steering/plugins/git");
386
+ assert.equal(parsed.userRules[0]?.name, "u1");
387
+ assert.equal(parsed.userObservers[0]?.name, "obs1");
388
+ assert.deepEqual(parsed.userObservers[0]?.writes, ["thing-happened"]);
389
+ });
390
+
391
+ it("rejects an unknown --format value", async () => {
392
+ const r = await runCli({ cwd: scratch }, "list", "--format=yaml");
393
+ assert.equal(r.code, 1);
394
+ assert.match(r.stderr, /unknown --format value "yaml"/);
395
+ });
396
+
397
+ it("rejects unknown flags", async () => {
398
+ const r = await runCli({ cwd: scratch }, "list", "--nope");
399
+ assert.equal(r.code, 1);
400
+ assert.match(r.stderr, /unknown flag "--nope"/);
401
+ });
402
+
403
+ it("list --help prints per-subcommand help", async () => {
404
+ const r = await runCli({ cwd: scratch }, "list", "--help");
405
+ assert.equal(r.code, 0);
406
+ assert.match(r.stdout, /pi-steering list — show the resolved config/);
407
+ assert.match(r.stdout, /--format=text\|json/);
408
+ });
409
+
410
+ it("summarizes happened: predicate with its event", async () => {
411
+ writeSteeringDirConfig(
412
+ scratch,
413
+ `export default {
414
+ rules: [
415
+ {
416
+ name: "rq",
417
+ tool: "bash",
418
+ field: "command",
419
+ pattern: /^git push/,
420
+ when: { happened: { event: "tests-passed", in: "agent_loop" } },
421
+ reason: "no",
422
+ },
423
+ ],
424
+ };`,
425
+ );
426
+ const r = await runCli({ cwd: scratch }, "list");
427
+ assert.equal(r.code, 0);
428
+ assert.match(r.stdout, /when: happened:tests-passed/);
429
+ });
430
+
431
+ it("marks disabled rules with '(disabled)' suffix in text output (F4)", async () => {
432
+ writeSteeringDirConfig(
433
+ scratch,
434
+ `export default {
435
+ plugins: [
436
+ {
437
+ name: "git",
438
+ rules: [
439
+ { name: "active-rule", tool: "bash", field: "command", pattern: /./, reason: "r" },
440
+ { name: "disabled-rule", tool: "bash", field: "command", pattern: /./, reason: "r" },
441
+ ],
442
+ },
443
+ ],
444
+ disabledRules: ["disabled-rule"],
445
+ };`,
446
+ );
447
+ const r = await runCli({ cwd: scratch }, "list");
448
+ assert.equal(r.code, 0);
449
+ // Active rule: no suffix.
450
+ assert.match(r.stdout, /active-rule\s+bash\s*$/m);
451
+ // Disabled rule: (disabled) suffix.
452
+ assert.match(r.stdout, /disabled-rule\s+bash\s+\(disabled\)/);
453
+ // Footer unchanged.
454
+ assert.match(r.stdout, /Disabled rules: disabled-rule/);
455
+ });
456
+
457
+ it("marks disabled plugins with '(disabled)' suffix on the header (F4)", async () => {
458
+ writeSteeringDirConfig(
459
+ scratch,
460
+ `export default {
461
+ plugins: [
462
+ {
463
+ name: "git",
464
+ rules: [
465
+ { name: "some-rule", tool: "bash", field: "command", pattern: /./, reason: "r" },
466
+ ],
467
+ },
468
+ ],
469
+ disabledPlugins: ["git"],
470
+ };`,
471
+ );
472
+ const r = await runCli({ cwd: scratch }, "list");
473
+ assert.equal(r.code, 0);
474
+ // Plugin header carries the (disabled) suffix.
475
+ assert.match(
476
+ r.stdout,
477
+ /git\s+\[pi-steering\/plugins\/git\]\s+\(disabled\)/,
478
+ );
479
+ assert.match(r.stdout, /Disabled plugins: git/);
480
+ });
481
+
482
+ it("JSON output tags disabled rules and plugins with 'disabled: true' (F4)", async () => {
483
+ writeSteeringDirConfig(
484
+ scratch,
485
+ `export default {
486
+ plugins: [
487
+ {
488
+ name: "git",
489
+ rules: [
490
+ { name: "active-rule", tool: "bash", field: "command", pattern: /./, reason: "r" },
491
+ { name: "disabled-rule", tool: "bash", field: "command", pattern: /./, reason: "r" },
492
+ ],
493
+ },
494
+ { name: "also-disabled" },
495
+ ],
496
+ disabledRules: ["disabled-rule"],
497
+ disabledPlugins: ["also-disabled"],
498
+ };`,
499
+ );
500
+ const r = await runCli({ cwd: scratch }, "list", "--format=json");
501
+ assert.equal(r.code, 0);
502
+ const parsed = JSON.parse(r.stdout) as {
503
+ plugins: Array<{
504
+ name: string;
505
+ disabled?: boolean;
506
+ rules: Array<{ name: string; disabled?: boolean }>;
507
+ }>;
508
+ };
509
+ const git = parsed.plugins.find((p) => p.name === "git");
510
+ const also = parsed.plugins.find((p) => p.name === "also-disabled");
511
+ assert.ok(git);
512
+ assert.ok(also);
513
+ assert.equal(
514
+ git.disabled,
515
+ undefined,
516
+ "git plugin is active; no disabled flag",
517
+ );
518
+ assert.equal(
519
+ also.disabled,
520
+ true,
521
+ "also-disabled plugin carries disabled: true",
522
+ );
523
+ const active = git.rules.find((r) => r.name === "active-rule");
524
+ const disabled = git.rules.find((r) => r.name === "disabled-rule");
525
+ assert.ok(active);
526
+ assert.ok(disabled);
527
+ assert.equal(active.disabled, undefined);
528
+ assert.equal(disabled.disabled, true);
529
+ });
530
+ });
531
+
532
+ // ---------------------------------------------------------------------------
533
+ // list: diagnostic surfacing on stderr
534
+ // ---------------------------------------------------------------------------
535
+
536
+ describe("pi-steering list: diagnostics on stderr", () => {
537
+ it("writes a layer-stray-file diagnostic to stderr in the legacy single-line shape", async () => {
538
+ const pi = join(scratch, ".pi", "steering");
539
+ mkdirSync(pi, { recursive: true });
540
+ writeFileSync(join(pi, "rules.json"), "{}", "utf8");
541
+ const r = await runCli({ cwd: scratch }, "list");
542
+ assert.equal(r.code, 0);
543
+ // Diagnostic on stderr; stdout shows the empty-config render.
544
+ assert.match(
545
+ r.stderr,
546
+ /\[pi-steering\] .*rules\.json: ignoring non-\.ts file under \.pi\/steering\//,
547
+ `expected stray-file diagnostic on stderr; got: ${r.stderr}`,
548
+ );
549
+ });
550
+
551
+ it("writes a layer-import-failed diagnostic to stderr when a layer fails to import", async () => {
552
+ const pi = join(scratch, ".pi");
553
+ mkdirSync(pi, { recursive: true });
554
+ writeFileSync(
555
+ join(pi, "steering.ts"),
556
+ "export default { rules: {{ not valid ts }} };",
557
+ "utf8",
558
+ );
559
+ const r = await runCli({ cwd: scratch }, "list");
560
+ assert.equal(r.code, 0);
561
+ assert.match(
562
+ r.stderr,
563
+ /\[pi-steering\] .*\.pi\/steering\.ts: failed to import:/,
564
+ `expected layer-import-failed diagnostic on stderr; got: ${r.stderr}`,
565
+ );
566
+ });
567
+
568
+ it("writes an error-class merge diagnostic to stderr with the [error] tag exactly once", async () => {
569
+ writeSteeringDirConfig(
570
+ scratch,
571
+ `const t = { initial: "?", unknown: "unknown", modifiers: {}, subshellSemantics: "isolated" };
572
+ export default {
573
+ plugins: [
574
+ { name: "pa", trackers: { branch: t } },
575
+ { name: "pb", trackers: { branch: t } },
576
+ ],
577
+ };`,
578
+ );
579
+ const r = await runCli({ cwd: scratch }, "list");
580
+ // Error-class diagnostic → exit 1 so CI pipelines using
581
+ // `pi-steering list` as a config-lint pre-flight gate the run
582
+ // without parsing stderr.
583
+ assert.equal(r.code, 1);
584
+ assert.match(
585
+ r.stderr,
586
+ /\[pi-steering\] \[error\] tracker name collision/,
587
+ `expected [error]-tagged tracker collision on stderr; got: ${r.stderr}`,
588
+ );
589
+ // The shared merge-pipeline helper short-circuits between
590
+ // buildConfig and resolvePlugins on error-class merge
591
+ // diagnostics, so the same collision should appear exactly once
592
+ // even though both buildConfig and resolvePlugins independently
593
+ // detect tracker-name collisions.
594
+ const occurrences = r.stderr.match(/tracker name collision/g) ?? [];
595
+ assert.equal(
596
+ occurrences.length,
597
+ 1,
598
+ `expected exactly one tracker-name-collision line on stderr; got ${occurrences.length}: ${r.stderr}`,
599
+ );
600
+ });
601
+
602
+ it("surfaces BOTH a tracker-name-collision AND a malformed user-config rule name on stderr in one run", async () => {
603
+ // Combined-error case: a config with both a merge-side error
604
+ // (tracker-name-collision) AND a user-config-side error
605
+ // (malformed rule name) should produce both diagnostics on
606
+ // stderr in a single `pi-steering list` invocation. Before this change,
607
+ // `runMergerPipeline`'s short-circuit gated
608
+ // `validateUserConfigNames` behind the merge-error branch — the
609
+ // CLI user fixed the tracker, re-ran, and only THEN saw the
610
+ // malformed name. After this change, user-config name validation runs
611
+ // unconditionally so both classes of error surface together,
612
+ // matching the production runtime's aggregated throw.
613
+ writeSteeringDirConfig(
614
+ scratch,
615
+ `const t = { initial: "?", unknown: "unknown", modifiers: {}, subshellSemantics: "isolated" };
616
+ export default {
617
+ plugins: [
618
+ { name: "pa", trackers: { branch: t } },
619
+ { name: "pb", trackers: { branch: t } },
620
+ ],
621
+ rules: [
622
+ {
623
+ name: "phony] BAD",
624
+ tool: "bash",
625
+ field: "command",
626
+ pattern: /^never$/,
627
+ reason: "r",
628
+ },
629
+ ],
630
+ };`,
631
+ );
632
+ const r = await runCli({ cwd: scratch }, "list");
633
+ assert.equal(
634
+ r.code,
635
+ 1,
636
+ `expected exit 1 on combined error stream; got: code=${r.code}, stderr=${r.stderr}`,
637
+ );
638
+ assert.match(
639
+ r.stderr,
640
+ /\[pi-steering\] \[error\] tracker name collision/,
641
+ `expected [error]-tagged tracker-name-collision on stderr; got: ${r.stderr}`,
642
+ );
643
+ assert.match(
644
+ r.stderr,
645
+ /\[pi-steering\] \[error\] rule name "phony\] BAD" \(user config\).*disallowed/,
646
+ `expected [error]-tagged invalid-name on stderr; got: ${r.stderr}`,
647
+ );
648
+ });
649
+
650
+ it("writes an [error]-tagged reserved-tracker-name diagnostic from the plugin merger to stderr", async () => {
651
+ // Reserved-name violations fire only at the plugin-merger surface;
652
+ // without the merger pass in `runList`, a user running
653
+ // `pi-steering list` on a config with a reserved tracker name
654
+ // would see no error, then hit the same violation at extension factory time.
655
+ writeSteeringDirConfig(
656
+ scratch,
657
+ `const t = { initial: "?", unknown: "unknown", modifiers: {}, subshellSemantics: "isolated" };
658
+ export default {
659
+ plugins: [
660
+ { name: "reserved-plugin", trackers: { events: t } },
661
+ ],
662
+ };`,
663
+ );
664
+ const r = await runCli({ cwd: scratch }, "list");
665
+ assert.equal(r.code, 1);
666
+ assert.match(
667
+ r.stderr,
668
+ /\[pi-steering\] \[error\] tracker name "events" is reserved/,
669
+ `expected reserved-tracker-name diagnostic on stderr; got: ${r.stderr}`,
670
+ );
671
+ });
672
+
673
+ it("writes an [error]-tagged invalid-name diagnostic from the plugin merger to stderr", async () => {
674
+ // Malformed plugin / rule / observer names flow through the
675
+ // merger's diagnostic stream after the validateName refactor.
676
+ writeSteeringDirConfig(
677
+ scratch,
678
+ `export default {
679
+ plugins: [
680
+ {
681
+ name: "forge-plugin",
682
+ rules: [
683
+ {
684
+ name: "bad name",
685
+ tool: "bash",
686
+ field: "command",
687
+ pattern: /^never$/,
688
+ reason: "r",
689
+ },
690
+ ],
691
+ },
692
+ ],
693
+ };`,
694
+ );
695
+ const r = await runCli({ cwd: scratch }, "list");
696
+ assert.equal(r.code, 1);
697
+ assert.match(
698
+ r.stderr,
699
+ /\[pi-steering\] \[error\] rule name "bad name" \(plugin "forge-plugin"\).*disallowed/,
700
+ `expected invalid-name diagnostic on stderr; got: ${r.stderr}`,
701
+ );
702
+ });
703
+
704
+ it("a clean config produces no diagnostic lines on stderr and exits 0", async () => {
705
+ writeSteeringDirConfig(
706
+ scratch,
707
+ `export default {
708
+ rules: [
709
+ {
710
+ name: "clean-rule",
711
+ tool: "bash",
712
+ field: "command",
713
+ pattern: /^never$/,
714
+ reason: "r",
715
+ },
716
+ ],
717
+ };`,
718
+ );
719
+ const r = await runCli({ cwd: scratch }, "list");
720
+ assert.equal(r.code, 0);
721
+ const diagnosticLines = r.stderr
722
+ .split("\n")
723
+ .filter((line) => line.startsWith("[pi-steering]"));
724
+ assert.equal(
725
+ diagnosticLines.length,
726
+ 0,
727
+ `expected zero diagnostic lines; got: ${JSON.stringify(diagnosticLines)}`,
728
+ );
729
+ });
730
+
731
+ it("a warning-only diagnostic stream still exits 0 (warnings are fail-soft on the CLI)", async () => {
732
+ // Warning-class diagnostics like `layer-stray-file` are advisory
733
+ // — they don't prevent production from starting on this config.
734
+ // CI pipelines treating exit 1 as "config rejected" should not
735
+ // trip on warnings; the CLI only escalates exit code on error-
736
+ // class diagnostics.
737
+ const pi = join(scratch, ".pi", "steering");
738
+ mkdirSync(pi, { recursive: true });
739
+ writeFileSync(join(pi, "rules.json"), "{}", "utf8");
740
+ const r = await runCli({ cwd: scratch }, "list");
741
+ assert.equal(r.code, 0);
742
+ assert.match(
743
+ r.stderr,
744
+ /ignoring non-\.ts file/,
745
+ `expected stray-file diagnostic on stderr; got: ${r.stderr}`,
746
+ );
747
+ });
748
+
749
+ it("flags a malformed user-config rule name with an invalid-name diagnostic and exits 1", async () => {
750
+ // User-config rule names are validated only at extension factory time
751
+ // (via the evaluator's build-time throw). Without this CLI pass,
752
+ // `pi-steering list` would render the malformed rule as a valid
753
+ // listing on stdout, then production would refuse to start —
754
+ // authors using the CLI as pre-flight would get a false-green.
755
+ writeSteeringDirConfig(
756
+ scratch,
757
+ `export default {
758
+ rules: [
759
+ {
760
+ name: "phony] ALL CLEAR [real",
761
+ tool: "bash",
762
+ field: "command",
763
+ pattern: /^never$/,
764
+ reason: "r",
765
+ },
766
+ ],
767
+ };`,
768
+ );
769
+ const r = await runCli({ cwd: scratch }, "list");
770
+ assert.equal(r.code, 1);
771
+ assert.match(
772
+ r.stderr,
773
+ /\[pi-steering\] \[error\] rule name "phony\] ALL CLEAR \[real" \(user config\).*disallowed/,
774
+ `expected user-config rule invalid-name diagnostic on stderr; got: ${r.stderr}`,
775
+ );
776
+ });
777
+
778
+ it("flags a malformed user-config observer name with an invalid-name diagnostic and exits 1", async () => {
779
+ // Same shape as the rule-name case but for observers — ensures
780
+ // the CLI's pre-flight surface covers both halves of the
781
+ // `validateUserConfigNames` helper.
782
+ writeSteeringDirConfig(
783
+ scratch,
784
+ `export default {
785
+ observers: [
786
+ { name: "evil] obs", onResult: () => {} },
787
+ ],
788
+ };`,
789
+ );
790
+ const r = await runCli({ cwd: scratch }, "list");
791
+ assert.equal(r.code, 1);
792
+ assert.match(
793
+ r.stderr,
794
+ /\[pi-steering\] \[error\] observer name "evil\] obs" \(user config\).*disallowed/,
795
+ `expected user-config observer invalid-name diagnostic on stderr; got: ${r.stderr}`,
796
+ );
797
+ });
798
+
799
+ it("surfaces an `observer dropped` info-level breadcrumb on stderr when an observer's writes are unconsumed", async () => {
800
+ // CLI mirrors `buildSessionRuntime`'s `dropUnusedObservers` pass
801
+ // so `pi-steering list` surfaces the same breadcrumb a session
802
+ // would, via the `console.info` interception in
803
+ // `runCliMergeWithInfoCapture` (lands on stderr; stdout stays
804
+ // clean for the structured listing).
805
+ writeSteeringDirConfig(
806
+ scratch,
807
+ `export default {
808
+ observers: [
809
+ {
810
+ name: "unread",
811
+ writes: ["never_consumed"],
812
+ onResult: () => {},
813
+ },
814
+ ],
815
+ rules: [
816
+ {
817
+ name: "r",
818
+ tool: "bash",
819
+ field: "command",
820
+ pattern: /^never$/,
821
+ reason: "r",
822
+ },
823
+ ],
824
+ };`,
825
+ );
826
+ const r = await runCli({ cwd: scratch }, "list");
827
+ // Drop pass is informational, not error-class — exit 0.
828
+ assert.equal(
829
+ r.code,
830
+ 0,
831
+ `expected exit 0 (info-level breadcrumb only); got code=${r.code}, stderr=${r.stderr}`,
832
+ );
833
+ assert.match(
834
+ r.stderr,
835
+ /\[pi-steering\] observer 'unread' dropped; its writes \(never_consumed\) are not consumed by any rule/,
836
+ `expected observer-drop breadcrumb on stderr; got: ${r.stderr}`,
837
+ );
838
+ });
839
+
840
+ it("surfaces an `observer dropped` breadcrumb when the consumer rule is disabled via `disabledRules` (parity with runtime)", async () => {
841
+ // CLI must filter `merged.rules` against `disabledRules` before
842
+ // `dropUnusedObservers` so the observer set the runtime would
843
+ // drop at extension factory time matches what `pi-steering list`
844
+ // reports.
845
+ writeSteeringDirConfig(
846
+ scratch,
847
+ `export default {
848
+ disabledRules: ["consumer"],
849
+ observers: [
850
+ {
851
+ name: "obs-x",
852
+ writes: ["X"],
853
+ onResult: () => {},
854
+ },
855
+ ],
856
+ rules: [
857
+ {
858
+ name: "consumer",
859
+ tool: "bash",
860
+ field: "command",
861
+ pattern: /^never$/,
862
+ reason: "r",
863
+ when: { happened: { event: "X" } },
864
+ },
865
+ ],
866
+ };`,
867
+ );
868
+ const r = await runCli({ cwd: scratch }, "list");
869
+ assert.equal(
870
+ r.code,
871
+ 0,
872
+ `expected exit 0 (info-level breadcrumb only); got code=${r.code}, stderr=${r.stderr}`,
873
+ );
874
+ assert.match(
875
+ r.stderr,
876
+ /\[pi-steering\] observer 'obs-x' dropped; its writes \(X\) are not consumed by any rule/,
877
+ `expected observer-drop breadcrumb on stderr (consumer rule was disabled, observer should be reported as dropped to match runtime); got: ${r.stderr}`,
878
+ );
879
+ });
880
+
881
+ it("does NOT drop the observer when the consumer rule is enabled (inverse parity)", async () => {
882
+ // Pins the inverse direction so a refactor that flips the
883
+ // filter (`disabledRules.has(r.name)` ↔ `!disabledRules.has`)
884
+ // surfaces here — the disabled-true case alone would not catch
885
+ // it.
886
+ writeSteeringDirConfig(
887
+ scratch,
888
+ `export default {
889
+ observers: [
890
+ {
891
+ name: "obs-x",
892
+ writes: ["X"],
893
+ onResult: () => {},
894
+ },
895
+ ],
896
+ rules: [
897
+ {
898
+ name: "consumer",
899
+ tool: "bash",
900
+ field: "command",
901
+ pattern: /^never$/,
902
+ reason: "r",
903
+ when: { happened: { event: "X" } },
904
+ },
905
+ ],
906
+ };`,
907
+ );
908
+ const r = await runCli({ cwd: scratch }, "list");
909
+ assert.equal(
910
+ r.code,
911
+ 0,
912
+ `expected exit 0 (clean run); got code=${r.code}, stderr=${r.stderr}`,
913
+ );
914
+ assert.doesNotMatch(
915
+ r.stderr,
916
+ /\[pi-steering\] observer 'obs-x' dropped/,
917
+ `expected NO observer-drop breadcrumb on stderr (consumer rule is enabled, observer is consumed); got: ${r.stderr}`,
918
+ );
919
+ });
920
+ });