@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.
- package/CHANGELOG.md +36 -0
- package/LICENSE +29 -0
- package/README.md +920 -0
- package/dist/__test-helpers__.d.ts +95 -0
- package/dist/__test-helpers__.d.ts.map +1 -0
- package/dist/__test-helpers__.js +174 -0
- package/dist/__test-helpers__.js.map +1 -0
- package/dist/bin/pi-steering.d.ts +9 -0
- package/dist/bin/pi-steering.d.ts.map +1 -0
- package/dist/bin/pi-steering.js +506 -0
- package/dist/bin/pi-steering.js.map +1 -0
- package/dist/compat.d.ts +50 -0
- package/dist/compat.d.ts.map +1 -0
- package/dist/compat.js +180 -0
- package/dist/compat.js.map +1 -0
- package/dist/defaults.d.ts +61 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +106 -0
- package/dist/defaults.js.map +1 -0
- package/dist/define-config.d.ts +235 -0
- package/dist/define-config.d.ts.map +1 -0
- package/dist/define-config.js +118 -0
- package/dist/define-config.js.map +1 -0
- package/dist/define-predicate.d.ts +49 -0
- package/dist/define-predicate.d.ts.map +1 -0
- package/dist/define-predicate.js +35 -0
- package/dist/define-predicate.js.map +1 -0
- package/dist/evaluator-internals/context.d.ts +186 -0
- package/dist/evaluator-internals/context.d.ts.map +1 -0
- package/dist/evaluator-internals/context.js +219 -0
- package/dist/evaluator-internals/context.js.map +1 -0
- package/dist/evaluator-internals/override.d.ts +19 -0
- package/dist/evaluator-internals/override.d.ts.map +1 -0
- package/dist/evaluator-internals/override.js +79 -0
- package/dist/evaluator-internals/override.js.map +1 -0
- package/dist/evaluator-internals/predicates.d.ts +152 -0
- package/dist/evaluator-internals/predicates.d.ts.map +1 -0
- package/dist/evaluator-internals/predicates.js +849 -0
- package/dist/evaluator-internals/predicates.js.map +1 -0
- package/dist/evaluator-internals/speculative-synthesis.d.ts +102 -0
- package/dist/evaluator-internals/speculative-synthesis.d.ts.map +1 -0
- package/dist/evaluator-internals/speculative-synthesis.js +101 -0
- package/dist/evaluator-internals/speculative-synthesis.js.map +1 -0
- package/dist/evaluator.d.ts +101 -0
- package/dist/evaluator.d.ts.map +1 -0
- package/dist/evaluator.js +714 -0
- package/dist/evaluator.js.map +1 -0
- package/dist/helpers/walker-unknown-cwd-reason.d.ts +68 -0
- package/dist/helpers/walker-unknown-cwd-reason.d.ts.map +1 -0
- package/dist/helpers/walker-unknown-cwd-reason.js +73 -0
- package/dist/helpers/walker-unknown-cwd-reason.js.map +1 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +93 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/drop-unused-observers.d.ts +43 -0
- package/dist/internal/drop-unused-observers.d.ts.map +1 -0
- package/dist/internal/drop-unused-observers.js +50 -0
- package/dist/internal/drop-unused-observers.js.map +1 -0
- package/dist/internal/finalize-plugin-state.d.ts +22 -0
- package/dist/internal/finalize-plugin-state.d.ts.map +1 -0
- package/dist/internal/finalize-plugin-state.js +14 -0
- package/dist/internal/finalize-plugin-state.js.map +1 -0
- package/dist/internal/merge-observers.d.ts +30 -0
- package/dist/internal/merge-observers.d.ts.map +1 -0
- package/dist/internal/merge-observers.js +27 -0
- package/dist/internal/merge-observers.js.map +1 -0
- package/dist/internal/pattern-utils.d.ts +33 -0
- package/dist/internal/pattern-utils.d.ts.map +1 -0
- package/dist/internal/pattern-utils.js +20 -0
- package/dist/internal/pattern-utils.js.map +1 -0
- package/dist/internal/ref-text.d.ts +11 -0
- package/dist/internal/ref-text.d.ts.map +1 -0
- package/dist/internal/ref-text.js +15 -0
- package/dist/internal/ref-text.js.map +1 -0
- package/dist/internal/session-runtime.d.ts +44 -0
- package/dist/internal/session-runtime.d.ts.map +1 -0
- package/dist/internal/session-runtime.js +143 -0
- package/dist/internal/session-runtime.js.map +1 -0
- package/dist/internal/watch-matcher.d.ts +56 -0
- package/dist/internal/watch-matcher.d.ts.map +1 -0
- package/dist/internal/watch-matcher.js +179 -0
- package/dist/internal/watch-matcher.js.map +1 -0
- package/dist/loader.d.ts +97 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +527 -0
- package/dist/loader.js.map +1 -0
- package/dist/observer-dispatcher.d.ts +69 -0
- package/dist/observer-dispatcher.d.ts.map +1 -0
- package/dist/observer-dispatcher.js +184 -0
- package/dist/observer-dispatcher.js.map +1 -0
- package/dist/plugin-merger.d.ts +175 -0
- package/dist/plugin-merger.d.ts.map +1 -0
- package/dist/plugin-merger.js +450 -0
- package/dist/plugin-merger.js.map +1 -0
- package/dist/plugins/git/branch-tracker.d.ts +85 -0
- package/dist/plugins/git/branch-tracker.d.ts.map +1 -0
- package/dist/plugins/git/branch-tracker.js +181 -0
- package/dist/plugins/git/branch-tracker.js.map +1 -0
- package/dist/plugins/git/cwd-extensions.d.ts +10 -0
- package/dist/plugins/git/cwd-extensions.d.ts.map +1 -0
- package/dist/plugins/git/cwd-extensions.js +155 -0
- package/dist/plugins/git/cwd-extensions.js.map +1 -0
- package/dist/plugins/git/git-ops.d.ts +89 -0
- package/dist/plugins/git/git-ops.d.ts.map +1 -0
- package/dist/plugins/git/git-ops.js +107 -0
- package/dist/plugins/git/git-ops.js.map +1 -0
- package/dist/plugins/git/index.d.ts +225 -0
- package/dist/plugins/git/index.d.ts.map +1 -0
- package/dist/plugins/git/index.js +52 -0
- package/dist/plugins/git/index.js.map +1 -0
- package/dist/plugins/git/predicates.d.ts +312 -0
- package/dist/plugins/git/predicates.d.ts.map +1 -0
- package/dist/plugins/git/predicates.js +580 -0
- package/dist/plugins/git/predicates.js.map +1 -0
- package/dist/plugins/git/rules.d.ts +209 -0
- package/dist/plugins/git/rules.d.ts.map +1 -0
- package/dist/plugins/git/rules.js +267 -0
- package/dist/plugins/git/rules.js.map +1 -0
- package/dist/schema.d.ts +1799 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +4 -0
- package/dist/schema.js.map +1 -0
- package/dist/testing/index.d.ts +621 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +747 -0
- package/dist/testing/index.js.map +1 -0
- package/examples/README.md +83 -0
- package/examples/combined-git-discipline/README.md +52 -0
- package/examples/combined-git-discipline/node_modules/.bin/pi-steering +43 -0
- package/examples/combined-git-discipline/package.json +18 -0
- package/examples/combined-git-discipline/steering.json +27 -0
- package/examples/combined-git-discipline/steering.test.ts +42 -0
- package/examples/combined-git-discipline/steering.ts +64 -0
- package/examples/combined-git-discipline/tsconfig.json +7 -0
- package/examples/draft-prs-only/README.md +42 -0
- package/examples/draft-prs-only/node_modules/.bin/pi-steering +43 -0
- package/examples/draft-prs-only/package.json +18 -0
- package/examples/draft-prs-only/steering.json +12 -0
- package/examples/draft-prs-only/steering.test.ts +48 -0
- package/examples/draft-prs-only/steering.ts +36 -0
- package/examples/draft-prs-only/tsconfig.json +7 -0
- package/examples/dynamic-reason-runtime-cwd/README.md +148 -0
- package/examples/dynamic-reason-runtime-cwd/node_modules/.bin/pi-steering +43 -0
- package/examples/dynamic-reason-runtime-cwd/package.json +18 -0
- package/examples/dynamic-reason-runtime-cwd/steering.test.ts +130 -0
- package/examples/dynamic-reason-runtime-cwd/steering.ts +82 -0
- package/examples/dynamic-reason-runtime-cwd/tsconfig.json +7 -0
- package/examples/force-push-strict/README.md +75 -0
- package/examples/force-push-strict/node_modules/.bin/pi-steering +43 -0
- package/examples/force-push-strict/package.json +18 -0
- package/examples/force-push-strict/steering.json +12 -0
- package/examples/force-push-strict/steering.test.ts +51 -0
- package/examples/force-push-strict/steering.ts +54 -0
- package/examples/force-push-strict/tsconfig.json +7 -0
- package/examples/no-amend/README.md +52 -0
- package/examples/no-amend/node_modules/.bin/pi-steering +43 -0
- package/examples/no-amend/package.json +18 -0
- package/examples/no-amend/steering.cwd-scoped.json +12 -0
- package/examples/no-amend/steering.json +11 -0
- package/examples/no-amend/steering.test.ts +49 -0
- package/examples/no-amend/steering.ts +38 -0
- package/examples/no-amend/tsconfig.json +7 -0
- package/examples/work-item-plugin/README.md +103 -0
- package/examples/work-item-plugin/node_modules/.bin/pi-steering +43 -0
- package/examples/work-item-plugin/package.json +19 -0
- package/examples/work-item-plugin/src/index.test.ts +168 -0
- package/examples/work-item-plugin/src/index.ts +122 -0
- package/examples/work-item-plugin/src/observers/npm-test-tracker.test.ts +95 -0
- package/examples/work-item-plugin/src/observers/npm-test-tracker.ts +88 -0
- package/examples/work-item-plugin/src/observers/retest-required-tracker.test.ts +81 -0
- package/examples/work-item-plugin/src/observers/retest-required-tracker.ts +81 -0
- package/examples/work-item-plugin/src/predicates/work-item-format.test.ts +157 -0
- package/examples/work-item-plugin/src/predicates/work-item-format.ts +99 -0
- package/examples/work-item-plugin/src/rules/commit-description-check.test.ts +107 -0
- package/examples/work-item-plugin/src/rules/commit-description-check.ts +83 -0
- package/examples/work-item-plugin/src/rules/commit-requires-work-item.test.ts +79 -0
- package/examples/work-item-plugin/src/rules/commit-requires-work-item.ts +56 -0
- package/examples/work-item-plugin/src/rules/push-requires-tests.test.ts +245 -0
- package/examples/work-item-plugin/src/rules/push-requires-tests.ts +62 -0
- package/examples/work-item-plugin/tsconfig.json +8 -0
- package/package.json +88 -0
- package/skills/steering-authoring/SKILL.md +102 -0
- package/src/INVARIANTS.md +116 -0
- package/src/__test-helpers__.ts +243 -0
- package/src/bin/pi-steering.test.ts +920 -0
- package/src/bin/pi-steering.ts +584 -0
- package/src/compat.test.ts +431 -0
- package/src/compat.ts +282 -0
- package/src/defaults.test.ts +585 -0
- package/src/defaults.ts +148 -0
- package/src/define-config.test.ts +888 -0
- package/src/define-config.ts +375 -0
- package/src/define-predicate.test.ts +70 -0
- package/src/define-predicate.ts +57 -0
- package/src/evaluator-internals/context.ts +290 -0
- package/src/evaluator-internals/override.test.ts +99 -0
- package/src/evaluator-internals/override.ts +83 -0
- package/src/evaluator-internals/predicates.ts +1037 -0
- package/src/evaluator-internals/speculative-synthesis.test.ts +411 -0
- package/src/evaluator-internals/speculative-synthesis.ts +199 -0
- package/src/evaluator.test.ts +6296 -0
- package/src/evaluator.ts +1106 -0
- package/src/factory-time-load.test.ts +630 -0
- package/src/helpers/walker-unknown-cwd-reason.test.ts +73 -0
- package/src/helpers/walker-unknown-cwd-reason.ts +83 -0
- package/src/index.test.ts +836 -0
- package/src/index.ts +229 -0
- package/src/internal/drop-unused-observers.test.ts +141 -0
- package/src/internal/drop-unused-observers.ts +81 -0
- package/src/internal/finalize-plugin-state.ts +43 -0
- package/src/internal/merge-observers.ts +51 -0
- package/src/internal/pattern-utils.test.ts +52 -0
- package/src/internal/pattern-utils.ts +38 -0
- package/src/internal/ref-text.test.ts +54 -0
- package/src/internal/ref-text.ts +17 -0
- package/src/internal/session-runtime.test.ts +629 -0
- package/src/internal/session-runtime.ts +201 -0
- package/src/internal/watch-matcher.test.ts +326 -0
- package/src/internal/watch-matcher.ts +200 -0
- package/src/jsdoc-propagation.test.ts +277 -0
- package/src/loader.test.ts +1098 -0
- package/src/loader.ts +581 -0
- package/src/not-block-onunknown.test.ts +554 -0
- package/src/observer-dispatcher.test.ts +939 -0
- package/src/observer-dispatcher.ts +290 -0
- package/src/per-predicate-typing.test.ts +361 -0
- package/src/plugin-merger.test.ts +790 -0
- package/src/plugin-merger.ts +598 -0
- package/src/plugins/git/README.md +485 -0
- package/src/plugins/git/branch-tracker.test.ts +281 -0
- package/src/plugins/git/branch-tracker.ts +187 -0
- package/src/plugins/git/cwd-extensions.test.ts +230 -0
- package/src/plugins/git/cwd-extensions.ts +160 -0
- package/src/plugins/git/git-ops.test.ts +397 -0
- package/src/plugins/git/git-ops.ts +173 -0
- package/src/plugins/git/index.ts +253 -0
- package/src/plugins/git/integration.test.ts +705 -0
- package/src/plugins/git/predicates.test.ts +1401 -0
- package/src/plugins/git/predicates.ts +693 -0
- package/src/plugins/git/rules.test.ts +1001 -0
- package/src/plugins/git/rules.ts +307 -0
- package/src/schema.test.ts +259 -0
- package/src/schema.ts +1963 -0
- package/src/testing/index.test.ts +1696 -0
- 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
|
+
}
|