@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
package/src/index.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
//
|
|
4
|
+
// pi-steering — deterministic steering hooks for pi agents.
|
|
5
|
+
// Inspired by @samfp/pi-steering-hooks (schema, override-comment,
|
|
6
|
+
// defaults). AST backend + command-level effective-cwd via
|
|
7
|
+
// unbash-walker. This file is the thin wiring layer between pi's
|
|
8
|
+
// extension API and the engine (loader + plugin-merger + evaluator
|
|
9
|
+
// + observer-dispatcher).
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
ExtensionAPI,
|
|
13
|
+
ExtensionContext,
|
|
14
|
+
} from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import type { EvaluatorHost } from "./evaluator.ts";
|
|
16
|
+
import { buildSessionRuntime } from "./internal/session-runtime.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Pi extension factory. Wires the steering engine onto pi's
|
|
20
|
+
* lifecycle events.
|
|
21
|
+
*
|
|
22
|
+
* Strict-mode contract: see {@link SteeringConfig.failOnWarnings}.
|
|
23
|
+
*
|
|
24
|
+
* Lifecycle wiring:
|
|
25
|
+
*
|
|
26
|
+
* - factory time — eager load via {@link buildSessionRuntime};
|
|
27
|
+
* throws on diagnostic per the strict-mode
|
|
28
|
+
* contract.
|
|
29
|
+
* - `agent_start` — bump the internal `agentLoopIndex` counter
|
|
30
|
+
* so tool_call / tool_result handlers can
|
|
31
|
+
* forward it into the evaluator + dispatcher.
|
|
32
|
+
* One agent loop = one user prompt + all the
|
|
33
|
+
* tool calls it spawns.
|
|
34
|
+
* - `session_start` — emit a `console.warn` if the resumed
|
|
35
|
+
* session's `ctx.cwd` differs from the launch
|
|
36
|
+
* cwd captured at factory time (cross-project
|
|
37
|
+
* resume). The engine continues evaluating
|
|
38
|
+
* with launch-cwd rules.
|
|
39
|
+
* - `tool_call` / `tool_result` route through the evaluator and
|
|
40
|
+
* dispatcher (see {@link buildSessionRuntime}).
|
|
41
|
+
*
|
|
42
|
+
* Exported as the default export per pi's extension convention.
|
|
43
|
+
*/
|
|
44
|
+
export default async function register(pi: ExtensionAPI): Promise<void> {
|
|
45
|
+
let agentLoopIndex = 0;
|
|
46
|
+
|
|
47
|
+
const host: EvaluatorHost = {
|
|
48
|
+
exec: pi.exec,
|
|
49
|
+
appendEntry: pi.appendEntry,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const launchCwd = process.cwd();
|
|
53
|
+
|
|
54
|
+
const { evaluator, dispatcher } = await buildSessionRuntime(launchCwd, host);
|
|
55
|
+
|
|
56
|
+
pi.on("agent_start", () => {
|
|
57
|
+
agentLoopIndex += 1;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
pi.on("session_start", (_event, ctx: ExtensionContext) => {
|
|
61
|
+
if (ctx.cwd !== launchCwd) {
|
|
62
|
+
console.warn(
|
|
63
|
+
`[pi-steering] session cwd (${ctx.cwd}) differs from launch cwd ` +
|
|
64
|
+
`(${launchCwd}). Steering rules loaded from launch cwd; ` +
|
|
65
|
+
`session-cwd rules NOT applied. To use session-cwd rules, ` +
|
|
66
|
+
`exit pi and re-launch from ${ctx.cwd}.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
pi.on("tool_call", (event, ctx) =>
|
|
72
|
+
evaluator.evaluate(event, ctx, agentLoopIndex),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
pi.on("tool_result", (event, ctx) =>
|
|
76
|
+
dispatcher.dispatch(event, ctx, agentLoopIndex),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Public surface — the engine.
|
|
82
|
+
//
|
|
83
|
+
// Consumers embedding the engine (building their own extensions, a CLI
|
|
84
|
+
// that lints commands, a test harness, …) import these from the
|
|
85
|
+
// package root.
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
// Walker types re-exported for plugin authors. Forward-compatible with
|
|
89
|
+
// future unbash-walker extraction — imports from this package won't
|
|
90
|
+
// break.
|
|
91
|
+
export type {
|
|
92
|
+
Command,
|
|
93
|
+
CommandRef,
|
|
94
|
+
EnvState,
|
|
95
|
+
Modifier,
|
|
96
|
+
Node,
|
|
97
|
+
Script,
|
|
98
|
+
SubshellSemantics,
|
|
99
|
+
Tracker,
|
|
100
|
+
WalkResult,
|
|
101
|
+
Word,
|
|
102
|
+
WordPart,
|
|
103
|
+
} from "@cad0p/unbash-walker";
|
|
104
|
+
// Walker functions re-exported for plugin authors writing custom
|
|
105
|
+
// predicates and trackers. Forward-compatible with future
|
|
106
|
+
// unbash-walker extraction.
|
|
107
|
+
export {
|
|
108
|
+
cwdTracker,
|
|
109
|
+
envTracker,
|
|
110
|
+
expandWrapperCommands,
|
|
111
|
+
extractAllCommandsFromAST,
|
|
112
|
+
formatCommand,
|
|
113
|
+
getBasename,
|
|
114
|
+
getCommandArgs,
|
|
115
|
+
getCommandName,
|
|
116
|
+
isStaticallyResolvable,
|
|
117
|
+
parse,
|
|
118
|
+
resolveWord,
|
|
119
|
+
walk,
|
|
120
|
+
} from "@cad0p/unbash-walker";
|
|
121
|
+
// JSON compat — convert v1 JSON configs to v2 TS configs.
|
|
122
|
+
export { FromJSONError, fromJSON } from "./compat.ts";
|
|
123
|
+
// Defaults — bundled rule and plugin starter set.
|
|
124
|
+
export { DEFAULT_PLUGINS, DEFAULT_RULES } from "./defaults.ts";
|
|
125
|
+
export type { DefineConfigInput } from "./define-config.ts";
|
|
126
|
+
// Config helper (preferred entry point).
|
|
127
|
+
export { defineConfig } from "./define-config.ts";
|
|
128
|
+
// Predicate helper.
|
|
129
|
+
export { definePredicate } from "./define-predicate.ts";
|
|
130
|
+
// Auto-tag key for session-entry writes. Exposed so plugin authors
|
|
131
|
+
// inspecting raw session entries via `findEntries` can reference the
|
|
132
|
+
// constant instead of hardcoding the string.
|
|
133
|
+
export { AGENT_LOOP_INDEX_KEY } from "./evaluator-internals/context.ts";
|
|
134
|
+
// Reason-text helper for custom predicates that read runtime
|
|
135
|
+
// `ctx.cwd` (shell-exec or filesystem queries) rather than
|
|
136
|
+
// walker-tracked state.
|
|
137
|
+
//
|
|
138
|
+
// `walkerUnknownCwdReason`: composable agent-facing reason text
|
|
139
|
+
// for ReasonFns to call on the walker-unknown branch of those
|
|
140
|
+
// runtime-cwd predicates (typical handler shape:
|
|
141
|
+
// `if (ctx.walkerState?.cwd === "unknown") return "unknown";`,
|
|
142
|
+
// then the engine projects via `onUnknown:` policy).
|
|
143
|
+
export { walkerUnknownCwdReason } from "./helpers/walker-unknown-cwd-reason.ts";
|
|
144
|
+
// Loader — walk-up config discovery + merge.
|
|
145
|
+
export { buildConfig, loadConfigs, loadSteeringConfig } from "./loader.ts";
|
|
146
|
+
// Schema types — the public authoring surface.
|
|
147
|
+
export type {
|
|
148
|
+
AnyPredicateHandler,
|
|
149
|
+
BaseRule,
|
|
150
|
+
BashRule,
|
|
151
|
+
BuiltInWhenLeaves,
|
|
152
|
+
BuiltInWhenLeavesInner,
|
|
153
|
+
BuiltInWhenLeavesOuter,
|
|
154
|
+
DefaultSpreadBase,
|
|
155
|
+
EditRule,
|
|
156
|
+
ExecOpts,
|
|
157
|
+
ExecResult,
|
|
158
|
+
InnerValue,
|
|
159
|
+
Observer,
|
|
160
|
+
ObserverContext,
|
|
161
|
+
ObserverWatch,
|
|
162
|
+
OperatorField,
|
|
163
|
+
OuterValue,
|
|
164
|
+
Pattern,
|
|
165
|
+
Patterns,
|
|
166
|
+
Plugin,
|
|
167
|
+
PluginPredicateKey,
|
|
168
|
+
PredicateContext,
|
|
169
|
+
PredicateFn,
|
|
170
|
+
PredicateHandler,
|
|
171
|
+
PredicateModifiers,
|
|
172
|
+
PredicateShape,
|
|
173
|
+
PredicateToolInput,
|
|
174
|
+
PredicateVerdict,
|
|
175
|
+
ReasonFn,
|
|
176
|
+
ReservedPredicateKey,
|
|
177
|
+
Rule,
|
|
178
|
+
SteeringConfig,
|
|
179
|
+
SteeringDiagnostic,
|
|
180
|
+
SteeringDiagnosticKind,
|
|
181
|
+
ToolResultEvent,
|
|
182
|
+
TopLevelWhenClause,
|
|
183
|
+
TopLevelWhenClauseNoRecurse,
|
|
184
|
+
WhenClause,
|
|
185
|
+
WhenWalkerState,
|
|
186
|
+
WriteRule,
|
|
187
|
+
} from "./schema.ts";
|
|
188
|
+
export type {
|
|
189
|
+
BashShorthand,
|
|
190
|
+
CreateRecordingHostOptions,
|
|
191
|
+
EditShorthand,
|
|
192
|
+
ExpectBlocksOptions,
|
|
193
|
+
Harness,
|
|
194
|
+
LoadHarnessOptions,
|
|
195
|
+
MatrixCase,
|
|
196
|
+
MatrixCaseResult,
|
|
197
|
+
MatrixResult,
|
|
198
|
+
MockContextOptions,
|
|
199
|
+
MockEntry,
|
|
200
|
+
MockObserverContextOptions,
|
|
201
|
+
PriorEntryOptions,
|
|
202
|
+
RecordedExecCall,
|
|
203
|
+
RecordedSessionEntry,
|
|
204
|
+
RecordingHost,
|
|
205
|
+
ToolCallShorthand,
|
|
206
|
+
ToolResultShorthand,
|
|
207
|
+
WriteShorthand,
|
|
208
|
+
} from "./testing/index.ts";
|
|
209
|
+
// Testing primitives — re-exported at the root for discoverability.
|
|
210
|
+
// The canonical import path is `@cad0p/pi-steering/testing`;
|
|
211
|
+
// this root re-export means a test file that already imports
|
|
212
|
+
// `defineConfig` from the root doesn't need a second import line for
|
|
213
|
+
// `loadHarness`. See `./testing/index.ts` for the API docs.
|
|
214
|
+
export {
|
|
215
|
+
createRecordingHost,
|
|
216
|
+
expectAllows,
|
|
217
|
+
expectBlocks,
|
|
218
|
+
expectRuleFires,
|
|
219
|
+
formatMatrix,
|
|
220
|
+
getAppendedEntries,
|
|
221
|
+
loadHarness,
|
|
222
|
+
mockContext,
|
|
223
|
+
mockExtensionContext,
|
|
224
|
+
mockObserverContext,
|
|
225
|
+
priorEntry,
|
|
226
|
+
runMatrix,
|
|
227
|
+
testObserver,
|
|
228
|
+
testPredicate,
|
|
229
|
+
} from "./testing/index.ts";
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import type { Observer, Rule } from "../schema.ts";
|
|
7
|
+
import { dropUnusedObservers } from "./drop-unused-observers.ts";
|
|
8
|
+
|
|
9
|
+
/** Minimal Observer for tests. */
|
|
10
|
+
function mkObserver(name: string, writes?: readonly string[]): Observer {
|
|
11
|
+
const base = {
|
|
12
|
+
name,
|
|
13
|
+
watch: { toolName: "bash" as const },
|
|
14
|
+
onResult: () => {},
|
|
15
|
+
} satisfies Observer;
|
|
16
|
+
return writes === undefined ? base : { ...base, writes };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Minimal Rule with a specific `happened` shape. */
|
|
20
|
+
function mkRuleWithHappened(name: string, event: string, since?: string): Rule {
|
|
21
|
+
return {
|
|
22
|
+
name,
|
|
23
|
+
tool: "bash",
|
|
24
|
+
field: "command",
|
|
25
|
+
pattern: /^x/,
|
|
26
|
+
when: {
|
|
27
|
+
happened: since
|
|
28
|
+
? { event, in: "agent_loop", since }
|
|
29
|
+
: { event, in: "agent_loop" },
|
|
30
|
+
},
|
|
31
|
+
reason: "test",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Rule with no happened at all. */
|
|
36
|
+
function mkRuleNoHappened(name: string): Rule {
|
|
37
|
+
return {
|
|
38
|
+
name,
|
|
39
|
+
tool: "bash",
|
|
40
|
+
field: "command",
|
|
41
|
+
pattern: /^x/,
|
|
42
|
+
reason: "test",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("dropUnusedObservers", () => {
|
|
47
|
+
it("keeps observer whose write is consumed by a rule's happened.event", () => {
|
|
48
|
+
const observers = [mkObserver("a", ["X"])];
|
|
49
|
+
const rules = [mkRuleWithHappened("r1", "X")];
|
|
50
|
+
const { kept, dropped } = dropUnusedObservers(observers, rules);
|
|
51
|
+
assert.equal(kept.length, 1);
|
|
52
|
+
assert.equal(kept[0]?.name, "a");
|
|
53
|
+
assert.equal(dropped.length, 0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("drops observer whose writes are not consumed", () => {
|
|
57
|
+
const observers = [mkObserver("a", ["X"])];
|
|
58
|
+
const rules = [mkRuleNoHappened("r1")];
|
|
59
|
+
const { kept, dropped } = dropUnusedObservers(observers, rules);
|
|
60
|
+
assert.equal(kept.length, 0);
|
|
61
|
+
assert.equal(dropped.length, 1);
|
|
62
|
+
assert.equal(dropped[0]?.name, "a");
|
|
63
|
+
assert.deepEqual(dropped[0]?.writes, ["X"]);
|
|
64
|
+
assert.equal(dropped[0]?.reason, "unused-writes");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("keeps observer when at least one of its writes is consumed", () => {
|
|
68
|
+
const observers = [mkObserver("a", ["X", "Y"])];
|
|
69
|
+
const rules = [mkRuleWithHappened("r1", "X")];
|
|
70
|
+
const { kept, dropped } = dropUnusedObservers(observers, rules);
|
|
71
|
+
assert.equal(kept.length, 1);
|
|
72
|
+
assert.equal(dropped.length, 0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("keeps observer with `writes: undefined` (conservative)", () => {
|
|
76
|
+
const observers = [mkObserver("a", undefined)];
|
|
77
|
+
const rules = [mkRuleNoHappened("r1")];
|
|
78
|
+
const { kept, dropped } = dropUnusedObservers(observers, rules);
|
|
79
|
+
assert.equal(kept.length, 1);
|
|
80
|
+
assert.equal(dropped.length, 0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("keeps observer with empty `writes: []` (conservative parity)", () => {
|
|
84
|
+
const observers = [mkObserver("a", [])];
|
|
85
|
+
const rules = [mkRuleNoHappened("r1")];
|
|
86
|
+
const { kept, dropped } = dropUnusedObservers(observers, rules);
|
|
87
|
+
assert.equal(kept.length, 1);
|
|
88
|
+
assert.equal(dropped.length, 0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("keeps observer whose write is consumed only by happened.since", () => {
|
|
92
|
+
const observers = [mkObserver("a", ["X"])];
|
|
93
|
+
const rules = [mkRuleWithHappened("r1", "Y", /* since */ "X")];
|
|
94
|
+
const { kept, dropped } = dropUnusedObservers(observers, rules);
|
|
95
|
+
assert.equal(kept.length, 1);
|
|
96
|
+
assert.equal(dropped.length, 0);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("partitions a mixed observer set correctly", () => {
|
|
100
|
+
const observers = [
|
|
101
|
+
mkObserver("consumed", ["X"]),
|
|
102
|
+
mkObserver("unused", ["Z"]),
|
|
103
|
+
mkObserver("undeclared"),
|
|
104
|
+
];
|
|
105
|
+
const rules = [mkRuleWithHappened("r1", "X")];
|
|
106
|
+
const { kept, dropped } = dropUnusedObservers(observers, rules);
|
|
107
|
+
assert.deepEqual(kept.map((o) => o.name).sort(), [
|
|
108
|
+
"consumed",
|
|
109
|
+
"undeclared",
|
|
110
|
+
]);
|
|
111
|
+
assert.deepEqual(
|
|
112
|
+
dropped.map((d) => d.name),
|
|
113
|
+
["unused"],
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("drops everything when no rule uses `when.happened`", () => {
|
|
118
|
+
const observers = [mkObserver("a", ["X"]), mkObserver("b", ["Y", "Z"])];
|
|
119
|
+
const rules = [mkRuleNoHappened("r1"), mkRuleNoHappened("r2")];
|
|
120
|
+
const { kept, dropped } = dropUnusedObservers(observers, rules);
|
|
121
|
+
assert.equal(kept.length, 0);
|
|
122
|
+
assert.equal(dropped.length, 2);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("returns empty arrays for empty inputs", () => {
|
|
126
|
+
const { kept, dropped } = dropUnusedObservers([], []);
|
|
127
|
+
assert.equal(kept.length, 0);
|
|
128
|
+
assert.equal(dropped.length, 0);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("honors caller-applied rule filtering (disabledRules)", () => {
|
|
132
|
+
// Caller is responsible for filtering disabled rules before
|
|
133
|
+
// passing to this helper — demonstrate that a rule NOT in the
|
|
134
|
+
// input list does NOT cause its observer writes to stay alive.
|
|
135
|
+
const observers = [mkObserver("a", ["X"])];
|
|
136
|
+
const rulesAfterDisable: Rule[] = []; // r1 (consumer) disabled → filtered out
|
|
137
|
+
const { kept, dropped } = dropUnusedObservers(observers, rulesAfterDisable);
|
|
138
|
+
assert.equal(kept.length, 0);
|
|
139
|
+
assert.equal(dropped.length, 1);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Observer-drop optimization.
|
|
6
|
+
*
|
|
7
|
+
* At config-build time, observers whose declared `writes: [...]` are
|
|
8
|
+
* entirely unconsumed by any rule's `happened.event` / `happened.since`
|
|
9
|
+
* can be dropped: they'd only fire on `tool_result` and write entries
|
|
10
|
+
* no rule ever reads. Dropping them skips both the write (cheaper
|
|
11
|
+
* session JSONL) and the speculative-synthesis contribution (no chain
|
|
12
|
+
* entries for events nobody consults).
|
|
13
|
+
*
|
|
14
|
+
* Observers with `writes === undefined` or `writes: []` are kept
|
|
15
|
+
* unconditionally — without a declaration we can't prove they're
|
|
16
|
+
* unused, and `[]` is conservative parity with undeclared. Plugin
|
|
17
|
+
* authors are encouraged to declare `writes` both for this
|
|
18
|
+
* optimization and for `defineConfig`'s compile-time type inference.
|
|
19
|
+
*
|
|
20
|
+
* Pure function — no logging. Callers (session-runtime, testing)
|
|
21
|
+
* log on drop so tests can inspect `dropped` directly without
|
|
22
|
+
* intercepting stdout.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { Observer, Rule } from "../schema.ts";
|
|
26
|
+
|
|
27
|
+
/** An observer dropped by {@link dropUnusedObservers}. */
|
|
28
|
+
export interface DroppedObserver {
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly writes: readonly string[];
|
|
31
|
+
readonly reason: "unused-writes";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Partition `observers` into kept and dropped based on whether each
|
|
36
|
+
* observer's declared writes are consumed by any rule.
|
|
37
|
+
*
|
|
38
|
+
* @param observers - Observers to filter (any origin — plugin-merged
|
|
39
|
+
* or user-authored).
|
|
40
|
+
* @param rules - Full rule set whose `happened.event` / `happened.since`
|
|
41
|
+
* references determine consumption. Disabled rules should already
|
|
42
|
+
* be filtered out by the caller — this helper honors whatever's
|
|
43
|
+
* passed in.
|
|
44
|
+
*/
|
|
45
|
+
export function dropUnusedObservers(
|
|
46
|
+
observers: readonly Observer[],
|
|
47
|
+
rules: readonly Rule[],
|
|
48
|
+
): { kept: readonly Observer[]; dropped: readonly DroppedObserver[] } {
|
|
49
|
+
const consumed = collectConsumedEvents(rules);
|
|
50
|
+
const kept: Observer[] = [];
|
|
51
|
+
const dropped: DroppedObserver[] = [];
|
|
52
|
+
for (const o of observers) {
|
|
53
|
+
if (o.writes === undefined || o.writes.length === 0) {
|
|
54
|
+
kept.push(o);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (o.writes.some((w) => consumed.has(w))) {
|
|
58
|
+
kept.push(o);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
dropped.push({
|
|
62
|
+
name: o.name,
|
|
63
|
+
writes: o.writes,
|
|
64
|
+
reason: "unused-writes",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return { kept, dropped };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Collect every event referenced by any rule's `happened.event` /
|
|
71
|
+
* `happened.since`. Rules without `when.happened` contribute nothing. */
|
|
72
|
+
function collectConsumedEvents(rules: readonly Rule[]): Set<string> {
|
|
73
|
+
const consumed = new Set<string>();
|
|
74
|
+
for (const rule of rules) {
|
|
75
|
+
const h = rule.when?.happened;
|
|
76
|
+
if (h === undefined) continue;
|
|
77
|
+
if (typeof h.event === "string") consumed.add(h.event);
|
|
78
|
+
if (typeof h.since === "string") consumed.add(h.since);
|
|
79
|
+
}
|
|
80
|
+
return consumed;
|
|
81
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Late-stage plugin-state finalization shared by `buildSessionRuntime`,
|
|
6
|
+
* `loadHarness`, and the CLI `list` path. Centralizes the
|
|
7
|
+
* `dropUnusedObservers` + breadcrumb pass so all three stay byte-equal
|
|
8
|
+
* in their breadcrumb output and observer-drop semantics.
|
|
9
|
+
*
|
|
10
|
+
* Drops observers whose declared `writes` are unconsumed across both
|
|
11
|
+
* plugin-merged and user-authored streams, using the union of all
|
|
12
|
+
* rule `happened` references. Emits an `info`-level breadcrumb per
|
|
13
|
+
* dropped observer so plugin authors debugging "why isn't my
|
|
14
|
+
* observer firing?" have a trail to follow without it bubbling up as
|
|
15
|
+
* a diagnostic the user has to action.
|
|
16
|
+
*
|
|
17
|
+
* Callers must pre-filter `config.disabledRules` out of `userRules`
|
|
18
|
+
* — see {@link dropUnusedObservers}'s contract.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { Observer, Rule } from "../schema.ts";
|
|
22
|
+
import { dropUnusedObservers } from "./drop-unused-observers.ts";
|
|
23
|
+
|
|
24
|
+
export function finalizePluginState(
|
|
25
|
+
userRules: readonly Rule[],
|
|
26
|
+
pluginRules: readonly Rule[],
|
|
27
|
+
userObservers: readonly Observer[],
|
|
28
|
+
pluginObservers: readonly Observer[],
|
|
29
|
+
): {
|
|
30
|
+
pluginKept: readonly Observer[];
|
|
31
|
+
userKept: readonly Observer[];
|
|
32
|
+
} {
|
|
33
|
+
const allRules = [...userRules, ...pluginRules];
|
|
34
|
+
const pluginDrop = dropUnusedObservers(pluginObservers, allRules);
|
|
35
|
+
const userDrop = dropUnusedObservers(userObservers, allRules);
|
|
36
|
+
for (const d of [...pluginDrop.dropped, ...userDrop.dropped]) {
|
|
37
|
+
console.info(
|
|
38
|
+
`[pi-steering] observer '${d.name}' dropped; its writes ` +
|
|
39
|
+
`(${d.writes.join(", ")}) are not consumed by any rule`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return { pluginKept: pluginDrop.kept, userKept: userDrop.kept };
|
|
43
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared observer-merge helper used by both the evaluator's reverse
|
|
6
|
+
* index (`when.happened` with `in: "tool_call"`) and the observer-dispatcher's
|
|
7
|
+
* fire list.
|
|
8
|
+
*
|
|
9
|
+
* Both callers need the same answer to the question "what observers
|
|
10
|
+
* does this config have, after resolving plugins and applying user
|
|
11
|
+
* overrides?" Keeping a single implementation here prevents silent
|
|
12
|
+
* drift — if the dispatcher drops a plugin observer because a user
|
|
13
|
+
* observer shadows it by name, the evaluator's reverse-index must do
|
|
14
|
+
* the same, or tool_call-scope speculative allow can grant on a watch
|
|
15
|
+
* pattern that will never actually fire.
|
|
16
|
+
*
|
|
17
|
+
* Merge semantics (must match `buildObserverDispatcher`):
|
|
18
|
+
* 1. User observers come first (user-wins on name collision).
|
|
19
|
+
* 2. Plugin observers fill in any names not already seen.
|
|
20
|
+
*
|
|
21
|
+
* The order mirrors the rule-list "user overrides plugin by declaring
|
|
22
|
+
* their own" pattern documented on `buildObserverDispatcher`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { Observer } from "../schema.ts";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Merge user-level and plugin-level observers into a single list with
|
|
29
|
+
* first-registered dedup by `name`. User observers always precede
|
|
30
|
+
* plugin observers of the same name.
|
|
31
|
+
*
|
|
32
|
+
* Returns a fresh array; neither input is mutated.
|
|
33
|
+
*/
|
|
34
|
+
export function mergeObserversUserFirst(
|
|
35
|
+
userObservers: readonly Observer[],
|
|
36
|
+
pluginObservers: readonly Observer[],
|
|
37
|
+
): Observer[] {
|
|
38
|
+
const merged: Observer[] = [];
|
|
39
|
+
const seen = new Set<string>();
|
|
40
|
+
for (const o of userObservers) {
|
|
41
|
+
if (seen.has(o.name)) continue;
|
|
42
|
+
seen.add(o.name);
|
|
43
|
+
merged.push(o);
|
|
44
|
+
}
|
|
45
|
+
for (const o of pluginObservers) {
|
|
46
|
+
if (seen.has(o.name)) continue;
|
|
47
|
+
seen.add(o.name);
|
|
48
|
+
merged.push(o);
|
|
49
|
+
}
|
|
50
|
+
return merged;
|
|
51
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import { isPattern } from "./pattern-utils.ts";
|
|
7
|
+
|
|
8
|
+
describe("isPattern", () => {
|
|
9
|
+
it("narrows strings to Pattern", () => {
|
|
10
|
+
assert.equal(isPattern("hello"), true);
|
|
11
|
+
assert.equal(isPattern(""), true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("narrows RegExp to Pattern", () => {
|
|
15
|
+
assert.equal(isPattern(/foo/), true);
|
|
16
|
+
assert.equal(isPattern(/(?:)/), true);
|
|
17
|
+
assert.equal(isPattern(/^bar$/i), true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("rejects primitives that aren't string", () => {
|
|
21
|
+
assert.equal(isPattern(123), false);
|
|
22
|
+
assert.equal(isPattern(true), false);
|
|
23
|
+
assert.equal(isPattern(false), false);
|
|
24
|
+
assert.equal(isPattern(null), false);
|
|
25
|
+
assert.equal(isPattern(undefined), false);
|
|
26
|
+
assert.equal(isPattern(Symbol("foo")), false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("rejects objects + arrays", () => {
|
|
30
|
+
assert.equal(isPattern({}), false);
|
|
31
|
+
assert.equal(isPattern({ pattern: "x" }), false);
|
|
32
|
+
assert.equal(isPattern([]), false);
|
|
33
|
+
assert.equal(isPattern(["foo"]), false);
|
|
34
|
+
assert.equal(isPattern([/foo/]), false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("narrows correctly through Array.every (compile-time check)", () => {
|
|
38
|
+
// This is the exact use site that motivated the type predicate:
|
|
39
|
+
// `arr.every(isPattern)` MUST narrow `unknown[]` to `Pattern[]`
|
|
40
|
+
// at the type level. The runtime assertion below is incidental;
|
|
41
|
+
// the real test is that this file typechecks.
|
|
42
|
+
const arr: unknown[] = ["foo", /bar/];
|
|
43
|
+
if (arr.every(isPattern)) {
|
|
44
|
+
// Inside this branch, `arr` is Pattern[]; the .some call
|
|
45
|
+
// would be a type error if narrowing weren't working.
|
|
46
|
+
const someResult: boolean = arr.some(
|
|
47
|
+
(p) => typeof p === "string" || p instanceof RegExp,
|
|
48
|
+
);
|
|
49
|
+
assert.equal(someResult, true);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared pattern utilities reused across the engine and the gitPlugin's
|
|
6
|
+
* predicate handlers. Lives in `internal/` because both the engine
|
|
7
|
+
* (`evaluator-internals/predicates.ts`) and the gitPlugin
|
|
8
|
+
* (`plugins/git/predicates.ts`) need the same `Pattern` narrowing
|
|
9
|
+
* helper, and the engine cannot import from a plugin without inverting
|
|
10
|
+
* the dependency graph.
|
|
11
|
+
*
|
|
12
|
+
* The two normalization sites stay independent (the engine's
|
|
13
|
+
* `evaluateCwd` keeps its inline fast-path optimization for the
|
|
14
|
+
* single-pattern shorthand; the gitPlugin's `unwrapPatternArg`
|
|
15
|
+
* normalizes once and shares the result across `branch` / `upstream`
|
|
16
|
+
* / `remote`); only the type predicate is shared.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Pattern } from "../schema.ts";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Type predicate for {@link Pattern} (`string | RegExp`). Used by the
|
|
23
|
+
* pattern-valued predicates (`cwd`, `branch`, `upstream`, `remote`) to
|
|
24
|
+
* narrow `unknown[]` array elements to `Pattern[]` for safe iteration:
|
|
25
|
+
*
|
|
26
|
+
* if (Array.isArray(value) && value.every(isPattern)) {
|
|
27
|
+
* // value is Pattern[] here
|
|
28
|
+
* return value.some(p => matchesPattern(p, target));
|
|
29
|
+
* }
|
|
30
|
+
*
|
|
31
|
+
* Without the `v is Pattern` type predicate, the same expression
|
|
32
|
+
* (`value.every(v => typeof v === "string" || v instanceof RegExp)`)
|
|
33
|
+
* does NOT narrow `value` from `unknown[]` to `Pattern[]` — TypeScript
|
|
34
|
+
* needs the explicit type-predicate signature for the narrowing to
|
|
35
|
+
* propagate through `Array.isArray` + `.every`.
|
|
36
|
+
*/
|
|
37
|
+
export const isPattern = (v: unknown): v is Pattern =>
|
|
38
|
+
typeof v === "string" || v instanceof RegExp;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
//
|
|
4
|
+
// Unit tests for refToText. The function is a thin wrapper around
|
|
5
|
+
// unbash-walker's getBasename / getCommandArgs; these tests pin the
|
|
6
|
+
// exact rendered shape observer-watch patterns see so a future
|
|
7
|
+
// refactor that accidentally changes spacing (e.g. drops the trim)
|
|
8
|
+
// doesn't silently break every `watch.inputMatches.command` in the
|
|
9
|
+
// wild.
|
|
10
|
+
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { describe, it } from "node:test";
|
|
13
|
+
import { extractAllCommandsFromAST, parse } from "@cad0p/unbash-walker";
|
|
14
|
+
import { refToText } from "./ref-text.ts";
|
|
15
|
+
|
|
16
|
+
function refsFor(command: string) {
|
|
17
|
+
const script = parse(command);
|
|
18
|
+
return extractAllCommandsFromAST(script, command);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("refToText", () => {
|
|
22
|
+
it("renders bare command with no args", () => {
|
|
23
|
+
const [ref] = refsFor("alpha");
|
|
24
|
+
assert.equal(refToText(ref!), "alpha");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("renders command with single arg", () => {
|
|
28
|
+
const [ref] = refsFor("git push");
|
|
29
|
+
assert.equal(refToText(ref!), "git push");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("renders command with multiple args, space-joined", () => {
|
|
33
|
+
const [ref] = refsFor("git push origin main --force");
|
|
34
|
+
assert.equal(refToText(ref!), "git push origin main --force");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("trims trailing space from empty args list", () => {
|
|
38
|
+
const [ref] = refsFor("noop");
|
|
39
|
+
assert.equal(
|
|
40
|
+
refToText(ref!),
|
|
41
|
+
"noop",
|
|
42
|
+
"no trailing space when args is empty",
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("basename strips path prefix", () => {
|
|
47
|
+
const [ref] = refsFor("/usr/local/bin/cr --all");
|
|
48
|
+
assert.equal(
|
|
49
|
+
refToText(ref!),
|
|
50
|
+
"cr --all",
|
|
51
|
+
"rendered command uses basename, not absolute path",
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
});
|