@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,1472 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Testing primitives for rule and plugin authors.
6
+ *
7
+ * Subpath export: `@cad0p/pi-steering/testing`. Also re-exported
8
+ * at the package root for discoverability.
9
+ *
10
+ * Phase 5a ships four low-level primitives that wrap the engine's
11
+ * internals without forcing authors to stand up a pi runtime stub:
12
+ *
13
+ * - {@link loadHarness} — build an evaluator + dispatcher
14
+ * pair from a static
15
+ * {@link SteeringConfig}. No walk-up
16
+ * loading — tests pass explicit
17
+ * config.
18
+ * - {@link mockContext} — build a {@link PredicateContext}
19
+ * for unit-testing predicate
20
+ * handlers in isolation.
21
+ * - {@link mockObserverContext} — same for {@link ObserverContext}.
22
+ * - {@link getAppendedEntries} — read back `appendEntry` writes
23
+ * captured by either mock context.
24
+ *
25
+ * The convenience wrappers (`testPredicate`, `expectBlocks`,
26
+ * `runMatrix`, …) that build on these live in a Phase 5b follow-up.
27
+ *
28
+ * Design notes:
29
+ *
30
+ * - `exec` is deliberately stub-required. The defaults reject with a
31
+ * clear error so a test forgetting to stub fails loudly instead of
32
+ * silently evaluating predicates against an always-empty exec
33
+ * result.
34
+ * - `appendEntry` captures are tracked in a module-level WeakMap
35
+ * keyed by the context object, so {@link getAppendedEntries}
36
+ * accesses cleanly without leaking state across tests and without
37
+ * requiring users to pass capture buffers around.
38
+ * - `findEntries` draws from an entries array passed in at context
39
+ * build time; it does NOT pick up entries written via the
40
+ * context's own `appendEntry`. That mirrors the production
41
+ * evaluator's per-call snapshot semantics — appends are visible
42
+ * on the NEXT evaluation, not the current one.
43
+ */
44
+
45
+ import type {
46
+ ExtensionContext,
47
+ ExecResult as PiExecResult,
48
+ ToolCallEvent,
49
+ ToolCallEventResult,
50
+ } from "@earendil-works/pi-coding-agent";
51
+ import { DEFAULT_PLUGINS, DEFAULT_RULES } from "../defaults.ts";
52
+ import {
53
+ buildEvaluator,
54
+ EVALUATOR_BUILTIN_TRACKERS,
55
+ type EvaluatorHost,
56
+ type EvaluatorRuntime,
57
+ } from "../evaluator.ts";
58
+ import {
59
+ AGENT_LOOP_INDEX_KEY,
60
+ createAppendEntry,
61
+ isPlainObject,
62
+ } from "../evaluator-internals/context.ts";
63
+ import type { SyntheticEntry } from "../evaluator-internals/speculative-synthesis.ts";
64
+ import { finalizePluginState } from "../internal/finalize-plugin-state.ts";
65
+ import { runMergerPipeline } from "../internal/session-runtime.ts";
66
+ import {
67
+ buildObserverDispatcher,
68
+ matchesWatch,
69
+ type ObserverDispatcher,
70
+ } from "../observer-dispatcher.ts";
71
+ import type { ResolvedPluginState } from "../plugin-merger.ts";
72
+ import type {
73
+ ExecOpts,
74
+ ExecResult,
75
+ Observer,
76
+ ObserverContext,
77
+ PredicateContext,
78
+ PredicateHandler,
79
+ PredicateToolInput,
80
+ PredicateVerdict,
81
+ ToolResultEvent as SchemaToolResultEvent,
82
+ SteeringConfig,
83
+ SteeringDiagnostic,
84
+ WhenWalkerState,
85
+ } from "../schema.ts";
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Capture tracking
89
+ // ---------------------------------------------------------------------------
90
+
91
+ /**
92
+ * Entry shape recorded by a mock context's `appendEntry`. Mirrors the
93
+ * call signature `appendEntry<T>(customType, data?)`; `data` is
94
+ * optional because pi's API accepts a bare customType.
95
+ */
96
+ interface CapturedEntry {
97
+ readonly customType: string;
98
+ readonly data?: unknown;
99
+ }
100
+
101
+ /**
102
+ * Global, per-context append buffers. Weak so dropped contexts free
103
+ * the buffer. Never holds a reference to the test's context object
104
+ * itself beyond the weak slot.
105
+ */
106
+ const appendBuffers = new WeakMap<object, CapturedEntry[]>();
107
+
108
+ /**
109
+ * Minimal {@link EvaluatorHost} whose `appendEntry` pushes into the
110
+ * given capture buffer. Used by {@link mockContext} /
111
+ * {@link mockObserverContext} to share the production
112
+ * `createAppendEntry` wrapper: the wrapper expects an
113
+ * `EvaluatorHost`, and wiring a buffering host here lets the mocks
114
+ * auto-tag writes with `_agentLoopIndex` in the exact same shape the
115
+ * real engine and dispatcher produce.
116
+ *
117
+ * `exec` is stubbed to reject — it's never touched on this path
118
+ * (`createAppendEntry` only calls `host.appendEntry`) but has to be
119
+ * present to satisfy the {@link EvaluatorHost} shape.
120
+ */
121
+ function bufferingAppendHost(buffer: CapturedEntry[]): EvaluatorHost {
122
+ return {
123
+ exec: () =>
124
+ Promise.reject(
125
+ new Error(
126
+ "[@cad0p/pi-steering/testing] internal: bufferingAppendHost.exec " +
127
+ "should never be called",
128
+ ),
129
+ ),
130
+ appendEntry: (customType: string, data?: unknown) => {
131
+ buffer.push({
132
+ customType,
133
+ ...(data !== undefined ? { data } : {}),
134
+ });
135
+ },
136
+ };
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Harness
141
+ // ---------------------------------------------------------------------------
142
+
143
+ /**
144
+ * Build-once, invoke-many handle a test uses to drive the engine
145
+ * against a scenario. `evaluate` and `dispatch` have identical
146
+ * signatures to {@link EvaluatorRuntime.evaluate} and
147
+ * {@link ObserverDispatcher.dispatch} so production call sites can be
148
+ * ported verbatim.
149
+ */
150
+ export interface Harness {
151
+ /** See {@link EvaluatorRuntime.evaluate}. */
152
+ evaluate: EvaluatorRuntime["evaluate"];
153
+ /** See {@link ObserverDispatcher.dispatch}. */
154
+ dispatch: ObserverDispatcher["dispatch"];
155
+ /**
156
+ * The effective config the harness was built from (after default
157
+ * injection and `disable` filtering).
158
+ *
159
+ * No-op short-circuit caveat: when `harness.diagnostics` has an
160
+ * error-class entry, treat `harness.config` as the post-merge,
161
+ * post-disabledRules-filter snapshot pi-steering would have
162
+ * handed to `buildEvaluator` if the diagnostics had been clean —
163
+ * provided as a debugging artifact, NOT an executable config
164
+ * production would accept (production would have thrown).
165
+ * Inspect `harness.diagnostics` first to learn which surface
166
+ * flagged a problem, then fix the source config and re-load
167
+ * before reading `config` for any "production would have run on
168
+ * this shape" interpretation.
169
+ */
170
+ readonly config: SteeringConfig;
171
+ /**
172
+ * The plugin merger's resolved state, for introspection /
173
+ * assertions.
174
+ *
175
+ * No-op short-circuit caveat: when an error-class diagnostic fires
176
+ * and the harness returns the no-op evaluator/dispatcher pair,
177
+ * `resolved.diagnostics` carries the FULL diagnostic list (merge-
178
+ * side + user-config-name + resolve-side) to mirror
179
+ * `harness.diagnostics`. In the regular path, `resolved.diagnostics`
180
+ * carries only the resolve-side stream — see
181
+ * {@link SteeringDiagnosticKind} for the per-kind
182
+ * loader-vs-merger split. Consumers should prefer
183
+ * `harness.diagnostics` for the canonical full list.
184
+ */
185
+ readonly resolved: ResolvedPluginState;
186
+ /**
187
+ * Every {@link SteeringDiagnostic} produced while building the
188
+ * harness — merge-side (within-layer collisions, cross-config
189
+ * collisions when {@link LoadHarnessOptions.includeDefaults} is
190
+ * `true`) and plugin-merger-side (predicate / observer / rule /
191
+ * extension-orphan / reserved-name / invalid-name diagnostics,
192
+ * plus user-config rule and observer name validation).
193
+ *
194
+ * Unlike production, `loadHarness` does NOT throw on error-class
195
+ * diagnostics. Plugin-author tests assert directly on this array
196
+ * (e.g. `harness.diagnostics.some(d => d.kind === "reserved-tracker-name")`)
197
+ * so the failure surface is observable in test output rather than
198
+ * a thrown error that hides which other diagnostics fired. Both
199
+ * plugin-shipped and user-config malformed names route through the
200
+ * same `kind: "invalid-name"` diagnostic stream — plugin-author
201
+ * tests can use a uniform matrix of malformed-name cases without
202
+ * worrying about which surface raised the issue.
203
+ *
204
+ * Production-strictness divergence: `loadHarness` does NOT honor
205
+ * the merged config's `failOnWarnings` flag. Production's
206
+ * `buildSessionRuntime` throws when `failOnWarnings !== false`
207
+ * (default `true`) AND any warning-class diagnostic is present;
208
+ * the harness ignores `failOnWarnings` and returns a real
209
+ * evaluator/dispatcher running on the post-collision merged
210
+ * state. Tests intending to use the harness as a "production
211
+ * prediction" should check this array against the strict-mode
212
+ * rule themselves, e.g.
213
+ * `harness.diagnostics.some(d => d.type === "error" || (config.failOnWarnings !== false && d.type === "warning"))`
214
+ * before treating the harness's verdict as production-faithful.
215
+ */
216
+ readonly diagnostics: readonly SteeringDiagnostic[];
217
+ }
218
+
219
+ /**
220
+ * Options for {@link loadHarness}.
221
+ */
222
+ export interface LoadHarnessOptions {
223
+ /** The config under test. */
224
+ readonly config: SteeringConfig;
225
+
226
+ /**
227
+ * Prepend {@link DEFAULT_PLUGINS} to `config.plugins` and
228
+ * {@link DEFAULT_RULES} to `config.rules` at the innermost
229
+ * position. Mirrors the production flag via
230
+ * `!config.disableDefaults`, but kept explicit here so tests can
231
+ * exercise default rules without editing the config under test.
232
+ *
233
+ * Default: `false`.
234
+ */
235
+ readonly includeDefaults?: boolean;
236
+
237
+ /**
238
+ * Host to drive `exec` / `appendEntry` off. Defaults to an
239
+ * in-memory stub whose `exec` rejects with a clear error (tests
240
+ * needing exec must stub it explicitly) and whose `appendEntry`
241
+ * is a silent sink.
242
+ */
243
+ readonly host?: EvaluatorHost;
244
+ }
245
+
246
+ /**
247
+ * Build an evaluator + observer dispatcher pair from a static
248
+ * {@link SteeringConfig}. Tests drive rules through the same pipeline
249
+ * production uses, without needing a pi runtime stub or walk-up
250
+ * loading.
251
+ */
252
+ export function loadHarness(options: LoadHarnessOptions): Harness {
253
+ const inputConfig = options.config;
254
+ const includeDefaults = options.includeDefaults ?? false;
255
+
256
+ // Run the same merge that production does (single layer here, since
257
+ // loadHarness operates on an in-memory config rather than a walk-up
258
+ // chain). The shared helper short-circuits between buildConfig and
259
+ // resolvePlugins on error-class merge diagnostics so a
260
+ // `tracker-name-collision` flagged by `buildConfig` is not also
261
+ // re-flagged by `resolvePlugins`. The diagnostics surface within-
262
+ // layer rule-name and observer-name collisions, plus tracker-name
263
+ // collisions and the cross-config plugin-name collisions that
264
+ // `includeDefaults: true` can introduce against DEFAULT_PLUGINS.
265
+ const defaults: SteeringConfig | undefined = includeDefaults
266
+ ? { rules: DEFAULT_RULES, plugins: DEFAULT_PLUGINS }
267
+ : undefined;
268
+ const {
269
+ merged: mergedConfig,
270
+ resolved,
271
+ diagnostics,
272
+ } = runMergerPipeline([inputConfig], defaults, EVALUATOR_BUILTIN_TRACKERS);
273
+
274
+ // Apply `config.disabledRules` to user + default rules. Plugin-shipped
275
+ // rules are filtered inside `resolvePlugins`. Mirrors
276
+ // `buildSessionRuntime`.
277
+ const disabled = new Set(mergedConfig.disabledRules ?? []);
278
+ const filteredConfig: SteeringConfig = { ...mergedConfig };
279
+ if (mergedConfig.rules !== undefined) {
280
+ const kept = mergedConfig.rules.filter((r) => !disabled.has(r.name));
281
+ if (kept.length > 0) filteredConfig.rules = kept;
282
+ else delete filteredConfig.rules;
283
+ }
284
+
285
+ // Aggregate every diagnostic produced during construction. Unlike
286
+ // `buildSessionRuntime`, loadHarness does NOT throw on error-class
287
+ // diagnostics — plugin-author tests assert on the array directly so
288
+ // they can see every diagnostic that fired in one read.
289
+ //
290
+ // Short-circuit on ANY error-class diagnostic — from the cross-
291
+ // layer merge (`buildConfig`'s `detectTrackerNameCollisions`) or
292
+ // from the plugin merger (`reserved-tracker-name`,
293
+ // `reserved-predicate-key`, `invalid-name`, `tracker-name-collision`).
294
+ // All error-class diagnostics produce the same no-op harness so
295
+ // plugin-author tests see uniform behavior regardless of which
296
+ // surface flagged the problem. Mirrors production's bridge-disabled
297
+ // state under the same conditions.
298
+ if (resolved === null || diagnostics.some((d) => d.type === "error")) {
299
+ return buildNoopHarness(filteredConfig, diagnostics);
300
+ }
301
+
302
+ // Mirror session-runtime's unused-observer drop so loadHarness
303
+ // tests produce the same verdicts as production for rules that
304
+ // rely on observer writes.
305
+ const { pluginKept, userKept } = finalizePluginState(
306
+ filteredConfig.rules ?? [],
307
+ resolved.rules,
308
+ filteredConfig.observers ?? [],
309
+ resolved.observers,
310
+ );
311
+ const filteredResolved = { ...resolved, observers: [...pluginKept] };
312
+
313
+ const host = options.host ?? defaultHarnessHost();
314
+ const evaluator = buildEvaluator(filteredConfig, filteredResolved, host);
315
+ const dispatcher = buildObserverDispatcher(filteredResolved, userKept, host);
316
+
317
+ return {
318
+ evaluate: evaluator.evaluate,
319
+ dispatch: dispatcher.dispatch,
320
+ config: filteredConfig,
321
+ resolved: filteredResolved,
322
+ diagnostics,
323
+ };
324
+ }
325
+
326
+ /**
327
+ * Build a no-op {@link Harness} that surfaces the given diagnostics
328
+ * but doesn't drive the evaluator / dispatcher. Used when an
329
+ * error-class loader diagnostic prevents safe construction of the
330
+ * runtime; mirrors production's bridge-disabled state under the same
331
+ * conditions.
332
+ */
333
+ function buildNoopHarness(
334
+ config: SteeringConfig,
335
+ diagnostics: readonly SteeringDiagnostic[],
336
+ ): Harness {
337
+ const emptyResolved: ResolvedPluginState = {
338
+ predicates: {},
339
+ observers: [],
340
+ trackers: {},
341
+ trackerModifiers: {},
342
+ composedTrackers: {},
343
+ rules: [],
344
+ rulePluginOwners: {},
345
+ // Mirror harness.diagnostics so consumers reading either surface
346
+ // (harness.resolved.diagnostics or harness.diagnostics) see the
347
+ // same list. Otherwise harness.resolved.diagnostics would be
348
+ // silently empty in the no-op short-circuit branch while the
349
+ // outer harness.diagnostics carries the real entries.
350
+ diagnostics: [...diagnostics],
351
+ };
352
+ return {
353
+ evaluate: async () => {},
354
+ dispatch: async () => {},
355
+ config,
356
+ resolved: emptyResolved,
357
+ diagnostics: [...diagnostics],
358
+ };
359
+ }
360
+
361
+ /**
362
+ * Default in-memory host for {@link loadHarness}. `exec` rejects
363
+ * explicitly — authors needing a stub pass their own host. `appendEntry`
364
+ * is a silent sink (writes into a throwaway array not exposed on the
365
+ * return).
366
+ */
367
+ function defaultHarnessHost(): EvaluatorHost {
368
+ return {
369
+ exec: () =>
370
+ Promise.reject(
371
+ new Error(
372
+ "loadHarness: exec not stubbed — pass options.host with an exec implementation",
373
+ ),
374
+ ),
375
+ appendEntry: () => {},
376
+ };
377
+ }
378
+
379
+ // ---------------------------------------------------------------------------
380
+ // mockContext
381
+ // ---------------------------------------------------------------------------
382
+
383
+ /**
384
+ * Shape of an entry fed into {@link mockContext} / {@link
385
+ * mockObserverContext} to back `findEntries`. Mirrors the subset of
386
+ * pi's `CustomEntry` the evaluator + dispatcher actually read.
387
+ */
388
+ export interface MockEntry {
389
+ readonly type: "custom";
390
+ readonly customType: string;
391
+ readonly data: unknown;
392
+ readonly timestamp: string;
393
+ }
394
+
395
+ /**
396
+ * Options for {@link priorEntry}.
397
+ */
398
+ export interface PriorEntryOptions {
399
+ /**
400
+ * Agent-loop index to stamp on the payload. The engine's live
401
+ * `appendEntry` wrapper stamps this automatically on every write;
402
+ * fixture entries must reproduce the same shape so `when.happened:
403
+ * { in: "agent_loop" }` scope filtering works identically whether
404
+ * the entry was written at runtime or seeded into the mock.
405
+ *
406
+ * Defaults to `0`. Set to the value of {@link MockContextOptions.agentLoopIndex}
407
+ * to place the entry in the current agent loop; set to a lower
408
+ * value (or 0 with `agentLoopIndex: 1+` on the context) to place
409
+ * it in a prior agent loop.
410
+ */
411
+ readonly agentLoopIndex?: number;
412
+
413
+ /**
414
+ * ISO-8601 timestamp string. Defaults to `"2026-01-01T00:00:00.000Z"`.
415
+ * Use distinct, monotonically-increasing timestamps when seeding
416
+ * multiple entries the `since` invalidation sentinel needs to
417
+ * order.
418
+ */
419
+ readonly timestamp?: string;
420
+ }
421
+
422
+ /**
423
+ * Build a {@link MockEntry} for {@link MockContextOptions.entries}
424
+ * (and the observer-context equivalent) with the reserved
425
+ * `_agentLoopIndex` tag stamped on the payload exactly as the live
426
+ * engine's `appendEntry` wrapper would.
427
+ *
428
+ * The reserved-key name is kept as an internal detail of the engine
429
+ * so plugin / fixture authors don't have to remember the underscore
430
+ * prefix. A typo on the `agentLoopIndex` field of {@link PriorEntryOptions}
431
+ * is a TypeScript compile error; the equivalent typo on a hand-rolled
432
+ * `data: { agentLoopIndex: 5 }` literal is silent — the entry passes
433
+ * through `findEntries` but then fails to match the current
434
+ * agent-loop scope, and the rule under test appears to misbehave.
435
+ *
436
+ * Payload shaping mirrors the live `createAppendEntry`:
437
+ * - Plain-object `data`: merged as `{ ...data, _agentLoopIndex }`.
438
+ * - Anything else (arrays, Date, Map, Set, Error, primitives,
439
+ * null, undefined): wrapped as `{ value: data, _agentLoopIndex }`.
440
+ *
441
+ * @example
442
+ * const ctx = mockContext({
443
+ * agentLoopIndex: 5,
444
+ * entries: [
445
+ * priorEntry("ws-sync-done", {}, { agentLoopIndex: 5 }),
446
+ * ],
447
+ * });
448
+ * // `when.happened: { event: "ws-sync-done", in: "agent_loop" }`
449
+ * // now sees the entry as "happened in the current loop".
450
+ */
451
+ export function priorEntry(
452
+ customType: string,
453
+ data?: unknown,
454
+ opts?: PriorEntryOptions,
455
+ ): MockEntry {
456
+ const agentLoopIndex = opts?.agentLoopIndex ?? 0;
457
+ const tagged = isPlainObject(data)
458
+ ? { ...data, [AGENT_LOOP_INDEX_KEY]: agentLoopIndex }
459
+ : { value: data, [AGENT_LOOP_INDEX_KEY]: agentLoopIndex };
460
+ return {
461
+ type: "custom",
462
+ customType,
463
+ timestamp: opts?.timestamp ?? "2026-01-01T00:00:00.000Z",
464
+ data: tagged,
465
+ };
466
+ }
467
+
468
+ /**
469
+ * Re-exported for plugin authors constructing `toolCallEvents`
470
+ * fixtures on {@link MockContextOptions}. Structurally `{ data,
471
+ * timestamp, speculative: true }` — the same shape the walker-level
472
+ * speculative-entry synthesis pass produces in production. Plugin
473
+ * predicates that filter out speculative entries check
474
+ * `entry.speculative === true`.
475
+ */
476
+ export type { SyntheticEntry } from "../evaluator-internals/speculative-synthesis.ts";
477
+
478
+ /**
479
+ * Options for {@link mockContext}.
480
+ */
481
+ export interface MockContextOptions {
482
+ /** Defaults to `"/tmp/test"`. */
483
+ readonly cwd?: string;
484
+
485
+ /** Engine agent-loop counter. Defaults to `0`. */
486
+ readonly agentLoopIndex?: number;
487
+
488
+ /**
489
+ * Which tool this predicate is evaluating under. Defaults to
490
+ * `"bash"`. Drives the default shape of {@link input} when the
491
+ * caller doesn't supply one.
492
+ */
493
+ readonly tool?: "bash" | "write" | "edit";
494
+
495
+ /**
496
+ * Tool input. Omitted: derived from {@link tool} as the empty
497
+ * shape for that tool (bash: `{ command: "" }`, write:
498
+ * `{ path: "", content: "" }`, edit: `{ path: "", edits: [] }`).
499
+ */
500
+ readonly input?: PredicateToolInput;
501
+
502
+ /**
503
+ * Walker-state snapshot the predicate sees via
504
+ * {@link PredicateContext.walkerState}.
505
+ *
506
+ * Defaults to `{ cwd: options.cwd, env: new Map() }` so the
507
+ * built-in `when.cwd` predicate and any plugin reading
508
+ * `walkerState.env` work without wiring up a full walker. Callers
509
+ * who want a specific env map or branch tracker state pass it in
510
+ * via this option.
511
+ *
512
+ * The typed shape is {@link WhenWalkerState} with every field
513
+ * optional (partial) so tests that only care about one dimension
514
+ * don't have to fill in the others. The engine's production path
515
+ * always populates `cwd` + `env`; the mock's default matches.
516
+ *
517
+ * Partial override: fields you pass merge over the defaults, so
518
+ * `mockContext({ walkerState: { cwd: "/x" } })` keeps the default
519
+ * empty env Map (same shape as production). Pass `env` explicitly
520
+ * only when you need a seeded map.
521
+ */
522
+ readonly walkerState?: Partial<WhenWalkerState> & Record<string, unknown>;
523
+
524
+ /**
525
+ * Stub for `ctx.exec`. Defaults to rejecting with a clear error
526
+ * message — tests that call out to exec must stub explicitly
527
+ * (silent `undefined` would make predicate logic hard to reason
528
+ * about).
529
+ */
530
+ readonly exec?: (
531
+ cmd: string,
532
+ args: readonly string[],
533
+ opts?: ExecOpts,
534
+ ) => ExecResult | Promise<ExecResult>;
535
+
536
+ /**
537
+ * Prior session entries `findEntries` reads from. Filtered by
538
+ * customType; timestamps parsed from the ISO string to epoch-ms,
539
+ * matching the production shape.
540
+ *
541
+ * For rules that use `when.happened: { in: "agent_loop" }` (or
542
+ * `in: "session"` with the same-loop filter), construct entries
543
+ * via {@link priorEntry} so the engine's reserved
544
+ * `_agentLoopIndex` tag is stamped correctly — hand-rolled
545
+ * literals with a typo (`agentLoopIndex` instead of the underscore
546
+ * form) silently fail to match the current agent-loop scope and
547
+ * the rule appears to misbehave.
548
+ */
549
+ readonly entries?: ReadonlyArray<MockEntry>;
550
+
551
+ /**
552
+ * Per-ref speculative events the built-in `when.happened` predicate
553
+ * reads from `ctx.walkerState.events` (see
554
+ * {@link PredicateContext.walkerState}'s reserved `events` key).
555
+ * Keys are the `customType` event literals; values are the
556
+ * synthetic entries for that type. When provided, overwrites any
557
+ * `events` entry on the caller's {@link walkerState}.
558
+ *
559
+ * Use this to drive `when.happened` with `in: "tool_call"` (or any plugin
560
+ * predicate that introspects `walkerState.events`) in isolation
561
+ * without wiring up `loadHarness` + a full bash event. The shape
562
+ * matches what the walker-level synthesis pass produces in
563
+ * production — `{ data, timestamp, speculative: true }` per entry.
564
+ */
565
+ readonly toolCallEvents?: Readonly<Record<string, readonly SyntheticEntry[]>>;
566
+ }
567
+
568
+ /**
569
+ * Build a {@link PredicateContext} for unit-testing predicates in
570
+ * isolation. See {@link MockContextOptions} for defaults. The returned
571
+ * context's `appendEntry` captures into a buffer accessible via
572
+ * {@link getAppendedEntries}.
573
+ */
574
+ export function mockContext(
575
+ options: MockContextOptions = {},
576
+ ): PredicateContext {
577
+ const cwd = options.cwd ?? "/tmp/test";
578
+ const tool = options.tool ?? "bash";
579
+ const input = options.input ?? defaultInputFor(tool);
580
+ // Default walker state satisfies the required `cwd` + `env` fields
581
+ // of {@link WhenWalkerState}. Callers supplying their own
582
+ // walkerState get a shallow merge: defaults first, override last,
583
+ // so `mockContext({ walkerState: { cwd: "/x" } })` keeps the
584
+ // default env Map instead of dropping it (which would crash any
585
+ // predicate that reads `ctx.walkerState.env.get(...)`). This
586
+ // matches the production evaluator, which always populates both
587
+ // cwd and env.
588
+ const baseWalkerState: Record<string, unknown> = {
589
+ cwd,
590
+ env: new Map<string, string>(),
591
+ ...(options.walkerState as Record<string, unknown> | undefined),
592
+ };
593
+ // Fold `toolCallEvents` (option) into `walkerState.events` (ctx
594
+ // shape) the same way the evaluator's `prepareBashState` does —
595
+ // the caller doesn't have to know the reserved-key convention.
596
+ // Explicit option wins over any `events` entry the caller placed
597
+ // directly on `walkerState`.
598
+ const walkerState: Record<string, unknown> =
599
+ options.toolCallEvents !== undefined
600
+ ? { ...baseWalkerState, events: options.toolCallEvents }
601
+ : baseWalkerState;
602
+ const agentLoopIndex = options.agentLoopIndex ?? 0;
603
+ const buffer: CapturedEntry[] = [];
604
+
605
+ // Route through the production `createAppendEntry` wrapper so mock
606
+ // and real engine stay in lockstep: plain-object payloads get
607
+ // `_agentLoopIndex` merged in, everything else wraps as
608
+ // `{ value, _agentLoopIndex }`. Without this, a rule author testing
609
+ // their self-mark pattern via `mockContext` would see un-tagged
610
+ // entries that would never have been written that way in
611
+ // production, and a follow-up `when.happened: { in: "agent_loop" }`
612
+ // simulation would disagree with the real engine.
613
+ const bufferingHost = bufferingAppendHost(buffer);
614
+
615
+ const ctx: PredicateContext = {
616
+ cwd,
617
+ tool,
618
+ input,
619
+ agentLoopIndex,
620
+ exec: buildExec(options.exec, "mockContext"),
621
+ appendEntry: createAppendEntry(bufferingHost, agentLoopIndex),
622
+ findEntries: buildFindEntries(options.entries ?? []),
623
+ // Cast: mockContext's walkerState may be a user-supplied `Partial<
624
+ // WhenWalkerState>`. The default path above fills in cwd + env;
625
+ // explicit-override callers might omit them intentionally (testing
626
+ // plugin predicates that don't read cwd / env). The production
627
+ // evaluator always populates both, so tests that care match that
628
+ // via the default. The Partial<> option shape signals "bring what
629
+ // you need"; this cast acknowledges the resulting schema-strict
630
+ // shape is the mock's responsibility.
631
+ walkerState: walkerState as Readonly<WhenWalkerState>,
632
+ };
633
+
634
+ appendBuffers.set(ctx, buffer);
635
+ return ctx;
636
+ }
637
+
638
+ /**
639
+ * Shape-of-`input` default per tool. Kept narrow — just the shape
640
+ * required by `PredicateToolInput` so unit tests don't have to invent
641
+ * placeholder values.
642
+ */
643
+ function defaultInputFor(tool: "bash" | "write" | "edit"): PredicateToolInput {
644
+ switch (tool) {
645
+ case "bash":
646
+ return { tool: "bash", command: "" };
647
+ case "write":
648
+ return { tool: "write", path: "", content: "" };
649
+ case "edit":
650
+ return { tool: "edit", path: "", edits: [] };
651
+ }
652
+ }
653
+
654
+ // ---------------------------------------------------------------------------
655
+ // mockObserverContext
656
+ // ---------------------------------------------------------------------------
657
+
658
+ /**
659
+ * Options for {@link mockObserverContext}. Observers don't see
660
+ * `tool`, `input`, or `walkerState` — those are predicate-side
661
+ * concepts — so those fields are omitted here.
662
+ */
663
+ export type MockObserverContextOptions = Omit<
664
+ MockContextOptions,
665
+ "tool" | "input" | "walkerState" | "toolCallEvents"
666
+ >;
667
+
668
+ /**
669
+ * Build an {@link ObserverContext} for unit-testing observer
670
+ * `onResult` handlers. Same capture + `findEntries` pattern as
671
+ * {@link mockContext}.
672
+ *
673
+ * Note: production `ObserverContext` does NOT expose `exec` — but the
674
+ * mock does (as an `exec`-like stub on a different property name is
675
+ * more confusing than forbidding it outright). Observer authors that
676
+ * reach for `exec` are probably using the wrong hook; rules / plugins
677
+ * carrying that logic belong in a predicate. The mock still accepts
678
+ * the stub so tests composing an observer + predicate through a shared
679
+ * options object don't have to strip the field.
680
+ *
681
+ * We DO NOT attach `exec` to the returned ObserverContext — the
682
+ * schema doesn't expose it. The stub is accepted but silently unused
683
+ * at this phase; the follow-up `testObserver` wrapper (Phase 5b) will
684
+ * surface a warning when the stub is set but can never fire.
685
+ */
686
+ export function mockObserverContext(
687
+ options: MockObserverContextOptions = {},
688
+ ): ObserverContext {
689
+ const cwd = options.cwd ?? "/tmp/test";
690
+ const agentLoopIndex = options.agentLoopIndex ?? 0;
691
+ const buffer: CapturedEntry[] = [];
692
+
693
+ // Same wrapper as mockContext: keeps the mock observer context
694
+ // writing entries in the auto-tagged shape the real dispatcher
695
+ // produces.
696
+ const bufferingHost = bufferingAppendHost(buffer);
697
+
698
+ const ctx: ObserverContext = {
699
+ cwd,
700
+ agentLoopIndex,
701
+ appendEntry: createAppendEntry(bufferingHost, agentLoopIndex),
702
+ findEntries: buildFindEntries(options.entries ?? []),
703
+ };
704
+
705
+ appendBuffers.set(ctx, buffer);
706
+ return ctx;
707
+ }
708
+
709
+ // ---------------------------------------------------------------------------
710
+ // createRecordingHost + mockExtensionContext
711
+ // ---------------------------------------------------------------------------
712
+ //
713
+ // Low-level factories for tests that drive `harness.evaluate` /
714
+ // `harness.dispatch` directly and need to inspect what the engine
715
+ // wrote. Most plugin authors should reach for {@link loadHarness} +
716
+ // {@link expectBlocks} / {@link expectAllows} first — those cover the
717
+ // common rule-gating assertions without exposing the host/ctx surface.
718
+ //
719
+ // The factories here are the escape hatch for the 10% of tests that:
720
+ //
721
+ // - Drive a multi-call sequence where earlier `appendEntry` writes
722
+ // must be visible to later `findEntries` reads (self-marking rules,
723
+ // `when.happened` gating, observer → rule handoff).
724
+ // - Assert the exact shape of `appendEntry` writes (audit entries,
725
+ // tracker state) without going through a shorthand expectation.
726
+ // - Compose a custom {@link EvaluatorHost} for an existing
727
+ // {@link loadHarness} call while still getting typed access to
728
+ // recorded exec / appendEntry calls.
729
+ //
730
+ // Use {@link createRecordingHost} with {@link loadHarness} as follows:
731
+ //
732
+ // ```ts
733
+ // const host = createRecordingHost();
734
+ // const ctx = mockExtensionContext("/tmp/test", host.entries);
735
+ // const harness = loadHarness({ config: {...}, host });
736
+ // await harness.evaluate(event, ctx, 1);
737
+ // assert.ok(host.entries.some((e) => e.customType === "my-mark"));
738
+ // ```
739
+ //
740
+ // Prior art: these graduate the `makeTrackedHost` + `makeCtx` helpers
741
+ // previously private to `src/__test-helpers__.ts`. Kept as a separate
742
+ // pair (not merged into {@link loadHarness}) so authors can wire the
743
+ // host and ctx to their own production-runtime facsimile when the
744
+ // default harness plumbing doesn't fit.
745
+
746
+ /**
747
+ * Shape of a session-entry produced by {@link createRecordingHost}'s
748
+ * `appendEntry`, readable by an {@link ExtensionContext} built from
749
+ * {@link mockExtensionContext}. Mirrors the subset of pi's
750
+ * `CustomEntry` the engine reads — `id` and `parentId` exist on real
751
+ * pi entries, so we populate them too to keep type-shape drift from
752
+ * masking silent divergence.
753
+ */
754
+ export interface RecordedSessionEntry {
755
+ readonly type: "custom";
756
+ readonly customType: string;
757
+ readonly data: unknown;
758
+ readonly timestamp: string;
759
+ readonly id: string;
760
+ readonly parentId: string | null;
761
+ }
762
+
763
+ /**
764
+ * Exec-call record captured by {@link createRecordingHost}. One entry
765
+ * per invocation, in registration order. `args` is defensively copied
766
+
767
+ * so later mutation of the caller's argv array doesn't corrupt the
768
+ * record.
769
+ */
770
+ export interface RecordedExecCall {
771
+ readonly cmd: string;
772
+ readonly args: readonly string[];
773
+ readonly cwd: string;
774
+ }
775
+
776
+ /**
777
+ * Options for {@link createRecordingHost}.
778
+ */
779
+ export interface CreateRecordingHostOptions {
780
+ /**
781
+ * Stub for `host.exec`. Receives the normalized `cwd` (either the
782
+ * caller's `opts.cwd` or `"/"`). Defaults to resolving with an
783
+ * empty successful result — override when tests need to assert
784
+ * behavior against a specific stdout / exit code.
785
+ */
786
+ readonly exec?: (
787
+ cmd: string,
788
+ args: readonly string[],
789
+ cwd: string,
790
+ ) => Promise<PiExecResult>;
791
+ }
792
+
793
+ /**
794
+ * Recording {@link EvaluatorHost}. Every `exec` and `appendEntry`
795
+ * call is captured into readable accumulators; `entries` mirrors the
796
+ * `appendEntry` writes in the shape pi's `sessionManager.getEntries()`
797
+ * returns, so feeding `host.entries` into {@link mockExtensionContext}
798
+ * makes the engine's writes visible to its subsequent reads in the
799
+ * same test.
800
+ *
801
+ * Note: `entries` and `execCalls` / `appendedEntries` are returned as
802
+ * mutable arrays so asserts can use `.some`, `.find`, etc. directly
803
+ * without a copy. They are owned by the host; don't splice or reassign
804
+ * them out from under it.
805
+ */
806
+ export interface RecordingHost extends EvaluatorHost {
807
+ /**
808
+ * Session-entry log backing {@link mockExtensionContext}. Mutated
809
+ * in-place on every `appendEntry` call. Pass this array to
810
+ * `mockExtensionContext(cwd, host.entries)` so the host and the
811
+ * ctx share the same store.
812
+ */
813
+ readonly entries: RecordedSessionEntry[];
814
+
815
+ /** Every `exec` invocation, in call order. */
816
+ readonly execCalls: RecordedExecCall[];
817
+
818
+ /**
819
+ * Every `appendEntry(type, data)` invocation, in call order. The
820
+ * `data` field is stored verbatim — NOT the auto-tagged shape the
821
+ * engine produces (that's reflected in {@link entries} instead).
822
+ * This buffer is the raw host-level log; use it for assertions that
823
+ * care about exactly which calls the engine made.
824
+ */
825
+ readonly appendedEntries: Array<{ type: string; data: unknown }>;
826
+ }
827
+
828
+ /**
829
+ * Build a {@link RecordingHost}. Every `exec` call is recorded, and
830
+ * every `appendEntry` call appends both to {@link RecordingHost.
831
+ * appendedEntries} (raw host-level log) and to {@link RecordingHost.
832
+ * entries} (session-entry shape used by {@link mockExtensionContext}).
833
+ *
834
+ * Timestamps on the session-entry log are monotonically-incrementing
835
+ * ISO strings starting at `2026-01-01T00:00:00Z` (+ 1s per entry) so
836
+ * chronological-order asserts stay stable across test runs without a
837
+ * live clock dependency. Override with a wrapping host if your test
838
+ * needs real timestamps.
839
+ *
840
+ * The default `exec` stub resolves with an empty successful result —
841
+ * safer than rejecting by default because most tests don't exercise
842
+ * exec at all and a loud reject would swamp the signal. Opt in to
843
+ * rejection via `options.exec` when a test must assert "exec was NOT
844
+ * called".
845
+ */
846
+ export function createRecordingHost(
847
+ options: CreateRecordingHostOptions = {},
848
+ ): RecordingHost {
849
+ const execCalls: RecordedExecCall[] = [];
850
+ const appendedEntries: Array<{ type: string; data: unknown }> = [];
851
+ const entries: RecordedSessionEntry[] = [];
852
+ let idCounter = 0;
853
+ return {
854
+ execCalls,
855
+ appendedEntries,
856
+ entries,
857
+ exec: async (cmd, args, opts) => {
858
+ const cwd = opts?.cwd ?? "/";
859
+ execCalls.push({ cmd, args: [...args], cwd });
860
+ if (options.exec) {
861
+ return options.exec(cmd, args, cwd);
862
+ }
863
+ return { stdout: "", stderr: "", code: 0, killed: false };
864
+ },
865
+ appendEntry: (type, data) => {
866
+ appendedEntries.push({ type, data });
867
+ entries.push({
868
+ type: "custom",
869
+ customType: type,
870
+ data,
871
+ timestamp: new Date(
872
+ Date.UTC(2026, 0, 1, 0, 0, idCounter++),
873
+ ).toISOString(),
874
+ id: `entry-${idCounter}`,
875
+ parentId: null,
876
+ });
877
+ },
878
+ };
879
+ }
880
+
881
+ /**
882
+ * Build a minimal {@link ExtensionContext} stub backed by a
883
+ * {@link RecordedSessionEntry} array. Used with {@link loadHarness}'s
884
+ * `harness.evaluate` / `harness.dispatch` when a test needs the engine
885
+ * to see entries a {@link RecordingHost} previously recorded.
886
+ *
887
+ * Only `cwd` and `sessionManager.getEntries()` are populated — the
888
+ * two fields the engine actually reads. Everything else on
889
+ * `ExtensionContext` throws on access (via an `unknown` cast) so an
890
+ * accidental reliance on unsupported surface surfaces as a clear
891
+ * `TypeError` rather than silently passing.
892
+ *
893
+ * Pass `host.entries` from {@link createRecordingHost} to share the
894
+ * backing store between the engine's writes and its subsequent reads.
895
+ *
896
+ * Choosing between this and {@link loadHarness} alone:
897
+ *
898
+ * - Use {@link loadHarness} + {@link expectBlocks}/{@link expectAllows}
899
+ * when the test only asserts block vs allow on a single event.
900
+ * - Use {@link createRecordingHost} + `mockExtensionContext` when the
901
+ * test drives a multi-call sequence, asserts on session-entry
902
+ * shape, or inspects exec calls.
903
+ */
904
+ export function mockExtensionContext(
905
+ cwd: string,
906
+ entries: ReadonlyArray<RecordedSessionEntry> = [],
907
+ ): ExtensionContext {
908
+ return {
909
+ cwd,
910
+ sessionManager: {
911
+ getEntries: () => entries,
912
+ // Other SessionManager methods are stubbed to throw via the
913
+ // unknown-cast below; any accidental dependency surfaces as a
914
+ // clear TypeError rather than silently passing.
915
+ } as unknown as ExtensionContext["sessionManager"],
916
+ } as ExtensionContext;
917
+ }
918
+
919
+ // ---------------------------------------------------------------------------
920
+ // getAppendedEntries
921
+ // ---------------------------------------------------------------------------
922
+
923
+ /**
924
+ * Read the `appendEntry` capture buffer for a mock context.
925
+ *
926
+ * Returns an empty array when:
927
+ * - nothing has been appended yet, OR
928
+ * - the context wasn't built by {@link mockContext} /
929
+ * {@link mockObserverContext} (safe lookup — no throw).
930
+ *
931
+ * The returned array is a snapshot (copy) so callers can iterate
932
+ * without worrying about concurrent appends racing the assertion.
933
+ */
934
+ export function getAppendedEntries(
935
+ ctx: PredicateContext | ObserverContext,
936
+ ): ReadonlyArray<{ customType: string; data?: unknown }> {
937
+ const buf = appendBuffers.get(ctx);
938
+ if (buf === undefined) return [];
939
+ return [...buf];
940
+ }
941
+
942
+ // ---------------------------------------------------------------------------
943
+ // Internal helpers
944
+ // ---------------------------------------------------------------------------
945
+
946
+ /**
947
+ * Build the `exec` closure for a mock context. Wraps a user-supplied
948
+ * stub or returns a "not stubbed" rejecter. Normalizes the return
949
+ * type to `Promise<ExecResult>` so sync stubs work too.
950
+ */
951
+ function buildExec(
952
+ stub: MockContextOptions["exec"],
953
+ who: "mockContext" | "mockObserverContext",
954
+ ): PredicateContext["exec"] {
955
+ if (stub === undefined) {
956
+ return () =>
957
+ Promise.reject(new Error(`${who}: exec not stubbed — pass options.exec`));
958
+ }
959
+ return async (cmd, args, opts) => stub(cmd, args, opts);
960
+ }
961
+
962
+ /**
963
+ * Build the `findEntries` closure backing mock contexts. Filters the
964
+ * entries array by customType and projects timestamps from ISO
965
+ * strings to epoch-ms — matches {@link createFindEntries} on the
966
+ * production path.
967
+ *
968
+ * No caching here: test-context entry lists are tiny and the cache
969
+ * would make it harder to reason about repeated reads during a test
970
+ * mutating the underlying array.
971
+ */
972
+ function buildFindEntries(
973
+ entries: ReadonlyArray<MockEntry>,
974
+ ): PredicateContext["findEntries"] {
975
+ return <T>(customType: string) => {
976
+ const out: Array<{ data: T; timestamp: number }> = [];
977
+ for (const entry of entries) {
978
+ if (entry.type !== "custom") continue;
979
+ if (entry.customType !== customType) continue;
980
+ const ts = Date.parse(entry.timestamp);
981
+ out.push({
982
+ data: entry.data as T,
983
+ timestamp: Number.isNaN(ts) ? 0 : ts,
984
+ });
985
+ }
986
+ return out;
987
+ };
988
+ }
989
+
990
+ // ===========================================================================
991
+ // Phase 5b — Convenience wrappers
992
+ // ===========================================================================
993
+
994
+ // ---------------------------------------------------------------------------
995
+ // Shorthand input types
996
+ // ---------------------------------------------------------------------------
997
+
998
+ /**
999
+ * Convenience shape for a bash tool-call event. Accepted by
1000
+ * {@link expectBlocks}, {@link expectAllows}, {@link expectRuleFires},
1001
+ * and {@link runMatrix} in place of a full {@link ToolCallEvent}.
1002
+ */
1003
+ export interface BashShorthand {
1004
+ readonly command: string;
1005
+ readonly cwd?: string;
1006
+ }
1007
+
1008
+ /** Convenience shape for a write tool-call event. */
1009
+ export interface WriteShorthand {
1010
+ readonly write: { readonly path: string; readonly content: string };
1011
+ readonly cwd?: string;
1012
+ }
1013
+
1014
+ /** Convenience shape for an edit tool-call event. */
1015
+ export interface EditShorthand {
1016
+ readonly edit: {
1017
+ readonly path: string;
1018
+ readonly edits: ReadonlyArray<{
1019
+ readonly oldText: string;
1020
+ readonly newText: string;
1021
+ }>;
1022
+ };
1023
+ readonly cwd?: string;
1024
+ }
1025
+
1026
+ /** Union of the bash/write/edit shorthands. */
1027
+ export type ToolCallShorthand = BashShorthand | WriteShorthand | EditShorthand;
1028
+
1029
+ /**
1030
+ * Convenience shape for a tool-result event, accepted by
1031
+ * {@link testObserver}. Mirrors the minimal {@link SchemaToolResultEvent}
1032
+ * fields observers actually read.
1033
+ */
1034
+ export interface ToolResultShorthand {
1035
+ readonly toolName: string;
1036
+ readonly input?: unknown;
1037
+ readonly output?: unknown;
1038
+ readonly exitCode?: number;
1039
+ }
1040
+
1041
+ // ---------------------------------------------------------------------------
1042
+ // Event + context resolution helpers
1043
+ // ---------------------------------------------------------------------------
1044
+
1045
+ /**
1046
+ * Detect a {@link ToolCallShorthand} by its tag field. Actual
1047
+ * {@link ToolCallEvent} instances carry a `type: "tool_call"` marker
1048
+ * that shorthands never have.
1049
+ */
1050
+ function isShorthand(
1051
+ input: ToolCallEvent | ToolCallShorthand,
1052
+ ): input is ToolCallShorthand {
1053
+ return !("type" in input && input.type === "tool_call");
1054
+ }
1055
+
1056
+ /**
1057
+ * Resolve a shorthand-or-event input into a concrete
1058
+ * {@link ToolCallEvent} + a minimal {@link ExtensionContext} stub.
1059
+ * The stub carries only `cwd` and a `sessionManager.getEntries()`
1060
+ * returning `[]` — enough for the evaluator to build its per-call
1061
+ * closures without failing on undefined reads.
1062
+ */
1063
+ function resolveToolCallEvent(
1064
+ input: ToolCallEvent | ToolCallShorthand,
1065
+ fallbackCwd: string,
1066
+ ): { event: ToolCallEvent; ctx: ExtensionContext } {
1067
+ const event = isShorthand(input) ? shorthandToEvent(input) : input;
1068
+ const cwd = isShorthand(input) ? (input.cwd ?? fallbackCwd) : fallbackCwd;
1069
+ const ctx = {
1070
+ cwd,
1071
+ sessionManager: { getEntries: () => [] },
1072
+ } as unknown as ExtensionContext;
1073
+ return { event, ctx };
1074
+ }
1075
+
1076
+ /** Build a synthetic {@link ToolCallEvent} from a shorthand. */
1077
+ function shorthandToEvent(s: ToolCallShorthand): ToolCallEvent {
1078
+ if ("command" in s) {
1079
+ return {
1080
+ type: "tool_call",
1081
+ toolName: "bash",
1082
+ input: { command: s.command },
1083
+ } as unknown as ToolCallEvent;
1084
+ }
1085
+ if ("write" in s) {
1086
+ return {
1087
+ type: "tool_call",
1088
+ toolName: "write",
1089
+ input: { path: s.write.path, content: s.write.content },
1090
+ } as unknown as ToolCallEvent;
1091
+ }
1092
+ return {
1093
+ type: "tool_call",
1094
+ toolName: "edit",
1095
+ input: { path: s.edit.path, edits: s.edit.edits },
1096
+ } as unknown as ToolCallEvent;
1097
+ }
1098
+
1099
+ /** Short human-readable summary of an event for failure messages. */
1100
+ function describeEvent(event: ToolCallEvent): string {
1101
+ const input = (event as unknown as { input: unknown }).input;
1102
+ if (
1103
+ event.toolName === "bash" &&
1104
+ typeof input === "object" &&
1105
+ input !== null &&
1106
+ "command" in input
1107
+ ) {
1108
+ const cmd = (input as { command: unknown }).command;
1109
+ return `bash \`${String(cmd)}\``;
1110
+ }
1111
+ if (typeof input === "object" && input !== null && "path" in input) {
1112
+ const p = (input as { path: unknown }).path;
1113
+ return `${event.toolName} ${String(p)}`;
1114
+ }
1115
+ return event.toolName;
1116
+ }
1117
+
1118
+ /**
1119
+ * Resolve a tool-result event or shorthand into a full
1120
+ * {@link SchemaToolResultEvent}. Used by {@link testObserver} to drive
1121
+ * observers without making the caller stand up a pi-shape result.
1122
+ */
1123
+ function resolveToolResultEvent(
1124
+ input: SchemaToolResultEvent | ToolResultShorthand,
1125
+ ): SchemaToolResultEvent {
1126
+ // Both shapes carry `toolName` + `input` + `output` + `exitCode?`.
1127
+ // Accept either; project to the minimal schema shape.
1128
+ return {
1129
+ toolName: input.toolName,
1130
+ input: (input as { input?: unknown }).input ?? {},
1131
+ output: (input as { output?: unknown }).output ?? {},
1132
+ ...(input.exitCode !== undefined ? { exitCode: input.exitCode } : {}),
1133
+ };
1134
+ }
1135
+
1136
+ // ---------------------------------------------------------------------------
1137
+ // testPredicate
1138
+ // ---------------------------------------------------------------------------
1139
+
1140
+ /**
1141
+ * Drive a single {@link PredicateHandler} against a {@link mockContext}.
1142
+ * Returns the boolean verdict.
1143
+ *
1144
+ * Usage:
1145
+ * ```ts
1146
+ * const fires = await testPredicate(branch, /^main$/, {
1147
+ * walkerState: { branch: "main" },
1148
+ * });
1149
+ * ```
1150
+ *
1151
+ * Chain-aware predicates (e.g. the built-in `happened` with its
1152
+ * `&&`-chain speculative allow) read per-ref synthetic events from
1153
+ * `ctx.walkerState.events`. Populate `toolCallEvents` (or set
1154
+ * `walkerState` directly) in {@link MockContextOptions} to simulate
1155
+ * that surface in isolation without wiring up `loadHarness` + a
1156
+ * full bash event.
1157
+ */
1158
+ export async function testPredicate<A = unknown>(
1159
+ predicate: PredicateHandler<A>,
1160
+ args: A,
1161
+ options: MockContextOptions = {},
1162
+ ): Promise<PredicateVerdict> {
1163
+ const ctx = mockContext(options);
1164
+ return predicate(args, ctx);
1165
+ }
1166
+
1167
+ // ---------------------------------------------------------------------------
1168
+ // testObserver
1169
+ // ---------------------------------------------------------------------------
1170
+
1171
+ /**
1172
+ * Fire an {@link Observer} at an event, returning the captured
1173
+ * `appendEntry` writes plus whether the observer's `watch` filter
1174
+ * accepted the event. Use the `entries` field to assert what the
1175
+ * observer recorded; use `watchMatched` to assert the filter gated
1176
+ * firing correctly.
1177
+ *
1178
+ * If the observer's `watch` did NOT match, `onResult` is NOT called
1179
+ * (mirrors production dispatch).
1180
+ *
1181
+ * If `options.exec` is supplied, emits a `console.warn` — observers
1182
+ * don't see `exec`, so the stub can never fire. Exists on the options
1183
+ * shape only because {@link MockObserverContextOptions} is derived
1184
+ * from {@link MockContextOptions} for ergonomic test composition.
1185
+ */
1186
+ export async function testObserver(
1187
+ observer: Observer,
1188
+ event: SchemaToolResultEvent | ToolResultShorthand,
1189
+ options: MockObserverContextOptions = {},
1190
+ ): Promise<{
1191
+ entries: ReadonlyArray<{ customType: string; data?: unknown }>;
1192
+ watchMatched: boolean;
1193
+ }> {
1194
+ if (options.exec !== undefined) {
1195
+ console.warn(
1196
+ "testObserver: exec option ignored — ObserverContext doesn't expose exec",
1197
+ );
1198
+ }
1199
+
1200
+ const ctx = mockObserverContext(options);
1201
+ const resolvedEvent = resolveToolResultEvent(event);
1202
+ const watchMatched = matchesWatch(observer.watch, resolvedEvent);
1203
+
1204
+ if (watchMatched) {
1205
+ await Promise.resolve(observer.onResult(resolvedEvent, ctx));
1206
+ }
1207
+
1208
+ return { entries: getAppendedEntries(ctx), watchMatched };
1209
+ }
1210
+
1211
+ // ---------------------------------------------------------------------------
1212
+ // expectBlocks / expectAllows / expectRuleFires
1213
+ // ---------------------------------------------------------------------------
1214
+
1215
+ /** Options for {@link expectBlocks}. */
1216
+ export interface ExpectBlocksOptions {
1217
+ /**
1218
+ * Expected rule name — matched against the `[steering:<name>@<source>]`
1219
+ * prefix (source-tagged format per ADR §11). The source suffix is
1220
+ * ignored for matching; pass the bare rule name.
1221
+ */
1222
+ readonly rule?: string;
1223
+ /** Expected reason — exact string match (string) or pattern match (RegExp). */
1224
+ readonly reason?: string | RegExp;
1225
+ }
1226
+
1227
+ /**
1228
+ * Extract the rule name from a block reason. Reasons are source-tagged
1229
+ * as `[steering:<rule>@<source>] …`; we return the `<rule>` portion
1230
+ * so callers can assert by name without caring which plugin shipped
1231
+ * the rule.
1232
+ */
1233
+ function extractRuleName(reason: string): string | null {
1234
+ const m = reason.match(/^\[steering:([^@\]]+)(?:@[^\]]+)?\]/);
1235
+ return m?.[1] ?? null;
1236
+ }
1237
+
1238
+ /** Normalize the `ToolCallEventResult` to a concrete block payload or null. */
1239
+ // biome-ignore lint/suspicious/noConfusingVoidType: mirrors EvaluatorRuntime["evaluate"]'s return type (`void` when the event passes through unblocked); callers pass its result through unchanged.
1240
+ function interpretResult(result: ToolCallEventResult | void): {
1241
+ blocked: boolean;
1242
+ reason: string | null;
1243
+ } {
1244
+ if (result === undefined || result === null) {
1245
+ return { blocked: false, reason: null };
1246
+ }
1247
+ const r = result as { block?: boolean; reason?: unknown };
1248
+ if (r.block !== true) return { blocked: false, reason: null };
1249
+ return {
1250
+ blocked: true,
1251
+ reason: typeof r.reason === "string" ? r.reason : String(r.reason ?? ""),
1252
+ };
1253
+ }
1254
+
1255
+ /**
1256
+ * Assert that the harness blocks the given event. Returns the block
1257
+ * payload for further inspection. Throws on allow.
1258
+ *
1259
+ * Optional `expected.rule` / `expected.reason` narrow the assertion:
1260
+ * - `rule: "no-force-push"` — the fired rule's name must match.
1261
+ * - `reason: /force-push/` — the reason string must match (exact
1262
+ * string or regex).
1263
+ */
1264
+ export async function expectBlocks(
1265
+ harness: Harness,
1266
+ event: ToolCallEvent | ToolCallShorthand,
1267
+ expected: ExpectBlocksOptions = {},
1268
+ ): Promise<ToolCallEventResult> {
1269
+ const { event: resolvedEvent, ctx } = resolveToolCallEvent(
1270
+ event,
1271
+ "/tmp/test",
1272
+ );
1273
+ const result = await harness.evaluate(resolvedEvent, ctx, 0);
1274
+ const { blocked, reason } = interpretResult(result);
1275
+
1276
+ if (!blocked) {
1277
+ throw new Error(
1278
+ `expectBlocks: expected block, got allow for ${describeEvent(resolvedEvent)} at ${ctx.cwd}`,
1279
+ );
1280
+ }
1281
+
1282
+ if (expected.rule !== undefined) {
1283
+ const firedRule = extractRuleName(reason ?? "");
1284
+ if (firedRule !== expected.rule) {
1285
+ throw new Error(
1286
+ `expectBlocks: expected rule "${expected.rule}" to fire, ` +
1287
+ `got "${firedRule ?? "<none>"}" for ${describeEvent(resolvedEvent)}\n` +
1288
+ ` reason: ${reason}`,
1289
+ );
1290
+ }
1291
+ }
1292
+
1293
+ if (expected.reason !== undefined && reason !== null) {
1294
+ const matches =
1295
+ expected.reason instanceof RegExp
1296
+ ? expected.reason.test(reason)
1297
+ : expected.reason === reason;
1298
+ if (!matches) {
1299
+ throw new Error(
1300
+ `expectBlocks: reason did not match expected pattern\n` +
1301
+ ` expected: ${String(expected.reason)}\n` +
1302
+ ` got: ${reason}`,
1303
+ );
1304
+ }
1305
+ }
1306
+
1307
+ return result as ToolCallEventResult;
1308
+ }
1309
+
1310
+ /**
1311
+ * Assert that the harness allows the given event (no rule fires).
1312
+ * Throws with a rich message on block.
1313
+ */
1314
+ export async function expectAllows(
1315
+ harness: Harness,
1316
+ event: ToolCallEvent | ToolCallShorthand,
1317
+ ): Promise<void> {
1318
+ const { event: resolvedEvent, ctx } = resolveToolCallEvent(
1319
+ event,
1320
+ "/tmp/test",
1321
+ );
1322
+ const result = await harness.evaluate(resolvedEvent, ctx, 0);
1323
+ const { blocked, reason } = interpretResult(result);
1324
+
1325
+ if (blocked) {
1326
+ const firedRule = extractRuleName(reason ?? "") ?? "<unknown>";
1327
+ throw new Error(
1328
+ `expectAllows: expected allow, got block for ${describeEvent(resolvedEvent)}\n` +
1329
+ ` rule: ${firedRule}\n` +
1330
+ ` reason: ${reason}`,
1331
+ );
1332
+ }
1333
+ }
1334
+
1335
+ /**
1336
+ * Assert that a specific rule fires on the given event. Thin alias
1337
+ * over {@link expectBlocks}; kept as a distinct helper for tests whose
1338
+ * intent is "which rule fired" rather than "the tool was blocked".
1339
+ */
1340
+ export async function expectRuleFires(
1341
+ harness: Harness,
1342
+ event: ToolCallEvent | ToolCallShorthand,
1343
+ ruleName: string,
1344
+ ): Promise<void> {
1345
+ await expectBlocks(harness, event, { rule: ruleName });
1346
+ }
1347
+
1348
+ // ---------------------------------------------------------------------------
1349
+ // runMatrix / formatMatrix
1350
+ // ---------------------------------------------------------------------------
1351
+
1352
+ /** One row of a {@link runMatrix} input. */
1353
+ export interface MatrixCase {
1354
+ readonly name: string;
1355
+ readonly event: ToolCallEvent | ToolCallShorthand;
1356
+ readonly expect:
1357
+ | "block"
1358
+ | "allow"
1359
+ | { readonly block: true; readonly rule?: string };
1360
+ readonly cwd?: string;
1361
+ }
1362
+
1363
+ /** Per-case outcome. */
1364
+ export interface MatrixCaseResult {
1365
+ readonly case: MatrixCase;
1366
+ readonly passed: boolean;
1367
+ readonly actual: "block" | "allow";
1368
+ readonly reason?: string;
1369
+ readonly errorMessage?: string;
1370
+ }
1371
+
1372
+ /** Aggregate outcome of {@link runMatrix}. */
1373
+ export interface MatrixResult {
1374
+ readonly total: number;
1375
+ readonly passed: number;
1376
+ readonly failed: number;
1377
+ readonly cases: ReadonlyArray<MatrixCaseResult>;
1378
+ }
1379
+
1380
+ /**
1381
+ * Batch-evaluate a list of cases against a harness. Never throws —
1382
+ * failures surface in `result.cases`. Pair with {@link formatMatrix}
1383
+ * to render a human-readable report.
1384
+ */
1385
+ export async function runMatrix(
1386
+ harness: Harness,
1387
+ cases: readonly MatrixCase[],
1388
+ ): Promise<MatrixResult> {
1389
+ const caseResults: MatrixCaseResult[] = [];
1390
+
1391
+ for (const c of cases) {
1392
+ const fallback = c.cwd ?? "/tmp/test";
1393
+ const { event, ctx } = resolveToolCallEvent(c.event, fallback);
1394
+ const evalResult = await harness.evaluate(event, ctx, 0);
1395
+ const { blocked, reason } = interpretResult(evalResult);
1396
+ const actual: "block" | "allow" = blocked ? "block" : "allow";
1397
+
1398
+ let passed = false;
1399
+ let errorMessage: string | undefined;
1400
+
1401
+ if (c.expect === "allow") {
1402
+ passed = !blocked;
1403
+ if (!passed) {
1404
+ errorMessage = `expected allow; got block (${extractRuleName(reason ?? "") ?? "<unknown>"})`;
1405
+ }
1406
+ } else if (c.expect === "block") {
1407
+ passed = blocked;
1408
+ if (!passed) errorMessage = "expected block; got allow";
1409
+ } else {
1410
+ if (!blocked) {
1411
+ passed = false;
1412
+ errorMessage = "expected block; got allow";
1413
+ } else if (c.expect.rule !== undefined) {
1414
+ const firedRule = extractRuleName(reason ?? "");
1415
+ passed = firedRule === c.expect.rule;
1416
+ if (!passed) {
1417
+ errorMessage = `expected rule "${c.expect.rule}"; got "${firedRule ?? "<unknown>"}"`;
1418
+ }
1419
+ } else {
1420
+ passed = true;
1421
+ }
1422
+ }
1423
+
1424
+ caseResults.push({
1425
+ case: c,
1426
+ passed,
1427
+ actual,
1428
+ ...(reason !== null ? { reason } : {}),
1429
+ ...(errorMessage !== undefined ? { errorMessage } : {}),
1430
+ });
1431
+ }
1432
+
1433
+ const passed = caseResults.filter((r) => r.passed).length;
1434
+ return {
1435
+ total: caseResults.length,
1436
+ passed,
1437
+ failed: caseResults.length - passed,
1438
+ cases: caseResults,
1439
+ };
1440
+ }
1441
+
1442
+ /**
1443
+ * Pretty-print a {@link MatrixResult}. ASCII-friendly for CI log
1444
+ * aggregators; structure mirrors the adversarial-matrix report style.
1445
+ */
1446
+ export function formatMatrix(result: MatrixResult): string {
1447
+ const lines: string[] = [];
1448
+ lines.push(
1449
+ `MATRIX — ${result.total} cases. ${result.passed} pass, ${result.failed} fail.`,
1450
+ );
1451
+ lines.push("=".repeat(64));
1452
+ for (const r of result.cases) {
1453
+ const expect =
1454
+ typeof r.case.expect === "string"
1455
+ ? r.case.expect
1456
+ : `block:${r.case.expect.rule ?? "*"}`;
1457
+ const actualLabel =
1458
+ r.actual === "block"
1459
+ ? `BLOCK (${extractRuleName(r.reason ?? "") ?? "?"})`
1460
+ : "allow";
1461
+ const status = r.passed ? "" : " FAIL";
1462
+ lines.push(
1463
+ `[${r.case.name}] expect:${expect} actual:${actualLabel}${status}`,
1464
+ );
1465
+ if (!r.passed && r.errorMessage) {
1466
+ lines.push(` ↳ ${r.errorMessage}`);
1467
+ }
1468
+ }
1469
+ lines.push("=".repeat(64));
1470
+ lines.push(`PASS: ${result.passed}/${result.total}`);
1471
+ return lines.join("\n");
1472
+ }