@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,1696 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tests for the Phase 5a testing primitives (`./index.ts`).
|
|
6
|
+
*
|
|
7
|
+
* Coverage axis:
|
|
8
|
+
* - `loadHarness` — evaluator + dispatcher built from a
|
|
9
|
+
* minimal config; includeDefaults on/off;
|
|
10
|
+
* plugin merging; config.disabledRules; custom
|
|
11
|
+
* host override.
|
|
12
|
+
* - `mockContext` — default shape; per-option overrides; exec
|
|
13
|
+
* stubbing + unstubbed reject; findEntries
|
|
14
|
+
* filter + timestamp parsing; appendEntry
|
|
15
|
+
* capture visible via getAppendedEntries.
|
|
16
|
+
* - `mockObserverContext` — default shape; exec stub pass-through;
|
|
17
|
+
* entries/appendEntry capture.
|
|
18
|
+
* - `getAppendedEntries` — empty on fresh ctx, populated after
|
|
19
|
+
* appendEntry, empty on non-mock ctx.
|
|
20
|
+
*
|
|
21
|
+
* These are UNIT tests against the primitives — they don't exercise
|
|
22
|
+
* the underlying evaluator / observer dispatcher semantics in depth
|
|
23
|
+
* (those are covered in the *.test.ts suites). Here we verify the
|
|
24
|
+
* wrappers assemble the right plumbing.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import assert from "node:assert/strict";
|
|
28
|
+
import { describe, it } from "node:test";
|
|
29
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
30
|
+
import type { EvaluatorHost } from "../evaluator.ts";
|
|
31
|
+
import type {
|
|
32
|
+
Observer,
|
|
33
|
+
ObserverContext,
|
|
34
|
+
Plugin,
|
|
35
|
+
PredicateContext,
|
|
36
|
+
PredicateHandler,
|
|
37
|
+
Rule,
|
|
38
|
+
} from "../schema.ts";
|
|
39
|
+
import {
|
|
40
|
+
createRecordingHost,
|
|
41
|
+
expectAllows,
|
|
42
|
+
expectBlocks,
|
|
43
|
+
expectRuleFires,
|
|
44
|
+
formatMatrix,
|
|
45
|
+
getAppendedEntries,
|
|
46
|
+
loadHarness,
|
|
47
|
+
mockContext,
|
|
48
|
+
mockExtensionContext,
|
|
49
|
+
mockObserverContext,
|
|
50
|
+
priorEntry,
|
|
51
|
+
runMatrix,
|
|
52
|
+
testObserver,
|
|
53
|
+
testPredicate,
|
|
54
|
+
} from "./index.ts";
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Shared stubs
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Minimal `ExtensionContext` stub for harness evaluate/dispatch
|
|
62
|
+
* invocations. Only `cwd` + `sessionManager.getEntries` are read by
|
|
63
|
+
* the evaluator pipeline; everything else we leave unset.
|
|
64
|
+
*/
|
|
65
|
+
function makeExtCtx(cwd = "/repo"): ExtensionContext {
|
|
66
|
+
return {
|
|
67
|
+
cwd,
|
|
68
|
+
sessionManager: {
|
|
69
|
+
getEntries: () => [],
|
|
70
|
+
} as unknown as ExtensionContext["sessionManager"],
|
|
71
|
+
} as ExtensionContext;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// loadHarness
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
describe("loadHarness", () => {
|
|
79
|
+
it("builds evaluator + dispatcher from a minimal (empty) config", async () => {
|
|
80
|
+
const h = loadHarness({ config: {} });
|
|
81
|
+
// evaluator fires: allow (no rules → undefined).
|
|
82
|
+
const res = await h.evaluate(
|
|
83
|
+
{
|
|
84
|
+
type: "tool_call",
|
|
85
|
+
toolCallId: "t",
|
|
86
|
+
toolName: "bash",
|
|
87
|
+
input: { command: "echo hi" },
|
|
88
|
+
},
|
|
89
|
+
makeExtCtx(),
|
|
90
|
+
0,
|
|
91
|
+
);
|
|
92
|
+
assert.equal(res, undefined);
|
|
93
|
+
// dispatcher fires: no observers, no throw.
|
|
94
|
+
await h.dispatch(
|
|
95
|
+
{
|
|
96
|
+
type: "tool_result",
|
|
97
|
+
toolCallId: "t",
|
|
98
|
+
toolName: "bash",
|
|
99
|
+
input: { command: "echo hi" },
|
|
100
|
+
content: [{ type: "text", text: "" }],
|
|
101
|
+
isError: false,
|
|
102
|
+
details: { exitCode: 0 },
|
|
103
|
+
} as unknown as Parameters<typeof h.dispatch>[0],
|
|
104
|
+
makeExtCtx(),
|
|
105
|
+
0,
|
|
106
|
+
);
|
|
107
|
+
// Resolved shape is sane.
|
|
108
|
+
assert.deepEqual(h.resolved.rules, []);
|
|
109
|
+
assert.deepEqual(h.resolved.observers, []);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("includeDefaults: true injects DEFAULT_RULES (no-force-push fires)", async () => {
|
|
113
|
+
const h = loadHarness({ config: {}, includeDefaults: true });
|
|
114
|
+
const res = await h.evaluate(
|
|
115
|
+
{
|
|
116
|
+
type: "tool_call",
|
|
117
|
+
toolCallId: "t",
|
|
118
|
+
toolName: "bash",
|
|
119
|
+
input: { command: "git push --force" },
|
|
120
|
+
},
|
|
121
|
+
makeExtCtx(),
|
|
122
|
+
0,
|
|
123
|
+
);
|
|
124
|
+
assert.ok(res && res.block === true);
|
|
125
|
+
assert.match(res.reason ?? "", /\[steering:no-force-push@[^\]]+\]/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("includeDefaults: false (default) does NOT inject defaults", async () => {
|
|
129
|
+
const h = loadHarness({ config: {} });
|
|
130
|
+
const res = await h.evaluate(
|
|
131
|
+
{
|
|
132
|
+
type: "tool_call",
|
|
133
|
+
toolCallId: "t",
|
|
134
|
+
toolName: "bash",
|
|
135
|
+
input: { command: "git push --force" },
|
|
136
|
+
},
|
|
137
|
+
makeExtCtx(),
|
|
138
|
+
0,
|
|
139
|
+
);
|
|
140
|
+
assert.equal(res, undefined);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("merges a custom plugin's rules + predicates", async () => {
|
|
144
|
+
// Custom rule + plugin predicate; evaluate a matching bash call.
|
|
145
|
+
const rule: Rule = {
|
|
146
|
+
name: "no-cowsay",
|
|
147
|
+
tool: "bash",
|
|
148
|
+
field: "command",
|
|
149
|
+
pattern: "^cowsay\\b",
|
|
150
|
+
reason: "no cows",
|
|
151
|
+
};
|
|
152
|
+
const plugin: Plugin = {
|
|
153
|
+
name: "petting-zoo",
|
|
154
|
+
predicates: {
|
|
155
|
+
// Loose predicate: always-true; here to verify the merger
|
|
156
|
+
// registered it so `when` can reference it by name.
|
|
157
|
+
always: async () => true,
|
|
158
|
+
},
|
|
159
|
+
rules: [rule],
|
|
160
|
+
};
|
|
161
|
+
const h = loadHarness({ config: { plugins: [plugin] } });
|
|
162
|
+
assert.ok(h.resolved.predicates["always"] !== undefined);
|
|
163
|
+
assert.equal(h.resolved.rules.length, 1);
|
|
164
|
+
assert.equal(h.resolved.rules[0]!.name, "no-cowsay");
|
|
165
|
+
|
|
166
|
+
const res = await h.evaluate(
|
|
167
|
+
{
|
|
168
|
+
type: "tool_call",
|
|
169
|
+
toolCallId: "t",
|
|
170
|
+
toolName: "bash",
|
|
171
|
+
input: { command: "cowsay hello" },
|
|
172
|
+
},
|
|
173
|
+
makeExtCtx(),
|
|
174
|
+
0,
|
|
175
|
+
);
|
|
176
|
+
assert.ok(res && res.block === true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("applies config.disabledRules to named rules", async () => {
|
|
180
|
+
const h = loadHarness({
|
|
181
|
+
config: { disabledRules: ["no-force-push"] },
|
|
182
|
+
includeDefaults: true,
|
|
183
|
+
});
|
|
184
|
+
// Rule was filtered out → force-push no longer blocks.
|
|
185
|
+
const res = await h.evaluate(
|
|
186
|
+
{
|
|
187
|
+
type: "tool_call",
|
|
188
|
+
toolCallId: "t",
|
|
189
|
+
toolName: "bash",
|
|
190
|
+
input: { command: "git push --force" },
|
|
191
|
+
},
|
|
192
|
+
makeExtCtx(),
|
|
193
|
+
0,
|
|
194
|
+
);
|
|
195
|
+
assert.equal(res, undefined);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("respects a custom host option", async () => {
|
|
199
|
+
let execCalls = 0;
|
|
200
|
+
const host: EvaluatorHost = {
|
|
201
|
+
exec: async () => {
|
|
202
|
+
execCalls++;
|
|
203
|
+
return { stdout: "main", stderr: "", code: 0, killed: false };
|
|
204
|
+
},
|
|
205
|
+
appendEntry: () => {},
|
|
206
|
+
};
|
|
207
|
+
// A rule with a condition that calls ctx.exec.
|
|
208
|
+
const rule: Rule = {
|
|
209
|
+
name: "uses-exec",
|
|
210
|
+
tool: "bash",
|
|
211
|
+
field: "command",
|
|
212
|
+
pattern: "^git\\b",
|
|
213
|
+
reason: "calls exec",
|
|
214
|
+
when: {
|
|
215
|
+
condition: async (ctx) => {
|
|
216
|
+
await ctx.exec("git", ["rev-parse", "HEAD"]);
|
|
217
|
+
return true;
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
const h = loadHarness({ config: { rules: [rule] }, host });
|
|
222
|
+
const res = await h.evaluate(
|
|
223
|
+
{
|
|
224
|
+
type: "tool_call",
|
|
225
|
+
toolCallId: "t",
|
|
226
|
+
toolName: "bash",
|
|
227
|
+
input: { command: "git status" },
|
|
228
|
+
},
|
|
229
|
+
makeExtCtx(),
|
|
230
|
+
0,
|
|
231
|
+
);
|
|
232
|
+
assert.ok(res && res.block === true);
|
|
233
|
+
assert.equal(execCalls, 1);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("default host's unstubbed exec surfaces as a logged warning + fires fail-CLOSED (S1)", async () => {
|
|
237
|
+
// S1: a throwing predicate (here: the default host's `exec`
|
|
238
|
+
// throwing 'exec not stubbed') is caught by the evaluator and
|
|
239
|
+
// treated as `"unknown"`. Outer-level `condition:` is
|
|
240
|
+
// bare-`PredicateFn`-typed (no leaf-level `onUnknown:` opt-in
|
|
241
|
+
// available), so the projection always uses the default `"block"`
|
|
242
|
+
// policy and the rule fires fail-CLOSED. The test's original
|
|
243
|
+
// intent — 'authors who forget to stub exec see a clear error' —
|
|
244
|
+
// still holds: the warning names the rule + its source and carries
|
|
245
|
+
// the original error message.
|
|
246
|
+
//
|
|
247
|
+
// Symmetry note: outer-level `condition:` throws now mirror the
|
|
248
|
+
// inner not-block treatment + the plugin-handler exception
|
|
249
|
+
// contract — throw → `"unknown"` → default `"block"` projection.
|
|
250
|
+
// Authors who want fail-OPEN treatment for a throwing condition
|
|
251
|
+
// need to express the policy at the containing block level (e.g.
|
|
252
|
+
// inside `not: { condition: fn, onUnknown: "allow" }`).
|
|
253
|
+
const rule: Rule = {
|
|
254
|
+
name: "uses-exec",
|
|
255
|
+
tool: "bash",
|
|
256
|
+
field: "command",
|
|
257
|
+
pattern: "^git\\b",
|
|
258
|
+
reason: "calls exec",
|
|
259
|
+
when: {
|
|
260
|
+
condition: async (ctx) => {
|
|
261
|
+
await ctx.exec("git", ["rev-parse", "HEAD"]);
|
|
262
|
+
return true;
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
const h = loadHarness({ config: { rules: [rule] } });
|
|
267
|
+
const warnings: string[] = [];
|
|
268
|
+
const originalWarn = console.warn;
|
|
269
|
+
console.warn = (...args: unknown[]) => {
|
|
270
|
+
warnings.push(args.map((a) => String(a)).join(" "));
|
|
271
|
+
};
|
|
272
|
+
try {
|
|
273
|
+
const result = await h.evaluate(
|
|
274
|
+
{
|
|
275
|
+
type: "tool_call",
|
|
276
|
+
toolCallId: "t",
|
|
277
|
+
toolName: "bash",
|
|
278
|
+
input: { command: "git status" },
|
|
279
|
+
},
|
|
280
|
+
makeExtCtx(),
|
|
281
|
+
0,
|
|
282
|
+
);
|
|
283
|
+
// Rule fires fail-CLOSED (condition: threw → unknown → default
|
|
284
|
+
// leaf-level `"block"` policy fires the rule).
|
|
285
|
+
assert.ok(result && result.block === true);
|
|
286
|
+
// Warning names the rule + its `@<source>` tag + the
|
|
287
|
+
// unstubbed-exec message via the `when.condition threw` channel.
|
|
288
|
+
assert.ok(
|
|
289
|
+
warnings.some((w) =>
|
|
290
|
+
/Rule "uses-exec"@user.*when\.condition threw.*exec not stubbed/.test(
|
|
291
|
+
w,
|
|
292
|
+
),
|
|
293
|
+
),
|
|
294
|
+
`no matching warning in:\n${warnings.join("\n")}`,
|
|
295
|
+
);
|
|
296
|
+
} finally {
|
|
297
|
+
console.warn = originalWarn;
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("exposes harness.config reflecting the effective (disable-filtered) state (T3)", () => {
|
|
302
|
+
const harness = loadHarness({
|
|
303
|
+
config: {
|
|
304
|
+
rules: [
|
|
305
|
+
{
|
|
306
|
+
name: "keep-me",
|
|
307
|
+
tool: "bash",
|
|
308
|
+
field: "command",
|
|
309
|
+
pattern: /^keep/,
|
|
310
|
+
reason: "keep",
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
name: "drop-me",
|
|
314
|
+
tool: "bash",
|
|
315
|
+
field: "command",
|
|
316
|
+
pattern: /^drop/,
|
|
317
|
+
reason: "drop",
|
|
318
|
+
},
|
|
319
|
+
],
|
|
320
|
+
disabledRules: ["drop-me"],
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
// Config mirror is post-filter: only `keep-me` survives in
|
|
324
|
+
// `config.rules`; the `disabledRules` list is preserved for
|
|
325
|
+
// introspection.
|
|
326
|
+
assert.ok(harness.config);
|
|
327
|
+
assert.equal(harness.config.rules?.length, 1);
|
|
328
|
+
assert.equal(harness.config.rules?.[0]?.name, "keep-me");
|
|
329
|
+
assert.deepEqual(harness.config.disabledRules, ["drop-me"]);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("exposes harness.resolved with plugin-side rules after merger (T3)", () => {
|
|
333
|
+
const pluginRule = {
|
|
334
|
+
name: "plugin-rule",
|
|
335
|
+
tool: "bash" as const,
|
|
336
|
+
field: "command" as const,
|
|
337
|
+
pattern: /^keep/,
|
|
338
|
+
reason: "plugin",
|
|
339
|
+
};
|
|
340
|
+
const droppedPluginRule = {
|
|
341
|
+
name: "dropped-plugin-rule",
|
|
342
|
+
tool: "bash" as const,
|
|
343
|
+
field: "command" as const,
|
|
344
|
+
pattern: /^drop/,
|
|
345
|
+
reason: "drop",
|
|
346
|
+
};
|
|
347
|
+
const harness = loadHarness({
|
|
348
|
+
config: {
|
|
349
|
+
plugins: [{ name: "demo", rules: [pluginRule, droppedPluginRule] }],
|
|
350
|
+
disabledRules: ["dropped-plugin-rule"],
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
// `resolved` reflects plugin-merger output: plugin-shipped rules
|
|
354
|
+
// after disable filtering. User rules live on `harness.config`,
|
|
355
|
+
// not here.
|
|
356
|
+
const resolvedNames = harness.resolved.rules.map((r) => r.name);
|
|
357
|
+
assert.deepEqual(resolvedNames, ["plugin-rule"]);
|
|
358
|
+
assert.ok(
|
|
359
|
+
!resolvedNames.includes("dropped-plugin-rule"),
|
|
360
|
+
"disabled plugin rule must not appear in resolved state",
|
|
361
|
+
);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("surfaces an empty diagnostics array for a clean config", () => {
|
|
365
|
+
const rule = {
|
|
366
|
+
name: "clean",
|
|
367
|
+
tool: "bash" as const,
|
|
368
|
+
field: "command" as const,
|
|
369
|
+
pattern: /^never$/,
|
|
370
|
+
reason: "clean",
|
|
371
|
+
};
|
|
372
|
+
const harness = loadHarness({ config: { rules: [rule] } });
|
|
373
|
+
assert.deepEqual(harness.diagnostics, []);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("does NOT throw on a malformed plugin name; surfaces it as an invalid-name error-class diagnostic", () => {
|
|
377
|
+
// `validateName` flows through the diagnostic stream rather than
|
|
378
|
+
// throwing so plugin-author tests can read the failure surface
|
|
379
|
+
// from `harness.diagnostics` instead of catching a thrown Error.
|
|
380
|
+
const plugin: Plugin = {
|
|
381
|
+
// Spaces in a plugin name forge the `[steering:<name>@<source>]`
|
|
382
|
+
// block-reason tag (S3 boundary).
|
|
383
|
+
name: "bad name",
|
|
384
|
+
};
|
|
385
|
+
const harness = loadHarness({ config: { plugins: [plugin] } });
|
|
386
|
+
const hit = harness.diagnostics.find((d) => d.kind === "invalid-name");
|
|
387
|
+
assert.ok(
|
|
388
|
+
hit,
|
|
389
|
+
`expected an invalid-name diagnostic; got: ${JSON.stringify(harness.diagnostics)}`,
|
|
390
|
+
);
|
|
391
|
+
assert.equal(hit.type, "error");
|
|
392
|
+
assert.match(hit.message, /^plugin name "bad name".*disallowed/);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("returns a no-op harness on a plugin-merger-side error-class diagnostic (reserved-tracker-name)", () => {
|
|
396
|
+
// Symmetric short-circuit: any error-class diagnostic — from the
|
|
397
|
+
// loader, the cross-config merge, OR the plugin merger — produces
|
|
398
|
+
// a no-op harness so plugin-author tests see uniform behavior
|
|
399
|
+
// regardless of which surface flagged the problem. Before the
|
|
400
|
+
// short-circuit moved to AFTER `resolvePlugins`, plugin-merger
|
|
401
|
+
// errors flowed through to a partially-built harness.
|
|
402
|
+
const t = {
|
|
403
|
+
initial: "?" as const,
|
|
404
|
+
unknown: "unknown" as const,
|
|
405
|
+
modifiers: {},
|
|
406
|
+
subshellSemantics: "isolated" as const,
|
|
407
|
+
};
|
|
408
|
+
const plugin: Plugin = {
|
|
409
|
+
name: "reserved-name-plugin",
|
|
410
|
+
trackers: { events: t as never },
|
|
411
|
+
rules: [
|
|
412
|
+
{
|
|
413
|
+
name: "would-block",
|
|
414
|
+
tool: "bash" as const,
|
|
415
|
+
field: "command" as const,
|
|
416
|
+
pattern: /^.*$/,
|
|
417
|
+
reason: "never",
|
|
418
|
+
},
|
|
419
|
+
],
|
|
420
|
+
};
|
|
421
|
+
const harness = loadHarness({ config: { plugins: [plugin] } });
|
|
422
|
+
// Diagnostic surfaced.
|
|
423
|
+
const hit = harness.diagnostics.find(
|
|
424
|
+
(d) => d.kind === "reserved-tracker-name",
|
|
425
|
+
);
|
|
426
|
+
assert.ok(
|
|
427
|
+
hit,
|
|
428
|
+
`expected a reserved-tracker-name diagnostic; got: ${JSON.stringify(harness.diagnostics)}`,
|
|
429
|
+
);
|
|
430
|
+
// Harness is no-op: resolved is empty (no rules / no observers /
|
|
431
|
+
// no trackers), so the would-block rule that the plugin shipped
|
|
432
|
+
// alongside the bad tracker doesn't fire either.
|
|
433
|
+
assert.equal(harness.resolved.rules.length, 0);
|
|
434
|
+
assert.equal(harness.resolved.observers.length, 0);
|
|
435
|
+
assert.deepEqual(harness.resolved.trackers, {});
|
|
436
|
+
// Consistency: resolved.diagnostics mirrors the outer
|
|
437
|
+
// harness.diagnostics in the no-op short-circuit branch so
|
|
438
|
+
// consumers reading either surface get the same list.
|
|
439
|
+
assert.equal(
|
|
440
|
+
harness.resolved.diagnostics.length,
|
|
441
|
+
harness.diagnostics.length,
|
|
442
|
+
);
|
|
443
|
+
assert.ok(
|
|
444
|
+
harness.resolved.diagnostics.some(
|
|
445
|
+
(d) => d.kind === "reserved-tracker-name",
|
|
446
|
+
),
|
|
447
|
+
);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it("surfaces an error-class diagnostic when a plugin claims a reserved tracker name", () => {
|
|
451
|
+
// Plugin authors writing tests against `loadHarness` should see
|
|
452
|
+
// reserved-name violations in their diagnostics array, NOT a thrown
|
|
453
|
+
// error that hides which other diagnostics fired alongside.
|
|
454
|
+
const t = {
|
|
455
|
+
initial: "?" as const,
|
|
456
|
+
unknown: "unknown" as const,
|
|
457
|
+
modifiers: {},
|
|
458
|
+
subshellSemantics: "isolated" as const,
|
|
459
|
+
};
|
|
460
|
+
const plugin: Plugin = {
|
|
461
|
+
name: "reserved-name-plugin",
|
|
462
|
+
// `events` is reserved for the evaluator's speculative-entry
|
|
463
|
+
// synthesis; plugins must not register a tracker under that name.
|
|
464
|
+
trackers: { events: t as never },
|
|
465
|
+
};
|
|
466
|
+
const harness = loadHarness({ config: { plugins: [plugin] } });
|
|
467
|
+
const hit = harness.diagnostics.find(
|
|
468
|
+
(d) => d.kind === "reserved-tracker-name",
|
|
469
|
+
);
|
|
470
|
+
assert.ok(
|
|
471
|
+
hit,
|
|
472
|
+
`expected a reserved-tracker-name diagnostic; got: ${JSON.stringify(harness.diagnostics)}`,
|
|
473
|
+
);
|
|
474
|
+
assert.equal(hit.type, "error");
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it("surfaces a warning-class diagnostic when two plugins register the same predicate key", () => {
|
|
478
|
+
const plugin1: Plugin = {
|
|
479
|
+
name: "p1",
|
|
480
|
+
predicates: { branch: () => true },
|
|
481
|
+
};
|
|
482
|
+
const plugin2: Plugin = {
|
|
483
|
+
name: "p2",
|
|
484
|
+
predicates: { branch: () => false },
|
|
485
|
+
};
|
|
486
|
+
const harness = loadHarness({
|
|
487
|
+
config: { plugins: [plugin1, plugin2] },
|
|
488
|
+
});
|
|
489
|
+
const hit = harness.diagnostics.find(
|
|
490
|
+
(d) => d.kind === "predicate-collision",
|
|
491
|
+
);
|
|
492
|
+
assert.ok(
|
|
493
|
+
hit,
|
|
494
|
+
`expected a predicate-collision diagnostic; got: ${JSON.stringify(harness.diagnostics)}`,
|
|
495
|
+
);
|
|
496
|
+
assert.equal(hit.type, "warning");
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("does NOT throw on a tracker-name collision; surfaces it as a single error-class diagnostic", () => {
|
|
500
|
+
// loadHarness aggregates loader + buildConfig + resolvePlugins
|
|
501
|
+
// diagnostics and short-circuits to a no-op evaluator/dispatcher
|
|
502
|
+
// when any error-class diagnostic fires, so plugin-author tests
|
|
503
|
+
// can read the diagnostic from `harness.diagnostics` instead of
|
|
504
|
+
// catching a thrown Error. The shared merge-pipeline helper
|
|
505
|
+
// short-circuits before resolvePlugins, so the same collision
|
|
506
|
+
// should appear exactly once even though both buildConfig's
|
|
507
|
+
// detectTrackerNameCollisions and resolvePlugins independently
|
|
508
|
+
// flag the same shape.
|
|
509
|
+
const t = {
|
|
510
|
+
initial: "?" as const,
|
|
511
|
+
unknown: "unknown" as const,
|
|
512
|
+
modifiers: {},
|
|
513
|
+
subshellSemantics: "isolated" as const,
|
|
514
|
+
};
|
|
515
|
+
const harness = loadHarness({
|
|
516
|
+
config: {
|
|
517
|
+
plugins: [
|
|
518
|
+
{ name: "pa", trackers: { branch: t as never } },
|
|
519
|
+
{ name: "pb", trackers: { branch: t as never } },
|
|
520
|
+
],
|
|
521
|
+
},
|
|
522
|
+
});
|
|
523
|
+
const hits = harness.diagnostics.filter(
|
|
524
|
+
(d) => d.kind === "tracker-name-collision",
|
|
525
|
+
);
|
|
526
|
+
assert.equal(
|
|
527
|
+
hits.length,
|
|
528
|
+
1,
|
|
529
|
+
`expected a single tracker-name-collision diagnostic; got: ${JSON.stringify(harness.diagnostics)}`,
|
|
530
|
+
);
|
|
531
|
+
const [hit] = hits;
|
|
532
|
+
assert.equal(hit?.type, "error");
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("does NOT throw on a malformed user-config rule name; surfaces it as an invalid-name error-class diagnostic", () => {
|
|
536
|
+
// User-config rule name validation runs inside the shared
|
|
537
|
+
// merge-pipeline helper before `resolvePlugins`, so a malformed
|
|
538
|
+
// rule name in the harness's input config produces the same
|
|
539
|
+
// `kind: "invalid-name"` diagnostic shape as a malformed
|
|
540
|
+
// plugin-shipped rule name. Without unification the harness
|
|
541
|
+
// would throw a plain `pi-steering: rule name "..."` Error from
|
|
542
|
+
// `buildEvaluator`, contradicting the documented "does NOT throw
|
|
543
|
+
// on error-class diagnostics" contract.
|
|
544
|
+
const harness = loadHarness({
|
|
545
|
+
config: {
|
|
546
|
+
rules: [
|
|
547
|
+
{
|
|
548
|
+
name: "phony] BAD",
|
|
549
|
+
tool: "bash" as const,
|
|
550
|
+
field: "command" as const,
|
|
551
|
+
pattern: /^never$/,
|
|
552
|
+
reason: "r",
|
|
553
|
+
},
|
|
554
|
+
],
|
|
555
|
+
},
|
|
556
|
+
});
|
|
557
|
+
const hit = harness.diagnostics.find((d) => d.kind === "invalid-name");
|
|
558
|
+
assert.ok(
|
|
559
|
+
hit,
|
|
560
|
+
`expected an invalid-name diagnostic; got: ${JSON.stringify(harness.diagnostics)}`,
|
|
561
|
+
);
|
|
562
|
+
assert.equal(hit.type, "error");
|
|
563
|
+
assert.match(hit.message, /^rule name "phony\] BAD".*disallowed/);
|
|
564
|
+
assert.match(hit.message, /\(user config\)/);
|
|
565
|
+
// Harness short-circuits to a no-op evaluator/dispatcher pair
|
|
566
|
+
// the same way it does for plugin-shipped errors.
|
|
567
|
+
assert.equal(harness.resolved.rules.length, 0);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it("does NOT throw on a malformed user-config observer name; surfaces it as an invalid-name error-class diagnostic", () => {
|
|
571
|
+
// Same shape as the rule-name case but for observers — confirms
|
|
572
|
+
// the validation covers both surfaces of `validateUserConfigNames`.
|
|
573
|
+
const harness = loadHarness({
|
|
574
|
+
config: {
|
|
575
|
+
observers: [
|
|
576
|
+
{
|
|
577
|
+
name: "bad observer name",
|
|
578
|
+
watch: { toolName: "bash" as const },
|
|
579
|
+
onResult: () => {},
|
|
580
|
+
},
|
|
581
|
+
],
|
|
582
|
+
},
|
|
583
|
+
});
|
|
584
|
+
const hit = harness.diagnostics.find((d) => d.kind === "invalid-name");
|
|
585
|
+
assert.ok(
|
|
586
|
+
hit,
|
|
587
|
+
`expected an invalid-name diagnostic; got: ${JSON.stringify(harness.diagnostics)}`,
|
|
588
|
+
);
|
|
589
|
+
assert.equal(hit.type, "error");
|
|
590
|
+
assert.match(hit.message, /^observer name "bad observer name".*disallowed/);
|
|
591
|
+
assert.match(hit.message, /\(user config\)/);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it("surfaces BOTH a tracker-name-collision AND a malformed user-config rule name in one harness load", () => {
|
|
595
|
+
// Combined error: tracker-name-collision (merge-side) plus a
|
|
596
|
+
// malformed user-config rule name. Pins that user-config name
|
|
597
|
+
// validation runs unconditionally so both surface in one
|
|
598
|
+
// harness load, not on consecutive runs.
|
|
599
|
+
const t = {
|
|
600
|
+
initial: "?" as const,
|
|
601
|
+
unknown: "unknown" as const,
|
|
602
|
+
modifiers: {},
|
|
603
|
+
subshellSemantics: "isolated" as const,
|
|
604
|
+
};
|
|
605
|
+
const harness = loadHarness({
|
|
606
|
+
config: {
|
|
607
|
+
plugins: [
|
|
608
|
+
{ name: "pa", trackers: { branch: t as never } },
|
|
609
|
+
{ name: "pb", trackers: { branch: t as never } },
|
|
610
|
+
],
|
|
611
|
+
rules: [
|
|
612
|
+
{
|
|
613
|
+
name: "phony] BAD",
|
|
614
|
+
tool: "bash" as const,
|
|
615
|
+
field: "command" as const,
|
|
616
|
+
pattern: /^never$/,
|
|
617
|
+
reason: "r",
|
|
618
|
+
},
|
|
619
|
+
],
|
|
620
|
+
},
|
|
621
|
+
});
|
|
622
|
+
assert.ok(
|
|
623
|
+
harness.diagnostics.some((d) => d.kind === "tracker-name-collision"),
|
|
624
|
+
`expected a tracker-name-collision diagnostic; got: ${JSON.stringify(harness.diagnostics)}`,
|
|
625
|
+
);
|
|
626
|
+
assert.ok(
|
|
627
|
+
harness.diagnostics.some(
|
|
628
|
+
(d) =>
|
|
629
|
+
d.kind === "invalid-name" &&
|
|
630
|
+
/phony\] BAD/.test(d.message) &&
|
|
631
|
+
/\(user config\)/.test(d.message),
|
|
632
|
+
),
|
|
633
|
+
`expected an invalid-name diagnostic for the malformed user-config rule; got: ${JSON.stringify(harness.diagnostics)}`,
|
|
634
|
+
);
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
// ---------------------------------------------------------------------------
|
|
639
|
+
// mockContext
|
|
640
|
+
// ---------------------------------------------------------------------------
|
|
641
|
+
|
|
642
|
+
describe("mockContext", () => {
|
|
643
|
+
it("returns a ctx with all required PredicateContext fields populated", () => {
|
|
644
|
+
const ctx = mockContext();
|
|
645
|
+
assert.equal(typeof ctx.cwd, "string");
|
|
646
|
+
assert.equal(ctx.tool, "bash");
|
|
647
|
+
assert.deepEqual(ctx.input, { tool: "bash", command: "" });
|
|
648
|
+
assert.equal(ctx.agentLoopIndex, 0);
|
|
649
|
+
assert.equal(typeof ctx.exec, "function");
|
|
650
|
+
assert.equal(typeof ctx.appendEntry, "function");
|
|
651
|
+
assert.equal(typeof ctx.findEntries, "function");
|
|
652
|
+
// Default walkerState populates cwd + an empty env map (Tier B /
|
|
653
|
+
// D1: plugin authors reading `walkerState.env.get(...)` get a
|
|
654
|
+
// well-typed Map even when the test doesn't wire up the env
|
|
655
|
+
// tracker explicitly).
|
|
656
|
+
assert.deepEqual(ctx.walkerState, {
|
|
657
|
+
cwd: "/tmp/test",
|
|
658
|
+
env: new Map<string, string>(),
|
|
659
|
+
});
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it("applies cwd / agentLoopIndex / tool / input / walkerState overrides", () => {
|
|
663
|
+
const ctx = mockContext({
|
|
664
|
+
cwd: "/work",
|
|
665
|
+
agentLoopIndex: 7,
|
|
666
|
+
tool: "write",
|
|
667
|
+
input: { tool: "write", path: "/a.ts", content: "x" },
|
|
668
|
+
walkerState: { cwd: "/work", branch: "main" },
|
|
669
|
+
});
|
|
670
|
+
assert.equal(ctx.cwd, "/work");
|
|
671
|
+
assert.equal(ctx.agentLoopIndex, 7);
|
|
672
|
+
assert.equal(ctx.tool, "write");
|
|
673
|
+
assert.deepEqual(ctx.input, {
|
|
674
|
+
tool: "write",
|
|
675
|
+
path: "/a.ts",
|
|
676
|
+
content: "x",
|
|
677
|
+
});
|
|
678
|
+
// walkerState overrides merge OVER the defaults (shallow merge),
|
|
679
|
+
// so branch lands, cwd overrides the default, and env stays as the
|
|
680
|
+
// default empty Map — matching production evaluator shape.
|
|
681
|
+
assert.deepEqual(ctx.walkerState, {
|
|
682
|
+
cwd: "/work",
|
|
683
|
+
env: new Map(),
|
|
684
|
+
branch: "main",
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it("derives default input shape per tool", () => {
|
|
689
|
+
const w = mockContext({ tool: "write" });
|
|
690
|
+
assert.deepEqual(w.input, { tool: "write", path: "", content: "" });
|
|
691
|
+
const e = mockContext({ tool: "edit" });
|
|
692
|
+
assert.deepEqual(e.input, { tool: "edit", path: "", edits: [] });
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it("stubs exec: passes through cmd/args/opts", async () => {
|
|
696
|
+
const seen: Array<{
|
|
697
|
+
cmd: string;
|
|
698
|
+
args: readonly string[];
|
|
699
|
+
cwd?: string | undefined;
|
|
700
|
+
}> = [];
|
|
701
|
+
const ctx = mockContext({
|
|
702
|
+
exec: (cmd, args, opts) => {
|
|
703
|
+
seen.push({ cmd, args, cwd: opts?.cwd });
|
|
704
|
+
return { stdout: "ok", stderr: "", exitCode: 0 };
|
|
705
|
+
},
|
|
706
|
+
});
|
|
707
|
+
const r = await ctx.exec("git", ["status"], { cwd: "/x" });
|
|
708
|
+
assert.equal(r.stdout, "ok");
|
|
709
|
+
assert.deepEqual(seen, [{ cmd: "git", args: ["status"], cwd: "/x" }]);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it("unstubbed exec rejects with a clear error", async () => {
|
|
713
|
+
const ctx = mockContext();
|
|
714
|
+
await assert.rejects(
|
|
715
|
+
() => ctx.exec("git", ["status"]),
|
|
716
|
+
/mockContext: exec not stubbed/,
|
|
717
|
+
);
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it("findEntries filters by customType and parses timestamps to epoch-ms", () => {
|
|
721
|
+
const iso = "2026-01-02T03:04:05.000Z";
|
|
722
|
+
const ctx = mockContext({
|
|
723
|
+
entries: [
|
|
724
|
+
{
|
|
725
|
+
type: "custom",
|
|
726
|
+
customType: "a",
|
|
727
|
+
data: { v: 1 },
|
|
728
|
+
timestamp: iso,
|
|
729
|
+
},
|
|
730
|
+
{
|
|
731
|
+
type: "custom",
|
|
732
|
+
customType: "b",
|
|
733
|
+
data: { v: 2 },
|
|
734
|
+
timestamp: iso,
|
|
735
|
+
},
|
|
736
|
+
{
|
|
737
|
+
type: "custom",
|
|
738
|
+
customType: "a",
|
|
739
|
+
data: { v: 3 },
|
|
740
|
+
timestamp: "not-a-date",
|
|
741
|
+
},
|
|
742
|
+
],
|
|
743
|
+
});
|
|
744
|
+
const hits = ctx.findEntries<{ v: number }>("a");
|
|
745
|
+
assert.equal(hits.length, 2);
|
|
746
|
+
assert.deepEqual(hits[0]!.data, { v: 1 });
|
|
747
|
+
assert.equal(hits[0]!.timestamp, Date.parse(iso));
|
|
748
|
+
// Unparseable timestamp → 0 (documented fallback).
|
|
749
|
+
assert.equal(hits[1]!.timestamp, 0);
|
|
750
|
+
// No match → empty.
|
|
751
|
+
assert.deepEqual(ctx.findEntries("missing"), []);
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
it("appendEntry writes are captured (visible via getAppendedEntries)", () => {
|
|
755
|
+
// Auto-tag: object payloads get `_agentLoopIndex` merged in,
|
|
756
|
+
// bare calls wrap as `{ value: undefined, _agentLoopIndex }`.
|
|
757
|
+
// Same shape the real engine writes (production parity is the
|
|
758
|
+
// whole point of routing through createAppendEntry).
|
|
759
|
+
const ctx = mockContext();
|
|
760
|
+
ctx.appendEntry("x", { a: 1 });
|
|
761
|
+
ctx.appendEntry("y"); // no data — pi allows bare customType.
|
|
762
|
+
const captured = getAppendedEntries(ctx);
|
|
763
|
+
assert.equal(captured.length, 2);
|
|
764
|
+
assert.equal(captured[0]!.customType, "x");
|
|
765
|
+
assert.deepEqual(captured[0]!.data, { a: 1, _agentLoopIndex: 0 });
|
|
766
|
+
assert.equal(captured[1]!.customType, "y");
|
|
767
|
+
assert.deepEqual(captured[1]!.data, {
|
|
768
|
+
value: undefined,
|
|
769
|
+
_agentLoopIndex: 0,
|
|
770
|
+
});
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
it("appendEntry auto-tags object payloads with agentLoopIndex (G2)", () => {
|
|
774
|
+
const ctx = mockContext({ agentLoopIndex: 5 });
|
|
775
|
+
ctx.appendEntry("marker", { foo: 1 });
|
|
776
|
+
const entries = getAppendedEntries(ctx);
|
|
777
|
+
assert.deepEqual(entries, [
|
|
778
|
+
{ customType: "marker", data: { foo: 1, _agentLoopIndex: 5 } },
|
|
779
|
+
]);
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
it("appendEntry wraps primitive payloads as { value, _agentLoopIndex } (G2)", () => {
|
|
783
|
+
const ctx = mockContext({ agentLoopIndex: 2 });
|
|
784
|
+
ctx.appendEntry("num", 42);
|
|
785
|
+
const entries = getAppendedEntries(ctx);
|
|
786
|
+
assert.deepEqual(entries, [
|
|
787
|
+
{ customType: "num", data: { value: 42, _agentLoopIndex: 2 } },
|
|
788
|
+
]);
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
it("appendEntry wraps array payloads (F2 / G2 / G3)", () => {
|
|
792
|
+
const ctx = mockContext({ agentLoopIndex: 5 });
|
|
793
|
+
ctx.appendEntry("items", [1, 2, 3]);
|
|
794
|
+
const entries = getAppendedEntries(ctx);
|
|
795
|
+
assert.deepEqual(entries, [
|
|
796
|
+
{ customType: "items", data: { value: [1, 2, 3], _agentLoopIndex: 5 } },
|
|
797
|
+
]);
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
// ---- toolCallEvents: walkerState.events surface for tool_call-
|
|
801
|
+
// ---- scope `when.happened` and plugin predicates over synthesized entries
|
|
802
|
+
|
|
803
|
+
it("toolCallEvents default: walkerState has no `events` key when the option is omitted", () => {
|
|
804
|
+
// Matches the production shape for non-bash candidates or configs
|
|
805
|
+
// with no eligible observer — the evaluator's synthesis pass runs
|
|
806
|
+
// only for bash, and mockContext doesn't manufacture one on the
|
|
807
|
+
// caller's behalf.
|
|
808
|
+
const ctx = mockContext();
|
|
809
|
+
assert.equal(
|
|
810
|
+
(ctx.walkerState as Record<string, unknown> | undefined)?.["events"],
|
|
811
|
+
undefined,
|
|
812
|
+
);
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
it("toolCallEvents threads through to ctx.walkerState.events", () => {
|
|
816
|
+
// Surface-level: plugin authors drive `when.happened` with `in: "tool_call"`
|
|
817
|
+
// in isolation by passing `toolCallEvents`. The option merges
|
|
818
|
+
// into walkerState under the reserved `events` key, same shape
|
|
819
|
+
// the walker-level synthesis pass produces in production.
|
|
820
|
+
const events = {
|
|
821
|
+
SYNC: [{ data: {}, timestamp: 2 ** 52 + 1, speculative: true as const }],
|
|
822
|
+
};
|
|
823
|
+
const ctx = mockContext({ toolCallEvents: events });
|
|
824
|
+
const got = (ctx.walkerState as Record<string, unknown>)["events"];
|
|
825
|
+
assert.equal(got, events);
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
it("toolCallEvents overrides an `events` entry placed on walkerState directly", () => {
|
|
829
|
+
// Explicit option wins: the caller who opts into `toolCallEvents`
|
|
830
|
+
// gets the canonical shape without having to strip their own
|
|
831
|
+
// `walkerState.events` entry. Mirrors the evaluator's merge order
|
|
832
|
+
// (`{ ...trackerState, events }`).
|
|
833
|
+
const override = {
|
|
834
|
+
X: [{ data: 1, timestamp: 7, speculative: true as const }],
|
|
835
|
+
};
|
|
836
|
+
const ctx = mockContext({
|
|
837
|
+
walkerState: { cwd: "/w", events: { X: [] } },
|
|
838
|
+
toolCallEvents: override,
|
|
839
|
+
});
|
|
840
|
+
const got = (ctx.walkerState as Record<string, unknown>)["events"];
|
|
841
|
+
assert.equal(got, override);
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
it("testPredicate forwards toolCallEvents so plugin predicates over walkerState.events can be driven", async () => {
|
|
845
|
+
// End-to-end: a plugin predicate introspects walkerState.events,
|
|
846
|
+
// and testPredicate (which forwards the full options object to
|
|
847
|
+
// mockContext) lets the caller exercise both branches.
|
|
848
|
+
const fires: PredicateHandler<string> = async (event, ctx) => {
|
|
849
|
+
const events = (ctx.walkerState as Record<string, unknown> | undefined)?.[
|
|
850
|
+
"events"
|
|
851
|
+
] as Record<string, readonly unknown[]> | undefined;
|
|
852
|
+
return (events?.[event]?.length ?? 0) > 0;
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
const cold = await testPredicate(fires, "SYNC", {});
|
|
856
|
+
assert.equal(cold, false, "no toolCallEvents → predicate sees nothing");
|
|
857
|
+
|
|
858
|
+
const warm = await testPredicate(fires, "SYNC", {
|
|
859
|
+
toolCallEvents: {
|
|
860
|
+
SYNC: [
|
|
861
|
+
{ data: {}, timestamp: 2 ** 52 + 1, speculative: true as const },
|
|
862
|
+
],
|
|
863
|
+
},
|
|
864
|
+
});
|
|
865
|
+
assert.equal(warm, true, "toolCallEvents populated → predicate fires");
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
// -------------------------------------------------------------------
|
|
869
|
+
// Env surface (Tier B / PR #5 — D1)
|
|
870
|
+
// -------------------------------------------------------------------
|
|
871
|
+
|
|
872
|
+
it("default walkerState carries an empty env Map", () => {
|
|
873
|
+
const ctx = mockContext();
|
|
874
|
+
const env = ctx.walkerState?.["env"];
|
|
875
|
+
assert.ok(env instanceof Map, "walkerState.env is a Map");
|
|
876
|
+
assert.equal((env as Map<string, string>).size, 0);
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
it("walkerState override SHALLOW-MERGES with defaults so env stays a Map (M8)", () => {
|
|
880
|
+
// Correctness fix M8: `mockContext({ walkerState: { cwd: "/x" } })`
|
|
881
|
+
// previously replaced the entire default, dropping `env: new Map()`
|
|
882
|
+
// and crashing any predicate that did `ctx.walkerState.env.get(...)`.
|
|
883
|
+
// The merge now preserves defaults for fields the override omits.
|
|
884
|
+
const ctx = mockContext({ walkerState: { cwd: "/x" } });
|
|
885
|
+
assert.equal(ctx.walkerState?.["cwd"], "/x", "override cwd lands");
|
|
886
|
+
const env = ctx.walkerState?.["env"];
|
|
887
|
+
assert.ok(
|
|
888
|
+
env instanceof Map,
|
|
889
|
+
"walkerState.env is still a Map — default not dropped by partial override",
|
|
890
|
+
);
|
|
891
|
+
assert.equal((env as Map<string, string>).size, 0);
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
it("walkerState.env override threads through to ctx", () => {
|
|
895
|
+
const ctx = mockContext({
|
|
896
|
+
walkerState: {
|
|
897
|
+
cwd: "/ws/pkg",
|
|
898
|
+
env: new Map([["WS", "/ws"]]),
|
|
899
|
+
},
|
|
900
|
+
});
|
|
901
|
+
const env = ctx.walkerState?.["env"] as ReadonlyMap<string, string>;
|
|
902
|
+
assert.equal(env.get("WS"), "/ws");
|
|
903
|
+
assert.equal(ctx.walkerState?.["cwd"], "/ws/pkg");
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
it("walkerState.cwd === 'unknown' is a legal input (exercises onUnknown branch)", async () => {
|
|
907
|
+
// Spec requirement: mockContext should accept walkerState.cwd
|
|
908
|
+
// at the "unknown" sentinel so tests can exercise the
|
|
909
|
+
// fail-closed `onUnknown: 'block'` path without wiring up a
|
|
910
|
+
// full walker.
|
|
911
|
+
const ctx = mockContext({
|
|
912
|
+
walkerState: { cwd: "unknown", env: new Map() },
|
|
913
|
+
});
|
|
914
|
+
assert.equal(ctx.walkerState?.["cwd"], "unknown");
|
|
915
|
+
|
|
916
|
+
// Sanity: a plugin predicate reading the sentinel can discriminate.
|
|
917
|
+
const onUnknownBlock = async (_args: unknown, c: typeof ctx) =>
|
|
918
|
+
c.walkerState?.["cwd"] === "unknown";
|
|
919
|
+
assert.equal(await onUnknownBlock(null, ctx), true);
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
it("env is consumable by a ReasonFn-style predicate via testPredicate", async () => {
|
|
923
|
+
// End-to-end: a plugin reads walkerState.env.get('NAME') and
|
|
924
|
+
// fires the rule when the var resolves to a specific value.
|
|
925
|
+
const envGuard: PredicateHandler<string> = async (name, ctx) => {
|
|
926
|
+
const env = ctx.walkerState?.["env"] as
|
|
927
|
+
| ReadonlyMap<string, string>
|
|
928
|
+
| undefined;
|
|
929
|
+
return env?.get(name) === "/workspace";
|
|
930
|
+
};
|
|
931
|
+
const hit = await testPredicate(envGuard, "WS", {
|
|
932
|
+
walkerState: {
|
|
933
|
+
cwd: "/start",
|
|
934
|
+
env: new Map([["WS", "/workspace"]]),
|
|
935
|
+
},
|
|
936
|
+
});
|
|
937
|
+
assert.equal(hit, true);
|
|
938
|
+
|
|
939
|
+
const miss = await testPredicate(envGuard, "WS", {
|
|
940
|
+
walkerState: { cwd: "/start", env: new Map() },
|
|
941
|
+
});
|
|
942
|
+
assert.equal(miss, false);
|
|
943
|
+
});
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
// ---------------------------------------------------------------------------
|
|
947
|
+
// mockObserverContext
|
|
948
|
+
// ---------------------------------------------------------------------------
|
|
949
|
+
|
|
950
|
+
describe("mockObserverContext", () => {
|
|
951
|
+
it("returns a ctx with all required ObserverContext fields populated", () => {
|
|
952
|
+
const ctx = mockObserverContext();
|
|
953
|
+
assert.equal(ctx.cwd, "/tmp/test");
|
|
954
|
+
assert.equal(ctx.agentLoopIndex, 0);
|
|
955
|
+
assert.equal(typeof ctx.appendEntry, "function");
|
|
956
|
+
assert.equal(typeof ctx.findEntries, "function");
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
it("applies cwd / agentLoopIndex / entries overrides", () => {
|
|
960
|
+
const iso = "2026-03-04T05:06:07.000Z";
|
|
961
|
+
const ctx = mockObserverContext({
|
|
962
|
+
cwd: "/work",
|
|
963
|
+
agentLoopIndex: 3,
|
|
964
|
+
entries: [
|
|
965
|
+
{
|
|
966
|
+
type: "custom",
|
|
967
|
+
customType: "seen",
|
|
968
|
+
data: { n: 42 },
|
|
969
|
+
timestamp: iso,
|
|
970
|
+
},
|
|
971
|
+
],
|
|
972
|
+
});
|
|
973
|
+
assert.equal(ctx.cwd, "/work");
|
|
974
|
+
assert.equal(ctx.agentLoopIndex, 3);
|
|
975
|
+
const hits = ctx.findEntries<{ n: number }>("seen");
|
|
976
|
+
assert.equal(hits.length, 1);
|
|
977
|
+
assert.deepEqual(hits[0]!.data, { n: 42 });
|
|
978
|
+
assert.equal(hits[0]!.timestamp, Date.parse(iso));
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
it("appendEntry captures are independent per context", () => {
|
|
982
|
+
const a = mockObserverContext({ agentLoopIndex: 0 });
|
|
983
|
+
const b = mockObserverContext({ agentLoopIndex: 0 });
|
|
984
|
+
a.appendEntry("one", { x: 1 });
|
|
985
|
+
b.appendEntry("two", { y: 2 });
|
|
986
|
+
assert.deepEqual(getAppendedEntries(a), [
|
|
987
|
+
{ customType: "one", data: { x: 1, _agentLoopIndex: 0 } },
|
|
988
|
+
]);
|
|
989
|
+
assert.deepEqual(getAppendedEntries(b), [
|
|
990
|
+
{ customType: "two", data: { y: 2, _agentLoopIndex: 0 } },
|
|
991
|
+
]);
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
it("observer appendEntry auto-tags with agentLoopIndex (G2)", () => {
|
|
995
|
+
const ctx = mockObserverContext({ agentLoopIndex: 9 });
|
|
996
|
+
ctx.appendEntry("seen", { foo: 1 });
|
|
997
|
+
assert.deepEqual(getAppendedEntries(ctx), [
|
|
998
|
+
{ customType: "seen", data: { foo: 1, _agentLoopIndex: 9 } },
|
|
999
|
+
]);
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
it("observer appendEntry wraps primitive payloads (G2)", () => {
|
|
1003
|
+
const ctx = mockObserverContext({ agentLoopIndex: 3 });
|
|
1004
|
+
ctx.appendEntry("n", 7);
|
|
1005
|
+
assert.deepEqual(getAppendedEntries(ctx), [
|
|
1006
|
+
{ customType: "n", data: { value: 7, _agentLoopIndex: 3 } },
|
|
1007
|
+
]);
|
|
1008
|
+
});
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
// ---------------------------------------------------------------------------
|
|
1012
|
+
// getAppendedEntries
|
|
1013
|
+
// ---------------------------------------------------------------------------
|
|
1014
|
+
|
|
1015
|
+
describe("getAppendedEntries", () => {
|
|
1016
|
+
it("returns empty for a freshly-built mockContext (nothing appended yet)", () => {
|
|
1017
|
+
const ctx = mockContext();
|
|
1018
|
+
assert.deepEqual(getAppendedEntries(ctx), []);
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
it("returns writes after ctx.appendEntry calls", () => {
|
|
1022
|
+
const ctx = mockContext();
|
|
1023
|
+
ctx.appendEntry("x", { a: 1 });
|
|
1024
|
+
const captured = getAppendedEntries(ctx);
|
|
1025
|
+
assert.deepEqual(captured, [
|
|
1026
|
+
{ customType: "x", data: { a: 1, _agentLoopIndex: 0 } },
|
|
1027
|
+
]);
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
it("returns empty for a non-mock context (safe lookup, no throw)", () => {
|
|
1031
|
+
// Craft a minimal non-mock PredicateContext. Shape-only — we never
|
|
1032
|
+
// call its methods.
|
|
1033
|
+
const adhoc: PredicateContext = {
|
|
1034
|
+
cwd: "/",
|
|
1035
|
+
tool: "bash",
|
|
1036
|
+
input: { tool: "bash", command: "" },
|
|
1037
|
+
agentLoopIndex: 0,
|
|
1038
|
+
exec: () => Promise.resolve({ stdout: "", stderr: "", exitCode: 0 }),
|
|
1039
|
+
appendEntry: () => {},
|
|
1040
|
+
findEntries: () => [],
|
|
1041
|
+
};
|
|
1042
|
+
assert.deepEqual(getAppendedEntries(adhoc), []);
|
|
1043
|
+
// Same for an ad-hoc ObserverContext.
|
|
1044
|
+
const adhocObs: ObserverContext = {
|
|
1045
|
+
cwd: "/",
|
|
1046
|
+
agentLoopIndex: 0,
|
|
1047
|
+
appendEntry: () => {},
|
|
1048
|
+
findEntries: () => [],
|
|
1049
|
+
};
|
|
1050
|
+
assert.deepEqual(getAppendedEntries(adhocObs), []);
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
it("returned snapshot is decoupled from later writes", () => {
|
|
1054
|
+
const ctx = mockContext();
|
|
1055
|
+
ctx.appendEntry("first");
|
|
1056
|
+
const snap = getAppendedEntries(ctx);
|
|
1057
|
+
ctx.appendEntry("second");
|
|
1058
|
+
// Snapshot captured only the first entry.
|
|
1059
|
+
assert.equal(snap.length, 1);
|
|
1060
|
+
// Re-reading picks up both.
|
|
1061
|
+
assert.equal(getAppendedEntries(ctx).length, 2);
|
|
1062
|
+
});
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
// ===========================================================================
|
|
1066
|
+
// Phase 5b — Convenience wrappers
|
|
1067
|
+
// ===========================================================================
|
|
1068
|
+
|
|
1069
|
+
describe("testPredicate", () => {
|
|
1070
|
+
it("returns the predicate's boolean verdict", async () => {
|
|
1071
|
+
const alwaysTrue: PredicateHandler<unknown> = async () => true;
|
|
1072
|
+
const alwaysFalse: PredicateHandler<unknown> = async () => false;
|
|
1073
|
+
assert.equal(await testPredicate(alwaysTrue, null), true);
|
|
1074
|
+
assert.equal(await testPredicate(alwaysFalse, null), false);
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
it("threads args + ctx (walkerState + exec stub) to the predicate", async () => {
|
|
1078
|
+
const branchEq: PredicateHandler<string> = async (arg, ctx) => {
|
|
1079
|
+
return ctx.walkerState?.["branch"] === arg;
|
|
1080
|
+
};
|
|
1081
|
+
const fires = await testPredicate(branchEq, "main", {
|
|
1082
|
+
walkerState: { branch: "main" },
|
|
1083
|
+
});
|
|
1084
|
+
assert.equal(fires, true);
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
it("predicates can call stubbed exec via ctx", async () => {
|
|
1088
|
+
const readsExec: PredicateHandler<unknown> = async (_, ctx) => {
|
|
1089
|
+
const r = await ctx.exec("echo", ["hi"]);
|
|
1090
|
+
return r.exitCode === 0;
|
|
1091
|
+
};
|
|
1092
|
+
const fires = await testPredicate(readsExec, null, {
|
|
1093
|
+
exec: () => ({ stdout: "hi", stderr: "", exitCode: 0 }),
|
|
1094
|
+
});
|
|
1095
|
+
assert.equal(fires, true);
|
|
1096
|
+
});
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
describe("testObserver", () => {
|
|
1100
|
+
it("fires onResult when watch matches (or watch is absent)", async () => {
|
|
1101
|
+
const obs: Observer = {
|
|
1102
|
+
name: "all-events",
|
|
1103
|
+
onResult: (_evt, ctx) => {
|
|
1104
|
+
ctx.appendEntry("seen");
|
|
1105
|
+
},
|
|
1106
|
+
};
|
|
1107
|
+
const { entries, watchMatched } = await testObserver(obs, {
|
|
1108
|
+
toolName: "bash",
|
|
1109
|
+
input: { command: "ls" },
|
|
1110
|
+
output: {},
|
|
1111
|
+
exitCode: 0,
|
|
1112
|
+
});
|
|
1113
|
+
assert.equal(watchMatched, true);
|
|
1114
|
+
assert.equal(entries.length, 1);
|
|
1115
|
+
assert.equal(entries[0]?.customType, "seen");
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
it("does NOT fire onResult when watch filter rejects", async () => {
|
|
1119
|
+
const obs: Observer = {
|
|
1120
|
+
name: "bash-only",
|
|
1121
|
+
watch: { toolName: "bash" },
|
|
1122
|
+
onResult: (_evt, ctx) => {
|
|
1123
|
+
ctx.appendEntry("seen");
|
|
1124
|
+
},
|
|
1125
|
+
};
|
|
1126
|
+
const { entries, watchMatched } = await testObserver(obs, {
|
|
1127
|
+
toolName: "read",
|
|
1128
|
+
input: {},
|
|
1129
|
+
output: {},
|
|
1130
|
+
});
|
|
1131
|
+
assert.equal(watchMatched, false);
|
|
1132
|
+
assert.equal(entries.length, 0);
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
it("watch.inputMatches with absent key is fail-closed", async () => {
|
|
1136
|
+
const obs: Observer = {
|
|
1137
|
+
name: "cmd-match",
|
|
1138
|
+
watch: { inputMatches: { command: /^git/ } },
|
|
1139
|
+
onResult: () => {},
|
|
1140
|
+
};
|
|
1141
|
+
const { watchMatched } = await testObserver(obs, {
|
|
1142
|
+
toolName: "read",
|
|
1143
|
+
input: {}, // no `command` field
|
|
1144
|
+
output: {},
|
|
1145
|
+
});
|
|
1146
|
+
assert.equal(watchMatched, false);
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
it("watch.exitCode: success / failure / numeric / any", async () => {
|
|
1150
|
+
const mk = (code: number | "success" | "failure" | "any") => ({
|
|
1151
|
+
name: "e" + String(code),
|
|
1152
|
+
watch: { exitCode: code },
|
|
1153
|
+
onResult: () => {},
|
|
1154
|
+
});
|
|
1155
|
+
const ok = { toolName: "bash", input: {}, output: {}, exitCode: 0 };
|
|
1156
|
+
const fail = {
|
|
1157
|
+
toolName: "bash",
|
|
1158
|
+
input: {},
|
|
1159
|
+
output: {},
|
|
1160
|
+
exitCode: 2,
|
|
1161
|
+
};
|
|
1162
|
+
assert.equal((await testObserver(mk("success"), ok)).watchMatched, true);
|
|
1163
|
+
assert.equal((await testObserver(mk("success"), fail)).watchMatched, false);
|
|
1164
|
+
assert.equal((await testObserver(mk("failure"), fail)).watchMatched, true);
|
|
1165
|
+
assert.equal((await testObserver(mk(2), fail)).watchMatched, true);
|
|
1166
|
+
assert.equal((await testObserver(mk("any"), fail)).watchMatched, true);
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
it("warns when options.exec is set (observers don't see exec)", async () => {
|
|
1170
|
+
const obs: Observer = { name: "n", onResult: () => {} };
|
|
1171
|
+
const warnings: unknown[][] = [];
|
|
1172
|
+
const orig = console.warn;
|
|
1173
|
+
console.warn = (...args: unknown[]) => warnings.push(args);
|
|
1174
|
+
try {
|
|
1175
|
+
await testObserver(
|
|
1176
|
+
obs,
|
|
1177
|
+
{ toolName: "bash", input: {}, output: {} },
|
|
1178
|
+
{ exec: () => ({ stdout: "", stderr: "", exitCode: 0 }) },
|
|
1179
|
+
);
|
|
1180
|
+
} finally {
|
|
1181
|
+
console.warn = orig;
|
|
1182
|
+
}
|
|
1183
|
+
assert.equal(warnings.length, 1);
|
|
1184
|
+
assert.match(String(warnings[0]?.[0] ?? ""), /exec option ignored/);
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
it("watch.inputMatches.command is wrapper-aware on bash events (ADR §12)", async () => {
|
|
1188
|
+
// Regression test for the former testing-side reimplementation
|
|
1189
|
+
// of matchesWatch: it did a raw-string match only, so a watch of
|
|
1190
|
+
// `command: /^git commit/` silently missed `sh -c 'git commit ...'`
|
|
1191
|
+
// while production correctly fired on it. Now `testObserver`
|
|
1192
|
+
// shares `matchesWatch` with the dispatcher so both paths agree.
|
|
1193
|
+
const fired: string[] = [];
|
|
1194
|
+
const obs: Observer = {
|
|
1195
|
+
name: "commit-watcher",
|
|
1196
|
+
watch: {
|
|
1197
|
+
toolName: "bash",
|
|
1198
|
+
inputMatches: { command: /^git commit/ },
|
|
1199
|
+
},
|
|
1200
|
+
onResult: (evt) => {
|
|
1201
|
+
fired.push(String((evt.input as { command?: unknown }).command));
|
|
1202
|
+
},
|
|
1203
|
+
};
|
|
1204
|
+
|
|
1205
|
+
// Raw match still works (outer command IS `git commit ...`).
|
|
1206
|
+
const raw = await testObserver(obs, {
|
|
1207
|
+
toolName: "bash",
|
|
1208
|
+
input: { command: 'git commit -m "x"' },
|
|
1209
|
+
output: {},
|
|
1210
|
+
exitCode: 0,
|
|
1211
|
+
});
|
|
1212
|
+
assert.equal(raw.watchMatched, true);
|
|
1213
|
+
|
|
1214
|
+
// Wrapper-aware: outer is `sh -c '...'`, the inner extracted ref
|
|
1215
|
+
// is `git commit -m "x"` — the pattern matches the inner ref, so
|
|
1216
|
+
// the observer fires. This previously failed silently under the
|
|
1217
|
+
// reimplemented filter.
|
|
1218
|
+
const wrapped = await testObserver(obs, {
|
|
1219
|
+
toolName: "bash",
|
|
1220
|
+
input: { command: `sh -c 'git commit -m "x"'` },
|
|
1221
|
+
output: {},
|
|
1222
|
+
exitCode: 0,
|
|
1223
|
+
});
|
|
1224
|
+
assert.equal(wrapped.watchMatched, true);
|
|
1225
|
+
assert.equal(fired.length, 2);
|
|
1226
|
+
|
|
1227
|
+
// Negative control: an unrelated outer command doesn't match,
|
|
1228
|
+
// even though it's a bash event.
|
|
1229
|
+
const miss = await testObserver(obs, {
|
|
1230
|
+
toolName: "bash",
|
|
1231
|
+
input: { command: "ls -la" },
|
|
1232
|
+
output: {},
|
|
1233
|
+
exitCode: 0,
|
|
1234
|
+
});
|
|
1235
|
+
assert.equal(miss.watchMatched, false);
|
|
1236
|
+
});
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
describe("expectBlocks / expectAllows / expectRuleFires", () => {
|
|
1240
|
+
const blockAllRule: Rule = {
|
|
1241
|
+
name: "block-all",
|
|
1242
|
+
tool: "bash",
|
|
1243
|
+
field: "command",
|
|
1244
|
+
pattern: /.*/,
|
|
1245
|
+
reason: "test block",
|
|
1246
|
+
noOverride: true,
|
|
1247
|
+
};
|
|
1248
|
+
|
|
1249
|
+
it("expectBlocks returns result on block", async () => {
|
|
1250
|
+
const harness = loadHarness({ config: { rules: [blockAllRule] } });
|
|
1251
|
+
const result = await expectBlocks(harness, { command: "anything" });
|
|
1252
|
+
assert.ok(result);
|
|
1253
|
+
assert.equal(result.block, true);
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
it("expectBlocks throws AssertionError on allow", async () => {
|
|
1257
|
+
const harness = loadHarness({ config: { rules: [] } });
|
|
1258
|
+
await assert.rejects(
|
|
1259
|
+
() => expectBlocks(harness, { command: "anything" }),
|
|
1260
|
+
/expected block, got allow/,
|
|
1261
|
+
);
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
it("expectBlocks { rule } asserts rule name match", async () => {
|
|
1265
|
+
const harness = loadHarness({ config: { rules: [blockAllRule] } });
|
|
1266
|
+
await expectBlocks(harness, { command: "x" }, { rule: "block-all" });
|
|
1267
|
+
await assert.rejects(
|
|
1268
|
+
() => expectBlocks(harness, { command: "x" }, { rule: "other-rule" }),
|
|
1269
|
+
/expected rule "other-rule" to fire/,
|
|
1270
|
+
);
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
it("expectBlocks { reason: RegExp } asserts reason match", async () => {
|
|
1274
|
+
const harness = loadHarness({ config: { rules: [blockAllRule] } });
|
|
1275
|
+
await expectBlocks(harness, { command: "x" }, { reason: /test block/ });
|
|
1276
|
+
await assert.rejects(
|
|
1277
|
+
() => expectBlocks(harness, { command: "x" }, { reason: /wrong reason/ }),
|
|
1278
|
+
/reason did not match/,
|
|
1279
|
+
);
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
it("expectAllows succeeds on no block", async () => {
|
|
1283
|
+
const harness = loadHarness({ config: { rules: [] } });
|
|
1284
|
+
await expectAllows(harness, { command: "anything" });
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
it("expectAllows throws on block", async () => {
|
|
1288
|
+
const harness = loadHarness({ config: { rules: [blockAllRule] } });
|
|
1289
|
+
await assert.rejects(
|
|
1290
|
+
() => expectAllows(harness, { command: "x" }),
|
|
1291
|
+
/expected allow, got block/,
|
|
1292
|
+
);
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
it("expectRuleFires delegates to expectBlocks { rule }", async () => {
|
|
1296
|
+
const harness = loadHarness({ config: { rules: [blockAllRule] } });
|
|
1297
|
+
await expectRuleFires(harness, { command: "x" }, "block-all");
|
|
1298
|
+
await assert.rejects(
|
|
1299
|
+
() => expectRuleFires(harness, { command: "x" }, "nope"),
|
|
1300
|
+
/expected rule "nope" to fire/,
|
|
1301
|
+
);
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
it("accepts a WriteShorthand", async () => {
|
|
1305
|
+
const writeBlock: Rule = {
|
|
1306
|
+
name: "write-block",
|
|
1307
|
+
tool: "write",
|
|
1308
|
+
field: "content",
|
|
1309
|
+
pattern: /forbidden/,
|
|
1310
|
+
reason: "no",
|
|
1311
|
+
noOverride: true,
|
|
1312
|
+
};
|
|
1313
|
+
const harness = loadHarness({ config: { rules: [writeBlock] } });
|
|
1314
|
+
await expectBlocks(harness, {
|
|
1315
|
+
write: { path: "f.txt", content: "forbidden content" },
|
|
1316
|
+
});
|
|
1317
|
+
await expectAllows(harness, {
|
|
1318
|
+
write: { path: "f.txt", content: "ok content" },
|
|
1319
|
+
});
|
|
1320
|
+
});
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
describe("runMatrix / formatMatrix", () => {
|
|
1324
|
+
const blockAllRule: Rule = {
|
|
1325
|
+
name: "block-all",
|
|
1326
|
+
tool: "bash",
|
|
1327
|
+
field: "command",
|
|
1328
|
+
pattern: /.*/,
|
|
1329
|
+
reason: "test block",
|
|
1330
|
+
noOverride: true,
|
|
1331
|
+
};
|
|
1332
|
+
|
|
1333
|
+
it("tallies pass / fail counts", async () => {
|
|
1334
|
+
const harness = loadHarness({ config: { rules: [blockAllRule] } });
|
|
1335
|
+
const result = await runMatrix(harness, [
|
|
1336
|
+
{ name: "a", event: { command: "x" }, expect: "block" },
|
|
1337
|
+
{
|
|
1338
|
+
name: "b",
|
|
1339
|
+
event: { command: "y" },
|
|
1340
|
+
expect: { block: true, rule: "block-all" },
|
|
1341
|
+
},
|
|
1342
|
+
{ name: "c", event: { command: "z" }, expect: "allow" }, // will fail
|
|
1343
|
+
]);
|
|
1344
|
+
assert.equal(result.total, 3);
|
|
1345
|
+
assert.equal(result.passed, 2);
|
|
1346
|
+
assert.equal(result.failed, 1);
|
|
1347
|
+
assert.equal(result.cases[0]?.passed, true);
|
|
1348
|
+
assert.equal(result.cases[1]?.passed, true);
|
|
1349
|
+
assert.equal(result.cases[2]?.passed, false);
|
|
1350
|
+
assert.match(
|
|
1351
|
+
result.cases[2]?.errorMessage ?? "",
|
|
1352
|
+
/expected allow.*got block/,
|
|
1353
|
+
);
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
it("block:{rule} expectation catches wrong-rule fires", async () => {
|
|
1357
|
+
const r1: Rule = {
|
|
1358
|
+
name: "rule-one",
|
|
1359
|
+
tool: "bash",
|
|
1360
|
+
field: "command",
|
|
1361
|
+
pattern: /^x/,
|
|
1362
|
+
reason: "r1",
|
|
1363
|
+
noOverride: true,
|
|
1364
|
+
};
|
|
1365
|
+
const r2: Rule = {
|
|
1366
|
+
name: "rule-two",
|
|
1367
|
+
tool: "bash",
|
|
1368
|
+
field: "command",
|
|
1369
|
+
pattern: /^y/,
|
|
1370
|
+
reason: "r2",
|
|
1371
|
+
noOverride: true,
|
|
1372
|
+
};
|
|
1373
|
+
const harness = loadHarness({ config: { rules: [r1, r2] } });
|
|
1374
|
+
const result = await runMatrix(harness, [
|
|
1375
|
+
{
|
|
1376
|
+
name: "wrong-rule",
|
|
1377
|
+
event: { command: "x" },
|
|
1378
|
+
expect: { block: true, rule: "rule-two" }, // r1 fires, not r2
|
|
1379
|
+
},
|
|
1380
|
+
]);
|
|
1381
|
+
assert.equal(result.passed, 0);
|
|
1382
|
+
assert.equal(result.failed, 1);
|
|
1383
|
+
assert.match(
|
|
1384
|
+
result.cases[0]?.errorMessage ?? "",
|
|
1385
|
+
/expected rule "rule-two"; got "rule-one"/,
|
|
1386
|
+
);
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
it("never throws — failures appear in the result", async () => {
|
|
1390
|
+
const harness = loadHarness({ config: { rules: [] } });
|
|
1391
|
+
// All cases will fail.
|
|
1392
|
+
const result = await runMatrix(harness, [
|
|
1393
|
+
{ name: "f1", event: { command: "x" }, expect: "block" },
|
|
1394
|
+
{ name: "f2", event: { command: "y" }, expect: "block" },
|
|
1395
|
+
]);
|
|
1396
|
+
assert.equal(result.passed, 0);
|
|
1397
|
+
assert.equal(result.failed, 2);
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1400
|
+
it("formatMatrix renders a readable report", async () => {
|
|
1401
|
+
const harness = loadHarness({ config: { rules: [blockAllRule] } });
|
|
1402
|
+
const result = await runMatrix(harness, [
|
|
1403
|
+
{ name: "case-a", event: { command: "x" }, expect: "block" },
|
|
1404
|
+
{ name: "case-b", event: { command: "y" }, expect: "allow" },
|
|
1405
|
+
]);
|
|
1406
|
+
const report = formatMatrix(result);
|
|
1407
|
+
assert.match(report, /MATRIX — 2 cases\. 1 pass, 1 fail/);
|
|
1408
|
+
assert.match(report, /\[case-a\].*expect:block.*actual:BLOCK/);
|
|
1409
|
+
assert.match(report, /\[case-b\].*expect:allow.*actual:BLOCK.*FAIL/);
|
|
1410
|
+
assert.match(report, /PASS: 1\/2/);
|
|
1411
|
+
});
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
// ===========================================================================
|
|
1415
|
+
// createRecordingHost + mockExtensionContext
|
|
1416
|
+
// ===========================================================================
|
|
1417
|
+
|
|
1418
|
+
describe("createRecordingHost", () => {
|
|
1419
|
+
it("returns a host with empty entries / execCalls / appendedEntries", () => {
|
|
1420
|
+
const host = createRecordingHost();
|
|
1421
|
+
assert.deepEqual(host.entries, []);
|
|
1422
|
+
assert.deepEqual(host.execCalls, []);
|
|
1423
|
+
assert.deepEqual(host.appendedEntries, []);
|
|
1424
|
+
assert.equal(typeof host.exec, "function");
|
|
1425
|
+
assert.equal(typeof host.appendEntry, "function");
|
|
1426
|
+
});
|
|
1427
|
+
|
|
1428
|
+
it("appendEntry records both raw (appendedEntries) and session-shape (entries) logs", () => {
|
|
1429
|
+
const host = createRecordingHost();
|
|
1430
|
+
host.appendEntry("marker", { foo: 1 });
|
|
1431
|
+
host.appendEntry("marker-bare"); // bare call — pi allows no data.
|
|
1432
|
+
assert.equal(host.appendedEntries.length, 2);
|
|
1433
|
+
assert.deepEqual(host.appendedEntries[0], {
|
|
1434
|
+
type: "marker",
|
|
1435
|
+
data: { foo: 1 },
|
|
1436
|
+
});
|
|
1437
|
+
assert.deepEqual(host.appendedEntries[1], {
|
|
1438
|
+
type: "marker-bare",
|
|
1439
|
+
data: undefined,
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
assert.equal(host.entries.length, 2);
|
|
1443
|
+
// Session-shape entries mirror what pi's sessionManager emits:
|
|
1444
|
+
// type, customType, data, timestamp, id, parentId.
|
|
1445
|
+
assert.equal(host.entries[0]?.type, "custom");
|
|
1446
|
+
assert.equal(host.entries[0]?.customType, "marker");
|
|
1447
|
+
assert.deepEqual(host.entries[0]?.data, { foo: 1 });
|
|
1448
|
+
assert.equal(typeof host.entries[0]?.timestamp, "string");
|
|
1449
|
+
assert.match(
|
|
1450
|
+
host.entries[0]?.timestamp ?? "",
|
|
1451
|
+
/^2026-01-01T00:00:/, // monotonic ISO starting at the 2026-01-01 epoch.
|
|
1452
|
+
);
|
|
1453
|
+
assert.equal(host.entries[0]?.parentId, null);
|
|
1454
|
+
// Timestamps strictly increase so chronological asserts are stable.
|
|
1455
|
+
assert.ok(
|
|
1456
|
+
(host.entries[0]?.timestamp ?? "") < (host.entries[1]?.timestamp ?? ""),
|
|
1457
|
+
"entry timestamps should be monotonically increasing",
|
|
1458
|
+
);
|
|
1459
|
+
});
|
|
1460
|
+
|
|
1461
|
+
it("exec defaults to empty-success, records cmd/args/cwd per call", async () => {
|
|
1462
|
+
const host = createRecordingHost();
|
|
1463
|
+
const r = await host.exec("git", ["status"], { cwd: "/work" });
|
|
1464
|
+
assert.deepEqual(r, {
|
|
1465
|
+
stdout: "",
|
|
1466
|
+
stderr: "",
|
|
1467
|
+
code: 0,
|
|
1468
|
+
killed: false,
|
|
1469
|
+
});
|
|
1470
|
+
assert.equal(host.execCalls.length, 1);
|
|
1471
|
+
assert.deepEqual(host.execCalls[0], {
|
|
1472
|
+
cmd: "git",
|
|
1473
|
+
args: ["status"],
|
|
1474
|
+
cwd: "/work",
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1477
|
+
// A call without explicit cwd falls back to "/".
|
|
1478
|
+
await host.exec("pwd", []);
|
|
1479
|
+
assert.equal(host.execCalls[1]?.cwd, "/");
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1482
|
+
it("exec override is threaded through, default still records the call", async () => {
|
|
1483
|
+
const host = createRecordingHost({
|
|
1484
|
+
exec: async (cmd, _args, cwd) => ({
|
|
1485
|
+
stdout: `ran:${cmd}@${cwd}`,
|
|
1486
|
+
stderr: "",
|
|
1487
|
+
code: 0,
|
|
1488
|
+
killed: false,
|
|
1489
|
+
}),
|
|
1490
|
+
});
|
|
1491
|
+
const r = await host.exec("echo", ["hi"], { cwd: "/x" });
|
|
1492
|
+
assert.equal(r.stdout, "ran:echo@/x");
|
|
1493
|
+
assert.equal(host.execCalls.length, 1);
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
it("defensively copies args so later mutation doesn't corrupt the record", async () => {
|
|
1497
|
+
const host = createRecordingHost();
|
|
1498
|
+
const args = ["status"];
|
|
1499
|
+
await host.exec("git", args, { cwd: "/x" });
|
|
1500
|
+
args.push("--mutated");
|
|
1501
|
+
assert.deepEqual(host.execCalls[0]?.args, ["status"]);
|
|
1502
|
+
});
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
describe("mockExtensionContext", () => {
|
|
1506
|
+
it("exposes cwd + sessionManager.getEntries", () => {
|
|
1507
|
+
const entries = [
|
|
1508
|
+
{
|
|
1509
|
+
type: "custom" as const,
|
|
1510
|
+
customType: "seen",
|
|
1511
|
+
data: { n: 1 },
|
|
1512
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
1513
|
+
id: "e1",
|
|
1514
|
+
parentId: null,
|
|
1515
|
+
},
|
|
1516
|
+
];
|
|
1517
|
+
const ctx = mockExtensionContext("/repo", entries);
|
|
1518
|
+
assert.equal(ctx.cwd, "/repo");
|
|
1519
|
+
assert.deepEqual(ctx.sessionManager.getEntries(), entries);
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
it("defaults entries to an empty array when omitted", () => {
|
|
1523
|
+
const ctx = mockExtensionContext("/repo");
|
|
1524
|
+
assert.deepEqual(ctx.sessionManager.getEntries(), []);
|
|
1525
|
+
});
|
|
1526
|
+
|
|
1527
|
+
it("round-trips: host.appendEntry writes visible via ctx.sessionManager.getEntries", () => {
|
|
1528
|
+
// The core contract: feeding `host.entries` into the ctx lets
|
|
1529
|
+
// the engine's writes flow back into its subsequent reads.
|
|
1530
|
+
const host = createRecordingHost();
|
|
1531
|
+
const ctx = mockExtensionContext("/repo", host.entries);
|
|
1532
|
+
|
|
1533
|
+
assert.deepEqual(ctx.sessionManager.getEntries(), []);
|
|
1534
|
+
host.appendEntry("mark", { a: 1 });
|
|
1535
|
+
const read = ctx.sessionManager.getEntries();
|
|
1536
|
+
assert.equal(read.length, 1);
|
|
1537
|
+
const first = read[0];
|
|
1538
|
+
assert.ok(first && first.type === "custom");
|
|
1539
|
+
assert.equal(first.customType, "mark");
|
|
1540
|
+
assert.deepEqual(first.data, { a: 1 });
|
|
1541
|
+
|
|
1542
|
+
host.appendEntry("mark", { a: 2 });
|
|
1543
|
+
assert.equal(ctx.sessionManager.getEntries().length, 2);
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
it("drives harness.evaluate + harness.dispatch end-to-end with a shared entries store", async () => {
|
|
1547
|
+
// Demonstrates the intended plugin-author usage pattern:
|
|
1548
|
+
// harness + recording host + shared ctx lets a test assert on
|
|
1549
|
+
// the cross-hook observer → evaluator handoff without any
|
|
1550
|
+
// `as any` escape hatches.
|
|
1551
|
+
const rule: Rule = {
|
|
1552
|
+
name: "needs-mark",
|
|
1553
|
+
tool: "bash",
|
|
1554
|
+
field: "command",
|
|
1555
|
+
pattern: /^git pu/,
|
|
1556
|
+
reason: "needs mark",
|
|
1557
|
+
noOverride: true,
|
|
1558
|
+
when: {
|
|
1559
|
+
// Default `when.happened` semantic: fires when the type has
|
|
1560
|
+
// NOT been written in the scope (ADR §5).
|
|
1561
|
+
happened: { event: "test-passed", in: "agent_loop" },
|
|
1562
|
+
},
|
|
1563
|
+
};
|
|
1564
|
+
const observer: Observer = {
|
|
1565
|
+
name: "test-tracker",
|
|
1566
|
+
watch: { toolName: "bash", inputMatches: { command: /^npm test/ } },
|
|
1567
|
+
onResult: (_evt, obsCtx) => {
|
|
1568
|
+
obsCtx.appendEntry("test-passed", {});
|
|
1569
|
+
},
|
|
1570
|
+
};
|
|
1571
|
+
|
|
1572
|
+
const host = createRecordingHost();
|
|
1573
|
+
const ctx = mockExtensionContext("/repo", host.entries);
|
|
1574
|
+
const harness = loadHarness({
|
|
1575
|
+
config: { rules: [rule], observers: [observer] },
|
|
1576
|
+
host,
|
|
1577
|
+
});
|
|
1578
|
+
|
|
1579
|
+
// Without a prior test-passed entry, the guarded command blocks.
|
|
1580
|
+
const blocked = await harness.evaluate(
|
|
1581
|
+
{
|
|
1582
|
+
type: "tool_call",
|
|
1583
|
+
toolCallId: "tc1",
|
|
1584
|
+
toolName: "bash",
|
|
1585
|
+
input: { command: "git pu origin feat/x" },
|
|
1586
|
+
} as unknown as Parameters<typeof harness.evaluate>[0],
|
|
1587
|
+
ctx,
|
|
1588
|
+
0,
|
|
1589
|
+
);
|
|
1590
|
+
assert.ok(blocked && blocked.block === true);
|
|
1591
|
+
|
|
1592
|
+
// Dispatch an `npm test` success → observer records test-passed.
|
|
1593
|
+
await harness.dispatch(
|
|
1594
|
+
{
|
|
1595
|
+
type: "tool_result",
|
|
1596
|
+
toolCallId: "tc1",
|
|
1597
|
+
toolName: "bash",
|
|
1598
|
+
input: { command: "npm test" },
|
|
1599
|
+
content: [],
|
|
1600
|
+
details: { exitCode: 0 },
|
|
1601
|
+
} as unknown as Parameters<typeof harness.dispatch>[0],
|
|
1602
|
+
ctx,
|
|
1603
|
+
0,
|
|
1604
|
+
);
|
|
1605
|
+
assert.ok(
|
|
1606
|
+
host.entries.some((e) => e.customType === "test-passed"),
|
|
1607
|
+
"observer should have recorded a test-passed entry",
|
|
1608
|
+
);
|
|
1609
|
+
|
|
1610
|
+
// Subsequent guarded command in the same agent loop passes.
|
|
1611
|
+
const allowed = await harness.evaluate(
|
|
1612
|
+
{
|
|
1613
|
+
type: "tool_call",
|
|
1614
|
+
toolCallId: "tc2",
|
|
1615
|
+
toolName: "bash",
|
|
1616
|
+
input: { command: "git pu origin feat/x" },
|
|
1617
|
+
} as unknown as Parameters<typeof harness.evaluate>[0],
|
|
1618
|
+
ctx,
|
|
1619
|
+
0,
|
|
1620
|
+
);
|
|
1621
|
+
assert.equal(allowed, undefined);
|
|
1622
|
+
});
|
|
1623
|
+
});
|
|
1624
|
+
|
|
1625
|
+
// ---------------------------------------------------------------------------
|
|
1626
|
+
// priorEntry
|
|
1627
|
+
// ---------------------------------------------------------------------------
|
|
1628
|
+
|
|
1629
|
+
describe("priorEntry", () => {
|
|
1630
|
+
it("merges _agentLoopIndex into plain-object data", () => {
|
|
1631
|
+
const entry = priorEntry(
|
|
1632
|
+
"ws-sync-done",
|
|
1633
|
+
{ note: "merged" },
|
|
1634
|
+
{ agentLoopIndex: 5 },
|
|
1635
|
+
);
|
|
1636
|
+
assert.deepEqual(entry, {
|
|
1637
|
+
type: "custom",
|
|
1638
|
+
customType: "ws-sync-done",
|
|
1639
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
1640
|
+
data: { note: "merged", _agentLoopIndex: 5 },
|
|
1641
|
+
});
|
|
1642
|
+
});
|
|
1643
|
+
|
|
1644
|
+
it("wraps primitive data as { value, _agentLoopIndex }", () => {
|
|
1645
|
+
const entry = priorEntry("counter", 42, { agentLoopIndex: 2 });
|
|
1646
|
+
assert.deepEqual(entry.data, { value: 42, _agentLoopIndex: 2 });
|
|
1647
|
+
});
|
|
1648
|
+
|
|
1649
|
+
it("wraps array data as { value, _agentLoopIndex } (not merged)", () => {
|
|
1650
|
+
const entry = priorEntry("list", [1, 2, 3], { agentLoopIndex: 1 });
|
|
1651
|
+
assert.deepEqual(entry.data, { value: [1, 2, 3], _agentLoopIndex: 1 });
|
|
1652
|
+
});
|
|
1653
|
+
|
|
1654
|
+
it("wraps undefined data as { value: undefined, _agentLoopIndex }", () => {
|
|
1655
|
+
const entry = priorEntry("flag", undefined, { agentLoopIndex: 3 });
|
|
1656
|
+
assert.deepEqual(entry.data, { value: undefined, _agentLoopIndex: 3 });
|
|
1657
|
+
});
|
|
1658
|
+
|
|
1659
|
+
it("wraps null data as { value: null, _agentLoopIndex } (null is not a plain object)", () => {
|
|
1660
|
+
const entry = priorEntry("sentinel", null, { agentLoopIndex: 0 });
|
|
1661
|
+
assert.deepEqual(entry.data, { value: null, _agentLoopIndex: 0 });
|
|
1662
|
+
});
|
|
1663
|
+
|
|
1664
|
+
it("defaults agentLoopIndex to 0 when opts omitted", () => {
|
|
1665
|
+
const entry = priorEntry("marker", {});
|
|
1666
|
+
assert.deepEqual(entry.data, { _agentLoopIndex: 0 });
|
|
1667
|
+
assert.equal(entry.timestamp, "2026-01-01T00:00:00.000Z");
|
|
1668
|
+
assert.equal(entry.type, "custom");
|
|
1669
|
+
assert.equal(entry.customType, "marker");
|
|
1670
|
+
});
|
|
1671
|
+
|
|
1672
|
+
it("respects custom timestamp when provided", () => {
|
|
1673
|
+
const entry = priorEntry(
|
|
1674
|
+
"t",
|
|
1675
|
+
{},
|
|
1676
|
+
{
|
|
1677
|
+
agentLoopIndex: 0,
|
|
1678
|
+
timestamp: "2026-06-15T12:34:56.789Z",
|
|
1679
|
+
},
|
|
1680
|
+
);
|
|
1681
|
+
assert.equal(entry.timestamp, "2026-06-15T12:34:56.789Z");
|
|
1682
|
+
});
|
|
1683
|
+
|
|
1684
|
+
it("round-trip with mockContext: entry is visible to findEntries with scope tag intact", () => {
|
|
1685
|
+
// The entry should flow from `entries` through `findEntries` with
|
|
1686
|
+
// its data shape preserved, INCLUDING the _agentLoopIndex tag —
|
|
1687
|
+
// that's what lets the engine's agent_loop scope filter match it.
|
|
1688
|
+
const ctx = mockContext({
|
|
1689
|
+
agentLoopIndex: 5,
|
|
1690
|
+
entries: [priorEntry("ws-sync-done", {}, { agentLoopIndex: 5 })],
|
|
1691
|
+
});
|
|
1692
|
+
const hits = ctx.findEntries<{ _agentLoopIndex: number }>("ws-sync-done");
|
|
1693
|
+
assert.equal(hits.length, 1);
|
|
1694
|
+
assert.equal(hits[0]?.data._agentLoopIndex, 5);
|
|
1695
|
+
});
|
|
1696
|
+
});
|