@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,705 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * End-to-end integration tests for the git plugin.
6
+ *
7
+ * These are the most valuable tests in Phase 4: they exercise the
8
+ * full wire-up from plugin registration through `resolvePlugins`,
9
+ * `buildEvaluator`, walker tracker composition, predicate dispatch,
10
+ * and override handling. Unit tests pin individual pieces; this
11
+ * suite pins that the pieces fit together.
12
+ *
13
+ * Scenarios covered:
14
+ *
15
+ * 1. Plugin resolution - predicates, rules, trackers, and
16
+ * trackerExtensions land in the resolved state.
17
+ * 2. `DEFAULT_RULES` still block basic force-push regardless of the
18
+ * plugin (sanity: plugin wiring hasn't broken the core).
19
+ * 3. Branch predicate against a fake git `exec` - fires on main,
20
+ * allows on feature.
21
+ * 4. `-C /other` cwd extension doesn't accidentally bypass the rule.
22
+ * 5. The WALKER-DRIVEN branch case: `git checkout main && git
23
+ * commit` - the branch tracker folds the checkout into the
24
+ * commit's state, so `no-main-commit` fires on the commit even
25
+ * though `exec` would see whatever the fake session is on.
26
+ */
27
+
28
+ import assert from "node:assert/strict";
29
+ import { describe, it } from "node:test";
30
+ import type {
31
+ BashToolCallEvent,
32
+ ExecResult as PiExecResult,
33
+ } from "@earendil-works/pi-coding-agent";
34
+ import { makeCtx, makeTrackedHost } from "../../__test-helpers__.ts";
35
+ import { DEFAULT_RULES } from "../../defaults.ts";
36
+ import { buildEvaluator } from "../../evaluator.ts";
37
+ import { resolvePlugins } from "../../plugin-merger.ts";
38
+ import type { SteeringConfig } from "../../schema.ts";
39
+ import gitPlugin from "./index.ts";
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Builders
43
+ // ---------------------------------------------------------------------------
44
+
45
+ function bashEvent(command: string): BashToolCallEvent {
46
+ return {
47
+ type: "tool_call",
48
+ toolCallId: "t1",
49
+ toolName: "bash",
50
+ input: { command },
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Build an evaluator that mirrors the realistic wiring: defaults +
56
+ * the git plugin + any extra user rules. Uses `makeTrackedHost` for
57
+ * a controllable `exec` stub.
58
+ */
59
+ function buildRuntime(
60
+ config: SteeringConfig,
61
+ execStub?: (cmd: string, args: string[]) => Promise<PiExecResult>,
62
+ ) {
63
+ const host = makeTrackedHost({
64
+ exec: async (cmd, args) => {
65
+ if (execStub) return execStub(cmd, args);
66
+ return { stdout: "", stderr: "", code: 1, killed: false };
67
+ },
68
+ });
69
+ const plugins = config.plugins ?? [];
70
+ // Pass `["cwd"]` as the known built-in tracker name so the git
71
+ // plugin's cwd extension (`--git-dir=` / `--work-tree=`) doesn't
72
+ // trigger an orphan warning and is preserved for the evaluator to
73
+ // compose onto the built-in `cwdTracker`. The pi extension runtime
74
+ // (`src/index.ts`) passes the same list.
75
+ const resolved = resolvePlugins(plugins, config, ["cwd"]);
76
+ const evaluator = buildEvaluator(
77
+ { ...config, rules: config.rules ?? [...DEFAULT_RULES] },
78
+ resolved,
79
+ host,
80
+ );
81
+ return { evaluator, host, resolved };
82
+ }
83
+
84
+ /**
85
+ * Stub exec that reports a given branch name for `git branch
86
+ * --show-current`. Every other `git` call (including `git config
87
+ * --get remote.origin.url`) returns exit 1, so predicates fall back
88
+ * to their `onUnknown` policy. The github-flavored
89
+ * `noMainCommitGithub` specialization is more specific via
90
+ * `remote: { pattern: ..., onUnknown: "allow" }`; on the resulting
91
+ * no-origin signal the github rule skips and the engine cleanly
92
+ * falls through to the generic `noMainCommit` for tests in this
93
+ * scope.
94
+ *
95
+ * Tests that need to exercise the github-flavored rule (or its
96
+ * non-github fall-through) construct their own exec stub that
97
+ * additionally returns a github URL on the `git config` call.
98
+ */
99
+ function branchExec(name: string) {
100
+ return async (cmd: string, args: string[]): Promise<PiExecResult> => {
101
+ if (cmd === "git" && args[0] === "branch" && args[1] === "--show-current") {
102
+ return { stdout: `${name}\n`, stderr: "", code: 0, killed: false };
103
+ }
104
+ return { stdout: "", stderr: "", code: 1, killed: false };
105
+ };
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // 1. Plugin resolution
110
+ // ---------------------------------------------------------------------------
111
+
112
+ describe("git plugin: registration + resolution", () => {
113
+ it("resolvePlugins surfaces all four plugin surfaces", () => {
114
+ const resolved = resolvePlugins([gitPlugin], {}, ["cwd"]);
115
+ // Predicates registered.
116
+ assert.ok("branch" in resolved.predicates);
117
+ assert.ok("upstream" in resolved.predicates);
118
+ assert.ok("commitsAhead" in resolved.predicates);
119
+ assert.ok("hasStagedChanges" in resolved.predicates);
120
+ assert.ok("isClean" in resolved.predicates);
121
+ assert.ok("remote" in resolved.predicates);
122
+ // Rules registered.
123
+ assert.ok(resolved.rules.some((r) => r.name === "no-main-commit"));
124
+ // Branch tracker registered.
125
+ assert.ok("branch" in resolved.trackers);
126
+ // Cwd extension captured: when the caller declares `"cwd"` as a
127
+ // known built-in tracker name, the merger keeps the extension in
128
+ // `trackerModifiers` (rather than dropping it as orphan) so the
129
+ // evaluator can compose it onto the built-in cwdTracker.
130
+ assert.ok("cwd" in resolved.trackerModifiers);
131
+ assert.ok(resolved.trackerModifiers["cwd"]?.["git"] !== undefined);
132
+ });
133
+
134
+ it("without the knownBuiltinTrackers hint, cwd extension falls through with an orphan warning", () => {
135
+ // Callers that DON'T declare cwd as a built-in see an orphan
136
+ // warning + the extension is dropped. Pin this behavior so the
137
+ // semantic doesn't silently drift: the merger trusts the caller's
138
+ // declaration, not implicit knowledge of built-in trackers.
139
+ const resolved = resolvePlugins([gitPlugin], {});
140
+ assert.ok(
141
+ resolved.diagnostics.some((w) => w.kind === "extension-orphan"),
142
+ "expected orphan warning when cwd isn't declared as built-in",
143
+ );
144
+ assert.ok(
145
+ !("cwd" in resolved.trackerModifiers),
146
+ "expected the cwd extension to be dropped",
147
+ );
148
+ });
149
+ });
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // 2. DEFAULT_RULES still work with the plugin loaded
153
+ // ---------------------------------------------------------------------------
154
+
155
+ describe("git plugin: does not break DEFAULT_RULES", () => {
156
+ it("`git push --force` still blocks via no-force-push", async () => {
157
+ const { evaluator } = buildRuntime({ plugins: [gitPlugin] });
158
+ const res = await evaluator.evaluate(
159
+ bashEvent("git push --force origin main"),
160
+ makeCtx("/repo"),
161
+ 0,
162
+ );
163
+ assert.ok(res && res.block === true);
164
+ assert.match(res.reason!, /\[steering:no-force-push@[^\]]+\]/);
165
+ });
166
+ });
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // 3. Branch predicate via fake exec
170
+ // ---------------------------------------------------------------------------
171
+
172
+ describe("git plugin: no-main-commit via branch predicate", () => {
173
+ it("fires on main", async () => {
174
+ const { evaluator } = buildRuntime(
175
+ {
176
+ plugins: [gitPlugin],
177
+ rules: [], // only plugin-shipped rules, no DEFAULT_RULES to confuse
178
+ },
179
+ branchExec("main"),
180
+ );
181
+ const res = await evaluator.evaluate(
182
+ bashEvent("git commit -m 'x'"),
183
+ makeCtx("/repo"),
184
+ 0,
185
+ );
186
+ assert.ok(res && res.block === true);
187
+ assert.match(res.reason!, /\[steering:no-main-commit@[^\]]+\]/);
188
+ });
189
+
190
+ it("allows on feature", async () => {
191
+ const { evaluator } = buildRuntime(
192
+ { plugins: [gitPlugin], rules: [] },
193
+ branchExec("feature-x"),
194
+ );
195
+ const res = await evaluator.evaluate(
196
+ bashEvent("git commit -m 'x'"),
197
+ makeCtx("/repo"),
198
+ 0,
199
+ );
200
+ assert.equal(res, undefined);
201
+ });
202
+ });
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // 4. `-C /other` doesn't bypass the rule
206
+ // ---------------------------------------------------------------------------
207
+
208
+ describe("git plugin: -C routing does not bypass no-main-commit", () => {
209
+ it("`git -C /other commit` still evaluates branch and fires on main", async () => {
210
+ // Stub reports main regardless of cwd - the predicate queries
211
+ // git at `ctx.cwd`, which for the `-C /other` ref is `/other`
212
+ // (walker cwd). Either way the stubbed branch is main.
213
+ const { evaluator } = buildRuntime(
214
+ { plugins: [gitPlugin], rules: [] },
215
+ branchExec("main"),
216
+ );
217
+ const res = await evaluator.evaluate(
218
+ bashEvent("git -C /other commit -m 'x'"),
219
+ makeCtx("/repo"),
220
+ 0,
221
+ );
222
+ assert.ok(res && res.block === true);
223
+ });
224
+
225
+ it("when.branch doesn't fire if predicate resolves non-main", async () => {
226
+ // Pair of the above: `-C /other commit` with exec reporting
227
+ // feature -> allow. Pins that branch predicate reads the stub
228
+ // every tool_call (not cached from a previous call).
229
+ const { evaluator } = buildRuntime(
230
+ { plugins: [gitPlugin], rules: [] },
231
+ branchExec("feature"),
232
+ );
233
+ const res = await evaluator.evaluate(
234
+ bashEvent("git -C /other commit -m 'x'"),
235
+ makeCtx("/repo"),
236
+ 0,
237
+ );
238
+ assert.equal(res, undefined);
239
+ });
240
+
241
+ it("`git --git-dir=/other commit` forwards effective cwd through to the branch predicate", async () => {
242
+ // This pins the cwd-tracker-extension wire-up end-to-end: the
243
+ // `--git-dir=/other` flag is parsed by the plugin's cwd extension,
244
+ // producing walker cwd `/other` for that command ref. The branch
245
+ // predicate then runs `git branch --show-current` with `cwd:
246
+ // "/other"`. We assert that cwd on the exec call to pin the
247
+ // extension reached the evaluator (via the `knownBuiltinTrackers`
248
+ // hint and the evaluator's `composeBuiltinCwd` helper).
249
+ const { evaluator, host } = buildRuntime(
250
+ { plugins: [gitPlugin], rules: [] },
251
+ branchExec("main"),
252
+ );
253
+ await evaluator.evaluate(
254
+ bashEvent("git --git-dir=/other commit -m 'x'"),
255
+ makeCtx("/repo"),
256
+ 0,
257
+ );
258
+ const branchCall = host.execCalls.find(
259
+ (c) =>
260
+ c.cmd === "git" &&
261
+ c.args[0] === "branch" &&
262
+ c.args[1] === "--show-current",
263
+ );
264
+ assert.ok(
265
+ branchCall !== undefined,
266
+ "expected a `git branch --show-current` call",
267
+ );
268
+ assert.equal(
269
+ branchCall.cwd,
270
+ "/other",
271
+ "branch predicate should run in the cwd produced by the --git-dir= extension",
272
+ );
273
+ });
274
+ });
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // 5. THE KEY TEST: walker-driven branch state
278
+ // ---------------------------------------------------------------------------
279
+
280
+ describe("git plugin: walker-driven branch state (the KEY test)", () => {
281
+ it("`git checkout main && git commit` - the second command is evaluated on branch=main", async () => {
282
+ // The fake session "current branch" is `feature-x` - a
283
+ // naive session-state predicate would see that and let the
284
+ // commit through. The branch TRACKER folds the in-chain
285
+ // `git checkout main` into the walker state seen by the
286
+ // second ref (`git commit`), so `no-main-commit` fires.
287
+ //
288
+ // The predicate prefers `ctx.walkerState.branch` over a shell
289
+ // call; the walker-resolved `main` wins over the stubbed
290
+ // `feature-x`. The `exec` stub is effectively unreachable
291
+ // here - if the predicate ever shelled out for this case it
292
+ // would incorrectly allow.
293
+ const { evaluator, host } = buildRuntime(
294
+ { plugins: [gitPlugin], rules: [] },
295
+ branchExec("feature-x"),
296
+ );
297
+ const res = await evaluator.evaluate(
298
+ bashEvent("git checkout main && git commit -m 'x'"),
299
+ makeCtx("/repo"),
300
+ 0,
301
+ );
302
+ assert.ok(res && res.block === true);
303
+ assert.match(res.reason!, /\[steering:no-main-commit@[^\]]+\]/);
304
+ // Regression guard: the branch predicate MUST NOT shell out
305
+ // when the walker already provided a concrete branch value.
306
+ assert.equal(
307
+ host.execCalls.filter(
308
+ (c) =>
309
+ c.cmd === "git" &&
310
+ c.args[0] === "branch" &&
311
+ c.args[1] === "--show-current",
312
+ ).length,
313
+ 0,
314
+ "branch predicate must read walkerState, not shell out",
315
+ );
316
+ });
317
+
318
+ it("`git checkout feature && git commit` - evaluated on branch=feature, allows", async () => {
319
+ // Pair test: walker folds the checkout in, lands on feature,
320
+ // rule skips. Exec stub would say `main` here - again
321
+ // walker-state wins.
322
+ const { evaluator } = buildRuntime(
323
+ { plugins: [gitPlugin], rules: [] },
324
+ branchExec("main"),
325
+ );
326
+ const res = await evaluator.evaluate(
327
+ bashEvent("git checkout feature && git commit -m 'x'"),
328
+ makeCtx("/repo"),
329
+ 0,
330
+ );
331
+ assert.equal(res, undefined);
332
+ });
333
+
334
+ it("checkout in a subshell does NOT escape - outer `git commit` allowed on feature", async () => {
335
+ // `(git checkout main)` is subshell-isolated; the outer
336
+ // `git commit` inherits the pre-subshell branch state. No
337
+ // branch-changing modifier fired in the outer scope, so the
338
+ // walker threads the tracker's `NO_CHECKOUT_IN_CHAIN` initial
339
+ // sentinel (distinct from the `"unknown"` sentinel that
340
+ // signals a dynamic checkout). The predicate reads this as
341
+ // `missing` -> exec fallback to `git branch --show-current`,
342
+ // which the stub reports as `feature`. Rule doesn't fire.
343
+ const { evaluator } = buildRuntime(
344
+ { plugins: [gitPlugin], rules: [] },
345
+ branchExec("feature"),
346
+ );
347
+ const res = await evaluator.evaluate(
348
+ bashEvent("(git checkout main) && git commit -m 'x'"),
349
+ makeCtx("/repo"),
350
+ 0,
351
+ );
352
+ assert.equal(res, undefined);
353
+ });
354
+
355
+ it("`git checkout $VAR && git commit` - walker unknown short-circuits to onUnknown, no exec fallback", async () => {
356
+ // When $VAR is not statically resolvable, the branch tracker
357
+ // collapses to its "unknown" sentinel. The predicate MUST
358
+ // short-circuit on this signal: a `git branch --show-current`
359
+ // exec fallback here would return the PRE-checkout branch,
360
+ // which is exactly the case the walker's in-chain tracking
361
+ // exists to catch. The predicate's `onUnknown: "block"`
362
+ // default then fires the rule.
363
+ //
364
+ // This pins U1: the exec stub reports "feature" (a non-
365
+ // protected branch). Pre-U1 the predicate treated walker
366
+ // "unknown" as "absent" and fell through to exec -> the rule
367
+ // would INCORRECTLY allow, defeating fail-closed. Post-U1:
368
+ // exec is not consulted, the rule correctly fires.
369
+ const { evaluator, host } = buildRuntime(
370
+ { plugins: [gitPlugin], rules: [] },
371
+ branchExec("feature"),
372
+ );
373
+ const res = await evaluator.evaluate(
374
+ bashEvent("git checkout $VAR && git commit -m 'x'"),
375
+ makeCtx("/repo"),
376
+ 0,
377
+ );
378
+ assert.ok(
379
+ res && res.block === true,
380
+ "unresolvable branch must fail-closed and fire no-main-commit",
381
+ );
382
+ // Regression guard: walker-unknown must NOT fall through to
383
+ // `git branch --show-current`. If this count is ever > 0 the
384
+ // U1 short-circuit has been re-broken.
385
+ assert.equal(
386
+ host.execCalls.filter(
387
+ (c) =>
388
+ c.cmd === "git" &&
389
+ c.args[0] === "branch" &&
390
+ c.args[1] === "--show-current",
391
+ ).length,
392
+ 0,
393
+ "walker-unknown short-circuits to onUnknown; predicate must not shell out",
394
+ );
395
+ });
396
+ });
397
+
398
+ // ---------------------------------------------------------------------------
399
+ // 6. no-main-commit-github + walker-unknown cwd
400
+ //
401
+ // The github-flavored specialization's reason fn has a dedicated
402
+ // walker-unknown branch — under `cd "$VAR" && git commit`, the engine
403
+ // Walker-unknown cwd: github-flavored rule has
404
+ // `remote: { pattern, onUnknown: "allow" }`. Under the trinary
405
+ // engine, `onUnknown: "allow"` at the leaf level genuinely means
406
+ // "skip the predicate when the value is unresolvable" — the
407
+ // `remote:` leaf surfaces trinary `"unknown"` (via the inline
408
+ // walker-unknown guard at the top of the handler body) and the
409
+ // engine's leaf adapter projects it to `false` (rule skips).
410
+ // The github-flavored rule therefore correctly defers to the
411
+ // generic `noMainCommit` rule under walker-unknown cwd, which
412
+ // consumes only the `branch:` predicate (no walker-unknown guard;
413
+ // stub returns "main\n") and fires fail-CLOSED via its default
414
+ // leaf-level `onUnknown: "block"`.
415
+ //
416
+ // What this test exercises (counterfactual rationale):
417
+ //
418
+ // 1. The `branch:` predicate matches `main` via the test stub
419
+ // (the stub returns "main\n" for `git branch --show-current`
420
+ // regardless of the runtime cwd that gets passed to it; in
421
+ // production the same exec at `cwd === "unknown"` would fail
422
+ // and the predicate would fall back to its `onUnknown:
423
+ // "block"` default — same firing verdict, different code
424
+ // path).
425
+ // 2. The `remote:` predicate inlines a walker-unknown-cwd guard
426
+ // at the top of its body and surfaces trinary `"unknown"`
427
+ // under `walkerState.cwd === "unknown"`. The leaf-level
428
+ // `onUnknown: "allow"` on the github rule's `remote:` arg
429
+ // then projects unknown → `false` — the github rule SKIPS.
430
+ // 3. The generic `noMainCommit` rule (with only `branch:`)
431
+ // fires next via its default `onUnknown: "block"`. Block
432
+ // verdict lands; the reason text is the generic
433
+ // protected-branch reason, not the github-specific one.
434
+ //
435
+ // Pinned: the steering tag is rendered, a block verdict lands,
436
+ // and the rule that fires is `no-main-commit` (generic), not
437
+ // `no-main-commit-github` — the github-flavored rule cleanly
438
+ // declines under walker-unknown cwd because its `remote:` leaf
439
+ // opts into "allow" semantics there.
440
+ // ---------------------------------------------------------------------------
441
+
442
+ describe("git plugin: no-main-commit-github walker-unknown cwd", () => {
443
+ it('`cd "$VAR" && git commit` on main + github remote → generic rule fires (github rule allows on walker-unknown via `onUnknown: "allow"` on its `remote:` leaf)', async () => {
444
+ // Explicit exec stubs make the test deterministic regardless
445
+ // of the runner's actual git state. Without the stubs, the
446
+ // `branch:` predicate (which has no inline walker-unknown-cwd
447
+ // guard — it tries the branch tracker first, then shells out)
448
+ // would shell out at the test runner's cwd — the test outcome
449
+ // would depend on whatever branch / remote that workspace
450
+ // happens to be on (flaky).
451
+ const { evaluator } = buildRuntime(
452
+ { plugins: [gitPlugin], rules: [] },
453
+ async (cmd: string, args: string[]): Promise<PiExecResult> => {
454
+ if (
455
+ cmd === "git" &&
456
+ args[0] === "branch" &&
457
+ args[1] === "--show-current"
458
+ ) {
459
+ return {
460
+ stdout: "main\n",
461
+ stderr: "",
462
+ code: 0,
463
+ killed: false,
464
+ };
465
+ }
466
+ if (
467
+ cmd === "git" &&
468
+ args[0] === "config" &&
469
+ args[1] === "--get" &&
470
+ args[2] === "remote.origin.url"
471
+ ) {
472
+ return {
473
+ stdout: "https://github.com/cad0p/repo.git\n",
474
+ stderr: "",
475
+ code: 0,
476
+ killed: false,
477
+ };
478
+ }
479
+ return { stdout: "", stderr: "", code: 1, killed: false };
480
+ },
481
+ );
482
+ const res = await evaluator.evaluate(
483
+ bashEvent("cd \"$VAR\" && git commit -m 'x'"),
484
+ makeCtx("/repo"),
485
+ 0,
486
+ );
487
+ assert.ok(
488
+ res && res.block === true,
489
+ 'walker-unknown cwd must still fire a block (generic no-main-commit, after github-flavored rule allows via its `onUnknown: "allow"` on `remote:`)',
490
+ );
491
+ assert.match(
492
+ res.reason!,
493
+ /\[steering:no-main-commit@[^\]]+\]/,
494
+ 'generic protected-branch rule fires (the github-flavored rule cleanly declines under walker-unknown via its `onUnknown: "allow"` on `remote:`)',
495
+ );
496
+ assert.doesNotMatch(
497
+ res.reason!,
498
+ /\[steering:no-main-commit-github@/,
499
+ 'github-flavored rule must NOT fire when its `remote:` leaf opts into `onUnknown: "allow"` and walker can\'t resolve cwd',
500
+ );
501
+ // The reason text should be the generic protected-branch reason,
502
+ // not github-flavored PR-flow guidance, and must not contain
503
+ // walker-unknown-branch error text (the github-flavored rule's
504
+ // bespoke "could not verify the current branch" line lives
505
+ // behind its bespoke reason fn — it's the github rule, not the
506
+ // generic, that handles walker-unknown branch).
507
+ assert.doesNotMatch(
508
+ res.reason!,
509
+ /walker.*unknown/i,
510
+ "generic rule's reason text doesn't mention walker-unknown (that's the github rule's domain, and the github rule didn't fire here)",
511
+ );
512
+ assert.doesNotMatch(
513
+ res.reason!,
514
+ /open a PR|pull request/i,
515
+ "generic rule's reason text doesn't carry github-flavored PR-flow guidance",
516
+ );
517
+ });
518
+ });
519
+
520
+ // ---------------------------------------------------------------------------
521
+ // 7. isClean spread-form engine end-to-end
522
+ //
523
+ // Pins the engine's leaf-onUnknown read + handler-dispatch contract
524
+ // for boolean predicates: the spread form `{ value: false,
525
+ // onUnknown: "allow" }` flows verbatim to the handler; the engine's
526
+ // `readLeafOnUnknown` reads the modifier and `projectVerdict` projects
527
+ // the handler's `"unknown"` returns under that policy. The handler
528
+ // itself treats `onUnknown:` as an opaque sibling field. This drives
529
+ // `evaluateWhen` → `readLeafOnUnknown` → `isClean` handler end-to-end.
530
+ // ---------------------------------------------------------------------------
531
+
532
+ describe("git plugin: isClean spread form drives through readLeafOnUnknown", () => {
533
+ it('`isClean: { value: false, onUnknown: "allow" }` fires when working tree is dirty (handler unwraps `value:` and ignores `onUnknown:`)', async () => {
534
+ // Stub `git status --porcelain` to report a dirty tree.
535
+ const { evaluator } = buildRuntime(
536
+ {
537
+ plugins: [gitPlugin],
538
+ rules: [
539
+ {
540
+ name: "deploy-requires-clean",
541
+ tool: "bash",
542
+ field: "command",
543
+ pattern: /^npm\s+run\s+deploy\b/,
544
+ reason: "Working tree dirty.",
545
+ when: { isClean: { value: false, onUnknown: "allow" } },
546
+ },
547
+ ],
548
+ },
549
+ async (cmd: string, args: string[]): Promise<PiExecResult> => {
550
+ if (
551
+ cmd === "git" &&
552
+ args[0] === "status" &&
553
+ args[1] === "--porcelain"
554
+ ) {
555
+ return {
556
+ stdout: " M file.ts\n",
557
+ stderr: "",
558
+ code: 0,
559
+ killed: false,
560
+ };
561
+ }
562
+ return { stdout: "", stderr: "", code: 1, killed: false };
563
+ },
564
+ );
565
+ const res = await evaluator.evaluate(
566
+ bashEvent("npm run deploy"),
567
+ makeCtx("/repo"),
568
+ 0,
569
+ );
570
+ assert.ok(
571
+ res && res.block === true,
572
+ "engine reads `onUnknown: 'allow'` via `readLeafOnUnknown`; handler receives the raw `{ value, onUnknown }` arg, unwraps `value: false`, sees dirty tree (boolean returns from a dirty tree are concrete false/true so `onUnknown` plays no role on this path), returns true → rule fires",
573
+ );
574
+ assert.match(res.reason!, /\[steering:deploy-requires-clean@[^\]]+\]/);
575
+ });
576
+
577
+ it('`isClean: { value: false, onUnknown: "allow" }` skips on walker-unknown cwd (handler surfaces `"unknown"` → leaf `"allow"` → false)', async () => {
578
+ // Pins the walker-unknown-cwd path: the inline guard at the
579
+ // handler's top surfaces `"unknown"`; the engine's leaf-level
580
+ // `onUnknown: "allow"` projects unknown → false, the rule skips.
581
+ const { evaluator } = buildRuntime(
582
+ {
583
+ plugins: [gitPlugin],
584
+ rules: [
585
+ {
586
+ name: "deploy-requires-clean",
587
+ tool: "bash",
588
+ field: "command",
589
+ pattern: /^npm\s+run\s+deploy\b/,
590
+ reason: "Working tree dirty.",
591
+ when: { isClean: { value: false, onUnknown: "allow" } },
592
+ },
593
+ ],
594
+ },
595
+ async (): Promise<PiExecResult> => ({
596
+ stdout: " M file.ts\n",
597
+ stderr: "",
598
+ code: 0,
599
+ killed: false,
600
+ }),
601
+ );
602
+ const res = await evaluator.evaluate(
603
+ bashEvent('cd "$VAR" && npm run deploy'),
604
+ makeCtx("/repo"),
605
+ 0,
606
+ );
607
+ assert.equal(
608
+ res,
609
+ undefined,
610
+ "walker-unknown cwd → handler returns 'unknown' → leaf-level 'allow' projects to false → rule skips",
611
+ );
612
+ });
613
+ });
614
+
615
+ // ---------------------------------------------------------------------------
616
+ // 8. isClean: false vs not: { isClean: true } — README equivalence pin
617
+ //
618
+ // The dynamic-reason-runtime-cwd example README documents that
619
+ // `isClean: false` and `not: { isClean: true }` agree on every truth-
620
+ // table row EXCEPT `walker-known + git fails`, where they diverge:
621
+ // - `isClean: false`: handler returns `false` on git failure → leaf
622
+ // verdict false → rule skips.
623
+ // - `not: { isClean: true }`: handler returns `false` → inner
624
+ // verdict false → Kleene-AND-false-absorbs → not-flip yields true
625
+ // → rule fires.
626
+ // This test pins both arms of the divergence so future engine drift
627
+ // trips the test alongside the README — the cross-link in the
628
+ // describe / test descriptions is intentional.
629
+ // ---------------------------------------------------------------------------
630
+
631
+ describe("git plugin: README equivalence — isClean: false vs not: { isClean: true } (walker-known + git fails row)", () => {
632
+ const gitFailsExec = async (
633
+ cmd: string,
634
+ args: string[],
635
+ ): Promise<PiExecResult> => {
636
+ // `git status --porcelain` exits non-zero (git failure path the
637
+ // handler treats as `null` → boolean `false`).
638
+ if (cmd === "git" && args[0] === "status" && args[1] === "--porcelain") {
639
+ return {
640
+ stdout: "",
641
+ stderr: "fatal: not a git repository",
642
+ code: 128,
643
+ killed: false,
644
+ };
645
+ }
646
+ return { stdout: "", stderr: "", code: 1, killed: false };
647
+ };
648
+
649
+ it("`when: { isClean: false }` does NOT fire on git failure (handler returns false → leaf false → rule skips)", async () => {
650
+ const { evaluator } = buildRuntime(
651
+ {
652
+ plugins: [gitPlugin],
653
+ rules: [
654
+ {
655
+ name: "deploy-requires-clean-positive",
656
+ tool: "bash",
657
+ field: "command",
658
+ pattern: /^npm\s+run\s+deploy\b/,
659
+ reason: "Working tree must be clean.",
660
+ when: { isClean: false },
661
+ },
662
+ ],
663
+ },
664
+ gitFailsExec,
665
+ );
666
+ const res = await evaluator.evaluate(
667
+ bashEvent("npm run deploy"),
668
+ makeCtx("/repo"),
669
+ 0,
670
+ );
671
+ assert.equal(
672
+ res,
673
+ undefined,
674
+ "git failure → handler returns false → leaf verdict false → rule skips",
675
+ );
676
+ });
677
+
678
+ it("`when: { not: { isClean: true } }` FIRES on git failure (handler returns false → Kleene-AND-false-absorbs → not-flip = true)", async () => {
679
+ const { evaluator } = buildRuntime(
680
+ {
681
+ plugins: [gitPlugin],
682
+ rules: [
683
+ {
684
+ name: "deploy-requires-clean-not",
685
+ tool: "bash",
686
+ field: "command",
687
+ pattern: /^npm\s+run\s+deploy\b/,
688
+ reason: "Working tree must be clean.",
689
+ when: { not: { isClean: true } },
690
+ },
691
+ ],
692
+ },
693
+ gitFailsExec,
694
+ );
695
+ const res = await evaluator.evaluate(
696
+ bashEvent("npm run deploy"),
697
+ makeCtx("/repo"),
698
+ 0,
699
+ );
700
+ assert.ok(
701
+ res && res.block === true,
702
+ "git failure → handler returns false → inner verdict false → Kleene-AND false absorbs → not(false) = true → rule fires",
703
+ );
704
+ });
705
+ });