@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,1401 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Tests for the git plugin's predicate handlers (`./predicates.ts`).
6
+ *
7
+ * Each handler is tested in isolation with a mock `PredicateContext`
8
+ * whose `exec` records every invocation and returns a stubbed
9
+ * `ExecResult`. This pins the shell-command shape each predicate
10
+ * emits AND the branching logic without spawning real git. Walker-
11
+ * state interactions for `branch` are covered here (the tracker's own
12
+ * modifier semantics live in `./branch-tracker.test.ts`).
13
+ */
14
+
15
+ import assert from "node:assert/strict";
16
+ import { describe, it } from "node:test";
17
+ import type {
18
+ ExecResult,
19
+ PredicateContext,
20
+ WhenWalkerState,
21
+ } from "../../index.ts";
22
+ import {
23
+ _unwrapBooleanLeafArg,
24
+ branch,
25
+ commitsAhead,
26
+ hasStagedChanges,
27
+ isClean,
28
+ remote,
29
+ upstream,
30
+ walkerString,
31
+ } from "./predicates.ts";
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Context builder
35
+ // ---------------------------------------------------------------------------
36
+
37
+ interface ExecCall {
38
+ cmd: string;
39
+ args: string[];
40
+ cwd?: string | undefined;
41
+ }
42
+
43
+ /**
44
+ * Minimal `PredicateContext` stub for predicate unit tests.
45
+ *
46
+ * `exec` dispatches on the FIRST matching key in `responses` (the
47
+ * table is consulted top-to-bottom), letting tests stub different
48
+ * results for different commands. Unmatched calls throw so the test
49
+ * catches accidental shell-out expansions.
50
+ */
51
+ function makeCtx(
52
+ responses: ReadonlyArray<{
53
+ match: (cmd: string, args: string[]) => boolean;
54
+ result?: ExecResult;
55
+ throwError?: Error;
56
+ }>,
57
+ opts?: {
58
+ cwd?: string;
59
+ walkerState?: Partial<WhenWalkerState> & Record<string, unknown>;
60
+ },
61
+ ): { ctx: PredicateContext; execCalls: ExecCall[] } {
62
+ const execCalls: ExecCall[] = [];
63
+ const ctx: PredicateContext = {
64
+ cwd: opts?.cwd ?? "/repo",
65
+ tool: "bash",
66
+ input: { tool: "bash", command: "" },
67
+ agentLoopIndex: 0,
68
+ exec: async (cmd, args, execOpts) => {
69
+ execCalls.push({ cmd, args: [...args], cwd: execOpts?.cwd });
70
+ for (const entry of responses) {
71
+ if (entry.match(cmd, args)) {
72
+ if (entry.throwError) throw entry.throwError;
73
+ if (entry.result) return entry.result;
74
+ }
75
+ }
76
+ throw new Error(`unexpected exec call: ${cmd} ${args.join(" ")}`);
77
+ },
78
+ appendEntry: () => {},
79
+ findEntries: () => [],
80
+ ...(opts?.walkerState !== undefined
81
+ ? { walkerState: opts.walkerState as Readonly<WhenWalkerState> }
82
+ : {}),
83
+ };
84
+ return { ctx, execCalls };
85
+ }
86
+
87
+ function execOk(stdout: string): ExecResult {
88
+ return { stdout, stderr: "", exitCode: 0 };
89
+ }
90
+
91
+ function execFail(exitCode: number, stderr = ""): ExecResult {
92
+ return { stdout: "", stderr, exitCode };
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // branch
97
+ // ---------------------------------------------------------------------------
98
+
99
+ describe("predicate: branch", () => {
100
+ it("reads ctx.walkerState.branch when set (no exec)", async () => {
101
+ const { ctx, execCalls } = makeCtx([], {
102
+ walkerState: { branch: "main" },
103
+ });
104
+ const matches = await branch(/^main$/, ctx);
105
+ assert.equal(matches, true);
106
+ assert.equal(execCalls.length, 0);
107
+ });
108
+
109
+ it("walkerState misses -> falls back to `git branch --show-current`", async () => {
110
+ const { ctx, execCalls } = makeCtx([
111
+ {
112
+ match: (cmd, args) =>
113
+ cmd === "git" && args[0] === "branch" && args[1] === "--show-current",
114
+ result: execOk("feature\n"),
115
+ },
116
+ ]);
117
+ const matches = await branch(/^feature$/, ctx);
118
+ assert.equal(matches, true);
119
+ assert.equal(execCalls.length, 1);
120
+ assert.deepEqual(execCalls[0]!.args, ["branch", "--show-current"]);
121
+ assert.equal(execCalls[0]!.cwd, "/repo");
122
+ });
123
+
124
+ it('walkerState value `"unknown"` (dynamic checkout) short-circuits: default onUnknown=block fires, no exec fallback', async () => {
125
+ // The walker saw something like `git checkout $VAR` - a write
126
+ // happened but the target branch can't be resolved statically.
127
+ // Falling through to `git branch --show-current` would return
128
+ // the PRE-checkout branch and silently defeat the walker's
129
+ // whole purpose. Predicate must apply onUnknown without exec.
130
+ const { ctx, execCalls } = makeCtx([], {
131
+ walkerState: { branch: "unknown" },
132
+ });
133
+ const matches = await branch(/^main$/, ctx);
134
+ assert.equal(matches, true);
135
+ assert.equal(
136
+ execCalls.length,
137
+ 0,
138
+ "exec must not be called when walker reports unknown",
139
+ );
140
+ });
141
+
142
+ it('walkerState value `"unknown"` with onUnknown: "allow" - rule skips, still no exec', async () => {
143
+ const { ctx, execCalls } = makeCtx([], {
144
+ walkerState: { branch: "unknown" },
145
+ });
146
+ const matches = await branch(
147
+ { pattern: /^main$/, onUnknown: "allow" },
148
+ ctx,
149
+ );
150
+ assert.equal(matches, false);
151
+ assert.equal(execCalls.length, 0);
152
+ });
153
+
154
+ it("walkerState missing entirely (no in-chain checkout) -> exec fallback", async () => {
155
+ // No `walkerState` on ctx at all: the tracker observed no git
156
+ // checkout in this chain, so the current shell-level branch
157
+ // value IS the predicate's answer. Exec is the right path.
158
+ const { ctx, execCalls } = makeCtx([
159
+ {
160
+ match: (cmd, args) =>
161
+ cmd === "git" && args[0] === "branch" && args[1] === "--show-current",
162
+ result: execOk("trunk\n"),
163
+ },
164
+ ]);
165
+ const matches = await branch(/^trunk$/, ctx);
166
+ assert.equal(matches, true);
167
+ assert.equal(execCalls.length, 1);
168
+ });
169
+
170
+ it("onUnknown defaults to block - exec failure fires the predicate", async () => {
171
+ // `git branch --show-current` fails (not a repo). Default
172
+ // `onUnknown: "block"` means "the rule fires" - the predicate
173
+ // reports match=true so the surrounding rule does NOT skip.
174
+ const { ctx } = makeCtx([
175
+ {
176
+ match: (cmd, args) => cmd === "git" && args[0] === "branch",
177
+ result: execFail(128, "not a git repository"),
178
+ },
179
+ ]);
180
+ const matches = await branch(/^main$/, ctx);
181
+ assert.equal(matches, true);
182
+ });
183
+
184
+ it('onUnknown: "allow" - exec failure skips the rule', async () => {
185
+ const { ctx } = makeCtx([
186
+ {
187
+ match: (cmd, args) => cmd === "git" && args[0] === "branch",
188
+ result: execFail(128),
189
+ },
190
+ ]);
191
+ const matches = await branch(
192
+ { pattern: /^main$/, onUnknown: "allow" },
193
+ ctx,
194
+ );
195
+ assert.equal(matches, false);
196
+ });
197
+
198
+ it("empty stdout (detached HEAD) applies onUnknown", async () => {
199
+ const { ctx } = makeCtx([
200
+ {
201
+ match: (cmd, args) => cmd === "git" && args[0] === "branch",
202
+ result: execOk(""),
203
+ },
204
+ ]);
205
+ // Default block -> fires.
206
+ assert.equal(await branch(/^main$/, ctx), true);
207
+ // Explicit allow -> skips.
208
+ const { ctx: ctxAllow } = makeCtx([
209
+ {
210
+ match: (cmd, args) => cmd === "git" && args[0] === "branch",
211
+ result: execOk(""),
212
+ },
213
+ ]);
214
+ assert.equal(
215
+ await branch({ pattern: /^main$/, onUnknown: "allow" }, ctxAllow),
216
+ false,
217
+ );
218
+ });
219
+
220
+ it("string pattern compiles as regex", async () => {
221
+ const { ctx } = makeCtx([], { walkerState: { branch: "feat-new" } });
222
+ assert.equal(await branch("^feat-", ctx), true);
223
+ assert.equal(await branch("^main$", ctx), false);
224
+ });
225
+
226
+ it("invalid arg shape returns false", async () => {
227
+ const { ctx } = makeCtx([], { walkerState: { branch: "main" } });
228
+ // Numeric is not a valid pattern - predicate returns false
229
+ // rather than throwing.
230
+ assert.equal(await branch(42 as unknown, ctx), false);
231
+ });
232
+
233
+ it("thrown exec error treated as failure + applies onUnknown", async () => {
234
+ const { ctx } = makeCtx([
235
+ {
236
+ match: (cmd) => cmd === "git",
237
+ throwError: new Error("spawn ENOENT"),
238
+ },
239
+ ]);
240
+ // Default block -> fires.
241
+ assert.equal(await branch(/^main$/, ctx), true);
242
+ });
243
+ });
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // upstream
247
+ // ---------------------------------------------------------------------------
248
+
249
+ describe("predicate: upstream", () => {
250
+ it("matches when git rev-parse resolves", async () => {
251
+ const { ctx, execCalls } = makeCtx([
252
+ {
253
+ match: (cmd, args) =>
254
+ cmd === "git" &&
255
+ args[0] === "rev-parse" &&
256
+ args[1] === "--abbrev-ref" &&
257
+ args[2] === "@{upstream}",
258
+ result: execOk("origin/main\n"),
259
+ },
260
+ ]);
261
+ assert.equal(await upstream(/^origin\/main$/, ctx), true);
262
+ assert.equal(execCalls.length, 1);
263
+ });
264
+
265
+ it("no upstream configured (exit != 0) -> onUnknown block (default)", async () => {
266
+ const { ctx } = makeCtx([
267
+ {
268
+ match: (cmd, args) => cmd === "git" && args[0] === "rev-parse",
269
+ result: execFail(128, "no upstream configured"),
270
+ },
271
+ ]);
272
+ assert.equal(await upstream(/./, ctx), true);
273
+ });
274
+
275
+ it("onUnknown: allow - exec failure skips", async () => {
276
+ const { ctx } = makeCtx([
277
+ {
278
+ match: (cmd, args) => cmd === "git" && args[0] === "rev-parse",
279
+ result: execFail(128),
280
+ },
281
+ ]);
282
+ assert.equal(
283
+ await upstream({ pattern: /./, onUnknown: "allow" }, ctx),
284
+ false,
285
+ );
286
+ });
287
+
288
+ it("pattern doesn't match stdout -> false (rule skips)", async () => {
289
+ const { ctx } = makeCtx([
290
+ { match: (cmd) => cmd === "git", result: execOk("origin/feature\n") },
291
+ ]);
292
+ assert.equal(await upstream(/^origin\/main$/, ctx), false);
293
+ });
294
+ });
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // commitsAhead
298
+ // ---------------------------------------------------------------------------
299
+
300
+ describe("predicate: commitsAhead", () => {
301
+ it("eq matches exact count", async () => {
302
+ const { ctx, execCalls } = makeCtx([
303
+ {
304
+ match: (cmd, args) =>
305
+ cmd === "git" &&
306
+ args[0] === "rev-list" &&
307
+ args[1] === "--count" &&
308
+ args[2] === "@{upstream}..HEAD",
309
+ result: execOk("1\n"),
310
+ },
311
+ ]);
312
+ assert.equal(await commitsAhead({ eq: 1 }, ctx), true);
313
+ assert.equal(execCalls.length, 1);
314
+ });
315
+
316
+ it("eq misses -> false", async () => {
317
+ const { ctx } = makeCtx([
318
+ {
319
+ match: (cmd, args) => cmd === "git" && args[0] === "rev-list",
320
+ result: execOk("3\n"),
321
+ },
322
+ ]);
323
+ assert.equal(await commitsAhead({ eq: 1 }, ctx), false);
324
+ });
325
+
326
+ it("gt strict greater-than", async () => {
327
+ const { ctx: ctx1 } = makeCtx([
328
+ {
329
+ match: (cmd) => cmd === "git",
330
+ result: execOk("0\n"),
331
+ },
332
+ ]);
333
+ assert.equal(await commitsAhead({ gt: 0 }, ctx1), false);
334
+
335
+ const { ctx: ctx2 } = makeCtx([
336
+ {
337
+ match: (cmd) => cmd === "git",
338
+ result: execOk("1\n"),
339
+ },
340
+ ]);
341
+ assert.equal(await commitsAhead({ gt: 0 }, ctx2), true);
342
+ });
343
+
344
+ it("gt + lt combined - all comparators must pass (AND)", async () => {
345
+ const { ctx: ctx3 } = makeCtx([
346
+ { match: (cmd) => cmd === "git", result: execOk("3\n") },
347
+ ]);
348
+ // 3 > 0 AND 3 < 5 -> true
349
+ assert.equal(await commitsAhead({ gt: 0, lt: 5 }, ctx3), true);
350
+
351
+ const { ctx: ctx5 } = makeCtx([
352
+ { match: (cmd) => cmd === "git", result: execOk("5\n") },
353
+ ]);
354
+ // 5 > 0 but 5 < 5 is false -> false
355
+ assert.equal(await commitsAhead({ gt: 0, lt: 5 }, ctx5), false);
356
+ });
357
+
358
+ it("custom wrt is forwarded to git rev-list", async () => {
359
+ const { ctx, execCalls } = makeCtx([
360
+ {
361
+ match: (cmd, args) =>
362
+ cmd === "git" &&
363
+ args[0] === "rev-list" &&
364
+ args[2] === "origin/main..HEAD",
365
+ result: execOk("2\n"),
366
+ },
367
+ ]);
368
+ assert.equal(await commitsAhead({ wrt: "origin/main", eq: 2 }, ctx), true);
369
+ assert.equal(execCalls[0]!.args[2], "origin/main..HEAD");
370
+ });
371
+
372
+ it("no comparators specified -> false (invalid config)", async () => {
373
+ // Don't register any responses - the predicate must not call
374
+ // exec when the arg shape is invalid.
375
+ const { ctx, execCalls } = makeCtx([]);
376
+ assert.equal(await commitsAhead({}, ctx), false);
377
+ assert.equal(execCalls.length, 0);
378
+ });
379
+
380
+ it("exec failure -> false (no upstream configured)", async () => {
381
+ const { ctx } = makeCtx([
382
+ { match: (cmd) => cmd === "git", result: execFail(128) },
383
+ ]);
384
+ assert.equal(await commitsAhead({ eq: 1 }, ctx), false);
385
+ });
386
+
387
+ it("non-numeric stdout -> false", async () => {
388
+ const { ctx } = makeCtx([
389
+ { match: (cmd) => cmd === "git", result: execOk("not-a-number") },
390
+ ]);
391
+ assert.equal(await commitsAhead({ eq: 0 }, ctx), false);
392
+ });
393
+
394
+ it("null / non-object args -> false", async () => {
395
+ const { ctx } = makeCtx([]);
396
+ assert.equal(
397
+ await commitsAhead(null as unknown as { eq: number }, ctx),
398
+ false,
399
+ );
400
+ assert.equal(
401
+ await commitsAhead("bogus" as unknown as { eq: number }, ctx),
402
+ false,
403
+ );
404
+ });
405
+
406
+ it("lt standalone - strict less-than boundary", async () => {
407
+ const { ctx: ctxAtLimit } = makeCtx([
408
+ { match: (cmd) => cmd === "git", result: execOk("5\n") },
409
+ ]);
410
+ assert.equal(await commitsAhead({ lt: 5 }, ctxAtLimit), false);
411
+ const { ctx: ctxBelow } = makeCtx([
412
+ { match: (cmd) => cmd === "git", result: execOk("4\n") },
413
+ ]);
414
+ assert.equal(await commitsAhead({ lt: 5 }, ctxBelow), true);
415
+ });
416
+
417
+ it("bare-number `commitsAhead: N` is sugar for `{ eq: N }` (schema-advertised shape)", async () => {
418
+ const { ctx, execCalls } = makeCtx([
419
+ {
420
+ match: (cmd, args) =>
421
+ cmd === "git" &&
422
+ args[0] === "rev-list" &&
423
+ args[1] === "--count" &&
424
+ args[2] === "@{upstream}..HEAD",
425
+ result: execOk("1\n"),
426
+ },
427
+ ]);
428
+ assert.equal(await commitsAhead(1, ctx), true);
429
+ assert.equal(execCalls.length, 1);
430
+ });
431
+
432
+ it("bare-number `commitsAhead: 0` matches when count is 0 (sugar for `{ eq: 0 }`)", async () => {
433
+ const { ctx } = makeCtx([
434
+ { match: (cmd) => cmd === "git", result: execOk("0\n") },
435
+ ]);
436
+ assert.equal(await commitsAhead(0, ctx), true);
437
+ });
438
+
439
+ it("bare-number `commitsAhead: 2` does NOT match when count is 1 (negative case for the bare-number sugar path)", async () => {
440
+ // Pins the bare-number sugar's miss path symmetrically with the
441
+ // `{ eq: 1 }` miss test above. The bare-number sugar dispatches
442
+ // through `eq = args` at the top of the handler, so the
443
+ // comparator-mismatch branch is the same code path, but the
444
+ // authoring shape differs and a regression that special-cased
445
+ // the bare-number entry could mask the miss case.
446
+ const { ctx } = makeCtx([
447
+ { match: (cmd) => cmd === "git", result: execOk("1\n") },
448
+ ]);
449
+ assert.equal(await commitsAhead(2, ctx), false);
450
+ });
451
+ });
452
+
453
+ // ---------------------------------------------------------------------------
454
+ // hasStagedChanges
455
+ // ---------------------------------------------------------------------------
456
+
457
+ describe("predicate: hasStagedChanges", () => {
458
+ it("exit 0 (no staged) matches `false`", async () => {
459
+ const { ctx, execCalls } = makeCtx([
460
+ {
461
+ match: (cmd, args) =>
462
+ cmd === "git" &&
463
+ args[0] === "diff" &&
464
+ args[1] === "--cached" &&
465
+ args[2] === "--quiet",
466
+ result: execOk(""),
467
+ },
468
+ ]);
469
+ assert.equal(await hasStagedChanges(false, ctx), true);
470
+ assert.equal(await hasStagedChanges(true, ctx), false);
471
+ assert.equal(execCalls.length, 2);
472
+ });
473
+
474
+ it("exit 1 (staged) matches `true`", async () => {
475
+ const { ctx } = makeCtx([
476
+ {
477
+ match: (cmd, args) => cmd === "git" && args[0] === "diff",
478
+ result: execFail(1),
479
+ },
480
+ ]);
481
+ assert.equal(await hasStagedChanges(true, ctx), true);
482
+ });
483
+
484
+ it("unexpected exit code -> false (don't fire)", async () => {
485
+ const { ctx } = makeCtx([
486
+ { match: (cmd) => cmd === "git", result: execFail(128) },
487
+ ]);
488
+ assert.equal(await hasStagedChanges(true, ctx), false);
489
+ const { ctx: ctx2 } = makeCtx([
490
+ { match: (cmd) => cmd === "git", result: execFail(128) },
491
+ ]);
492
+ assert.equal(await hasStagedChanges(false, ctx2), false);
493
+ });
494
+
495
+ it("thrown exec -> false", async () => {
496
+ const { ctx } = makeCtx([
497
+ {
498
+ match: (cmd) => cmd === "git",
499
+ throwError: new Error("spawn"),
500
+ },
501
+ ]);
502
+ assert.equal(await hasStagedChanges(true, ctx), false);
503
+ });
504
+
505
+ it("non-boolean arg -> false", async () => {
506
+ const { ctx } = makeCtx([]);
507
+ assert.equal(
508
+ await hasStagedChanges("yes" as unknown as boolean, ctx),
509
+ false,
510
+ );
511
+ });
512
+
513
+ it("spread `{ value: true }` is equivalent to bare `true` (schema-advertised shape)", async () => {
514
+ const { ctx, execCalls } = makeCtx([
515
+ {
516
+ match: (cmd, args) =>
517
+ cmd === "git" &&
518
+ args[0] === "diff" &&
519
+ args[1] === "--cached" &&
520
+ args[2] === "--quiet",
521
+ result: execFail(1), // staged changes exist
522
+ },
523
+ ]);
524
+ assert.equal(await hasStagedChanges({ value: true }, ctx), true);
525
+ assert.equal(execCalls.length, 1);
526
+ });
527
+
528
+ it("spread `{ value: false, onUnknown: 'allow' }` ignores onUnknown in handler body", async () => {
529
+ const { ctx } = makeCtx([
530
+ {
531
+ match: (cmd) => cmd === "git",
532
+ result: execOk(""), // no staged changes
533
+ },
534
+ ]);
535
+ // onUnknown is read by readLeafOnUnknown at the engine layer;
536
+ // the handler accepts the spread-with-modifier shape directly
537
+ // and unwraps `value:` only.
538
+ assert.equal(
539
+ await hasStagedChanges({ value: false, onUnknown: "allow" }, ctx),
540
+ true,
541
+ );
542
+ });
543
+ });
544
+
545
+ // ---------------------------------------------------------------------------
546
+ // isClean
547
+ // ---------------------------------------------------------------------------
548
+
549
+ describe("predicate: isClean", () => {
550
+ it("empty stdout matches `true` (clean)", async () => {
551
+ const { ctx, execCalls } = makeCtx([
552
+ {
553
+ match: (cmd, args) =>
554
+ cmd === "git" && args[0] === "status" && args[1] === "--porcelain",
555
+ result: execOk(""),
556
+ },
557
+ ]);
558
+ assert.equal(await isClean(true, ctx), true);
559
+ assert.equal(execCalls.length, 1);
560
+ });
561
+
562
+ it("dirty stdout matches `false`", async () => {
563
+ const { ctx } = makeCtx([
564
+ {
565
+ match: (cmd) => cmd === "git",
566
+ result: execOk(" M src/x.ts\n?? tmp.log\n"),
567
+ },
568
+ ]);
569
+ assert.equal(await isClean(false, ctx), true);
570
+ const { ctx: ctx2 } = makeCtx([
571
+ {
572
+ match: (cmd) => cmd === "git",
573
+ result: execOk(" M src/x.ts\n"),
574
+ },
575
+ ]);
576
+ assert.equal(await isClean(true, ctx2), false);
577
+ });
578
+
579
+ it("exec failure -> false", async () => {
580
+ const { ctx } = makeCtx([
581
+ { match: (cmd) => cmd === "git", result: execFail(128) },
582
+ ]);
583
+ assert.equal(await isClean(true, ctx), false);
584
+ });
585
+
586
+ it("non-boolean arg -> false", async () => {
587
+ const { ctx } = makeCtx([]);
588
+ assert.equal(await isClean(1 as unknown as boolean, ctx), false);
589
+ });
590
+
591
+ it("spread `{ value: true }` is equivalent to bare `true` (schema-advertised shape)", async () => {
592
+ const { ctx, execCalls } = makeCtx([
593
+ {
594
+ match: (cmd, args) =>
595
+ cmd === "git" && args[0] === "status" && args[1] === "--porcelain",
596
+ result: execOk(""), // clean
597
+ },
598
+ ]);
599
+ assert.equal(await isClean({ value: true }, ctx), true);
600
+ assert.equal(execCalls.length, 1);
601
+ });
602
+
603
+ it("spread `{ value: false, onUnknown: 'allow' }` unwraps value cleanly", async () => {
604
+ const { ctx } = makeCtx([
605
+ {
606
+ match: (cmd) => cmd === "git",
607
+ result: execOk(" M file.ts\n"), // dirty
608
+ },
609
+ ]);
610
+ assert.equal(
611
+ await isClean({ value: false, onUnknown: "allow" }, ctx),
612
+ true,
613
+ );
614
+ });
615
+ });
616
+
617
+ // ---------------------------------------------------------------------------
618
+ // remote
619
+ // ---------------------------------------------------------------------------
620
+
621
+ describe("predicate: remote", () => {
622
+ it("matches origin URL via regex", async () => {
623
+ const { ctx, execCalls } = makeCtx([
624
+ {
625
+ match: (cmd, args) =>
626
+ cmd === "git" &&
627
+ args[0] === "config" &&
628
+ args[1] === "--get" &&
629
+ args[2] === "remote.origin.url",
630
+ result: execOk("git@github.com:org/repo.git\n"),
631
+ },
632
+ ]);
633
+ assert.equal(await remote(/github\.com:org\//, ctx), true);
634
+ assert.equal(execCalls.length, 1);
635
+ });
636
+
637
+ it("no origin configured -> onUnknown block (default) fires", async () => {
638
+ const { ctx } = makeCtx([
639
+ {
640
+ match: (cmd) => cmd === "git",
641
+ result: execFail(1),
642
+ },
643
+ ]);
644
+ assert.equal(await remote(/./, ctx), true);
645
+ });
646
+
647
+ it("onUnknown: allow skips on failure", async () => {
648
+ const { ctx } = makeCtx([
649
+ { match: (cmd) => cmd === "git", result: execFail(1) },
650
+ ]);
651
+ assert.equal(
652
+ await remote({ pattern: /./, onUnknown: "allow" }, ctx),
653
+ false,
654
+ );
655
+ });
656
+
657
+ it("pattern doesn't match stdout -> false", async () => {
658
+ const { ctx } = makeCtx([
659
+ {
660
+ match: (cmd) => cmd === "git",
661
+ result: execOk("git@github.com:other-org/repo.git\n"),
662
+ },
663
+ ]);
664
+ assert.equal(await remote(/my-org\//, ctx), false);
665
+ });
666
+
667
+ it("matches https:// origin URL", async () => {
668
+ const { ctx } = makeCtx([
669
+ {
670
+ match: (cmd) => cmd === "git",
671
+ result: execOk("https://github.com/org/repo.git\n"),
672
+ },
673
+ ]);
674
+ assert.equal(await remote(/github\.com\/org\//, ctx), true);
675
+ });
676
+ });
677
+
678
+ // ---------------------------------------------------------------------------
679
+ // Explicit `onUnknown: "block"` form (pins the default behavior is
680
+ // identical to the explicit form)
681
+ // ---------------------------------------------------------------------------
682
+
683
+ describe("predicates: explicit onUnknown:block form", () => {
684
+ it('branch { pattern, onUnknown: "block" } behaves like default', async () => {
685
+ const { ctx } = makeCtx([
686
+ { match: (cmd) => cmd === "git", result: execFail(128) },
687
+ ]);
688
+ assert.equal(
689
+ await branch({ pattern: /^main$/, onUnknown: "block" }, ctx),
690
+ true,
691
+ );
692
+ });
693
+
694
+ it('upstream { pattern, onUnknown: "block" } behaves like default', async () => {
695
+ const { ctx } = makeCtx([
696
+ { match: (cmd) => cmd === "git", result: execFail(128) },
697
+ ]);
698
+ assert.equal(
699
+ await upstream({ pattern: /^origin\/main$/, onUnknown: "block" }, ctx),
700
+ true,
701
+ );
702
+ });
703
+
704
+ it('remote { pattern, onUnknown: "block" } behaves like default', async () => {
705
+ const { ctx } = makeCtx([
706
+ { match: (cmd) => cmd === "git", result: execFail(128) },
707
+ ]);
708
+ assert.equal(
709
+ await remote({ pattern: /my-org/, onUnknown: "block" }, ctx),
710
+ true,
711
+ );
712
+ });
713
+ });
714
+
715
+ // ---------------------------------------------------------------------------
716
+ // walkerString: tracker contract assertion
717
+ // ---------------------------------------------------------------------------
718
+
719
+ describe('walkerString: rejects initialSentinel === "unknown"', () => {
720
+ // Future tracker authors MUST NOT pass `"unknown"` as the
721
+ // `initialSentinel` argument: that sentinel is reserved for the
722
+ // dynamic-unresolvable signal, and overloading it collapses the
723
+ // three-way discrimination (value / unknown / missing) back to the
724
+ // pre-U1 two-step bug. The function's JSDoc flagged this; the
725
+ // assertion makes the contract un-foot-shootable.
726
+ it('throws a targeted error when called with initialSentinel === "unknown"', () => {
727
+ const { ctx } = makeCtx([]);
728
+ assert.throws(
729
+ () => walkerString(ctx, "branch", "unknown"),
730
+ (err: unknown) => {
731
+ assert.ok(err instanceof Error);
732
+ assert.match(err.message, /initialSentinel cannot be/);
733
+ assert.match(err.message, /"unknown"/);
734
+ return true;
735
+ },
736
+ );
737
+ });
738
+ });
739
+
740
+ // ---------------------------------------------------------------------------
741
+ // ---------------------------------------------------------------------------
742
+ // Walker-unknown cwd inline guard
743
+ //
744
+ // `isClean`, `hasStagedChanges`, `remote`, `upstream`, `commitsAhead` all
745
+ // call `ctx.exec("git", [...], { cwd: ctx.cwd })` at runtime. When the
746
+ // walker's cwd tracker couldn't statically resolve the effective cwd
747
+ // (e.g. `cd "$VAR/pkg" && git commit`) `ctx.cwd` falls back to the pre-cd
748
+ // ambient cwd — the pi session cwd, NOT the intended subpackage. Without
749
+ // a guard the predicate silently queries the wrong repo and a gate like
750
+ // `isClean: true` would miss the state that matters.
751
+ //
752
+ // The runtime-cwd predicates inline a `cwdIsWalkerUnknown(ctx)` check at
753
+ // the top of each handler and surface trinary `"unknown"` instead of
754
+ // fail-CLOSED `true`. The engine projects `"unknown"` to a definite
755
+ // verdict via the leaf-level
756
+ // (or, inside `not:`, the block-level) `onUnknown:` policy — default
757
+ // `"block"` keeps the fail-CLOSED behavior the wrap provided, while
758
+ // `onUnknown: "allow"` opts into fail-OPEN handling.
759
+ //
760
+ // These tests pin that contract: when `ctx.walkerState.cwd === "unknown"`,
761
+ // each handler returns the literal string `"unknown"` regardless of what
762
+ // the stubbed exec would have returned — in fact exec must not be called
763
+ // at all.
764
+ // ---------------------------------------------------------------------------
765
+
766
+ describe("predicates: inline walker-unknown cwd guard surfaces trinary unknown", () => {
767
+ it("isClean returns 'unknown' without calling exec when walker reports cwd unknown", async () => {
768
+ // Even though the stubbed `git status --porcelain` would return
769
+ // empty stdout (i.e. clean → `isClean: true` would MATCH and
770
+ // `isClean: false` would NOT), the inline guard must short-circuit
771
+ // BEFORE dispatch and surface trinary unknown regardless of the
772
+ // args value.
773
+ const { ctx, execCalls } = makeCtx(
774
+ [
775
+ {
776
+ match: (cmd, args) =>
777
+ cmd === "git" && args[0] === "status" && args[1] === "--porcelain",
778
+ result: execOk(""),
779
+ },
780
+ ],
781
+ { walkerState: { cwd: "unknown" } },
782
+ );
783
+ assert.equal(await isClean(true, ctx), "unknown");
784
+ assert.equal(await isClean(false, ctx), "unknown");
785
+ assert.equal(
786
+ execCalls.length,
787
+ 0,
788
+ "exec must not be called when walker reports cwd unknown",
789
+ );
790
+ });
791
+
792
+ it("hasStagedChanges returns 'unknown' without calling exec when walker reports cwd unknown", async () => {
793
+ // Stubbed exit 0 would classify as "no staged changes", so
794
+ // `hasStagedChanges: true` would NOT match under the normal
795
+ // code path. The inline guard must override that and surface
796
+ // unknown for both boolean args.
797
+ const { ctx, execCalls } = makeCtx(
798
+ [
799
+ {
800
+ match: (cmd, args) =>
801
+ cmd === "git" &&
802
+ args[0] === "diff" &&
803
+ args[1] === "--cached" &&
804
+ args[2] === "--quiet",
805
+ result: execOk(""),
806
+ },
807
+ ],
808
+ { walkerState: { cwd: "unknown" } },
809
+ );
810
+ assert.equal(await hasStagedChanges(true, ctx), "unknown");
811
+ assert.equal(await hasStagedChanges(false, ctx), "unknown");
812
+ assert.equal(execCalls.length, 0);
813
+ });
814
+
815
+ it("remote returns 'unknown' without calling exec when walker reports cwd unknown", async () => {
816
+ // Stubbed stdout matches the test pattern under normal
817
+ // dispatch (→ match = true → fire). Without the inline guard
818
+ // the rule would also fire, so the test is sharpened by
819
+ // asserting that exec is NOT called even once: the unknown
820
+ // verdict must come from the walker short-circuit, not from
821
+ // the stub.
822
+ const { ctx, execCalls } = makeCtx(
823
+ [
824
+ {
825
+ match: (cmd, args) =>
826
+ cmd === "git" &&
827
+ args[0] === "config" &&
828
+ args[1] === "--get" &&
829
+ args[2] === "remote.origin.url",
830
+ result: execOk("git@github.com:org/repo.git\n"),
831
+ },
832
+ ],
833
+ { walkerState: { cwd: "unknown" } },
834
+ );
835
+ assert.equal(await remote(/github\.com:org\//, ctx), "unknown");
836
+ // Also pin the inline guard fires even when the pattern would
837
+ // NOT have matched the stubbed stdout — the verdict is
838
+ // walker-driven, not pattern-driven.
839
+ assert.equal(await remote(/never-matches/, ctx), "unknown");
840
+ assert.equal(
841
+ execCalls.length,
842
+ 0,
843
+ "exec must not be called when walker reports cwd unknown",
844
+ );
845
+ });
846
+
847
+ it("isClean with known cwd still dispatches to the handler (guard is transparent)", async () => {
848
+ // Counter-pin: with walker cwd resolved, the inline guard must
849
+ // NOT interfere — the handler runs and the verdict reflects the
850
+ // git state. Guards against a refactor that over-fires on a
851
+ // walker-known cwd.
852
+ const { ctx, execCalls } = makeCtx(
853
+ [
854
+ {
855
+ match: (cmd, args) =>
856
+ cmd === "git" && args[0] === "status" && args[1] === "--porcelain",
857
+ result: execOk(""),
858
+ },
859
+ ],
860
+ { walkerState: { cwd: "/workplace/pkg" } },
861
+ );
862
+ assert.equal(await isClean(true, ctx), true);
863
+ assert.equal(execCalls.length, 1);
864
+ });
865
+
866
+ it("upstream returns 'unknown' without calling exec when walker reports cwd unknown", async () => {
867
+ // Same contract as isClean / hasStagedChanges / remote: the
868
+ // underlying `git rev-parse --abbrev-ref @{upstream}` call runs
869
+ // at `ctx.cwd`. When the walker bails, exec would target the pi
870
+ // session cwd — wrong repo — and a rule with
871
+ // `onUnknown: "allow"` would silently fail-OPEN. Pin that the
872
+ // inline guard surfaces unknown before dispatch.
873
+ const { ctx, execCalls } = makeCtx(
874
+ [
875
+ {
876
+ match: (cmd, args) =>
877
+ cmd === "git" &&
878
+ args[0] === "rev-parse" &&
879
+ args[1] === "--abbrev-ref" &&
880
+ args[2] === "@{upstream}",
881
+ result: execOk("origin/main\n"),
882
+ },
883
+ ],
884
+ { walkerState: { cwd: "unknown" } },
885
+ );
886
+ // Walker-unknown short-circuit: the handler surfaces trinary
887
+ // `"unknown"` regardless of the leaf-level `onUnknown:` field;
888
+ // the engine's leaf adapter then projects (default `"block"` =
889
+ // fail-CLOSED) at the call site.
890
+ assert.equal(await upstream(/^origin\/main$/, ctx), "unknown");
891
+ assert.equal(
892
+ await upstream({ pattern: /^origin\/main$/, onUnknown: "allow" }, ctx),
893
+ "unknown",
894
+ );
895
+ assert.equal(
896
+ execCalls.length,
897
+ 0,
898
+ "exec must not be called when walker reports cwd unknown",
899
+ );
900
+ });
901
+
902
+ it("upstream with known cwd still dispatches to the handler (guard is transparent)", async () => {
903
+ // Counter-pin: walker cwd resolved → handler runs.
904
+ const { ctx, execCalls } = makeCtx(
905
+ [
906
+ {
907
+ match: (cmd, args) =>
908
+ cmd === "git" &&
909
+ args[0] === "rev-parse" &&
910
+ args[1] === "--abbrev-ref" &&
911
+ args[2] === "@{upstream}",
912
+ result: execOk("origin/main\n"),
913
+ },
914
+ ],
915
+ { walkerState: { cwd: "/workplace/pkg" } },
916
+ );
917
+ assert.equal(await upstream(/^origin\/main$/, ctx), true);
918
+ assert.equal(execCalls.length, 1);
919
+ });
920
+
921
+ it("commitsAhead returns 'unknown' without calling exec when walker reports cwd unknown", async () => {
922
+ // commitsAhead has no `onUnknown` knob at all; its exec-
923
+ // failure path returns `false` (rule silently skips). That's
924
+ // exactly the silent fail-OPEN class the inline walker-unknown
925
+ // guard exists to close. Pin that the guard surfaces unknown
926
+ // ahead of dispatch for every comparator flavor.
927
+ const { ctx, execCalls } = makeCtx(
928
+ [
929
+ {
930
+ match: (cmd, args) => cmd === "git" && args[0] === "rev-list",
931
+ result: execOk("3\n"),
932
+ },
933
+ ],
934
+ { walkerState: { cwd: "unknown" } },
935
+ );
936
+ // Under normal dispatch, `3 === 1` would be false; under the
937
+ // guard it returns trinary unknown.
938
+ assert.equal(await commitsAhead({ eq: 1 }, ctx), "unknown");
939
+ // Normal dispatch would fire true for `gt: 0`; guard still
940
+ // returns trinary unknown without running exec.
941
+ assert.equal(await commitsAhead({ gt: 0 }, ctx), "unknown");
942
+ assert.equal(
943
+ execCalls.length,
944
+ 0,
945
+ "exec must not be called when walker reports cwd unknown",
946
+ );
947
+ });
948
+
949
+ it("commitsAhead with known cwd still dispatches to the handler (guard is transparent)", async () => {
950
+ const { ctx, execCalls } = makeCtx(
951
+ [
952
+ {
953
+ match: (cmd, args) =>
954
+ cmd === "git" && args[0] === "rev-list" && args[1] === "--count",
955
+ result: execOk("2\n"),
956
+ },
957
+ ],
958
+ { walkerState: { cwd: "/workplace/pkg" } },
959
+ );
960
+ assert.equal(await commitsAhead({ eq: 2 }, ctx), true);
961
+ assert.equal(execCalls.length, 1);
962
+ });
963
+
964
+ it("remote surfaces 'unknown' on walker-unknown cwd regardless of leaf-level onUnknown:allow", async () => {
965
+ // Pins the trinary handler contract: the inline
966
+ // walker-unknown-cwd guard surfaces trinary `"unknown"` BEFORE
967
+ // the inner exec or no-origin path runs, and BEFORE the leaf-
968
+ // level `onUnknown:` projection. The handler itself doesn't
969
+ // consume the leaf-level `onUnknown:` modifier on the
970
+ // walker-unknown branch — the engine's leaf adapter applies
971
+ // the leaf-level `onUnknown:` to project unknown → boolean at
972
+ // the call site (or the not-block evaluator applies the block-
973
+ // level `onUnknown:` inside `not:`). A refactor that consumed
974
+ // the leaf's `onUnknown:` inside the handler would double-
975
+ // project and break the inner-vs-outer split that closes the
976
+ // `not: { cwd: P }` silent fail-OPEN class.
977
+ const { ctx, execCalls } = makeCtx(
978
+ [
979
+ {
980
+ match: (cmd) => cmd === "git",
981
+ result: execOk("url\n"),
982
+ },
983
+ ],
984
+ { walkerState: { cwd: "unknown" } },
985
+ );
986
+ assert.equal(
987
+ await remote({ pattern: /./, onUnknown: "allow" }, ctx),
988
+ "unknown",
989
+ "handler surfaces trinary unknown on walker-unknown cwd; leaf-level `onUnknown:` projection happens at the engine, not the handler",
990
+ );
991
+ assert.equal(execCalls.length, 0);
992
+ });
993
+
994
+ it("hasStagedChanges with known cwd dispatches to handler", async () => {
995
+ // Counter-pin: `isClean`'s known-cwd dispatch is pinned above;
996
+ // this mirrors it for `hasStagedChanges` so the "walker-defined,
997
+ // cwd-resolved → delegate" branch of the wrap is exercised on
998
+ // every wrapped predicate, not just isClean.
999
+ const { ctx, execCalls } = makeCtx(
1000
+ [
1001
+ {
1002
+ match: (cmd, args) =>
1003
+ cmd === "git" &&
1004
+ args[0] === "diff" &&
1005
+ args[1] === "--cached" &&
1006
+ args[2] === "--quiet",
1007
+ result: execOk(""),
1008
+ },
1009
+ ],
1010
+ { walkerState: { cwd: "/workplace/pkg" } },
1011
+ );
1012
+ assert.equal(await hasStagedChanges(false, ctx), true);
1013
+ assert.equal(execCalls.length, 1);
1014
+ });
1015
+
1016
+ it("remote with known cwd dispatches to handler", async () => {
1017
+ // Counter-pin: mirrors the isClean / hasStagedChanges known-
1018
+ // cwd dispatch tests for remote. With walker cwd resolved, the
1019
+ // wrap must not interfere — the handler runs and the verdict
1020
+ // reflects the git state.
1021
+ const { ctx, execCalls } = makeCtx(
1022
+ [
1023
+ {
1024
+ match: (cmd) => cmd === "git",
1025
+ result: execOk("git@github.com:org/repo.git\n"),
1026
+ },
1027
+ ],
1028
+ { walkerState: { cwd: "/workplace/pkg" } },
1029
+ );
1030
+ assert.equal(await remote(/github\.com:org\//, ctx), true);
1031
+ assert.equal(execCalls.length, 1);
1032
+ });
1033
+ });
1034
+
1035
+ // ---------------------------------------------------------------------------
1036
+ // Pattern-matcher array form (cwd / branch / upstream / remote)
1037
+ // ---------------------------------------------------------------------------
1038
+ //
1039
+ // Pins the array shorthand and `{ pattern: Pattern[]; onUnknown }` form
1040
+ // across the gitPlugin's three pattern-valued predicates. (`cwd` lives in
1041
+ // the engine's `evaluator-internals/predicates.ts`; its array-form tests
1042
+ // are in evaluator.test.ts.)
1043
+ //
1044
+ // Array semantics: OR-of-matches. Empty arrays are invalid (rule skips);
1045
+ // arrays with non-Pattern elements are invalid (rule skips).
1046
+
1047
+ describe("predicate: branch (array form)", () => {
1048
+ it("matches when walker branch matches any pattern in the array", async () => {
1049
+ const { ctx, execCalls } = makeCtx([], {
1050
+ walkerState: { branch: "trunk" },
1051
+ });
1052
+ assert.equal(await branch([/^main$/, /^master$/, /^trunk$/], ctx), true);
1053
+ assert.equal(execCalls.length, 0);
1054
+ });
1055
+
1056
+ it("skips when walker branch matches none of the array patterns", async () => {
1057
+ const { ctx } = makeCtx([], {
1058
+ walkerState: { branch: "feature-x" },
1059
+ });
1060
+ assert.equal(await branch([/^main$/, /^master$/], ctx), false);
1061
+ });
1062
+
1063
+ it("accepts mixed string + RegExp Pattern[]", async () => {
1064
+ const { ctx } = makeCtx([], {
1065
+ walkerState: { branch: "main" },
1066
+ });
1067
+ assert.equal(await branch(["^main$", /^master$/], ctx), true);
1068
+ });
1069
+
1070
+ it("empty array is invalid (rule skips)", async () => {
1071
+ const { ctx, execCalls } = makeCtx([], {
1072
+ walkerState: { branch: "main" },
1073
+ });
1074
+ assert.equal(await branch([], ctx), false);
1075
+ assert.equal(execCalls.length, 0);
1076
+ });
1077
+
1078
+ it("array with non-Pattern element is invalid (rule skips)", async () => {
1079
+ const { ctx, execCalls } = makeCtx([], {
1080
+ walkerState: { branch: "main" },
1081
+ });
1082
+ assert.equal(
1083
+ await branch([/^main$/, 123 as unknown as string], ctx),
1084
+ false,
1085
+ );
1086
+ assert.equal(execCalls.length, 0);
1087
+ });
1088
+
1089
+ it("object form with Pattern[] + onUnknown: 'allow' applies the policy on unknown", async () => {
1090
+ const { ctx } = makeCtx([], {
1091
+ walkerState: { branch: "unknown" },
1092
+ });
1093
+ assert.equal(
1094
+ await branch(
1095
+ { pattern: [/^main$/, /^master$/], onUnknown: "allow" },
1096
+ ctx,
1097
+ ),
1098
+ false,
1099
+ );
1100
+ });
1101
+
1102
+ it("object form with Pattern[] + default onUnknown blocks on unknown", async () => {
1103
+ const { ctx } = makeCtx([], {
1104
+ walkerState: { branch: "unknown" },
1105
+ });
1106
+ assert.equal(await branch({ pattern: [/^main$/, /^master$/] }, ctx), true);
1107
+ });
1108
+ });
1109
+
1110
+ describe("predicate: upstream (array form)", () => {
1111
+ it("matches when upstream matches any pattern in the array", async () => {
1112
+ const { ctx } = makeCtx(
1113
+ [
1114
+ {
1115
+ match: (cmd, args) => cmd === "git" && args[0] === "rev-parse",
1116
+ result: execOk("origin/develop\n"),
1117
+ },
1118
+ ],
1119
+ { walkerState: { cwd: "/repo" } },
1120
+ );
1121
+ assert.equal(
1122
+ await upstream([/^origin\/main$/, /^origin\/develop$/], ctx),
1123
+ true,
1124
+ );
1125
+ });
1126
+
1127
+ it("skips when upstream matches none of the array patterns", async () => {
1128
+ const { ctx } = makeCtx(
1129
+ [
1130
+ {
1131
+ match: (cmd, args) => cmd === "git" && args[0] === "rev-parse",
1132
+ result: execOk("origin/feature\n"),
1133
+ },
1134
+ ],
1135
+ { walkerState: { cwd: "/repo" } },
1136
+ );
1137
+ assert.equal(
1138
+ await upstream([/^origin\/main$/, /^origin\/develop$/], ctx),
1139
+ false,
1140
+ );
1141
+ });
1142
+
1143
+ it("accepts mixed string + RegExp Pattern[]", async () => {
1144
+ // Cross-predicate parity: branch's mixed-Pattern[] test pins
1145
+ // the same `unwrapPatternArg` narrowing path; mirroring it on
1146
+ // upstream catches a regression that inlines a stricter
1147
+ // (RegExp-only) narrow at one predicate's call site without
1148
+ // updating the others.
1149
+ const { ctx } = makeCtx(
1150
+ [
1151
+ {
1152
+ match: (cmd, args) => cmd === "git" && args[0] === "rev-parse",
1153
+ result: execOk("origin/main\n"),
1154
+ },
1155
+ ],
1156
+ { walkerState: { cwd: "/repo" } },
1157
+ );
1158
+ assert.equal(
1159
+ await upstream(["^origin/main$", /^origin\/develop$/], ctx),
1160
+ true,
1161
+ );
1162
+ });
1163
+
1164
+ it("empty array is invalid (rule skips)", async () => {
1165
+ const { ctx, execCalls } = makeCtx([], {
1166
+ walkerState: { cwd: "/repo" },
1167
+ });
1168
+ assert.equal(await upstream([], ctx), false);
1169
+ assert.equal(execCalls.length, 0);
1170
+ });
1171
+
1172
+ it("array with non-Pattern element is invalid (rule skips)", async () => {
1173
+ // Cross-predicate parity with branch + remote: malformed array
1174
+ // elements (non-string, non-RegExp) fail-skip uniformly via the
1175
+ // shared `unwrapPatternArg` helper. Pinning at the upstream
1176
+ // call site catches a regression that inlines the helper at one
1177
+ // predicate and lets a typed-array malformed input fall through
1178
+ // to a silent regex-coercion path.
1179
+ const { ctx, execCalls } = makeCtx([], {
1180
+ walkerState: { cwd: "/repo" },
1181
+ });
1182
+ assert.equal(
1183
+ await upstream([/^origin\/main$/, 123 as unknown as string], ctx),
1184
+ false,
1185
+ );
1186
+ assert.equal(execCalls.length, 0);
1187
+ });
1188
+
1189
+ it("object form with Pattern[] + onUnknown: 'allow' on no-upstream skips", async () => {
1190
+ // No upstream configured (exec exits non-zero). With
1191
+ // `onUnknown: "allow"`, the predicate skips regardless of the
1192
+ // array shape — mirror of remote's no-origin pin and the
1193
+ // branch object-form pin.
1194
+ const { ctx } = makeCtx(
1195
+ [
1196
+ {
1197
+ match: (cmd, args) => cmd === "git" && args[0] === "rev-parse",
1198
+ result: execFail(128, "no upstream configured"),
1199
+ },
1200
+ ],
1201
+ { walkerState: { cwd: "/repo" } },
1202
+ );
1203
+ assert.equal(
1204
+ await upstream(
1205
+ {
1206
+ pattern: [/^origin\/main$/, /^origin\/develop$/],
1207
+ onUnknown: "allow",
1208
+ },
1209
+ ctx,
1210
+ ),
1211
+ false,
1212
+ );
1213
+ });
1214
+
1215
+ it("object form with Pattern[] + default onUnknown blocks on no-upstream", async () => {
1216
+ // Cross-predicate parity with branch's object-form default-
1217
+ // blocks pin: the default `onUnknown: "block"` posture must
1218
+ // fire fail-closed when the predicate can't determine the
1219
+ // upstream value (regardless of pattern shape — single or
1220
+ // array).
1221
+ const { ctx } = makeCtx(
1222
+ [
1223
+ {
1224
+ match: (cmd, args) => cmd === "git" && args[0] === "rev-parse",
1225
+ result: execFail(128, "no upstream configured"),
1226
+ },
1227
+ ],
1228
+ { walkerState: { cwd: "/repo" } },
1229
+ );
1230
+ assert.equal(
1231
+ await upstream({ pattern: [/^origin\/main$/, /^origin\/develop$/] }, ctx),
1232
+ true,
1233
+ );
1234
+ });
1235
+ });
1236
+
1237
+ describe("predicate: remote (array form)", () => {
1238
+ it("matches when remote URL matches any pattern in the array", async () => {
1239
+ const { ctx } = makeCtx(
1240
+ [
1241
+ {
1242
+ match: (cmd, args) =>
1243
+ cmd === "git" && args[0] === "config" && args[1] === "--get",
1244
+ result: execOk("https://github.com/cad0p/repo.git\n"),
1245
+ },
1246
+ ],
1247
+ { walkerState: { cwd: "/repo" } },
1248
+ );
1249
+ assert.equal(await remote([/github\.com\//, /gitlab\.com\//], ctx), true);
1250
+ });
1251
+
1252
+ it("skips when remote URL matches none of the array patterns", async () => {
1253
+ const { ctx } = makeCtx(
1254
+ [
1255
+ {
1256
+ match: (cmd, args) =>
1257
+ cmd === "git" && args[0] === "config" && args[1] === "--get",
1258
+ result: execOk("git@self-hosted.example.com:Foo/Bar.git\n"),
1259
+ },
1260
+ ],
1261
+ { walkerState: { cwd: "/repo" } },
1262
+ );
1263
+ assert.equal(await remote([/github\.com\//, /gitlab\.com\//], ctx), false);
1264
+ });
1265
+
1266
+ it("object form with Pattern[] + onUnknown: 'allow' on no-origin skips", async () => {
1267
+ // Remote URL fetch fails (no origin). With onUnknown: "allow",
1268
+ // the predicate skips regardless of array shape.
1269
+ const { ctx } = makeCtx(
1270
+ [
1271
+ {
1272
+ match: (cmd) => cmd === "git",
1273
+ result: execFail(1),
1274
+ },
1275
+ ],
1276
+ { walkerState: { cwd: "/repo" } },
1277
+ );
1278
+ assert.equal(
1279
+ await remote(
1280
+ { pattern: [/github\.com\//, /gitlab\.com\//], onUnknown: "allow" },
1281
+ ctx,
1282
+ ),
1283
+ false,
1284
+ );
1285
+ });
1286
+
1287
+ it("array with non-Pattern element (e.g. number) is invalid (rule skips)", async () => {
1288
+ const { ctx, execCalls } = makeCtx([], {
1289
+ walkerState: { cwd: "/repo" },
1290
+ });
1291
+ assert.equal(
1292
+ await remote([/github\.com\//, 123 as unknown as string], ctx),
1293
+ false,
1294
+ );
1295
+ assert.equal(execCalls.length, 0);
1296
+ });
1297
+
1298
+ it("accepts mixed string + RegExp Pattern[]", async () => {
1299
+ // Cross-predicate parity with branch + upstream's mixed-Pattern[]
1300
+ // pins: the same `unwrapPatternArg` narrowing path runs at all
1301
+ // three predicates' call sites. Mirroring the test on remote
1302
+ // catches a regression that inlines a stricter (RegExp-only)
1303
+ // narrow at one predicate without updating the others.
1304
+ const { ctx } = makeCtx(
1305
+ [
1306
+ {
1307
+ match: (cmd, args) =>
1308
+ cmd === "git" && args[0] === "config" && args[1] === "--get",
1309
+ result: execOk("https://github.com/cad0p/repo.git\n"),
1310
+ },
1311
+ ],
1312
+ { walkerState: { cwd: "/repo" } },
1313
+ );
1314
+ assert.equal(await remote(["github.com/", /gitlab\.com\//], ctx), true);
1315
+ });
1316
+
1317
+ it("empty array is invalid (rule skips)", async () => {
1318
+ // Cross-predicate parity with branch + upstream: empty arrays are
1319
+ // invalid uniformly across the three pattern-valued predicates,
1320
+ // short-circuiting before the predicate shells out.
1321
+ const { ctx, execCalls } = makeCtx([], {
1322
+ walkerState: { cwd: "/repo" },
1323
+ });
1324
+ assert.equal(await remote([], ctx), false);
1325
+ assert.equal(execCalls.length, 0);
1326
+ });
1327
+
1328
+ it("object form with Pattern[] + default onUnknown blocks on no-origin", async () => {
1329
+ // Cross-predicate parity with branch's object-form default-blocks
1330
+ // pin and upstream's default-blocks-on-no-upstream pin: the
1331
+ // default `onUnknown: "block"` posture must fire fail-closed when
1332
+ // the predicate can't determine the remote value (no origin
1333
+ // configured), regardless of pattern shape (single or array).
1334
+ const { ctx } = makeCtx(
1335
+ [
1336
+ {
1337
+ match: (cmd) => cmd === "git",
1338
+ result: execFail(1),
1339
+ },
1340
+ ],
1341
+ { walkerState: { cwd: "/repo" } },
1342
+ );
1343
+ assert.equal(
1344
+ await remote({ pattern: [/github\.com\//, /gitlab\.com\//] }, ctx),
1345
+ true,
1346
+ );
1347
+ });
1348
+ });
1349
+
1350
+ // ---------------------------------------------------------------------------
1351
+ // unwrapBooleanLeafArg (direct unit tests)
1352
+ //
1353
+ // `_unwrapBooleanLeafArg` is the test-only re-export of the
1354
+ // module-private helper used by `isClean` and `hasStagedChanges` to
1355
+ // accept the schema-advertised bare and spread shapes. End-to-end
1356
+ // coverage exists via the predicate handler tests above; the direct
1357
+ // tests pin malformed-input branches the engine has trouble driving.
1358
+ // ---------------------------------------------------------------------------
1359
+
1360
+ describe("unwrapBooleanLeafArg: direct shape coverage", () => {
1361
+ it("bare true passes through", () => {
1362
+ assert.equal(_unwrapBooleanLeafArg(true), true);
1363
+ });
1364
+
1365
+ it("bare false passes through", () => {
1366
+ assert.equal(_unwrapBooleanLeafArg(false), false);
1367
+ });
1368
+
1369
+ it("spread { value: true } unwraps to true (regardless of sibling onUnknown)", () => {
1370
+ assert.equal(_unwrapBooleanLeafArg({ value: true }), true);
1371
+ assert.equal(
1372
+ _unwrapBooleanLeafArg({ value: true, onUnknown: "allow" }),
1373
+ true,
1374
+ );
1375
+ });
1376
+
1377
+ it("spread { value: false } unwraps to false (regardless of sibling onUnknown)", () => {
1378
+ assert.equal(_unwrapBooleanLeafArg({ value: false }), false);
1379
+ assert.equal(
1380
+ _unwrapBooleanLeafArg({ value: false, onUnknown: "block" }),
1381
+ false,
1382
+ );
1383
+ });
1384
+
1385
+ it("malformed object without `value:` returns undefined", () => {
1386
+ assert.equal(_unwrapBooleanLeafArg({ wrongKey: true }), undefined);
1387
+ assert.equal(_unwrapBooleanLeafArg({}), undefined);
1388
+ });
1389
+
1390
+ it("primitive non-boolean returns undefined", () => {
1391
+ assert.equal(_unwrapBooleanLeafArg(42), undefined);
1392
+ assert.equal(_unwrapBooleanLeafArg("true"), undefined);
1393
+ assert.equal(_unwrapBooleanLeafArg(null), undefined);
1394
+ assert.equal(_unwrapBooleanLeafArg(undefined), undefined);
1395
+ });
1396
+
1397
+ it("array returns undefined (not a `{ value: boolean }` shape)", () => {
1398
+ assert.equal(_unwrapBooleanLeafArg([true]), undefined);
1399
+ assert.equal(_unwrapBooleanLeafArg([]), undefined);
1400
+ });
1401
+ });