@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,1001 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Tests for the git plugin's shipped rules (`./rules.ts`).
6
+ *
7
+ * These verify the rule definitions themselves - pattern shape,
8
+ * `when` wiring, `noOverride` semantics - against the evaluator
9
+ * pipeline. Each test constructs a minimal config with the plugin
10
+ * loaded and runs a bash tool_call through `buildEvaluator`.
11
+ *
12
+ * End-to-end wiring (walker branch state driving predicate behavior,
13
+ * plugin registration) is in `./integration.test.ts`; this file
14
+ * focuses on the rule definition's static shape.
15
+ */
16
+
17
+ import assert from "node:assert/strict";
18
+ import { describe, it } from "node:test";
19
+ import type {
20
+ BashToolCallEvent,
21
+ ExecResult as PiExecResult,
22
+ } from "@earendil-works/pi-coding-agent";
23
+ import { makeCtx, makeTrackedHost } from "../../__test-helpers__.ts";
24
+ import { buildEvaluator } from "../../evaluator.ts";
25
+ import { resolvePlugins } from "../../plugin-merger.ts";
26
+ import gitPlugin from "./index.ts";
27
+ import {
28
+ GIT_COMMIT_PATTERN,
29
+ noMainCommit,
30
+ noMainCommitGithub,
31
+ PROTECTED_BRANCH_PATTERN,
32
+ } from "./rules.ts";
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Harness
36
+ // ---------------------------------------------------------------------------
37
+
38
+ function bashEvent(command: string): BashToolCallEvent {
39
+ return {
40
+ type: "tool_call",
41
+ toolCallId: "t1",
42
+ toolName: "bash",
43
+ input: { command },
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Build an evaluator that includes the git plugin with a stub `exec`
49
+ * returning the given fake branch on `git branch --show-current`.
50
+ * Every other `git` call (including `git config --get
51
+ * remote.origin.url`) returns exit 1, so predicates fall back to
52
+ * their `onUnknown` policy. The github-flavored
53
+ * `noMainCommitGithub` specialization (more specific via
54
+ * `remote: { pattern: ..., onUnknown: "allow" }`) skips on the
55
+ * resulting no-origin signal, and tests in this scope cleanly
56
+ * exercise the generic `noMainCommit` rule.
57
+ *
58
+ * Tests that need to exercise the github-flavored rule (or the
59
+ * non-github fall-through path) use {@link buildWithBranchAndRemote}
60
+ * below — it parameterizes the origin URL.
61
+ */
62
+ function buildWithBranch(branchName: string) {
63
+ const host = makeTrackedHost({
64
+ exec: async (cmd, args): Promise<PiExecResult> => {
65
+ if (
66
+ cmd === "git" &&
67
+ args[0] === "branch" &&
68
+ args[1] === "--show-current"
69
+ ) {
70
+ return {
71
+ stdout: `${branchName}\n`,
72
+ stderr: "",
73
+ code: 0,
74
+ killed: false,
75
+ };
76
+ }
77
+ return { stdout: "", stderr: "", code: 1, killed: false };
78
+ },
79
+ });
80
+ const resolved = resolvePlugins([gitPlugin], {});
81
+ const evaluator = buildEvaluator({}, resolved, host);
82
+ return { evaluator, host };
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // no-main-commit
87
+ // ---------------------------------------------------------------------------
88
+
89
+ describe("rules: no-main-commit shape", () => {
90
+ it("exists on the plugin with the expected name + pattern + overridable flag", () => {
91
+ const rule = gitPlugin.rules?.find((r) => r.name === "no-main-commit");
92
+ assert.ok(rule);
93
+ assert.equal(rule.tool, "bash");
94
+ assert.equal(rule.field, "command");
95
+ assert.equal(rule.noOverride, false);
96
+ // Accept both shapes: runtime value can be string | RegExp per the
97
+ // schema, even though the narrowed literal type is string-only after
98
+ // the `as const satisfies Rule` narrowing in ./rules.ts.
99
+ const pattern = rule.pattern as string | RegExp;
100
+ const patternSource =
101
+ typeof pattern === "string" ? pattern : pattern.source;
102
+ assert.ok(patternSource.includes("commit"));
103
+ assert.ok(
104
+ rule.when !== undefined &&
105
+ "branch" in (rule.when as Record<string, unknown>),
106
+ );
107
+ });
108
+
109
+ it("fires on `git commit` when branch predicate resolves to main", async () => {
110
+ const { evaluator } = buildWithBranch("main");
111
+ const res = await evaluator.evaluate(
112
+ bashEvent("git commit -m 'x'"),
113
+ makeCtx("/repo"),
114
+ 0,
115
+ );
116
+ assert.ok(res && res.block === true);
117
+ assert.match(res.reason!, /\[steering:no-main-commit@[^\]]+\]/);
118
+ });
119
+
120
+ it("allows `git commit` on a feature branch", async () => {
121
+ const { evaluator } = buildWithBranch("feat-login");
122
+ const res = await evaluator.evaluate(
123
+ bashEvent("git commit -m 'x'"),
124
+ makeCtx("/repo"),
125
+ 0,
126
+ );
127
+ assert.equal(res, undefined);
128
+ });
129
+
130
+ it("fires on each protected-branch alias (master / mainline / trunk)", async () => {
131
+ for (const branchName of ["master", "mainline", "trunk"]) {
132
+ const { evaluator } = buildWithBranch(branchName);
133
+ const res = await evaluator.evaluate(
134
+ bashEvent("git commit -m 'x'"),
135
+ makeCtx("/repo"),
136
+ 0,
137
+ );
138
+ assert.ok(
139
+ res && res.block === true,
140
+ `expected block for branch=${branchName}`,
141
+ );
142
+ }
143
+ });
144
+
145
+ it("does NOT fire on git log (non-commit subcommand) even on main", async () => {
146
+ const { evaluator } = buildWithBranch("main");
147
+ const res = await evaluator.evaluate(
148
+ bashEvent("git log --oneline"),
149
+ makeCtx("/repo"),
150
+ 0,
151
+ );
152
+ assert.equal(res, undefined);
153
+ });
154
+
155
+ it("catches `git -C /other commit` via pre-subcommand flag slot", async () => {
156
+ const { evaluator } = buildWithBranch("main");
157
+ const res = await evaluator.evaluate(
158
+ bashEvent("git -C /other commit -m 'x'"),
159
+ makeCtx("/repo"),
160
+ 0,
161
+ );
162
+ assert.ok(res && res.block === true);
163
+ });
164
+
165
+ it("overridable via `# steering-override: no-main-commit` comment", async () => {
166
+ // noOverride: false on the rule - author-supplied override
167
+ // comment on the raw tool_call command is accepted, the event
168
+ // doesn't block, and the override is audit-logged.
169
+ const { evaluator, host } = buildWithBranch("main");
170
+ const res = await evaluator.evaluate(
171
+ bashEvent(
172
+ "git commit -m 'release' # steering-override: no-main-commit - release bump",
173
+ ),
174
+ makeCtx("/repo"),
175
+ 0,
176
+ );
177
+ assert.equal(res, undefined);
178
+ assert.ok(
179
+ host.appended.some((e) => e.type === "steering-override"),
180
+ "expected a steering-override audit entry",
181
+ );
182
+ });
183
+ });
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // no-main-commit: dynamic reason (Item 1 of PR #5 scope expansion)
187
+ //
188
+ // When the branch tracker has resolved the current branch statically
189
+ // (from a `git checkout <name>` earlier in the chain), the rule's
190
+ // reason text injects the branch name so the agent sees
191
+ // "You are on 'main'" instead of a generic reminder. When tracker
192
+ // state is missing (no checkout in chain, exec fallback) or the value
193
+ // is the walker's `"unknown"` sentinel (dynamic checkout), the
194
+ // dynamic clause is omitted — the static actionable tail still
195
+ // guides the agent to a feature branch.
196
+ // ---------------------------------------------------------------------------
197
+
198
+ describe("rules: no-main-commit dynamic reason", () => {
199
+ it("`git checkout main && git commit` - reason injects 'You are on main'", async () => {
200
+ // Walker folds the checkout into the branch state seen by the
201
+ // commit ref; the ReasonFn reads `ctx.walkerState.branch` and
202
+ // sees the concrete value `main`.
203
+ const { evaluator } = buildWithBranch("feature");
204
+ const res = await evaluator.evaluate(
205
+ bashEvent("git checkout main && git commit -m 'x'"),
206
+ makeCtx("/repo"),
207
+ 0,
208
+ );
209
+ assert.ok(res && res.block === true);
210
+ assert.match(
211
+ res.reason!,
212
+ /You are on 'main'/,
213
+ "reason must include the walker-resolved branch name",
214
+ );
215
+ // Prefix and static tail still present - the dynamic clause
216
+ // is additive, not a replacement.
217
+ assert.match(res.reason!, /\[steering:no-main-commit@[^\]]+\]/);
218
+ assert.match(res.reason!, /Create a feature branch first/);
219
+ });
220
+
221
+ it("`git checkout master && git commit` - injects the concrete protected branch name", async () => {
222
+ // Pin that the injected name is the tracker-resolved value,
223
+ // not a hardcoded "main" — master / trunk / mainline all get
224
+ // the same dynamic treatment.
225
+ const { evaluator } = buildWithBranch("feature");
226
+ const res = await evaluator.evaluate(
227
+ bashEvent("git checkout master && git commit -m 'x'"),
228
+ makeCtx("/repo"),
229
+ 0,
230
+ );
231
+ assert.ok(res && res.block === true);
232
+ assert.match(res.reason!, /You are on 'master'/);
233
+ });
234
+
235
+ it("no in-chain checkout + exec fallback - reason omits the dynamic clause", async () => {
236
+ // Exec reports `main` via `git branch --show-current`, so the
237
+ // rule fires — but the BRANCH TRACKER didn't see an in-chain
238
+ // checkout, so `ctx.walkerState.branch` is the tracker's
239
+ // `NO_CHECKOUT_IN_CHAIN` sentinel (not a real branch name).
240
+ // The ReasonFn must NOT leak that sentinel into the reason
241
+ // text; it falls back to the static form.
242
+ const { evaluator } = buildWithBranch("main");
243
+ const res = await evaluator.evaluate(
244
+ bashEvent("git commit -m 'x'"),
245
+ makeCtx("/repo"),
246
+ 0,
247
+ );
248
+ assert.ok(res && res.block === true);
249
+ assert.doesNotMatch(
250
+ res.reason!,
251
+ /You are on '/,
252
+ "reason must not include the dynamic clause when walker state is missing",
253
+ );
254
+ // Static tail still present.
255
+ assert.match(res.reason!, /Create a feature branch first/);
256
+ });
257
+
258
+ it("`git checkout $VAR && git commit` - walker-unknown branch - reason omits the dynamic clause", async () => {
259
+ // The branch tracker collapses `checkout $VAR` to its
260
+ // `"unknown"` sentinel. The predicate's onUnknown="block"
261
+ // default still fires, and the ReasonFn treats `"unknown"`
262
+ // as a non-concrete value — the dynamic clause is omitted
263
+ // rather than leaking the sentinel string into the message.
264
+ const { evaluator } = buildWithBranch("feature");
265
+ const res = await evaluator.evaluate(
266
+ bashEvent("git checkout $VAR && git commit -m 'x'"),
267
+ makeCtx("/repo"),
268
+ 0,
269
+ );
270
+ assert.ok(res && res.block === true);
271
+ assert.doesNotMatch(
272
+ res.reason!,
273
+ /You are on '/,
274
+ "reason must not include the dynamic clause when walker state is 'unknown'",
275
+ );
276
+ assert.doesNotMatch(
277
+ res.reason!,
278
+ /unknown/,
279
+ "reason must not leak the walker sentinel string",
280
+ );
281
+ });
282
+ });
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // Harness extension for `no-main-commit-github` — stubs both the branch and
286
+ // the origin remote URL so non-github fall-through is exercised
287
+ // deterministically.
288
+ // ---------------------------------------------------------------------------
289
+
290
+ /**
291
+ * Build an evaluator that includes the git plugin with a stub `exec`
292
+ * returning the given fake branch on `git branch --show-current` AND
293
+ * the given fake origin URL on `git config --get remote.origin.url`
294
+ * (when {@link remoteUrl} is non-null). Every other `git` call returns
295
+ * exit 1 so predicates fall back to their `onUnknown` policy.
296
+ *
297
+ * Pass `remoteUrl: null` to simulate a repo with no `origin` remote
298
+ * (the `git config --get remote.origin.url` call returns non-zero).
299
+ */
300
+ function buildWithBranchAndRemote(
301
+ branchName: string,
302
+ remoteUrl: string | null,
303
+ ) {
304
+ const host = makeTrackedHost({
305
+ exec: async (cmd, args): Promise<PiExecResult> => {
306
+ if (
307
+ cmd === "git" &&
308
+ args[0] === "branch" &&
309
+ args[1] === "--show-current"
310
+ ) {
311
+ return {
312
+ stdout: `${branchName}\n`,
313
+ stderr: "",
314
+ code: 0,
315
+ killed: false,
316
+ };
317
+ }
318
+ if (
319
+ cmd === "git" &&
320
+ args[0] === "config" &&
321
+ args[1] === "--get" &&
322
+ args[2] === "remote.origin.url" &&
323
+ remoteUrl !== null
324
+ ) {
325
+ return {
326
+ stdout: `${remoteUrl}\n`,
327
+ stderr: "",
328
+ code: 0,
329
+ killed: false,
330
+ };
331
+ }
332
+ return { stdout: "", stderr: "", code: 1, killed: false };
333
+ },
334
+ });
335
+ const resolved = resolvePlugins([gitPlugin], {});
336
+ const evaluator = buildEvaluator({}, resolved, host);
337
+ return { evaluator, host };
338
+ }
339
+
340
+ // ---------------------------------------------------------------------------
341
+ // no-main-commit-github
342
+ //
343
+ // Specialization of `no-main-commit` that emits PR-flow guidance on
344
+ // github.com clones. The shared `GIT_COMMIT_PATTERN` constant +
345
+ // first-match-wins ordering in the rule array are LOAD-BEARING — these
346
+ // tests pin the routing so a future maintainer reordering for stylistic
347
+ // reasons trips the suite rather than silently regressing the user-
348
+ // facing message.
349
+ // ---------------------------------------------------------------------------
350
+
351
+ describe("rules: no-main-commit-github", () => {
352
+ it("github clone + on main → fires github rule with PR-flow guidance + safety reminder", async () => {
353
+ const { evaluator } = buildWithBranchAndRemote(
354
+ "main",
355
+ "https://github.com/cad0p/repo.git",
356
+ );
357
+ const res = await evaluator.evaluate(
358
+ bashEvent("git commit -m 'x'"),
359
+ makeCtx("/repo"),
360
+ 0,
361
+ );
362
+ assert.ok(res && res.block === true);
363
+ assert.match(
364
+ res.reason!,
365
+ /\[steering:no-main-commit-github@[^\]]+\]/,
366
+ "github-flavored rule must fire (with the steering tag)",
367
+ );
368
+ assert.match(
369
+ res.reason!,
370
+ /github clone's protected branch/,
371
+ "reason must include the github-specific anchor (protected-branch wording mirrors the four-name when: clause)",
372
+ );
373
+ assert.match(
374
+ res.reason!,
375
+ /Open a PR for review/,
376
+ "reason must include the PR-flow guidance",
377
+ );
378
+ assert.match(
379
+ res.reason!,
380
+ /NEVER merge a PR or mark it ready-for-review/,
381
+ "reason must include the safety reminder",
382
+ );
383
+ // Multi-paragraph render-shape pin: the steering tag renders
384
+ // on its own line followed by a paragraph break, and the
385
+ // safety reminder stays a separate paragraph from the PR-flow
386
+ // body. Pins both the engine's paragraph-aware tag separator
387
+ // (in formatReason) AND this rule's `\n\n` body separator at
388
+ // one site. Counterfactual: a regression in either (e.g.,
389
+ // engine refactor that strips trailing/leading whitespace, or
390
+ // a future cleanup that changes `\n\n` → `\n` in the rule's
391
+ // reason text) would silently degrade rendering — the .match
392
+ // checks above don't catch newline drift.
393
+ assert.match(
394
+ res.reason!,
395
+ /^\[steering:no-main-commit-github@[^\]]+\]\n\nYou're on a github clone's protected branch/,
396
+ "tag must render on its own line followed by paragraph break",
397
+ );
398
+ assert.match(
399
+ res.reason!,
400
+ /\n\nSafety: NEVER merge a PR/,
401
+ "safety reminder must remain a separate paragraph from the PR-flow body",
402
+ );
403
+ });
404
+
405
+ it("non-github remote + on main → falls through to generic no-main-commit", async () => {
406
+ const { evaluator } = buildWithBranchAndRemote(
407
+ "main",
408
+ "git@self-hosted.example.com:Foo/Bar.git",
409
+ );
410
+ const res = await evaluator.evaluate(
411
+ bashEvent("git commit -m 'x'"),
412
+ makeCtx("/repo"),
413
+ 0,
414
+ );
415
+ assert.ok(res && res.block === true);
416
+ assert.match(
417
+ res.reason!,
418
+ /\[steering:no-main-commit@[^\]]+\]/,
419
+ "generic no-main-commit must fire on non-github remotes",
420
+ );
421
+ assert.doesNotMatch(
422
+ res.reason!,
423
+ /no-main-commit-github@/,
424
+ "github-flavored rule must NOT fire on non-github remotes",
425
+ );
426
+ assert.doesNotMatch(
427
+ res.reason!,
428
+ /github clone's protected branch/,
429
+ "reason must not claim github-specific context on non-github remotes",
430
+ );
431
+ });
432
+
433
+ it("no origin configured + on main → falls through to generic no-main-commit", async () => {
434
+ // Pins the `onUnknown: "allow"` posture on the github rule's
435
+ // `remote:` predicate. With the default `onUnknown: "block"`, the
436
+ // github rule would fire fail-closed on a repo with no origin
437
+ // configured — emitting github-flavored PR-flow guidance and
438
+ // claiming a github clone the engine couldn't actually verify.
439
+ // `onUnknown: "allow"` makes the github rule cleanly skip on
440
+ // no-origin so the generic `noMainCommit` (which has no
441
+ // `remote:` clause) fires instead, with branch-only context the
442
+ // engine can confirm.
443
+ const { evaluator } = buildWithBranchAndRemote("main", null);
444
+ const res = await evaluator.evaluate(
445
+ bashEvent("git commit -m 'x'"),
446
+ makeCtx("/repo"),
447
+ 0,
448
+ );
449
+ assert.ok(res && res.block === true);
450
+ assert.match(
451
+ res.reason!,
452
+ /\[steering:no-main-commit@[^\]]+\]/,
453
+ "generic no-main-commit must fire on no-origin repos",
454
+ );
455
+ assert.doesNotMatch(
456
+ res.reason!,
457
+ /no-main-commit-github@/,
458
+ "github-flavored rule must NOT fire on no-origin repos",
459
+ );
460
+ assert.doesNotMatch(
461
+ res.reason!,
462
+ /github clone's protected branch/,
463
+ "reason must not claim github-specific context on no-origin repos",
464
+ );
465
+ });
466
+
467
+ it("github clone + on feature branch → does not fire", async () => {
468
+ const { evaluator } = buildWithBranchAndRemote(
469
+ "feat-x",
470
+ "https://github.com/cad0p/repo.git",
471
+ );
472
+ const res = await evaluator.evaluate(
473
+ bashEvent("git commit -m 'x'"),
474
+ makeCtx("/repo"),
475
+ 0,
476
+ );
477
+ assert.equal(res, undefined);
478
+ });
479
+
480
+ it("non-commit verb (`git push`) on main → does not fire (pattern doesn't match)", async () => {
481
+ const { evaluator } = buildWithBranchAndRemote(
482
+ "main",
483
+ "https://github.com/cad0p/repo.git",
484
+ );
485
+ const res = await evaluator.evaluate(
486
+ bashEvent("git push origin main"),
487
+ makeCtx("/repo"),
488
+ 0,
489
+ );
490
+ assert.equal(
491
+ res,
492
+ undefined,
493
+ "shared GIT_COMMIT_PATTERN constant must only match `git commit` (not `git push`)",
494
+ );
495
+ });
496
+
497
+ it("vault path + on main + github remote → fires github rule (gitPlugin has no built-in vault knowledge)", async () => {
498
+ // Counterfactual rationale: a future refactor that adds a
499
+ // cwd-based vault exemption to the rule (e.g.,
500
+ // `not: { cwd: VAULT_DIRS }` baked into the default `when:`)
501
+ // would silently change this behavior — vault paths would skip
502
+ // the rule. The README's Customization section is built on
503
+ // the contract that vault exemption is downstream-consumer
504
+ // responsibility, NOT a built-in default; this test pins that
505
+ // contract.
506
+ const { evaluator } = buildWithBranchAndRemote(
507
+ "main",
508
+ "https://github.com/user/Goldmine.git",
509
+ );
510
+ const res = await evaluator.evaluate(
511
+ bashEvent("git commit -m 'note'"),
512
+ makeCtx("/home/user/Goldmine/notes"),
513
+ 0,
514
+ );
515
+ assert.ok(
516
+ res && res.block === true,
517
+ "vault path must NOT bypass the rule — gitPlugin doesn't ship vault awareness",
518
+ );
519
+ assert.match(
520
+ res.reason!,
521
+ /\[steering:no-main-commit-github@[^\]]+\]/,
522
+ "vault paths still get the github-flavored message; vault exemption is a downstream-consumer override",
523
+ );
524
+ });
525
+
526
+ it("SSH-form github URL (`git@github.com:...`) → fires github rule", async () => {
527
+ // The `remote:` regex `/github\.com[/:]/` accepts both the
528
+ // HTTPS path separator (`github.com/`) and the SSH user-host
529
+ // separator (`github.com:`). Pins the broader character class
530
+ // against an accidental narrowing back to `/github\.com\//`.
531
+ const { evaluator } = buildWithBranchAndRemote(
532
+ "main",
533
+ "git@github.com:cad0p/repo.git",
534
+ );
535
+ const res = await evaluator.evaluate(
536
+ bashEvent("git commit -m 'x'"),
537
+ makeCtx("/repo"),
538
+ 0,
539
+ );
540
+ assert.ok(res && res.block === true);
541
+ assert.match(
542
+ res.reason!,
543
+ /\[steering:no-main-commit-github@[^\]]+\]/,
544
+ "SSH-form github URL must route to the github-flavored message",
545
+ );
546
+ });
547
+
548
+ it("overridable via `# steering-override: no-main-commit-github` comment", async () => {
549
+ // Mirrors the override-comment test on the generic rule. The
550
+ // rule's `noOverride: false` field is set explicitly with a
551
+ // JSDoc rationale ("workflow rules are intentionally
552
+ // overridable") — this test exercises the engine's actual
553
+ // override-comment path on the github specialization, rather
554
+ // than relying on the rule-shape `assert.equal(noOverride,
555
+ // false)` pin alone. A future refactor that drops the field
556
+ // or flips the schema default would surface here as a failed
557
+ // override + a missing audit entry.
558
+ //
559
+ // Both `no-main-commit-github` and the generic
560
+ // `no-main-commit` fire on a github clone + main. Stacked
561
+ // override comments suppress both — the engine accepts
562
+ // multiple override markers on a single command (see
563
+ // `extractOverride`'s JSDoc on stacked overrides). The audit
564
+ // entry assertion specifically targets the github rule to pin
565
+ // the github-side override path was exercised.
566
+ const { evaluator, host } = buildWithBranchAndRemote(
567
+ "main",
568
+ "https://github.com/cad0p/repo.git",
569
+ );
570
+ const res = await evaluator.evaluate(
571
+ bashEvent(
572
+ "git commit -m 'release' " +
573
+ "# steering-override: no-main-commit-github - release process " +
574
+ "# steering-override: no-main-commit - release process",
575
+ ),
576
+ makeCtx("/repo"),
577
+ 0,
578
+ );
579
+ assert.equal(res, undefined);
580
+ assert.ok(
581
+ host.appended.some(
582
+ (e) =>
583
+ e.type === "steering-override" &&
584
+ (e.data as { rule?: string } | undefined)?.rule ===
585
+ "no-main-commit-github",
586
+ ),
587
+ "expected a steering-override audit entry for no-main-commit-github (proves the github rule's override path was exercised)",
588
+ );
589
+ });
590
+
591
+ it("`git checkout $VAR && git commit` on github clone → walker-unknown branch routes to the bespoke 'could not verify' message", async () => {
592
+ // Pins the walker-unknown-branch early-return in the reason fn.
593
+ // Walker-unknown CWD takes a different path: `remote:` opts into
594
+ // `onUnknown: "allow"` and projects unknown→false BEFORE this
595
+ // reason fn runs (the rule skips, deferring to the generic
596
+ // noMainCommit). This test scopes to walker-unknown BRANCH (not
597
+ // cwd): github remote stubbed + dynamic checkout target in the
598
+ // chain so the branch tracker collapses to its `"unknown"`
599
+ // sentinel under known cwd. The branch predicate's default
600
+ // `onUnknown: "block"` fires fail-closed; the reason fn detects
601
+ // `branchRes.kind === "unknown"` and emits the bespoke "could not
602
+ // verify the current branch" message rather than falling through
603
+ // to a positive claim about the protected branch.
604
+ //
605
+ // Counterfactual rationale: without the walker-unknown-branch
606
+ // sibling early-return, the reason fn falls through to the
607
+ // known-branch body ("You're on a github clone's protected
608
+ // branch"). The walkerString-driven dynamic clause
609
+ // ` You are on '${branch}'.` correctly omits itself when
610
+ // `branchRes.kind === "unknown"` (the ternary at
611
+ // `rules.ts` returns undefined → `onClause = ""`), so the
612
+ // `"unknown"` sentinel cannot leak — but the static body still
613
+ // claims github-flavored protected-branch context the engine
614
+ // hasn't verified. The agent then sees github-specific PR-flow
615
+ // guidance for a context the rule didn't actually verify.
616
+ const { evaluator } = buildWithBranchAndRemote(
617
+ "feature",
618
+ "https://github.com/cad0p/repo.git",
619
+ );
620
+ const res = await evaluator.evaluate(
621
+ bashEvent("git checkout $VAR && git commit -m 'x'"),
622
+ makeCtx("/repo"),
623
+ 0,
624
+ );
625
+ assert.ok(res && res.block === true);
626
+ assert.match(
627
+ res.reason!,
628
+ /\[steering:no-main-commit-github@[^\]]+\]/,
629
+ "github-flavored rule must fire on dynamic-checkout chain",
630
+ );
631
+ assert.match(
632
+ res.reason!,
633
+ /Could not verify the current branch/,
634
+ "reason must use the bespoke walker-unknown-branch message",
635
+ );
636
+ assert.doesNotMatch(
637
+ res.reason!,
638
+ /You're on a github clone's protected branch/,
639
+ "reason must NOT make an unverified positive claim about the protected branch",
640
+ );
641
+ assert.doesNotMatch(
642
+ res.reason!,
643
+ /You are on '/,
644
+ "reason must not include the dynamic branch-name clause when walker state is 'unknown'",
645
+ );
646
+ assert.doesNotMatch(
647
+ res.reason!,
648
+ /'unknown'/,
649
+ "reason must not leak the walker `unknown` sentinel into the agent-facing message",
650
+ );
651
+ // Cross-phase: paragraph-aware separator preserves the safety
652
+ // reminder as its own paragraph on the walker-unknown-branch
653
+ // body too.
654
+ assert.match(
655
+ res.reason!,
656
+ /\n\nSafety: NEVER merge a PR/,
657
+ "safety reminder must remain a separate paragraph on the walker-unknown-branch body",
658
+ );
659
+ });
660
+
661
+ it("`git checkout main && git commit` on github clone → reason injects 'You are on main'", async () => {
662
+ // Mirror of `noMainCommit`'s dynamic-clause pin for the github
663
+ // specialization: when the branch tracker has resolved a
664
+ // concrete protected-branch name from an in-chain checkout, the
665
+ // reason fn injects the name into the body. Pins the
666
+ // `walkerString`-driven interpolation against a regression that
667
+ // hardcodes "main".
668
+ const { evaluator } = buildWithBranchAndRemote(
669
+ "feature",
670
+ "https://github.com/cad0p/repo.git",
671
+ );
672
+ const res = await evaluator.evaluate(
673
+ bashEvent("git checkout main && git commit -m 'x'"),
674
+ makeCtx("/repo"),
675
+ 0,
676
+ );
677
+ assert.ok(res && res.block === true);
678
+ assert.match(res.reason!, /\[steering:no-main-commit-github@[^\]]+\]/);
679
+ assert.match(
680
+ res.reason!,
681
+ /You are on 'main'/,
682
+ "reason must include the walker-resolved branch name",
683
+ );
684
+ });
685
+
686
+ it("`git checkout master && git commit` on github clone → injects the concrete protected branch name", async () => {
687
+ // Sibling pin to the `main` test: master / mainline / trunk all
688
+ // flow through the same tracker-driven interpolation. Catches a
689
+ // regression that hardcodes a single protected-branch literal
690
+ // instead of reading from `walkerString`.
691
+ const { evaluator } = buildWithBranchAndRemote(
692
+ "feature",
693
+ "https://github.com/cad0p/repo.git",
694
+ );
695
+ const res = await evaluator.evaluate(
696
+ bashEvent("git checkout master && git commit -m 'x'"),
697
+ makeCtx("/repo"),
698
+ 0,
699
+ );
700
+ assert.ok(res && res.block === true);
701
+ assert.match(res.reason!, /You are on 'master'/);
702
+ });
703
+
704
+ it("`git checkout trunk && git commit` on github clone → injects 'trunk' (interpolation is tracker-driven)", async () => {
705
+ // Pin the third protected-branch alias to seal the
706
+ // tracker-driven contract: the injected name comes from
707
+ // `walkerString`, not a hardcoded set of `main` / `master`.
708
+ const { evaluator } = buildWithBranchAndRemote(
709
+ "feature",
710
+ "https://github.com/cad0p/repo.git",
711
+ );
712
+ const res = await evaluator.evaluate(
713
+ bashEvent("git checkout trunk && git commit -m 'x'"),
714
+ makeCtx("/repo"),
715
+ 0,
716
+ );
717
+ assert.ok(res && res.block === true);
718
+ assert.match(res.reason!, /You are on 'trunk'/);
719
+ });
720
+
721
+ it("no in-chain checkout on github clone → reason omits the dynamic branch clause", async () => {
722
+ // Exec reports `main` via `git branch --show-current`, so the
723
+ // rule fires — but the BRANCH TRACKER didn't see an in-chain
724
+ // checkout, so `walkerString` returns the
725
+ // `NO_CHECKOUT_IN_CHAIN` sentinel (not a real branch name).
726
+ // The reason fn must NOT leak the sentinel into the body and
727
+ // must omit the dynamic clause; the static github-flavored
728
+ // guidance still fires.
729
+ const { evaluator } = buildWithBranchAndRemote(
730
+ "main",
731
+ "https://github.com/cad0p/repo.git",
732
+ );
733
+ const res = await evaluator.evaluate(
734
+ bashEvent("git commit -m 'x'"),
735
+ makeCtx("/repo"),
736
+ 0,
737
+ );
738
+ assert.ok(res && res.block === true);
739
+ assert.match(res.reason!, /\[steering:no-main-commit-github@[^\]]+\]/);
740
+ assert.match(res.reason!, /You're on a github clone's protected branch/);
741
+ assert.doesNotMatch(
742
+ res.reason!,
743
+ /You are on '/,
744
+ "reason must not include the dynamic clause when no in-chain checkout was tracked",
745
+ );
746
+ });
747
+
748
+ it("rule-shape pin: `noOverride: false` + `when:` includes both branch + remote", () => {
749
+ const rule = gitPlugin.rules?.find(
750
+ (r) => r.name === "no-main-commit-github",
751
+ );
752
+ assert.ok(rule);
753
+ assert.equal(rule.tool, "bash");
754
+ assert.equal(rule.field, "command");
755
+ assert.equal(
756
+ rule.noOverride,
757
+ false,
758
+ "workflow rule must stay overridable via `# steering-override:` comment",
759
+ );
760
+ assert.ok(
761
+ rule.when !== undefined &&
762
+ "branch" in (rule.when as Record<string, unknown>) &&
763
+ "remote" in (rule.when as Record<string, unknown>),
764
+ "when: must include both branch + remote (specialization shape)",
765
+ );
766
+ });
767
+
768
+ it("`disabledRules: ['no-main-commit-github']` only + github clone → generic fires (clean fall-through)", async () => {
769
+ // Pins the canonical "swap to generic message" customization:
770
+ // disable just the github specialization, generic stays
771
+ // active, user gets the generic feature-branch reminder
772
+ // instead of PR-flow guidance. This is the user-facing
773
+ // behavior README's Customization section advertises for the
774
+ // disable-only path — pinned so a regression in the
775
+ // disabledRules filter (e.g., name-prefix-matching that
776
+ // accidentally disables both rules) surfaces here as a
777
+ // missing block.
778
+ const host = makeTrackedHost({
779
+ exec: async (cmd, args): Promise<PiExecResult> => {
780
+ if (
781
+ cmd === "git" &&
782
+ args[0] === "branch" &&
783
+ args[1] === "--show-current"
784
+ ) {
785
+ return {
786
+ stdout: "main\n",
787
+ stderr: "",
788
+ code: 0,
789
+ killed: false,
790
+ };
791
+ }
792
+ if (
793
+ cmd === "git" &&
794
+ args[0] === "config" &&
795
+ args[1] === "--get" &&
796
+ args[2] === "remote.origin.url"
797
+ ) {
798
+ return {
799
+ stdout: "https://github.com/cad0p/repo.git\n",
800
+ stderr: "",
801
+ code: 0,
802
+ killed: false,
803
+ };
804
+ }
805
+ return { stdout: "", stderr: "", code: 1, killed: false };
806
+ },
807
+ });
808
+ const resolved = resolvePlugins([gitPlugin], {
809
+ disabledRules: ["no-main-commit-github"],
810
+ });
811
+ const evaluator = buildEvaluator({}, resolved, host);
812
+ const res = await evaluator.evaluate(
813
+ bashEvent("git commit -m 'x'"),
814
+ makeCtx("/repo"),
815
+ 0,
816
+ );
817
+ assert.ok(
818
+ res && res.block === true,
819
+ "generic rule must still fire on protected branch when only the github rule is disabled",
820
+ );
821
+ assert.match(
822
+ res.reason!,
823
+ /\[steering:no-main-commit@[^\]]+\]/,
824
+ "generic no-main-commit fires on github clones too (its when: is just branch-based)",
825
+ );
826
+ assert.doesNotMatch(
827
+ res.reason!,
828
+ /no-main-commit-github@/,
829
+ "github-flavored rule must NOT fire when listed in disabledRules",
830
+ );
831
+ });
832
+
833
+ it("`disabledRules: ['no-main-commit-github']` only + non-github → generic fires (disable doesn't affect generic routing)", async () => {
834
+ // Sibling pin to the above: disabling the github rule must
835
+ // not affect the generic rule's routing on non-github
836
+ // remotes. A regression where `disabledRules` accidentally
837
+ // name-prefix-matched (`startsWith` instead of equality)
838
+ // would disable both rules here and surface as `res ===
839
+ // undefined`.
840
+ const host = makeTrackedHost({
841
+ exec: async (cmd, args): Promise<PiExecResult> => {
842
+ if (
843
+ cmd === "git" &&
844
+ args[0] === "branch" &&
845
+ args[1] === "--show-current"
846
+ ) {
847
+ return {
848
+ stdout: "main\n",
849
+ stderr: "",
850
+ code: 0,
851
+ killed: false,
852
+ };
853
+ }
854
+ return { stdout: "", stderr: "", code: 1, killed: false };
855
+ },
856
+ });
857
+ const resolved = resolvePlugins([gitPlugin], {
858
+ disabledRules: ["no-main-commit-github"],
859
+ });
860
+ const evaluator = buildEvaluator({}, resolved, host);
861
+ const res = await evaluator.evaluate(
862
+ bashEvent("git commit -m 'x'"),
863
+ makeCtx("/repo"),
864
+ 0,
865
+ );
866
+ assert.ok(
867
+ res && res.block === true,
868
+ "generic rule fires on non-github too — disable name-equality must scope cleanly",
869
+ );
870
+ assert.match(res.reason!, /\[steering:no-main-commit@[^\]]+\]/);
871
+ });
872
+
873
+ it("`disabledRules: ['no-main-commit-github', 'no-main-commit']` → no commit-on-main rule fires", async () => {
874
+ // Sanity-check pin for the both-disabled case. With both
875
+ // rules disabled, the engine has no commit-on-main guard at
876
+ // all → block does not surface. Useful as a regression seal
877
+ // against an accidental third commit-on-main rule being
878
+ // added without being listed here.
879
+ const host = makeTrackedHost({
880
+ exec: async (cmd, args): Promise<PiExecResult> => {
881
+ if (
882
+ cmd === "git" &&
883
+ args[0] === "branch" &&
884
+ args[1] === "--show-current"
885
+ ) {
886
+ return {
887
+ stdout: "main\n",
888
+ stderr: "",
889
+ code: 0,
890
+ killed: false,
891
+ };
892
+ }
893
+ if (
894
+ cmd === "git" &&
895
+ args[0] === "config" &&
896
+ args[1] === "--get" &&
897
+ args[2] === "remote.origin.url"
898
+ ) {
899
+ return {
900
+ stdout: "https://github.com/cad0p/repo.git\n",
901
+ stderr: "",
902
+ code: 0,
903
+ killed: false,
904
+ };
905
+ }
906
+ return { stdout: "", stderr: "", code: 1, killed: false };
907
+ },
908
+ });
909
+ const resolved = resolvePlugins([gitPlugin], {
910
+ disabledRules: ["no-main-commit-github", "no-main-commit"],
911
+ });
912
+ const evaluator = buildEvaluator({}, resolved, host);
913
+ const res = await evaluator.evaluate(
914
+ bashEvent("git commit -m 'x'"),
915
+ makeCtx("/repo"),
916
+ 0,
917
+ );
918
+ assert.equal(
919
+ res,
920
+ undefined,
921
+ "both rules disabled: no commit-on-main rule should fire",
922
+ );
923
+ });
924
+
925
+ it("both rules' pattern fields equal the exported GIT_COMMIT_PATTERN constant", () => {
926
+ // Value-equality pin against the exported constant. The pattern
927
+ // is a string primitive, so this is byte-equality, NOT shared-
928
+ // reference identity — a future maintainer who inlines the
929
+ // literal at one rule's definition site with the SAME bytes
930
+ // would not trip this assertion. What it DOES catch:
931
+ // - Either rule's pattern accidentally diverging from the
932
+ // exported constant (e.g. one drops `\b`, one anchors
933
+ // differently).
934
+ // - The constant itself getting renamed away or removed.
935
+ // True shared-reference factoring would need a `RegExp` (object)
936
+ // constant; today's design uses a string source so the regex-
937
+ // compile cache in the engine can dedupe across both rules
938
+ // without per-rule allocation.
939
+ assert.equal(
940
+ noMainCommit.pattern,
941
+ GIT_COMMIT_PATTERN,
942
+ "noMainCommit.pattern must equal the exported GIT_COMMIT_PATTERN constant",
943
+ );
944
+ assert.equal(
945
+ noMainCommitGithub.pattern,
946
+ GIT_COMMIT_PATTERN,
947
+ "noMainCommitGithub.pattern must equal the exported GIT_COMMIT_PATTERN constant",
948
+ );
949
+ });
950
+
951
+ it("both rules' branch fields share-reference the exported PROTECTED_BRANCH_PATTERN constant", () => {
952
+ // Shared-reference pin (`===` on the RegExp object), STRICTLY
953
+ // stronger than the byte-equality pin above for `pattern`. The
954
+ // protected-branch list is a `RegExp` (object) constant rather
955
+ // than a string source, so identity comparison is meaningful:
956
+ // any future maintainer who inlines `/^(main|master|mainline|trunk)$/`
957
+ // at a rule's definition site — even with byte-identical contents
958
+ // — trips this assertion because the inlined literal compiles to
959
+ // a fresh `RegExp` instance. That catches the failure mode the
960
+ // `GIT_COMMIT_PATTERN` value-equality pin cannot: silent
961
+ // re-inlining that re-introduces the duplication this constant
962
+ // was extracted to eliminate.
963
+ //
964
+ // Without the shared constant, the protected-branch list could
965
+ // drift between the two rules (one rule adds a vendor-specific
966
+ // default-branch alias, the other doesn't); pinning shared-
967
+ // reference forces both rules to pick up the alias from a single
968
+ // edit site.
969
+ assert.strictEqual(
970
+ noMainCommit.when.branch,
971
+ PROTECTED_BRANCH_PATTERN,
972
+ "noMainCommit.when.branch must reference the exported PROTECTED_BRANCH_PATTERN constant",
973
+ );
974
+ // `noMainCommitGithub.when` is the object form (with `remote:`);
975
+ // the `branch:` field still points directly at the regex.
976
+ assert.strictEqual(
977
+ noMainCommitGithub.when.branch,
978
+ PROTECTED_BRANCH_PATTERN,
979
+ "noMainCommitGithub.when.branch must reference the exported PROTECTED_BRANCH_PATTERN constant",
980
+ );
981
+ });
982
+
983
+ it("first-match-wins ordering: github rule appears BEFORE no-main-commit in the plugin's rule array", () => {
984
+ // Load-bearing ordering. On a github clone + on main, BOTH rules'
985
+ // `when:` clauses match — first-match-wins routes the github-
986
+ // flavored guidance to github users. Reordering for stylistic
987
+ // reasons (alphabetical, etc.) silently regresses user-facing
988
+ // behavior; this assertion makes that regression visible.
989
+ const rules = gitPlugin.rules ?? [];
990
+ const githubIdx = rules.findIndex(
991
+ (r) => r.name === "no-main-commit-github",
992
+ );
993
+ const genericIdx = rules.findIndex((r) => r.name === "no-main-commit");
994
+ assert.notEqual(githubIdx, -1, "no-main-commit-github must be registered");
995
+ assert.notEqual(genericIdx, -1, "no-main-commit must be registered");
996
+ assert.ok(
997
+ githubIdx < genericIdx,
998
+ `expected no-main-commit-github (idx ${githubIdx}) BEFORE no-main-commit (idx ${genericIdx})`,
999
+ );
1000
+ });
1001
+ });