@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,585 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Invariants for v2 DEFAULT_RULES. Ported from v1's `../defaults.test.ts`
6
+ * with ZERO semantic drift — the patterns are byte-for-byte identical
7
+ * and the spot-check cases match v1's exact expectations. If a case
8
+ * here flips vs. its v1 counterpart, the pattern drifted and the port
9
+ * is broken.
10
+ *
11
+ * Two suites:
12
+ *
13
+ * - Shape invariants (count, uniqueness, non-empty fields, valid
14
+ * regex) — guards against accidental edits to the default safety
15
+ * contract.
16
+ * - Pattern spot-checks — assert raw `new RegExp(...)` behavior for
17
+ * each default. Keeps pattern typos visible as defaults-test
18
+ * failures, not as evaluator-test noise.
19
+ *
20
+ * We also smoke-drive {@link buildEvaluator} with ONLY {@link
21
+ * DEFAULT_RULES} + {@link DEFAULT_PLUGINS} on a handful of
22
+ * representative commands, so the end-to-end path (load → merge →
23
+ * parse → walk → evaluate) sees the same verdicts the regex tests
24
+ * predict. This is the regression fence v1's
25
+ * `integration.test.ts` / `examples.test.ts` used to be.
26
+ */
27
+
28
+ import assert from "node:assert/strict";
29
+ import { describe, it } from "node:test";
30
+ import type { BashToolCallEvent } from "@earendil-works/pi-coding-agent";
31
+ import { makeCtx, makeTrackedHost as makeHost } from "./__test-helpers__.ts";
32
+ import { DEFAULT_PLUGINS, DEFAULT_RULES } from "./defaults.ts";
33
+ import { buildEvaluator } from "./evaluator.ts";
34
+ import { resolvePlugins } from "./plugin-merger.ts";
35
+ import gitPlugin from "./plugins/git/index.ts";
36
+ import type { Rule } from "./schema.ts";
37
+
38
+ // `DEFAULT_RULES` is `as const satisfies readonly Rule[]` — preserving
39
+ // each rule's `name` literal for the typo-check unions in
40
+ // `define-config.ts`. For shape-invariant iteration, the test code
41
+ // reads against the wider `Rule` view (optional fields visible,
42
+ // pattern not narrowed to a string-literal), so widen it once here.
43
+ const RULES_AS_RULE: readonly Rule[] = DEFAULT_RULES;
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Shape invariants
47
+ // ---------------------------------------------------------------------------
48
+
49
+ describe("defaults: DEFAULT_RULES shape", () => {
50
+ it("has the expected rule count (four)", () => {
51
+ // Locking the count keeps additions/removals a deliberate,
52
+ // reviewed edit.
53
+ assert.equal(DEFAULT_RULES.length, 4);
54
+ });
55
+
56
+ it("has unique rule names", () => {
57
+ const names = DEFAULT_RULES.map((r) => r.name);
58
+ assert.equal(new Set(names).size, names.length);
59
+ });
60
+
61
+ it("every rule has non-empty name, pattern, reason", () => {
62
+ for (const r of RULES_AS_RULE) {
63
+ assert.ok(r.name.length > 0, `empty name: ${JSON.stringify(r)}`);
64
+ const patternLen =
65
+ typeof r.pattern === "string"
66
+ ? r.pattern.length
67
+ : r.pattern.source.length;
68
+ assert.ok(patternLen > 0, `empty pattern in ${r.name}`);
69
+ assert.ok(r.reason.length > 0, `empty reason in ${r.name}`);
70
+ }
71
+ });
72
+
73
+ it("every rule has a valid regex pattern (requires/unless too)", () => {
74
+ for (const r of RULES_AS_RULE) {
75
+ if (typeof r.pattern === "string") {
76
+ assert.doesNotThrow(
77
+ () => new RegExp(r.pattern as string),
78
+ `bad pattern in ${r.name}`,
79
+ );
80
+ }
81
+ if (r.requires !== undefined && typeof r.requires === "string") {
82
+ assert.doesNotThrow(
83
+ () => new RegExp(r.requires as string),
84
+ `bad requires in ${r.name}`,
85
+ );
86
+ }
87
+ if (r.unless !== undefined && typeof r.unless === "string") {
88
+ assert.doesNotThrow(
89
+ () => new RegExp(r.unless as string),
90
+ `bad unless in ${r.name}`,
91
+ );
92
+ }
93
+ }
94
+ });
95
+
96
+ it("every default rule targets the bash tool (current scope)", () => {
97
+ // If we ever add a write/edit default, this test should be
98
+ // updated explicitly — keeps the scope of DEFAULT_RULES visible.
99
+ for (const r of DEFAULT_RULES) {
100
+ assert.equal(r.tool, "bash", `unexpected tool on ${r.name}: ${r.tool}`);
101
+ }
102
+ });
103
+
104
+ it("no-rm-rf-slash carries noOverride: true", () => {
105
+ const rule = DEFAULT_RULES.find((r) => r.name === "no-rm-rf-slash");
106
+ assert.ok(rule, "no-rm-rf-slash not found in defaults");
107
+ assert.equal(rule?.noOverride, true);
108
+ });
109
+ });
110
+
111
+ describe("defaults: DEFAULT_PLUGINS shape", () => {
112
+ it("ships NO default plugins (git plugin is opt-in)", () => {
113
+ // D1 was reversed in the v0.1.x monorepo split: the git plugin
114
+ // used to be promoted into DEFAULT_PLUGINS (default-on), which
115
+ // broke compile-time typo-checking for configs that relied on
116
+ // the default without declaring `plugins: [gitPlugin]` — the
117
+ // runtime registration existed but the rule / plugin name
118
+ // unions in `defineConfig`'s generics only see DECLARED
119
+ // plugins. Runtime defaults and type-level visibility must not
120
+ // diverge, so DEFAULT_PLUGINS is empty and the git plugin is
121
+ // opt-in via `plugins: [gitPlugin]` (see `plugins/git/index.ts`
122
+ // for the declared-plugin contract).
123
+ //
124
+ // This test is the count lock: any addition to the default
125
+ // plugin list is a deliberate ship-surface change and must
126
+ // update this assertion explicitly.
127
+ assert.equal(
128
+ DEFAULT_PLUGINS.length,
129
+ 0,
130
+ "DEFAULT_PLUGINS should be empty; domain plugins (git) are opt-in",
131
+ );
132
+ });
133
+ });
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Pattern spot-checks
137
+ // ---------------------------------------------------------------------------
138
+
139
+ describe("defaults: DEFAULT_RULES pattern spot-checks", () => {
140
+ function pattern(name: string): RegExp {
141
+ const r = DEFAULT_RULES.find((r) => r.name === name);
142
+ if (!r) throw new Error(`default rule not found: ${name}`);
143
+ // Defaults are authored as string patterns (see `./defaults.ts`);
144
+ // compile to RegExp for assertion via `.test(...)`.
145
+ if (typeof r.pattern !== "string") {
146
+ throw new Error(
147
+ `default rule ${name} uses RegExp pattern; update this helper`,
148
+ );
149
+ }
150
+ return new RegExp(r.pattern);
151
+ }
152
+
153
+ it("no-force-push matches `git push --force`", () => {
154
+ assert.equal(pattern("no-force-push").test("git push --force"), true);
155
+ });
156
+
157
+ it("no-force-push matches `git push -f`", () => {
158
+ assert.equal(pattern("no-force-push").test("git push -f"), true);
159
+ });
160
+
161
+ it("no-force-push does NOT match `git push --force-with-lease`", () => {
162
+ assert.equal(
163
+ pattern("no-force-push").test("git push --force-with-lease"),
164
+ false,
165
+ );
166
+ });
167
+
168
+ it("no-force-push does NOT match plain `git push origin main`", () => {
169
+ assert.equal(pattern("no-force-push").test("git push origin main"), false);
170
+ });
171
+
172
+ it("no-force-push matches `git push origin main --force`", () => {
173
+ assert.equal(
174
+ pattern("no-force-push").test("git push origin main --force"),
175
+ true,
176
+ );
177
+ });
178
+
179
+ it("no-force-push matches `git -C /other push --force` (pre-subcommand flag)", () => {
180
+ assert.equal(
181
+ pattern("no-force-push").test("git -C /other push --force"),
182
+ true,
183
+ );
184
+ });
185
+
186
+ it("no-force-push matches `git -c rerere.enabled=false push --force` (key=val config)", () => {
187
+ assert.equal(
188
+ pattern("no-force-push").test("git -c rerere.enabled=false push --force"),
189
+ true,
190
+ );
191
+ });
192
+
193
+ it("no-force-push matches `git --git-dir=/path push --force` (long-form pre-subcommand)", () => {
194
+ assert.equal(
195
+ pattern("no-force-push").test("git --git-dir=/path push --force"),
196
+ true,
197
+ );
198
+ });
199
+
200
+ it("no-force-push matches `git push --force-bar` (other --force-* suffix, accepted over-match)", () => {
201
+ assert.equal(pattern("no-force-push").test("git push --force-bar"), true);
202
+ });
203
+
204
+ it("no-hard-reset matches `git reset --hard`", () => {
205
+ assert.equal(pattern("no-hard-reset").test("git reset --hard"), true);
206
+ });
207
+
208
+ it("no-hard-reset matches `git reset --hard HEAD`", () => {
209
+ assert.equal(pattern("no-hard-reset").test("git reset --hard HEAD"), true);
210
+ });
211
+
212
+ it("no-hard-reset does NOT match `git reset --soft`", () => {
213
+ assert.equal(
214
+ pattern("no-hard-reset").test("git reset --soft HEAD~1"),
215
+ false,
216
+ );
217
+ });
218
+
219
+ it("no-hard-reset matches `git -C /other reset --hard` (pre-subcommand flag)", () => {
220
+ assert.equal(
221
+ pattern("no-hard-reset").test("git -C /other reset --hard"),
222
+ true,
223
+ );
224
+ });
225
+
226
+ it("no-hard-reset matches `git -c rerere.enabled=false reset --hard` (key=val config)", () => {
227
+ assert.equal(
228
+ pattern("no-hard-reset").test("git -c rerere.enabled=false reset --hard"),
229
+ true,
230
+ );
231
+ });
232
+
233
+ it("no-rm-rf-slash matches `rm -rf /`", () => {
234
+ assert.equal(pattern("no-rm-rf-slash").test("rm -rf /"), true);
235
+ });
236
+
237
+ it("no-rm-rf-slash matches `rm -fr /` (flag order agnostic)", () => {
238
+ assert.equal(pattern("no-rm-rf-slash").test("rm -fr /"), true);
239
+ });
240
+
241
+ it("no-rm-rf-slash matches `rm -r -f /` (separated flags)", () => {
242
+ assert.equal(pattern("no-rm-rf-slash").test("rm -r -f /"), true);
243
+ });
244
+
245
+ it("no-rm-rf-slash matches `rm --recursive --force /` (long-form flags)", () => {
246
+ assert.equal(
247
+ pattern("no-rm-rf-slash").test("rm --recursive --force /"),
248
+ true,
249
+ );
250
+ });
251
+
252
+ it("no-rm-rf-slash matches `rm -Rf /` (uppercase R)", () => {
253
+ assert.equal(pattern("no-rm-rf-slash").test("rm -Rf /"), true);
254
+ });
255
+
256
+ it("no-rm-rf-slash does NOT match `rm -rf /tmp`", () => {
257
+ assert.equal(pattern("no-rm-rf-slash").test("rm -rf /tmp"), false);
258
+ });
259
+
260
+ it("no-rm-rf-slash does NOT match `rm /tmp` (no flags)", () => {
261
+ assert.equal(pattern("no-rm-rf-slash").test("rm /tmp"), false);
262
+ });
263
+
264
+ it("no-rm-rf-slash does NOT match `rm -r /tmp` (missing force flag)", () => {
265
+ assert.equal(pattern("no-rm-rf-slash").test("rm -r /tmp"), false);
266
+ });
267
+
268
+ it("no-rm-rf-slash does NOT match `rm -f /` (missing recursive flag)", () => {
269
+ assert.equal(pattern("no-rm-rf-slash").test("rm -f /"), false);
270
+ });
271
+
272
+ it("no-rm-rf-slash does NOT match `rm -rf .`", () => {
273
+ assert.equal(pattern("no-rm-rf-slash").test("rm -rf ."), false);
274
+ });
275
+
276
+ it("no-long-running-commands matches `npm run dev`", () => {
277
+ assert.equal(pattern("no-long-running-commands").test("npm run dev"), true);
278
+ });
279
+
280
+ it("no-long-running-commands matches `tsc --watch`", () => {
281
+ assert.equal(pattern("no-long-running-commands").test("tsc --watch"), true);
282
+ });
283
+
284
+ it("no-long-running-commands does NOT match `npm run build`", () => {
285
+ assert.equal(
286
+ pattern("no-long-running-commands").test("npm run build"),
287
+ false,
288
+ );
289
+ });
290
+
291
+ it("no-long-running-commands matches `pnpm dev`", () => {
292
+ assert.equal(pattern("no-long-running-commands").test("pnpm dev"), true);
293
+ });
294
+
295
+ it("no-long-running-commands matches `pnpm run dev`", () => {
296
+ assert.equal(
297
+ pattern("no-long-running-commands").test("pnpm run dev"),
298
+ true,
299
+ );
300
+ });
301
+
302
+ it("no-long-running-commands does NOT match `pnpm build`", () => {
303
+ assert.equal(pattern("no-long-running-commands").test("pnpm build"), false);
304
+ });
305
+
306
+ it("no-long-running-commands matches `vite` (bare = dev server)", () => {
307
+ assert.equal(pattern("no-long-running-commands").test("vite"), true);
308
+ });
309
+
310
+ it("no-long-running-commands matches `vite dev`", () => {
311
+ assert.equal(pattern("no-long-running-commands").test("vite dev"), true);
312
+ });
313
+
314
+ it("no-long-running-commands does NOT match `vite build`", () => {
315
+ assert.equal(pattern("no-long-running-commands").test("vite build"), false);
316
+ });
317
+
318
+ it("no-long-running-commands matches `astro dev`", () => {
319
+ assert.equal(pattern("no-long-running-commands").test("astro dev"), true);
320
+ });
321
+
322
+ it("no-long-running-commands does NOT match `astro build`", () => {
323
+ assert.equal(
324
+ pattern("no-long-running-commands").test("astro build"),
325
+ false,
326
+ );
327
+ });
328
+
329
+ it("no-long-running-commands matches `next dev`", () => {
330
+ assert.equal(pattern("no-long-running-commands").test("next dev"), true);
331
+ });
332
+
333
+ it("no-long-running-commands does NOT match `next build`", () => {
334
+ assert.equal(pattern("no-long-running-commands").test("next build"), false);
335
+ });
336
+
337
+ it("no-long-running-commands matches `deno task dev`", () => {
338
+ assert.equal(
339
+ pattern("no-long-running-commands").test("deno task dev"),
340
+ true,
341
+ );
342
+ });
343
+
344
+ it("no-long-running-commands does NOT match `deno task build`", () => {
345
+ assert.equal(
346
+ pattern("no-long-running-commands").test("deno task build"),
347
+ false,
348
+ );
349
+ });
350
+
351
+ it("no-long-running-commands matches `bun dev`", () => {
352
+ assert.equal(pattern("no-long-running-commands").test("bun dev"), true);
353
+ });
354
+
355
+ it("no-long-running-commands matches `bun run dev`", () => {
356
+ assert.equal(pattern("no-long-running-commands").test("bun run dev"), true);
357
+ });
358
+
359
+ it("no-long-running-commands does NOT match `bun install`", () => {
360
+ assert.equal(
361
+ pattern("no-long-running-commands").test("bun install"),
362
+ false,
363
+ );
364
+ });
365
+ });
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // End-to-end: DEFAULT_RULES through buildEvaluator
369
+ // ---------------------------------------------------------------------------
370
+
371
+ /**
372
+ * Build an evaluator driven ONLY by {@link DEFAULT_RULES} +
373
+ * {@link DEFAULT_PLUGINS} so the rules' effective behavior (including
374
+ * the walker + wrapper expansion) gets end-to-end coverage. Everything
375
+ * else (predicates, observers, user-rules) is already exercised in
376
+ * `evaluator.test.ts`; this suite is the regression fence for the
377
+ * shipped defaults.
378
+ */
379
+ function defaultsEvaluator() {
380
+ const resolved = resolvePlugins(DEFAULT_PLUGINS, {});
381
+ return buildEvaluator({ rules: DEFAULT_RULES }, resolved, makeHost());
382
+ }
383
+
384
+ function bashEvent(command: string): BashToolCallEvent {
385
+ return {
386
+ type: "tool_call",
387
+ toolCallId: "t1",
388
+ toolName: "bash",
389
+ input: { command },
390
+ };
391
+ }
392
+
393
+ describe("defaults: end-to-end via buildEvaluator", () => {
394
+ it("blocks `git push --force`", async () => {
395
+ const ev = defaultsEvaluator();
396
+ const r = await ev.evaluate(
397
+ bashEvent("git push --force"),
398
+ makeCtx("/r"),
399
+ 0,
400
+ );
401
+ assert.equal((r as { block?: boolean } | undefined)?.block, true);
402
+ assert.match(
403
+ (r as { reason?: string } | undefined)?.reason ?? "",
404
+ /no-force-push/,
405
+ );
406
+ });
407
+
408
+ it("allows `git push --force-with-lease`", async () => {
409
+ const ev = defaultsEvaluator();
410
+ const r = await ev.evaluate(
411
+ bashEvent("git push --force-with-lease"),
412
+ makeCtx("/r"),
413
+ 0,
414
+ );
415
+ assert.equal(r, undefined);
416
+ });
417
+
418
+ it("catches `git push --force` behind `sh -c` wrapper", async () => {
419
+ // The AST backend's wrapper-expansion sees the inner command even
420
+ // behind sh/bash -c. Regex-on-raw would miss this — the whole
421
+ // reason the walker exists.
422
+ const ev = defaultsEvaluator();
423
+ const r = await ev.evaluate(
424
+ bashEvent("sh -c 'git push --force'"),
425
+ makeCtx("/r"),
426
+ 0,
427
+ );
428
+ assert.equal((r as { block?: boolean } | undefined)?.block, true);
429
+ });
430
+
431
+ it("does NOT block `echo 'git push --force'` (basename is echo)", async () => {
432
+ const ev = defaultsEvaluator();
433
+ const r = await ev.evaluate(
434
+ bashEvent("echo 'git push --force'"),
435
+ makeCtx("/r"),
436
+ 0,
437
+ );
438
+ assert.equal(r, undefined);
439
+ });
440
+
441
+ it("blocks `git reset --hard HEAD`", async () => {
442
+ const ev = defaultsEvaluator();
443
+ const r = await ev.evaluate(
444
+ bashEvent("git reset --hard HEAD"),
445
+ makeCtx("/r"),
446
+ 0,
447
+ );
448
+ assert.equal((r as { block?: boolean } | undefined)?.block, true);
449
+ assert.match(
450
+ (r as { reason?: string } | undefined)?.reason ?? "",
451
+ /no-hard-reset/,
452
+ );
453
+ });
454
+
455
+ it("allows `git reset --soft HEAD~1`", async () => {
456
+ const ev = defaultsEvaluator();
457
+ const r = await ev.evaluate(
458
+ bashEvent("git reset --soft HEAD~1"),
459
+ makeCtx("/r"),
460
+ 0,
461
+ );
462
+ assert.equal(r, undefined);
463
+ });
464
+
465
+ it("blocks `rm -rf /` and ignores override (noOverride: true)", async () => {
466
+ const ev = defaultsEvaluator();
467
+ const r = await ev.evaluate(
468
+ bashEvent("rm -rf / # steering-override: no-rm-rf-slash — nope"),
469
+ makeCtx("/r"),
470
+ 0,
471
+ );
472
+ assert.equal((r as { block?: boolean } | undefined)?.block, true);
473
+ assert.match(
474
+ (r as { reason?: string } | undefined)?.reason ?? "",
475
+ /no-rm-rf-slash/,
476
+ );
477
+ // noOverride rules should NOT advertise the "To override" hint,
478
+ // because the rule has no override path.
479
+ assert.doesNotMatch(
480
+ (r as { reason?: string } | undefined)?.reason ?? "",
481
+ /To override/,
482
+ );
483
+ });
484
+
485
+ it("allows `rm -rf /tmp/foo` (safe path)", async () => {
486
+ const ev = defaultsEvaluator();
487
+ const r = await ev.evaluate(bashEvent("rm -rf /tmp/foo"), makeCtx("/r"), 0);
488
+ assert.equal(r, undefined);
489
+ });
490
+
491
+ it("blocks `npm run dev`", async () => {
492
+ const ev = defaultsEvaluator();
493
+ const r = await ev.evaluate(bashEvent("npm run dev"), makeCtx("/r"), 0);
494
+ assert.equal((r as { block?: boolean } | undefined)?.block, true);
495
+ assert.match(
496
+ (r as { reason?: string } | undefined)?.reason ?? "",
497
+ /no-long-running-commands/,
498
+ );
499
+ });
500
+
501
+ it("allows `npm run build`", async () => {
502
+ const ev = defaultsEvaluator();
503
+ const r = await ev.evaluate(bashEvent("npm run build"), makeCtx("/r"), 0);
504
+ assert.equal(r, undefined);
505
+ });
506
+ });
507
+
508
+ // ---------------------------------------------------------------------------
509
+ // disabledPlugins: ["git"] e2e regression fence (D1)
510
+ // ---------------------------------------------------------------------------
511
+
512
+ /**
513
+ * The plugin-merger unit test proves that `disabledPlugins` filters by
514
+ * name against a SYNTHETIC `{ name: "git", ... }` plugin. That's
515
+ * sufficient for the filter itself, but doesn't prove the REAL git
516
+ * plugin — the one users declare via `plugins: [gitPlugin]` and opt
517
+ * out of — is fully removed across every surface the plugin registers
518
+ * (predicates, rules, trackers, tracker extensions). A regression that
519
+ * merged the real plugin in through a second code path would escape
520
+ * the synthetic test entirely.
521
+ *
522
+ * One e2e smoke guard here is enough: drive `resolvePlugins` over
523
+ * the real git plugin with `disabledPlugins: ["git"]` and assert every
524
+ * surface the git plugin ships is absent from the resolved state.
525
+ * (Since the monorepo split, `DEFAULT_PLUGINS` is empty — the git
526
+ * plugin is opt-in, so this fence passes the real plugin explicitly
527
+ * rather than via the defaults.)
528
+ */
529
+ describe('defaults: disabledPlugins: ["git"] opts out of the real git plugin', () => {
530
+ it("removes no-main-commit rule, branch predicate, and cwd.git tracker extension", () => {
531
+ const resolved = resolvePlugins([gitPlugin], {
532
+ disabledPlugins: ["git"],
533
+ });
534
+
535
+ // Rule surface: `no-main-commit` (the rule the git plugin ships)
536
+ // must not appear in the resolved rule list.
537
+ const ruleNames = resolved.rules.map((r) => r.name);
538
+ assert.ok(
539
+ !ruleNames.includes("no-main-commit"),
540
+ `no-main-commit leaked past disabledPlugins: ["git"]; rules: ${ruleNames.join(", ")}`,
541
+ );
542
+
543
+ // Predicate surface: `branch` (canonical git predicate) must not
544
+ // be registered. Spot-checking one predicate is enough — if the
545
+ // plugin's predicate bundle was registered at all, `branch` would
546
+ // be present.
547
+ assert.ok(
548
+ !("branch" in resolved.predicates),
549
+ 'branch predicate leaked past disabledPlugins: ["git"]',
550
+ );
551
+
552
+ // Tracker-extension surface: `cwd.git` (the --git-dir / --work-tree
553
+ // parser layered on the core cwd tracker) must not be registered.
554
+ const cwdExts = resolved.trackerModifiers["cwd"];
555
+ assert.ok(
556
+ cwdExts === undefined || !("git" in cwdExts),
557
+ 'cwd.git tracker extension leaked past disabledPlugins: ["git"]',
558
+ );
559
+
560
+ // Plugin-merger surfaces a `console.info` breadcrumb for every
561
+ // opted-out plugin; the CLI surfaces these for plugin authors
562
+ // debugging "why isn't my plugin firing?". Spot-check that the
563
+ // breadcrumb fires here too, so a regression that silently accepts
564
+ // an unknown name also trips this test.
565
+ const origInfo = console.info;
566
+ const infos: string[] = [];
567
+ console.info = (msg: unknown) => {
568
+ infos.push(String(msg));
569
+ };
570
+ try {
571
+ resolvePlugins([gitPlugin], { disabledPlugins: ["git"] });
572
+ } finally {
573
+ console.info = origInfo;
574
+ }
575
+ assert.ok(
576
+ infos.some(
577
+ (m) =>
578
+ m.includes("[pi-steering]") &&
579
+ m.includes('plugin "git"') &&
580
+ m.includes("disabled via config.disabledPlugins"),
581
+ ),
582
+ `expected a console.info breadcrumb for the disabled git plugin; got: ${JSON.stringify(infos)}`,
583
+ );
584
+ });
585
+ });