@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,747 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+ import { DEFAULT_PLUGINS, DEFAULT_RULES } from "../defaults.js";
4
+ import { buildEvaluator, EVALUATOR_BUILTIN_TRACKERS, } from "../evaluator.js";
5
+ import { AGENT_LOOP_INDEX_KEY, createAppendEntry, isPlainObject, } from "../evaluator-internals/context.js";
6
+ import { finalizePluginState } from "../internal/finalize-plugin-state.js";
7
+ import { runMergerPipeline } from "../internal/session-runtime.js";
8
+ import { buildObserverDispatcher, matchesWatch, } from "../observer-dispatcher.js";
9
+ /**
10
+ * Global, per-context append buffers. Weak so dropped contexts free
11
+ * the buffer. Never holds a reference to the test's context object
12
+ * itself beyond the weak slot.
13
+ */
14
+ const appendBuffers = new WeakMap();
15
+ /**
16
+ * Minimal {@link EvaluatorHost} whose `appendEntry` pushes into the
17
+ * given capture buffer. Used by {@link mockContext} /
18
+ * {@link mockObserverContext} to share the production
19
+ * `createAppendEntry` wrapper: the wrapper expects an
20
+ * `EvaluatorHost`, and wiring a buffering host here lets the mocks
21
+ * auto-tag writes with `_agentLoopIndex` in the exact same shape the
22
+ * real engine and dispatcher produce.
23
+ *
24
+ * `exec` is stubbed to reject — it's never touched on this path
25
+ * (`createAppendEntry` only calls `host.appendEntry`) but has to be
26
+ * present to satisfy the {@link EvaluatorHost} shape.
27
+ */
28
+ function bufferingAppendHost(buffer) {
29
+ return {
30
+ exec: () => Promise.reject(new Error("[@cad0p/pi-steering/testing] internal: bufferingAppendHost.exec " +
31
+ "should never be called")),
32
+ appendEntry: (customType, data) => {
33
+ buffer.push({
34
+ customType,
35
+ ...(data !== undefined ? { data } : {}),
36
+ });
37
+ },
38
+ };
39
+ }
40
+ /**
41
+ * Build an evaluator + observer dispatcher pair from a static
42
+ * {@link SteeringConfig}. Tests drive rules through the same pipeline
43
+ * production uses, without needing a pi runtime stub or walk-up
44
+ * loading.
45
+ */
46
+ export function loadHarness(options) {
47
+ const inputConfig = options.config;
48
+ const includeDefaults = options.includeDefaults ?? false;
49
+ // Run the same merge that production does (single layer here, since
50
+ // loadHarness operates on an in-memory config rather than a walk-up
51
+ // chain). The shared helper short-circuits between buildConfig and
52
+ // resolvePlugins on error-class merge diagnostics so a
53
+ // `tracker-name-collision` flagged by `buildConfig` is not also
54
+ // re-flagged by `resolvePlugins`. The diagnostics surface within-
55
+ // layer rule-name and observer-name collisions, plus tracker-name
56
+ // collisions and the cross-config plugin-name collisions that
57
+ // `includeDefaults: true` can introduce against DEFAULT_PLUGINS.
58
+ const defaults = includeDefaults
59
+ ? { rules: DEFAULT_RULES, plugins: DEFAULT_PLUGINS }
60
+ : undefined;
61
+ const { merged: mergedConfig, resolved, diagnostics, } = runMergerPipeline([inputConfig], defaults, EVALUATOR_BUILTIN_TRACKERS);
62
+ // Apply `config.disabledRules` to user + default rules. Plugin-shipped
63
+ // rules are filtered inside `resolvePlugins`. Mirrors
64
+ // `buildSessionRuntime`.
65
+ const disabled = new Set(mergedConfig.disabledRules ?? []);
66
+ const filteredConfig = { ...mergedConfig };
67
+ if (mergedConfig.rules !== undefined) {
68
+ const kept = mergedConfig.rules.filter((r) => !disabled.has(r.name));
69
+ if (kept.length > 0)
70
+ filteredConfig.rules = kept;
71
+ else
72
+ delete filteredConfig.rules;
73
+ }
74
+ // Aggregate every diagnostic produced during construction. Unlike
75
+ // `buildSessionRuntime`, loadHarness does NOT throw on error-class
76
+ // diagnostics — plugin-author tests assert on the array directly so
77
+ // they can see every diagnostic that fired in one read.
78
+ //
79
+ // Short-circuit on ANY error-class diagnostic — from the cross-
80
+ // layer merge (`buildConfig`'s `detectTrackerNameCollisions`) or
81
+ // from the plugin merger (`reserved-tracker-name`,
82
+ // `reserved-predicate-key`, `invalid-name`, `tracker-name-collision`).
83
+ // All error-class diagnostics produce the same no-op harness so
84
+ // plugin-author tests see uniform behavior regardless of which
85
+ // surface flagged the problem. Mirrors production's bridge-disabled
86
+ // state under the same conditions.
87
+ if (resolved === null || diagnostics.some((d) => d.type === "error")) {
88
+ return buildNoopHarness(filteredConfig, diagnostics);
89
+ }
90
+ // Mirror session-runtime's unused-observer drop so loadHarness
91
+ // tests produce the same verdicts as production for rules that
92
+ // rely on observer writes.
93
+ const { pluginKept, userKept } = finalizePluginState(filteredConfig.rules ?? [], resolved.rules, filteredConfig.observers ?? [], resolved.observers);
94
+ const filteredResolved = { ...resolved, observers: [...pluginKept] };
95
+ const host = options.host ?? defaultHarnessHost();
96
+ const evaluator = buildEvaluator(filteredConfig, filteredResolved, host);
97
+ const dispatcher = buildObserverDispatcher(filteredResolved, userKept, host);
98
+ return {
99
+ evaluate: evaluator.evaluate,
100
+ dispatch: dispatcher.dispatch,
101
+ config: filteredConfig,
102
+ resolved: filteredResolved,
103
+ diagnostics,
104
+ };
105
+ }
106
+ /**
107
+ * Build a no-op {@link Harness} that surfaces the given diagnostics
108
+ * but doesn't drive the evaluator / dispatcher. Used when an
109
+ * error-class loader diagnostic prevents safe construction of the
110
+ * runtime; mirrors production's bridge-disabled state under the same
111
+ * conditions.
112
+ */
113
+ function buildNoopHarness(config, diagnostics) {
114
+ const emptyResolved = {
115
+ predicates: {},
116
+ observers: [],
117
+ trackers: {},
118
+ trackerModifiers: {},
119
+ composedTrackers: {},
120
+ rules: [],
121
+ rulePluginOwners: {},
122
+ // Mirror harness.diagnostics so consumers reading either surface
123
+ // (harness.resolved.diagnostics or harness.diagnostics) see the
124
+ // same list. Otherwise harness.resolved.diagnostics would be
125
+ // silently empty in the no-op short-circuit branch while the
126
+ // outer harness.diagnostics carries the real entries.
127
+ diagnostics: [...diagnostics],
128
+ };
129
+ return {
130
+ evaluate: async () => { },
131
+ dispatch: async () => { },
132
+ config,
133
+ resolved: emptyResolved,
134
+ diagnostics: [...diagnostics],
135
+ };
136
+ }
137
+ /**
138
+ * Default in-memory host for {@link loadHarness}. `exec` rejects
139
+ * explicitly — authors needing a stub pass their own host. `appendEntry`
140
+ * is a silent sink (writes into a throwaway array not exposed on the
141
+ * return).
142
+ */
143
+ function defaultHarnessHost() {
144
+ return {
145
+ exec: () => Promise.reject(new Error("loadHarness: exec not stubbed — pass options.host with an exec implementation")),
146
+ appendEntry: () => { },
147
+ };
148
+ }
149
+ /**
150
+ * Build a {@link MockEntry} for {@link MockContextOptions.entries}
151
+ * (and the observer-context equivalent) with the reserved
152
+ * `_agentLoopIndex` tag stamped on the payload exactly as the live
153
+ * engine's `appendEntry` wrapper would.
154
+ *
155
+ * The reserved-key name is kept as an internal detail of the engine
156
+ * so plugin / fixture authors don't have to remember the underscore
157
+ * prefix. A typo on the `agentLoopIndex` field of {@link PriorEntryOptions}
158
+ * is a TypeScript compile error; the equivalent typo on a hand-rolled
159
+ * `data: { agentLoopIndex: 5 }` literal is silent — the entry passes
160
+ * through `findEntries` but then fails to match the current
161
+ * agent-loop scope, and the rule under test appears to misbehave.
162
+ *
163
+ * Payload shaping mirrors the live `createAppendEntry`:
164
+ * - Plain-object `data`: merged as `{ ...data, _agentLoopIndex }`.
165
+ * - Anything else (arrays, Date, Map, Set, Error, primitives,
166
+ * null, undefined): wrapped as `{ value: data, _agentLoopIndex }`.
167
+ *
168
+ * @example
169
+ * const ctx = mockContext({
170
+ * agentLoopIndex: 5,
171
+ * entries: [
172
+ * priorEntry("ws-sync-done", {}, { agentLoopIndex: 5 }),
173
+ * ],
174
+ * });
175
+ * // `when.happened: { event: "ws-sync-done", in: "agent_loop" }`
176
+ * // now sees the entry as "happened in the current loop".
177
+ */
178
+ export function priorEntry(customType, data, opts) {
179
+ const agentLoopIndex = opts?.agentLoopIndex ?? 0;
180
+ const tagged = isPlainObject(data)
181
+ ? { ...data, [AGENT_LOOP_INDEX_KEY]: agentLoopIndex }
182
+ : { value: data, [AGENT_LOOP_INDEX_KEY]: agentLoopIndex };
183
+ return {
184
+ type: "custom",
185
+ customType,
186
+ timestamp: opts?.timestamp ?? "2026-01-01T00:00:00.000Z",
187
+ data: tagged,
188
+ };
189
+ }
190
+ /**
191
+ * Build a {@link PredicateContext} for unit-testing predicates in
192
+ * isolation. See {@link MockContextOptions} for defaults. The returned
193
+ * context's `appendEntry` captures into a buffer accessible via
194
+ * {@link getAppendedEntries}.
195
+ */
196
+ export function mockContext(options = {}) {
197
+ const cwd = options.cwd ?? "/tmp/test";
198
+ const tool = options.tool ?? "bash";
199
+ const input = options.input ?? defaultInputFor(tool);
200
+ // Default walker state satisfies the required `cwd` + `env` fields
201
+ // of {@link WhenWalkerState}. Callers supplying their own
202
+ // walkerState get a shallow merge: defaults first, override last,
203
+ // so `mockContext({ walkerState: { cwd: "/x" } })` keeps the
204
+ // default env Map instead of dropping it (which would crash any
205
+ // predicate that reads `ctx.walkerState.env.get(...)`). This
206
+ // matches the production evaluator, which always populates both
207
+ // cwd and env.
208
+ const baseWalkerState = {
209
+ cwd,
210
+ env: new Map(),
211
+ ...options.walkerState,
212
+ };
213
+ // Fold `toolCallEvents` (option) into `walkerState.events` (ctx
214
+ // shape) the same way the evaluator's `prepareBashState` does —
215
+ // the caller doesn't have to know the reserved-key convention.
216
+ // Explicit option wins over any `events` entry the caller placed
217
+ // directly on `walkerState`.
218
+ const walkerState = options.toolCallEvents !== undefined
219
+ ? { ...baseWalkerState, events: options.toolCallEvents }
220
+ : baseWalkerState;
221
+ const agentLoopIndex = options.agentLoopIndex ?? 0;
222
+ const buffer = [];
223
+ // Route through the production `createAppendEntry` wrapper so mock
224
+ // and real engine stay in lockstep: plain-object payloads get
225
+ // `_agentLoopIndex` merged in, everything else wraps as
226
+ // `{ value, _agentLoopIndex }`. Without this, a rule author testing
227
+ // their self-mark pattern via `mockContext` would see un-tagged
228
+ // entries that would never have been written that way in
229
+ // production, and a follow-up `when.happened: { in: "agent_loop" }`
230
+ // simulation would disagree with the real engine.
231
+ const bufferingHost = bufferingAppendHost(buffer);
232
+ const ctx = {
233
+ cwd,
234
+ tool,
235
+ input,
236
+ agentLoopIndex,
237
+ exec: buildExec(options.exec, "mockContext"),
238
+ appendEntry: createAppendEntry(bufferingHost, agentLoopIndex),
239
+ findEntries: buildFindEntries(options.entries ?? []),
240
+ // Cast: mockContext's walkerState may be a user-supplied `Partial<
241
+ // WhenWalkerState>`. The default path above fills in cwd + env;
242
+ // explicit-override callers might omit them intentionally (testing
243
+ // plugin predicates that don't read cwd / env). The production
244
+ // evaluator always populates both, so tests that care match that
245
+ // via the default. The Partial<> option shape signals "bring what
246
+ // you need"; this cast acknowledges the resulting schema-strict
247
+ // shape is the mock's responsibility.
248
+ walkerState: walkerState,
249
+ };
250
+ appendBuffers.set(ctx, buffer);
251
+ return ctx;
252
+ }
253
+ /**
254
+ * Shape-of-`input` default per tool. Kept narrow — just the shape
255
+ * required by `PredicateToolInput` so unit tests don't have to invent
256
+ * placeholder values.
257
+ */
258
+ function defaultInputFor(tool) {
259
+ switch (tool) {
260
+ case "bash":
261
+ return { tool: "bash", command: "" };
262
+ case "write":
263
+ return { tool: "write", path: "", content: "" };
264
+ case "edit":
265
+ return { tool: "edit", path: "", edits: [] };
266
+ }
267
+ }
268
+ /**
269
+ * Build an {@link ObserverContext} for unit-testing observer
270
+ * `onResult` handlers. Same capture + `findEntries` pattern as
271
+ * {@link mockContext}.
272
+ *
273
+ * Note: production `ObserverContext` does NOT expose `exec` — but the
274
+ * mock does (as an `exec`-like stub on a different property name is
275
+ * more confusing than forbidding it outright). Observer authors that
276
+ * reach for `exec` are probably using the wrong hook; rules / plugins
277
+ * carrying that logic belong in a predicate. The mock still accepts
278
+ * the stub so tests composing an observer + predicate through a shared
279
+ * options object don't have to strip the field.
280
+ *
281
+ * We DO NOT attach `exec` to the returned ObserverContext — the
282
+ * schema doesn't expose it. The stub is accepted but silently unused
283
+ * at this phase; the follow-up `testObserver` wrapper (Phase 5b) will
284
+ * surface a warning when the stub is set but can never fire.
285
+ */
286
+ export function mockObserverContext(options = {}) {
287
+ const cwd = options.cwd ?? "/tmp/test";
288
+ const agentLoopIndex = options.agentLoopIndex ?? 0;
289
+ const buffer = [];
290
+ // Same wrapper as mockContext: keeps the mock observer context
291
+ // writing entries in the auto-tagged shape the real dispatcher
292
+ // produces.
293
+ const bufferingHost = bufferingAppendHost(buffer);
294
+ const ctx = {
295
+ cwd,
296
+ agentLoopIndex,
297
+ appendEntry: createAppendEntry(bufferingHost, agentLoopIndex),
298
+ findEntries: buildFindEntries(options.entries ?? []),
299
+ };
300
+ appendBuffers.set(ctx, buffer);
301
+ return ctx;
302
+ }
303
+ /**
304
+ * Build a {@link RecordingHost}. Every `exec` call is recorded, and
305
+ * every `appendEntry` call appends both to {@link RecordingHost.
306
+ * appendedEntries} (raw host-level log) and to {@link RecordingHost.
307
+ * entries} (session-entry shape used by {@link mockExtensionContext}).
308
+ *
309
+ * Timestamps on the session-entry log are monotonically-incrementing
310
+ * ISO strings starting at `2026-01-01T00:00:00Z` (+ 1s per entry) so
311
+ * chronological-order asserts stay stable across test runs without a
312
+ * live clock dependency. Override with a wrapping host if your test
313
+ * needs real timestamps.
314
+ *
315
+ * The default `exec` stub resolves with an empty successful result —
316
+ * safer than rejecting by default because most tests don't exercise
317
+ * exec at all and a loud reject would swamp the signal. Opt in to
318
+ * rejection via `options.exec` when a test must assert "exec was NOT
319
+ * called".
320
+ */
321
+ export function createRecordingHost(options = {}) {
322
+ const execCalls = [];
323
+ const appendedEntries = [];
324
+ const entries = [];
325
+ let idCounter = 0;
326
+ return {
327
+ execCalls,
328
+ appendedEntries,
329
+ entries,
330
+ exec: async (cmd, args, opts) => {
331
+ const cwd = opts?.cwd ?? "/";
332
+ execCalls.push({ cmd, args: [...args], cwd });
333
+ if (options.exec) {
334
+ return options.exec(cmd, args, cwd);
335
+ }
336
+ return { stdout: "", stderr: "", code: 0, killed: false };
337
+ },
338
+ appendEntry: (type, data) => {
339
+ appendedEntries.push({ type, data });
340
+ entries.push({
341
+ type: "custom",
342
+ customType: type,
343
+ data,
344
+ timestamp: new Date(Date.UTC(2026, 0, 1, 0, 0, idCounter++)).toISOString(),
345
+ id: `entry-${idCounter}`,
346
+ parentId: null,
347
+ });
348
+ },
349
+ };
350
+ }
351
+ /**
352
+ * Build a minimal {@link ExtensionContext} stub backed by a
353
+ * {@link RecordedSessionEntry} array. Used with {@link loadHarness}'s
354
+ * `harness.evaluate` / `harness.dispatch` when a test needs the engine
355
+ * to see entries a {@link RecordingHost} previously recorded.
356
+ *
357
+ * Only `cwd` and `sessionManager.getEntries()` are populated — the
358
+ * two fields the engine actually reads. Everything else on
359
+ * `ExtensionContext` throws on access (via an `unknown` cast) so an
360
+ * accidental reliance on unsupported surface surfaces as a clear
361
+ * `TypeError` rather than silently passing.
362
+ *
363
+ * Pass `host.entries` from {@link createRecordingHost} to share the
364
+ * backing store between the engine's writes and its subsequent reads.
365
+ *
366
+ * Choosing between this and {@link loadHarness} alone:
367
+ *
368
+ * - Use {@link loadHarness} + {@link expectBlocks}/{@link expectAllows}
369
+ * when the test only asserts block vs allow on a single event.
370
+ * - Use {@link createRecordingHost} + `mockExtensionContext` when the
371
+ * test drives a multi-call sequence, asserts on session-entry
372
+ * shape, or inspects exec calls.
373
+ */
374
+ export function mockExtensionContext(cwd, entries = []) {
375
+ return {
376
+ cwd,
377
+ sessionManager: {
378
+ getEntries: () => entries,
379
+ // Other SessionManager methods are stubbed to throw via the
380
+ // unknown-cast below; any accidental dependency surfaces as a
381
+ // clear TypeError rather than silently passing.
382
+ },
383
+ };
384
+ }
385
+ // ---------------------------------------------------------------------------
386
+ // getAppendedEntries
387
+ // ---------------------------------------------------------------------------
388
+ /**
389
+ * Read the `appendEntry` capture buffer for a mock context.
390
+ *
391
+ * Returns an empty array when:
392
+ * - nothing has been appended yet, OR
393
+ * - the context wasn't built by {@link mockContext} /
394
+ * {@link mockObserverContext} (safe lookup — no throw).
395
+ *
396
+ * The returned array is a snapshot (copy) so callers can iterate
397
+ * without worrying about concurrent appends racing the assertion.
398
+ */
399
+ export function getAppendedEntries(ctx) {
400
+ const buf = appendBuffers.get(ctx);
401
+ if (buf === undefined)
402
+ return [];
403
+ return [...buf];
404
+ }
405
+ // ---------------------------------------------------------------------------
406
+ // Internal helpers
407
+ // ---------------------------------------------------------------------------
408
+ /**
409
+ * Build the `exec` closure for a mock context. Wraps a user-supplied
410
+ * stub or returns a "not stubbed" rejecter. Normalizes the return
411
+ * type to `Promise<ExecResult>` so sync stubs work too.
412
+ */
413
+ function buildExec(stub, who) {
414
+ if (stub === undefined) {
415
+ return () => Promise.reject(new Error(`${who}: exec not stubbed — pass options.exec`));
416
+ }
417
+ return async (cmd, args, opts) => stub(cmd, args, opts);
418
+ }
419
+ /**
420
+ * Build the `findEntries` closure backing mock contexts. Filters the
421
+ * entries array by customType and projects timestamps from ISO
422
+ * strings to epoch-ms — matches {@link createFindEntries} on the
423
+ * production path.
424
+ *
425
+ * No caching here: test-context entry lists are tiny and the cache
426
+ * would make it harder to reason about repeated reads during a test
427
+ * mutating the underlying array.
428
+ */
429
+ function buildFindEntries(entries) {
430
+ return (customType) => {
431
+ const out = [];
432
+ for (const entry of entries) {
433
+ if (entry.type !== "custom")
434
+ continue;
435
+ if (entry.customType !== customType)
436
+ continue;
437
+ const ts = Date.parse(entry.timestamp);
438
+ out.push({
439
+ data: entry.data,
440
+ timestamp: Number.isNaN(ts) ? 0 : ts,
441
+ });
442
+ }
443
+ return out;
444
+ };
445
+ }
446
+ // ---------------------------------------------------------------------------
447
+ // Event + context resolution helpers
448
+ // ---------------------------------------------------------------------------
449
+ /**
450
+ * Detect a {@link ToolCallShorthand} by its tag field. Actual
451
+ * {@link ToolCallEvent} instances carry a `type: "tool_call"` marker
452
+ * that shorthands never have.
453
+ */
454
+ function isShorthand(input) {
455
+ return !("type" in input && input.type === "tool_call");
456
+ }
457
+ /**
458
+ * Resolve a shorthand-or-event input into a concrete
459
+ * {@link ToolCallEvent} + a minimal {@link ExtensionContext} stub.
460
+ * The stub carries only `cwd` and a `sessionManager.getEntries()`
461
+ * returning `[]` — enough for the evaluator to build its per-call
462
+ * closures without failing on undefined reads.
463
+ */
464
+ function resolveToolCallEvent(input, fallbackCwd) {
465
+ const event = isShorthand(input) ? shorthandToEvent(input) : input;
466
+ const cwd = isShorthand(input) ? (input.cwd ?? fallbackCwd) : fallbackCwd;
467
+ const ctx = {
468
+ cwd,
469
+ sessionManager: { getEntries: () => [] },
470
+ };
471
+ return { event, ctx };
472
+ }
473
+ /** Build a synthetic {@link ToolCallEvent} from a shorthand. */
474
+ function shorthandToEvent(s) {
475
+ if ("command" in s) {
476
+ return {
477
+ type: "tool_call",
478
+ toolName: "bash",
479
+ input: { command: s.command },
480
+ };
481
+ }
482
+ if ("write" in s) {
483
+ return {
484
+ type: "tool_call",
485
+ toolName: "write",
486
+ input: { path: s.write.path, content: s.write.content },
487
+ };
488
+ }
489
+ return {
490
+ type: "tool_call",
491
+ toolName: "edit",
492
+ input: { path: s.edit.path, edits: s.edit.edits },
493
+ };
494
+ }
495
+ /** Short human-readable summary of an event for failure messages. */
496
+ function describeEvent(event) {
497
+ const input = event.input;
498
+ if (event.toolName === "bash" &&
499
+ typeof input === "object" &&
500
+ input !== null &&
501
+ "command" in input) {
502
+ const cmd = input.command;
503
+ return `bash \`${String(cmd)}\``;
504
+ }
505
+ if (typeof input === "object" && input !== null && "path" in input) {
506
+ const p = input.path;
507
+ return `${event.toolName} ${String(p)}`;
508
+ }
509
+ return event.toolName;
510
+ }
511
+ /**
512
+ * Resolve a tool-result event or shorthand into a full
513
+ * {@link SchemaToolResultEvent}. Used by {@link testObserver} to drive
514
+ * observers without making the caller stand up a pi-shape result.
515
+ */
516
+ function resolveToolResultEvent(input) {
517
+ // Both shapes carry `toolName` + `input` + `output` + `exitCode?`.
518
+ // Accept either; project to the minimal schema shape.
519
+ return {
520
+ toolName: input.toolName,
521
+ input: input.input ?? {},
522
+ output: input.output ?? {},
523
+ ...(input.exitCode !== undefined ? { exitCode: input.exitCode } : {}),
524
+ };
525
+ }
526
+ // ---------------------------------------------------------------------------
527
+ // testPredicate
528
+ // ---------------------------------------------------------------------------
529
+ /**
530
+ * Drive a single {@link PredicateHandler} against a {@link mockContext}.
531
+ * Returns the boolean verdict.
532
+ *
533
+ * Usage:
534
+ * ```ts
535
+ * const fires = await testPredicate(branch, /^main$/, {
536
+ * walkerState: { branch: "main" },
537
+ * });
538
+ * ```
539
+ *
540
+ * Chain-aware predicates (e.g. the built-in `happened` with its
541
+ * `&&`-chain speculative allow) read per-ref synthetic events from
542
+ * `ctx.walkerState.events`. Populate `toolCallEvents` (or set
543
+ * `walkerState` directly) in {@link MockContextOptions} to simulate
544
+ * that surface in isolation without wiring up `loadHarness` + a
545
+ * full bash event.
546
+ */
547
+ export async function testPredicate(predicate, args, options = {}) {
548
+ const ctx = mockContext(options);
549
+ return predicate(args, ctx);
550
+ }
551
+ // ---------------------------------------------------------------------------
552
+ // testObserver
553
+ // ---------------------------------------------------------------------------
554
+ /**
555
+ * Fire an {@link Observer} at an event, returning the captured
556
+ * `appendEntry` writes plus whether the observer's `watch` filter
557
+ * accepted the event. Use the `entries` field to assert what the
558
+ * observer recorded; use `watchMatched` to assert the filter gated
559
+ * firing correctly.
560
+ *
561
+ * If the observer's `watch` did NOT match, `onResult` is NOT called
562
+ * (mirrors production dispatch).
563
+ *
564
+ * If `options.exec` is supplied, emits a `console.warn` — observers
565
+ * don't see `exec`, so the stub can never fire. Exists on the options
566
+ * shape only because {@link MockObserverContextOptions} is derived
567
+ * from {@link MockContextOptions} for ergonomic test composition.
568
+ */
569
+ export async function testObserver(observer, event, options = {}) {
570
+ if (options.exec !== undefined) {
571
+ console.warn("testObserver: exec option ignored — ObserverContext doesn't expose exec");
572
+ }
573
+ const ctx = mockObserverContext(options);
574
+ const resolvedEvent = resolveToolResultEvent(event);
575
+ const watchMatched = matchesWatch(observer.watch, resolvedEvent);
576
+ if (watchMatched) {
577
+ await Promise.resolve(observer.onResult(resolvedEvent, ctx));
578
+ }
579
+ return { entries: getAppendedEntries(ctx), watchMatched };
580
+ }
581
+ /**
582
+ * Extract the rule name from a block reason. Reasons are source-tagged
583
+ * as `[steering:<rule>@<source>] …`; we return the `<rule>` portion
584
+ * so callers can assert by name without caring which plugin shipped
585
+ * the rule.
586
+ */
587
+ function extractRuleName(reason) {
588
+ const m = reason.match(/^\[steering:([^@\]]+)(?:@[^\]]+)?\]/);
589
+ return m?.[1] ?? null;
590
+ }
591
+ /** Normalize the `ToolCallEventResult` to a concrete block payload or null. */
592
+ // biome-ignore lint/suspicious/noConfusingVoidType: mirrors EvaluatorRuntime["evaluate"]'s return type (`void` when the event passes through unblocked); callers pass its result through unchanged.
593
+ function interpretResult(result) {
594
+ if (result === undefined || result === null) {
595
+ return { blocked: false, reason: null };
596
+ }
597
+ const r = result;
598
+ if (r.block !== true)
599
+ return { blocked: false, reason: null };
600
+ return {
601
+ blocked: true,
602
+ reason: typeof r.reason === "string" ? r.reason : String(r.reason ?? ""),
603
+ };
604
+ }
605
+ /**
606
+ * Assert that the harness blocks the given event. Returns the block
607
+ * payload for further inspection. Throws on allow.
608
+ *
609
+ * Optional `expected.rule` / `expected.reason` narrow the assertion:
610
+ * - `rule: "no-force-push"` — the fired rule's name must match.
611
+ * - `reason: /force-push/` — the reason string must match (exact
612
+ * string or regex).
613
+ */
614
+ export async function expectBlocks(harness, event, expected = {}) {
615
+ const { event: resolvedEvent, ctx } = resolveToolCallEvent(event, "/tmp/test");
616
+ const result = await harness.evaluate(resolvedEvent, ctx, 0);
617
+ const { blocked, reason } = interpretResult(result);
618
+ if (!blocked) {
619
+ throw new Error(`expectBlocks: expected block, got allow for ${describeEvent(resolvedEvent)} at ${ctx.cwd}`);
620
+ }
621
+ if (expected.rule !== undefined) {
622
+ const firedRule = extractRuleName(reason ?? "");
623
+ if (firedRule !== expected.rule) {
624
+ throw new Error(`expectBlocks: expected rule "${expected.rule}" to fire, ` +
625
+ `got "${firedRule ?? "<none>"}" for ${describeEvent(resolvedEvent)}\n` +
626
+ ` reason: ${reason}`);
627
+ }
628
+ }
629
+ if (expected.reason !== undefined && reason !== null) {
630
+ const matches = expected.reason instanceof RegExp
631
+ ? expected.reason.test(reason)
632
+ : expected.reason === reason;
633
+ if (!matches) {
634
+ throw new Error(`expectBlocks: reason did not match expected pattern\n` +
635
+ ` expected: ${String(expected.reason)}\n` +
636
+ ` got: ${reason}`);
637
+ }
638
+ }
639
+ return result;
640
+ }
641
+ /**
642
+ * Assert that the harness allows the given event (no rule fires).
643
+ * Throws with a rich message on block.
644
+ */
645
+ export async function expectAllows(harness, event) {
646
+ const { event: resolvedEvent, ctx } = resolveToolCallEvent(event, "/tmp/test");
647
+ const result = await harness.evaluate(resolvedEvent, ctx, 0);
648
+ const { blocked, reason } = interpretResult(result);
649
+ if (blocked) {
650
+ const firedRule = extractRuleName(reason ?? "") ?? "<unknown>";
651
+ throw new Error(`expectAllows: expected allow, got block for ${describeEvent(resolvedEvent)}\n` +
652
+ ` rule: ${firedRule}\n` +
653
+ ` reason: ${reason}`);
654
+ }
655
+ }
656
+ /**
657
+ * Assert that a specific rule fires on the given event. Thin alias
658
+ * over {@link expectBlocks}; kept as a distinct helper for tests whose
659
+ * intent is "which rule fired" rather than "the tool was blocked".
660
+ */
661
+ export async function expectRuleFires(harness, event, ruleName) {
662
+ await expectBlocks(harness, event, { rule: ruleName });
663
+ }
664
+ /**
665
+ * Batch-evaluate a list of cases against a harness. Never throws —
666
+ * failures surface in `result.cases`. Pair with {@link formatMatrix}
667
+ * to render a human-readable report.
668
+ */
669
+ export async function runMatrix(harness, cases) {
670
+ const caseResults = [];
671
+ for (const c of cases) {
672
+ const fallback = c.cwd ?? "/tmp/test";
673
+ const { event, ctx } = resolveToolCallEvent(c.event, fallback);
674
+ const evalResult = await harness.evaluate(event, ctx, 0);
675
+ const { blocked, reason } = interpretResult(evalResult);
676
+ const actual = blocked ? "block" : "allow";
677
+ let passed = false;
678
+ let errorMessage;
679
+ if (c.expect === "allow") {
680
+ passed = !blocked;
681
+ if (!passed) {
682
+ errorMessage = `expected allow; got block (${extractRuleName(reason ?? "") ?? "<unknown>"})`;
683
+ }
684
+ }
685
+ else if (c.expect === "block") {
686
+ passed = blocked;
687
+ if (!passed)
688
+ errorMessage = "expected block; got allow";
689
+ }
690
+ else {
691
+ if (!blocked) {
692
+ passed = false;
693
+ errorMessage = "expected block; got allow";
694
+ }
695
+ else if (c.expect.rule !== undefined) {
696
+ const firedRule = extractRuleName(reason ?? "");
697
+ passed = firedRule === c.expect.rule;
698
+ if (!passed) {
699
+ errorMessage = `expected rule "${c.expect.rule}"; got "${firedRule ?? "<unknown>"}"`;
700
+ }
701
+ }
702
+ else {
703
+ passed = true;
704
+ }
705
+ }
706
+ caseResults.push({
707
+ case: c,
708
+ passed,
709
+ actual,
710
+ ...(reason !== null ? { reason } : {}),
711
+ ...(errorMessage !== undefined ? { errorMessage } : {}),
712
+ });
713
+ }
714
+ const passed = caseResults.filter((r) => r.passed).length;
715
+ return {
716
+ total: caseResults.length,
717
+ passed,
718
+ failed: caseResults.length - passed,
719
+ cases: caseResults,
720
+ };
721
+ }
722
+ /**
723
+ * Pretty-print a {@link MatrixResult}. ASCII-friendly for CI log
724
+ * aggregators; structure mirrors the adversarial-matrix report style.
725
+ */
726
+ export function formatMatrix(result) {
727
+ const lines = [];
728
+ lines.push(`MATRIX — ${result.total} cases. ${result.passed} pass, ${result.failed} fail.`);
729
+ lines.push("=".repeat(64));
730
+ for (const r of result.cases) {
731
+ const expect = typeof r.case.expect === "string"
732
+ ? r.case.expect
733
+ : `block:${r.case.expect.rule ?? "*"}`;
734
+ const actualLabel = r.actual === "block"
735
+ ? `BLOCK (${extractRuleName(r.reason ?? "") ?? "?"})`
736
+ : "allow";
737
+ const status = r.passed ? "" : " FAIL";
738
+ lines.push(`[${r.case.name}] expect:${expect} actual:${actualLabel}${status}`);
739
+ if (!r.passed && r.errorMessage) {
740
+ lines.push(` ↳ ${r.errorMessage}`);
741
+ }
742
+ }
743
+ lines.push("=".repeat(64));
744
+ lines.push(`PASS: ${result.passed}/${result.total}`);
745
+ return lines.join("\n");
746
+ }
747
+ //# sourceMappingURL=index.js.map