@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,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
|