@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,290 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* v2 observer dispatcher.
|
|
6
|
+
*
|
|
7
|
+
* Observers are the `tool_result` side of the steering engine: rules
|
|
8
|
+
* decide "can this run" pre-execution; observers record "what happened"
|
|
9
|
+
* post-execution. Typical use is to `appendEntry` into pi's session
|
|
10
|
+
* JSONL so later predicates can gate on prior turn state (the
|
|
11
|
+
* "description was read in a PRIOR turn" idiom from the ADR).
|
|
12
|
+
*
|
|
13
|
+
* This module merges user-declared observers (from
|
|
14
|
+
* {@link SteeringConfig.observers}) with plugin-shipped ones (from
|
|
15
|
+
* {@link ResolvedPluginState.observers}), then on every `tool_result`:
|
|
16
|
+
*
|
|
17
|
+
* 1. Applies the observer's `watch` filter (toolName + inputMatches
|
|
18
|
+
* + exitCode). No `watch` means "fire on every tool_result".
|
|
19
|
+
* 2. Calls `observer.onResult(event, observerCtx)`. Awaits if it
|
|
20
|
+
* returns a promise.
|
|
21
|
+
* 3. Catches thrown errors per-observer — one buggy observer does
|
|
22
|
+
* NOT prevent the rest from running.
|
|
23
|
+
*
|
|
24
|
+
* Observer context (`ObserverContext`) is built fresh per event, using
|
|
25
|
+
* `appendEntry` and `findEntries` closures shared with the evaluator.
|
|
26
|
+
* `exec` is deliberately NOT exposed — observers are expected to be
|
|
27
|
+
* lightweight state-recording hooks. Complex tool_result analysis that
|
|
28
|
+
* needs to shell out belongs in a separate pi extension hook or in a
|
|
29
|
+
* rule's `when.condition` pre-execution, not in an observer.
|
|
30
|
+
*
|
|
31
|
+
* Wiring (Phase 3c): the extension runtime subscribes to `tool_result`
|
|
32
|
+
* and forwards the event + current `agentLoopIndex` into `dispatch`.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type {
|
|
36
|
+
ExtensionContext,
|
|
37
|
+
ToolResultEvent as PiToolResultEvent,
|
|
38
|
+
} from "@earendil-works/pi-coding-agent";
|
|
39
|
+
import {
|
|
40
|
+
createAppendEntry,
|
|
41
|
+
createFindEntries,
|
|
42
|
+
createSessionEntryCache,
|
|
43
|
+
type EvaluatorHost,
|
|
44
|
+
} from "./evaluator-internals/context.ts";
|
|
45
|
+
import { mergeObserversUserFirst } from "./internal/merge-observers.ts";
|
|
46
|
+
import {
|
|
47
|
+
extractRefTextsForBash,
|
|
48
|
+
matchesWatch,
|
|
49
|
+
} from "./internal/watch-matcher.ts";
|
|
50
|
+
import type { ResolvedPluginState } from "./plugin-merger.ts";
|
|
51
|
+
import { validateName } from "./plugin-merger.ts";
|
|
52
|
+
import type {
|
|
53
|
+
Observer,
|
|
54
|
+
ObserverContext,
|
|
55
|
+
ToolResultEvent as SchemaToolResultEvent,
|
|
56
|
+
} from "./schema.ts";
|
|
57
|
+
|
|
58
|
+
// Re-export the shared filter contract so existing consumers that
|
|
59
|
+
// imported `matchesWatch` from this module (most notably the testing
|
|
60
|
+
// harness's `testObserver`) don't need to switch imports. The source
|
|
61
|
+
// of truth lives in `./internal/watch-matcher.ts`; this re-export
|
|
62
|
+
// keeps the public surface stable.
|
|
63
|
+
export { matchesWatch } from "./internal/watch-matcher.ts";
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Public surface
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Runtime-facing dispatcher handle. Phase 3c holds an instance per
|
|
71
|
+
* session and calls {@link dispatch} from the pi `tool_result` listener.
|
|
72
|
+
*/
|
|
73
|
+
export interface ObserverDispatcher {
|
|
74
|
+
/**
|
|
75
|
+
* Dispatch a single `tool_result` to every matching observer.
|
|
76
|
+
*
|
|
77
|
+
* Resolves when all observer handlers have settled. Handlers that
|
|
78
|
+
* throw are caught + logged via `console.warn` and do not prevent
|
|
79
|
+
* subsequent observers from running.
|
|
80
|
+
*/
|
|
81
|
+
dispatch(
|
|
82
|
+
event: PiToolResultEvent,
|
|
83
|
+
ctx: ExtensionContext,
|
|
84
|
+
agentLoopIndex: number,
|
|
85
|
+
): Promise<void>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Construct an {@link ObserverDispatcher}.
|
|
90
|
+
*
|
|
91
|
+
* Arguments:
|
|
92
|
+
* - `resolved` — merged plugin state from {@link resolvePlugins}.
|
|
93
|
+
* Source of plugin-shipped observers and the
|
|
94
|
+
* registry used by the evaluator at the same
|
|
95
|
+
* level.
|
|
96
|
+
* - `userObservers` — the user's `config.observers` list (already
|
|
97
|
+
* deduped at the loader level in Phase 2). User
|
|
98
|
+
* observers fire BEFORE plugin observers on the
|
|
99
|
+
* same event; within each group, registration
|
|
100
|
+
* order decides.
|
|
101
|
+
* - `host` — narrow surface exposing pi's `exec` +
|
|
102
|
+
* `appendEntry`. Passed straight through to the
|
|
103
|
+
* per-event observer context.
|
|
104
|
+
*/
|
|
105
|
+
export function buildObserverDispatcher(
|
|
106
|
+
resolved: ResolvedPluginState,
|
|
107
|
+
userObservers: readonly Observer[],
|
|
108
|
+
host: EvaluatorHost,
|
|
109
|
+
): ObserverDispatcher {
|
|
110
|
+
// S3 defense-in-depth: validate user-authored observer names so a
|
|
111
|
+
// malformed name surfaces with the same `(user config)` label as
|
|
112
|
+
// production diagnostics. Production routes through
|
|
113
|
+
// `runMergerPipeline`'s `invalid-name` diagnostic; this throw
|
|
114
|
+
// covers direct-caller paths (unit tests, SDK embedders).
|
|
115
|
+
// See ./INVARIANTS.md for the S/E tag glossary.
|
|
116
|
+
for (const o of userObservers) {
|
|
117
|
+
const d = validateName("observer", o.name, "user config");
|
|
118
|
+
if (d !== undefined) throw new Error(`[pi-steering] ${d.message}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Merge user and plugin observers; duplicates of observer.name are
|
|
122
|
+
// deduped here by first-registered (user takes precedence over a
|
|
123
|
+
// plugin observer of the same name — matches the "user overrides
|
|
124
|
+
// plugin by declaring their own" pattern the rule list uses). Shared
|
|
125
|
+
// with the evaluator's speculative-synthesis reverse-index via
|
|
126
|
+
// `mergeObserversUserFirst` so both callers see the same final list.
|
|
127
|
+
const merged = mergeObserversUserFirst(userObservers, resolved.observers);
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
dispatch: (event, ctx, agentLoopIndex) =>
|
|
131
|
+
dispatchEvent(event, ctx, agentLoopIndex, merged, host),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Dispatch
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
async function dispatchEvent(
|
|
140
|
+
event: PiToolResultEvent,
|
|
141
|
+
ctx: ExtensionContext,
|
|
142
|
+
agentLoopIndex: number,
|
|
143
|
+
observers: readonly Observer[],
|
|
144
|
+
host: EvaluatorHost,
|
|
145
|
+
): Promise<void> {
|
|
146
|
+
// Top-level fail-open wrap. Per-observer throws are already
|
|
147
|
+
// isolated in the inner loop; this outer wrap exists so a throw in
|
|
148
|
+
// the dispatch SCAFFOLDING (e.g. a session-JSONL read blowing up
|
|
149
|
+
// inside `createFindEntries`, or an unexpected shape on the
|
|
150
|
+
// incoming event) is logged rather than propagating back into pi's
|
|
151
|
+
// `tool_result` hook. Observers are best-effort state recorders —
|
|
152
|
+
// a broken engine should not take down the tool_result pipeline.
|
|
153
|
+
try {
|
|
154
|
+
await dispatchEventInner(event, ctx, agentLoopIndex, observers, host);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
console.warn(
|
|
157
|
+
`[pi-steering] observer dispatcher threw: ${formatError(err)}`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function dispatchEventInner(
|
|
163
|
+
event: PiToolResultEvent,
|
|
164
|
+
ctx: ExtensionContext,
|
|
165
|
+
agentLoopIndex: number,
|
|
166
|
+
observers: readonly Observer[],
|
|
167
|
+
host: EvaluatorHost,
|
|
168
|
+
): Promise<void> {
|
|
169
|
+
// Shared per-event session-entry cache: findEntries + appendEntry
|
|
170
|
+
// share it so an earlier observer's appendEntry invalidates the
|
|
171
|
+
// cached read for that customType, and a later observer's
|
|
172
|
+
// findEntries(customType) sees the fresh write (S2/E1). Without the
|
|
173
|
+
// shared cache, observer A appending "description-read" + observer
|
|
174
|
+
// B reading "description-read" on the same event would see a stale
|
|
175
|
+
// pre-write snapshot.
|
|
176
|
+
const entryCache = createSessionEntryCache();
|
|
177
|
+
const findEntries = createFindEntries(ctx, entryCache);
|
|
178
|
+
// Shared appendEntry: auto-tags writes with `_agentLoopIndex` so
|
|
179
|
+
// `when.happened: { in: "agent_loop" }` can filter by agent-loop
|
|
180
|
+
// scope. Safe to hoist out of the loop: the wrapper is stateless.
|
|
181
|
+
const appendEntry = createAppendEntry(host, agentLoopIndex, entryCache);
|
|
182
|
+
|
|
183
|
+
// Hoist the per-event projections out of the loop so N observers
|
|
184
|
+
// each get the identical event shape + exit code without paying N
|
|
185
|
+
// copies of the same work.
|
|
186
|
+
const exitCode = extractExitCode(event);
|
|
187
|
+
const schemaEvent = toSchemaEvent(event, exitCode);
|
|
188
|
+
|
|
189
|
+
// Wrapper-aware command-ref cache (ADR §12). Populated lazily on the
|
|
190
|
+
// first observer whose watch filter references `inputMatches.command`
|
|
191
|
+
// against a bash event; reused across subsequent observers on the
|
|
192
|
+
// same event so `sh -c '…'` / `sudo …` are parsed at most once per
|
|
193
|
+
// dispatch regardless of how many observers share the filter shape.
|
|
194
|
+
// `null` encodes "extraction attempted but failed (parse error or
|
|
195
|
+
// non-bash event)" so we don't retry per observer.
|
|
196
|
+
let refTextsCache: readonly string[] | null | undefined;
|
|
197
|
+
const getRefTexts = (): readonly string[] | null => {
|
|
198
|
+
if (refTextsCache !== undefined) return refTextsCache;
|
|
199
|
+
refTextsCache = extractRefTextsForBash(schemaEvent);
|
|
200
|
+
return refTextsCache;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
for (const observer of observers) {
|
|
204
|
+
if (!matchesWatch(observer.watch, schemaEvent, getRefTexts)) continue;
|
|
205
|
+
|
|
206
|
+
// Each observer gets its own ctx so appendEntry writes attribute
|
|
207
|
+
// cleanly. `exec` is intentionally absent — observers are recording
|
|
208
|
+
// hooks, not shell-out points.
|
|
209
|
+
const observerCtx: ObserverContext = {
|
|
210
|
+
cwd: ctx.cwd,
|
|
211
|
+
agentLoopIndex,
|
|
212
|
+
appendEntry,
|
|
213
|
+
findEntries,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
const result = observer.onResult(schemaEvent, observerCtx);
|
|
218
|
+
if (result instanceof Promise) {
|
|
219
|
+
await result;
|
|
220
|
+
}
|
|
221
|
+
} catch (err) {
|
|
222
|
+
// One observer's bug must not poison the rest. Log with the
|
|
223
|
+
// observer name so the operator can locate + disable it.
|
|
224
|
+
console.warn(
|
|
225
|
+
`[pi-steering] observer "${observer.name}" threw: ${formatError(err)}`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// Schema event projection
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Extract an exit code from a pi tool_result event. Only bash events
|
|
237
|
+
* carry one (via `details.exitCode`). Other tool results lack a
|
|
238
|
+
* meaningful numeric code; we return `undefined` and let the watch
|
|
239
|
+
* filter's `exitCode` check decide.
|
|
240
|
+
*/
|
|
241
|
+
function extractExitCode(event: PiToolResultEvent): number | undefined {
|
|
242
|
+
if (event.toolName !== "bash") return undefined;
|
|
243
|
+
const details = event.details as { exitCode?: number } | undefined;
|
|
244
|
+
if (!details || typeof details.exitCode !== "number") return undefined;
|
|
245
|
+
return details.exitCode;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Project pi's concrete `ToolResultEvent` onto the schema's minimal
|
|
250
|
+
* {@link SchemaToolResultEvent}. Observers are typed against the schema
|
|
251
|
+
* shape — they don't depend on pi's internal tool details unions. The
|
|
252
|
+
* fields we fill:
|
|
253
|
+
*
|
|
254
|
+
* - `toolName` + `input` — direct.
|
|
255
|
+
* - `output` — pi's `content` (TextContent/ImageContent
|
|
256
|
+
* array) passed through unchanged. Observer
|
|
257
|
+
* handlers cast to the shape they expect.
|
|
258
|
+
* - `exitCode` — passed in precomputed by the caller
|
|
259
|
+
* (so the extraction runs once per event,
|
|
260
|
+
* not once per matching observer); bash
|
|
261
|
+
* events only, others leave it `undefined`.
|
|
262
|
+
*/
|
|
263
|
+
function toSchemaEvent(
|
|
264
|
+
event: PiToolResultEvent,
|
|
265
|
+
exitCode: number | undefined,
|
|
266
|
+
): SchemaToolResultEvent {
|
|
267
|
+
const out: SchemaToolResultEvent = {
|
|
268
|
+
toolName: event.toolName,
|
|
269
|
+
input: event.input,
|
|
270
|
+
output: event.content,
|
|
271
|
+
};
|
|
272
|
+
if (exitCode !== undefined) out.exitCode = exitCode;
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
// Error formatting
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
function formatError(err: unknown): string {
|
|
281
|
+
if (err instanceof Error) return `${err.message}\n${err.stack ?? ""}`;
|
|
282
|
+
try {
|
|
283
|
+
return JSON.stringify(err);
|
|
284
|
+
} catch {
|
|
285
|
+
return String(err);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Re-export host type for symmetry with evaluator.ts.
|
|
290
|
+
export type { EvaluatorHost } from "./evaluator-internals/context.ts";
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Type-level pinning for the per-predicate typing scaffold introduced
|
|
6
|
+
* alongside the not-block onUnknown semantics.
|
|
7
|
+
*
|
|
8
|
+
* Runtime assertions are minimal — the value of these tests is the
|
|
9
|
+
* compile-time pinning of the registry-driven mapped types
|
|
10
|
+
* ({@link TopLevelWhenClause}, {@link TopLevelWhenClauseNoRecurse},
|
|
11
|
+
* {@link OuterValue}, {@link InnerValue}). If a future change widens
|
|
12
|
+
* a type incorrectly (e.g. accepting a leaf-level `onUnknown:` inside
|
|
13
|
+
* `not:`, or adding `not: not:` recursion), the `// @ts-expect-error`
|
|
14
|
+
* directives here surface the regression at typecheck time.
|
|
15
|
+
*
|
|
16
|
+
* Plugin-author registry shape: the `gitPlugin` module augments
|
|
17
|
+
* `PiSteeringPredicates` with `branch:`, `upstream:`, `remote:`,
|
|
18
|
+
* `isClean:`, `hasStagedChanges:`, `commitsAhead:` (see
|
|
19
|
+
* `plugins/git/index.ts`). `cwd:` is a built-in non-registry leaf on
|
|
20
|
+
* {@link BuiltInWhenLeaves} and is tested via
|
|
21
|
+
* {@link TopLevelWhenClause} directly. This test file imports
|
|
22
|
+
* gitPlugin so the augmentation is in scope before the type-level
|
|
23
|
+
* pins below.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import assert from "node:assert/strict";
|
|
27
|
+
import { describe, it } from "node:test";
|
|
28
|
+
// Importing gitPlugin pulls in its `declare global { interface
|
|
29
|
+
// PiSteeringPredicates { ... } }` augmentation; without this import
|
|
30
|
+
// the registry would be empty and `PluginPredicateKey` would be
|
|
31
|
+
// `never`, collapsing every mapped type to `{}` with the operator
|
|
32
|
+
// field. The reference below keeps the import live for the
|
|
33
|
+
// type-system without triggering unused-import diagnostics.
|
|
34
|
+
import gitPlugin from "./plugins/git/index.ts";
|
|
35
|
+
import type {
|
|
36
|
+
CommitsAheadArgs,
|
|
37
|
+
commitsAhead,
|
|
38
|
+
hasStagedChanges,
|
|
39
|
+
isClean,
|
|
40
|
+
} from "./plugins/git/predicates.ts";
|
|
41
|
+
import type {
|
|
42
|
+
BuiltInWhenLeaves,
|
|
43
|
+
InnerValue,
|
|
44
|
+
OuterValue,
|
|
45
|
+
PluginPredicateKey,
|
|
46
|
+
PredicateHandler,
|
|
47
|
+
TopLevelWhenClause,
|
|
48
|
+
TopLevelWhenClauseNoRecurse,
|
|
49
|
+
} from "./schema.ts";
|
|
50
|
+
|
|
51
|
+
const _gitPluginRegistered: typeof gitPlugin = gitPlugin;
|
|
52
|
+
void _gitPluginRegistered;
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Positive cases — must typecheck
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
describe("per-predicate typing: positive cases", () => {
|
|
59
|
+
it("registry pins gitPlugin's 6 predicate keys after `declare global`", () => {
|
|
60
|
+
// Type-level assertion: every gitPlugin predicate key is a
|
|
61
|
+
// member of `PluginPredicateKey`. A regression that drops one
|
|
62
|
+
// of the augmented entries surfaces here as a type error.
|
|
63
|
+
// `cwd` is NOT in this set — it's a built-in non-registry leaf
|
|
64
|
+
// on BuiltInWhenLeaves (covered by the `BuiltInWhenLeaves`
|
|
65
|
+
// shape pin in not-block-onunknown.test.ts).
|
|
66
|
+
const keys: PluginPredicateKey[] = [
|
|
67
|
+
"branch",
|
|
68
|
+
"upstream",
|
|
69
|
+
"remote",
|
|
70
|
+
"isClean",
|
|
71
|
+
"hasStagedChanges",
|
|
72
|
+
"commitsAhead",
|
|
73
|
+
];
|
|
74
|
+
assert.equal(keys.length, 6);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("TopLevelWhenClause: bare leaf forms (Pattern, boolean, number)", () => {
|
|
78
|
+
const w: TopLevelWhenClause = {
|
|
79
|
+
cwd: /work/,
|
|
80
|
+
branch: "main",
|
|
81
|
+
isClean: true,
|
|
82
|
+
commitsAhead: 1,
|
|
83
|
+
};
|
|
84
|
+
assert.ok(w.cwd !== undefined);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("TopLevelWhenClause: spread leaf forms with leaf-level onUnknown:", () => {
|
|
88
|
+
const w: TopLevelWhenClause = {
|
|
89
|
+
cwd: { pattern: /work/, onUnknown: "allow" },
|
|
90
|
+
isClean: { value: true, onUnknown: "allow" },
|
|
91
|
+
commitsAhead: { eq: 0, onUnknown: "allow" },
|
|
92
|
+
};
|
|
93
|
+
assert.ok(w.cwd !== undefined);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("TopLevelWhenClause: not?: with bare inner leaves + block-level onUnknown:", () => {
|
|
97
|
+
const w: TopLevelWhenClause = {
|
|
98
|
+
not: {
|
|
99
|
+
cwd: /github/,
|
|
100
|
+
onUnknown: "block",
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
assert.ok(w.not?.cwd !== undefined);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("TopLevelWhenClauseNoRecurse: spreadBase form (no modifiers) inside not:", () => {
|
|
107
|
+
const w: TopLevelWhenClause = {
|
|
108
|
+
not: {
|
|
109
|
+
commitsAhead: { lt: 5 },
|
|
110
|
+
cwd: { pattern: /github/ },
|
|
111
|
+
onUnknown: "block",
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
assert.ok(w.not?.commitsAhead !== undefined);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("OuterValue<K>: bare OR (spreadBase + modifiers) (built-in cwd via BuiltInWhenLeaves)", () => {
|
|
118
|
+
// `cwd:` is a built-in leaf on {@link BuiltInWhenLeaves}, not a
|
|
119
|
+
// registry-driven key, so its type pin uses the interface field
|
|
120
|
+
// directly rather than `OuterValue<"cwd">`. Registry-key version
|
|
121
|
+
// uses `branch:` (gitPlugin-augmented), with the same
|
|
122
|
+
// bare-or-spread+modifiers shape contract.
|
|
123
|
+
const bareCwd: NonNullable<BuiltInWhenLeaves["cwd"]> = /pattern/;
|
|
124
|
+
const spreadCwd: NonNullable<BuiltInWhenLeaves["cwd"]> = {
|
|
125
|
+
pattern: /pattern/,
|
|
126
|
+
onUnknown: "allow",
|
|
127
|
+
};
|
|
128
|
+
const bareBranch: OuterValue<"branch"> = /pattern/;
|
|
129
|
+
const spreadBranch: OuterValue<"branch"> = {
|
|
130
|
+
pattern: /pattern/,
|
|
131
|
+
onUnknown: "allow",
|
|
132
|
+
};
|
|
133
|
+
assert.ok(bareCwd !== undefined);
|
|
134
|
+
assert.ok(spreadCwd !== undefined);
|
|
135
|
+
assert.ok(bareBranch !== undefined);
|
|
136
|
+
assert.ok(spreadBranch !== undefined);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("InnerValue<K>: bare OR spreadBase (NO modifiers) (built-in cwd inside not: via BuiltInWhenLeaves)", () => {
|
|
140
|
+
// Inside `not:`, `cwd:` retains its built-in shape (Pattern |
|
|
141
|
+
// Pattern[] | { pattern, onUnknown? }) —
|
|
142
|
+
// {@link TopLevelWhenClauseNoRecurse} intersects
|
|
143
|
+
// {@link BuiltInWhenLeaves} verbatim. Registry-key version uses
|
|
144
|
+
// `branch:` to pin the bare-or-spreadBase (no modifiers) contract.
|
|
145
|
+
const bareBranch: InnerValue<"branch"> = /pattern/;
|
|
146
|
+
const spreadBranch: InnerValue<"branch"> = { pattern: /pattern/ };
|
|
147
|
+
assert.ok(bareBranch !== undefined);
|
|
148
|
+
assert.ok(spreadBranch !== undefined);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('InnerValue<"upstream">: bare OR spreadBase (NO modifiers)', () => {
|
|
152
|
+
// Mirrors the `branch` pin above for the other Pattern-leaf
|
|
153
|
+
// registry-driven predicates so a future shape regression on
|
|
154
|
+
// any one of them surfaces independently.
|
|
155
|
+
const bare: InnerValue<"upstream"> = /origin\/main/;
|
|
156
|
+
const spread: InnerValue<"upstream"> = { pattern: /origin\/main/ };
|
|
157
|
+
assert.ok(bare !== undefined);
|
|
158
|
+
assert.ok(spread !== undefined);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('InnerValue<"remote">: bare OR spreadBase (NO modifiers)', () => {
|
|
162
|
+
const bare: InnerValue<"remote"> = /github\.com/;
|
|
163
|
+
const spread: InnerValue<"remote"> = { pattern: /github\.com/ };
|
|
164
|
+
assert.ok(bare !== undefined);
|
|
165
|
+
assert.ok(spread !== undefined);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('InnerValue<"hasStagedChanges">: bare boolean OR { value: boolean } (NO modifiers)', () => {
|
|
169
|
+
const bare: InnerValue<"hasStagedChanges"> = true;
|
|
170
|
+
const spread: InnerValue<"hasStagedChanges"> = { value: true };
|
|
171
|
+
assert.ok(bare !== undefined);
|
|
172
|
+
assert.ok(spread !== undefined);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("upstream: bare + spread + leaf-level onUnknown:", () => {
|
|
176
|
+
const bare: OuterValue<"upstream"> = /origin\/main/;
|
|
177
|
+
const spread: OuterValue<"upstream"> = {
|
|
178
|
+
pattern: /origin\/main/,
|
|
179
|
+
onUnknown: "allow",
|
|
180
|
+
};
|
|
181
|
+
assert.ok(bare !== undefined);
|
|
182
|
+
assert.ok(spread !== undefined);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("remote: bare + spread + leaf-level onUnknown:", () => {
|
|
186
|
+
const bare: OuterValue<"remote"> = /github\.com/;
|
|
187
|
+
const spread: OuterValue<"remote"> = {
|
|
188
|
+
pattern: /github\.com/,
|
|
189
|
+
onUnknown: "allow",
|
|
190
|
+
};
|
|
191
|
+
assert.ok(bare !== undefined);
|
|
192
|
+
assert.ok(spread !== undefined);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("hasStagedChanges: bare boolean + spread { value, onUnknown? }", () => {
|
|
196
|
+
const bare: OuterValue<"hasStagedChanges"> = true;
|
|
197
|
+
const spread: OuterValue<"hasStagedChanges"> = {
|
|
198
|
+
value: true,
|
|
199
|
+
onUnknown: "allow",
|
|
200
|
+
};
|
|
201
|
+
assert.ok(bare !== undefined);
|
|
202
|
+
assert.ok(spread !== undefined);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Negative cases — must NOT typecheck (// @ts-expect-error guards)
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
describe("per-predicate typing: negative cases (compile-time)", () => {
|
|
211
|
+
it("rule-level onUnknown: is forbidden on TopLevelWhenClause", () => {
|
|
212
|
+
const w: TopLevelWhenClause = {
|
|
213
|
+
cwd: /work/,
|
|
214
|
+
// @ts-expect-error — rule-level onUnknown: is not on TopLevelWhenClause's type.
|
|
215
|
+
onUnknown: "block",
|
|
216
|
+
};
|
|
217
|
+
assert.ok(w.cwd !== undefined);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("leaf-level onUnknown: inside not: is forbidden (modifiers live at block level)", () => {
|
|
221
|
+
const w: TopLevelWhenClause = {
|
|
222
|
+
not: {
|
|
223
|
+
// @ts-expect-error — InnerValue<"branch"> is bare | spreadBase, no modifiers.
|
|
224
|
+
branch: { pattern: "main", onUnknown: "allow" },
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
assert.ok(w.not !== undefined);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("leaf-level onUnknown: inside not: is forbidden for boolean predicates too", () => {
|
|
231
|
+
const w: TopLevelWhenClause = {
|
|
232
|
+
not: {
|
|
233
|
+
// @ts-expect-error — InnerValue<"isClean"> is boolean | { value: boolean }, no modifiers.
|
|
234
|
+
isClean: { value: true, onUnknown: "allow" },
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
assert.ok(w.not !== undefined);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("leaf-level onUnknown: inside not: is forbidden for object-shaped predicates too", () => {
|
|
241
|
+
const w: TopLevelWhenClause = {
|
|
242
|
+
not: {
|
|
243
|
+
// @ts-expect-error — InnerValue<"commitsAhead"> is number | spreadBase, no modifiers.
|
|
244
|
+
commitsAhead: { lt: 5, onUnknown: "allow" },
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
assert.ok(w.not !== undefined);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("not: not: recursion is forbidden", () => {
|
|
251
|
+
const w: TopLevelWhenClause = {
|
|
252
|
+
not: {
|
|
253
|
+
// @ts-expect-error — TopLevelWhenClauseNoRecurse has no `not?:` field.
|
|
254
|
+
not: { cwd: "/" },
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
assert.ok(w.not !== undefined);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// PredicateShape auto-detect via DefaultSpreadBase
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
describe("PredicateShape: DefaultSpreadBase auto-detection", () => {
|
|
266
|
+
it("PredicateShape<Patterns> auto-detects spreadBase to { pattern: Patterns }", () => {
|
|
267
|
+
// Type-only assertion via assignability. If the auto-detect
|
|
268
|
+
// breaks (e.g. tuple-wrap removed and union distributes), this
|
|
269
|
+
// fails to typecheck. `branch:` carries the same Patterns-bare
|
|
270
|
+
// shape as the built-in `cwd:` leaf and lives in the registry,
|
|
271
|
+
// so it's the right registry-side anchor for this pin.
|
|
272
|
+
type BranchShape = PiSteeringPredicates["branch"];
|
|
273
|
+
const _spread: BranchShape["spreadBase"] = { pattern: /work/ };
|
|
274
|
+
void _spread;
|
|
275
|
+
assert.ok(true);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("PredicateShape<boolean> auto-detects spreadBase to { value: boolean }", () => {
|
|
279
|
+
type IsCleanShape = PiSteeringPredicates["isClean"];
|
|
280
|
+
const _spread: IsCleanShape["spreadBase"] = { value: true };
|
|
281
|
+
void _spread;
|
|
282
|
+
assert.ok(true);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("PredicateShape<number, ExplicitSpreadBase> uses the explicit override", () => {
|
|
286
|
+
// commitsAhead has explicit SpreadBase = { eq?, gt?, lt?, wrt? }
|
|
287
|
+
// (auto-detect from `number` would give { value: number }).
|
|
288
|
+
type CAShape = PiSteeringPredicates["commitsAhead"];
|
|
289
|
+
const _spread: CAShape["spreadBase"] = { eq: 1, wrt: "origin/main" };
|
|
290
|
+
void _spread;
|
|
291
|
+
assert.ok(true);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// PluginPredicateKey reserved-key filter
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
describe("PluginPredicateKey: reserved-key filter", () => {
|
|
300
|
+
it("filters out reserved names (`not`, `onUnknown`)", () => {
|
|
301
|
+
// Belt-and-suspenders type-only check. The runtime guard in
|
|
302
|
+
// `plugin-merger.ts` is the authoritative gate (it throws at
|
|
303
|
+
// config-resolve time when a plugin attempts to register a
|
|
304
|
+
// reserved key); this assertion pins the type-level filter that
|
|
305
|
+
// drops reserved names from the registry-driven mapped types
|
|
306
|
+
// (`TopLevelWhenClause`, `TopLevelWhenClauseNoRecurse`) so an IDE
|
|
307
|
+
// hover never suggests a key the runtime would reject.
|
|
308
|
+
type ContainsNot = "not" extends PluginPredicateKey ? true : false;
|
|
309
|
+
type ContainsOnUnknown = "onUnknown" extends PluginPredicateKey
|
|
310
|
+
? true
|
|
311
|
+
: false;
|
|
312
|
+
const _a: ContainsNot = false;
|
|
313
|
+
const _b: ContainsOnUnknown = false;
|
|
314
|
+
void _a;
|
|
315
|
+
void _b;
|
|
316
|
+
assert.ok(true);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
// gitPlugin handler signatures (named exports vs registry shapes)
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
describe("gitPlugin handler signatures: named-export type pins", () => {
|
|
325
|
+
it("`commitsAhead` is `PredicateHandler<number | CommitsAheadArgs>`", () => {
|
|
326
|
+
// Pins the named export against the registry shape. If a future
|
|
327
|
+
// change widens the handler arg (e.g., adding a new union member
|
|
328
|
+
// without updating the registry's `PredicateShape<number,
|
|
329
|
+
// CommitsAheadArgs>` SpreadBase), the conditional collapses to
|
|
330
|
+
// `false` and the assignment fails to typecheck.
|
|
331
|
+
type _CommitsAheadSig =
|
|
332
|
+
typeof commitsAhead extends PredicateHandler<number | CommitsAheadArgs>
|
|
333
|
+
? true
|
|
334
|
+
: false;
|
|
335
|
+
const _checkCommitsAhead: _CommitsAheadSig = true;
|
|
336
|
+
void _checkCommitsAhead;
|
|
337
|
+
assert.ok(true);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("`isClean` is `PredicateHandler<boolean | { value: boolean }>`", () => {
|
|
341
|
+
type _IsCleanSig =
|
|
342
|
+
typeof isClean extends PredicateHandler<boolean | { value: boolean }>
|
|
343
|
+
? true
|
|
344
|
+
: false;
|
|
345
|
+
const _checkIsClean: _IsCleanSig = true;
|
|
346
|
+
void _checkIsClean;
|
|
347
|
+
assert.ok(true);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("`hasStagedChanges` is `PredicateHandler<boolean | { value: boolean }>`", () => {
|
|
351
|
+
type _HasStagedSig =
|
|
352
|
+
typeof hasStagedChanges extends PredicateHandler<
|
|
353
|
+
boolean | { value: boolean }
|
|
354
|
+
>
|
|
355
|
+
? true
|
|
356
|
+
: false;
|
|
357
|
+
const _checkHasStaged: _HasStagedSig = true;
|
|
358
|
+
void _checkHasStaged;
|
|
359
|
+
assert.ok(true);
|
|
360
|
+
});
|
|
361
|
+
});
|