@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,200 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared observer-watch filter contract. Single source of truth for
|
|
6
|
+
* "does this observer's `watch` accept this tool_result event?"
|
|
7
|
+
*
|
|
8
|
+
* Used by BOTH:
|
|
9
|
+
*
|
|
10
|
+
* - the observer-dispatcher (production fire path — decides which
|
|
11
|
+
* observers see a concrete tool_result).
|
|
12
|
+
* - the evaluator's `when.happened` tool_call-scope speculative-
|
|
13
|
+
* allow (synthesizes a minimal successful bash event representing
|
|
14
|
+
* "this prior `&&` ref is about to run and succeed", then asks
|
|
15
|
+
* the same question).
|
|
16
|
+
*
|
|
17
|
+
* Co-locating the contract here retires a structural fragility PR #4
|
|
18
|
+
* reviewers caught three times: the speculative-allow path used to
|
|
19
|
+
* hand-roll a SUBSET of the watch filter (command-pattern only, then
|
|
20
|
+
* patched to also check toolName + exitCode). Each new `watch` field
|
|
21
|
+
* the dispatcher grew would create a fresh drift opportunity.
|
|
22
|
+
*
|
|
23
|
+
* Keeping both callers on this one function guarantees the two paths
|
|
24
|
+
* agree by construction. If the speculative-allow path wants to
|
|
25
|
+
* impose a STRICTER gate on top (e.g. "observer must declare
|
|
26
|
+
* `inputMatches.command`" — an authoring requirement to keep
|
|
27
|
+
* speculative-allow safe), it layers that gate before delegating to
|
|
28
|
+
* {@link matchesWatch} rather than re-implementing the filter body.
|
|
29
|
+
*
|
|
30
|
+
* Merge-observers helper lives in {@link ./merge-observers.ts} for the
|
|
31
|
+
* same single-source-of-truth reason. This file is its sibling on the
|
|
32
|
+
* watch-filter axis.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
expandWrapperCommands,
|
|
37
|
+
extractAllCommandsFromAST,
|
|
38
|
+
parse as parseBash,
|
|
39
|
+
} from "@cad0p/unbash-walker";
|
|
40
|
+
import { matchesPattern } from "../evaluator-internals/predicates.ts";
|
|
41
|
+
import type { ObserverWatch, Pattern, ToolResultEvent } from "../schema.ts";
|
|
42
|
+
import { refToText } from "./ref-text.ts";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* True if the observer's `watch` filter accepts this event. No watch
|
|
46
|
+
* → matches everything. Semantics per ADR "Observer schema":
|
|
47
|
+
*
|
|
48
|
+
* - `toolName` — exact match against `event.toolName`.
|
|
49
|
+
* - `inputMatches` — every declared key's Pattern must match against
|
|
50
|
+
* `event.input[key]` if that key exists AND the value is a string.
|
|
51
|
+
* Keys absent from the event's input (or non-string values) make
|
|
52
|
+
* the whole filter fail — documented fail-closed choice: subset
|
|
53
|
+
* checks don't silently pass when the expected field isn't present.
|
|
54
|
+
* - `exitCode` — `"success"` → 0, `"failure"` → non-zero,
|
|
55
|
+
* `"any"`/omitted → pass, numeric → exact match. `exitCode` is
|
|
56
|
+
* sourced from the event's `exitCode` field (bash only via pi's
|
|
57
|
+
* `details.exitCode` after projection to the schema shape); other
|
|
58
|
+
* tool results leave it `undefined` and satisfy everything except
|
|
59
|
+
* a numeric `exitCode:` (treated as "no match" — bash-specific
|
|
60
|
+
* filter).
|
|
61
|
+
*
|
|
62
|
+
* Wrapper-aware command matching (ADR §12): when `inputMatches.command`
|
|
63
|
+
* is set AND the event is a bash event, the pattern matches if EITHER
|
|
64
|
+
* the raw outer `event.input.command` OR any extracted command ref
|
|
65
|
+
* text matches. So `sh -c 'brazil ws sync'` with pattern
|
|
66
|
+
* `/^brazil\s+ws\s+sync$/` fires the observer — the outer raw command
|
|
67
|
+
* starts with `sh`, but the walker-extracted ref `brazil ws sync` does
|
|
68
|
+
* hit the anchored pattern.
|
|
69
|
+
*
|
|
70
|
+
* Performance: when multiple observers share the same event (the
|
|
71
|
+
* production dispatch path), pass a memoizing `refTextsProvider` to
|
|
72
|
+
* parse the bash command once across observers. Standalone callers
|
|
73
|
+
* (e.g. `testObserver` evaluating one observer in isolation, or the
|
|
74
|
+
* evaluator's speculative-allow synthesizing one event per prior
|
|
75
|
+
* `&&` ref) can omit it — the default provider parses on demand.
|
|
76
|
+
*/
|
|
77
|
+
export function matchesWatch(
|
|
78
|
+
watch: ObserverWatch | undefined,
|
|
79
|
+
event: ToolResultEvent,
|
|
80
|
+
refTextsProvider?: () => readonly string[] | null,
|
|
81
|
+
): boolean {
|
|
82
|
+
if (!watch) return true;
|
|
83
|
+
|
|
84
|
+
if (watch.toolName !== undefined && watch.toolName !== event.toolName) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (watch.inputMatches) {
|
|
89
|
+
const rawInput = event.input;
|
|
90
|
+
const input: Record<string, unknown> =
|
|
91
|
+
typeof rawInput === "object" && rawInput !== null
|
|
92
|
+
? (rawInput as Record<string, unknown>)
|
|
93
|
+
: {};
|
|
94
|
+
const getRefTexts =
|
|
95
|
+
refTextsProvider ?? (() => extractRefTextsForBash(event));
|
|
96
|
+
for (const [key, pat] of Object.entries(watch.inputMatches)) {
|
|
97
|
+
const value = input[key];
|
|
98
|
+
if (typeof value !== "string") return false;
|
|
99
|
+
if (!matchesInputField(key, pat, value, event, getRefTexts)) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (watch.exitCode !== undefined && watch.exitCode !== "any") {
|
|
106
|
+
if (!matchesExitCode(event.exitCode, watch.exitCode)) return false;
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Match a single `inputMatches` key/value against the event. `command`
|
|
113
|
+
* on a bash event is wrapper-aware per ADR §12 — the raw outer command
|
|
114
|
+
* OR any extracted ref text matches. All other keys (and `command` on
|
|
115
|
+
* non-bash events) keep the straight raw-string match the v0.0 engine
|
|
116
|
+
* shipped with.
|
|
117
|
+
*
|
|
118
|
+
* Share the evaluator's regex cache (module-scoped in `predicates.ts`)
|
|
119
|
+
* so observer `inputMatches` reuse the same compiled `RegExp` as
|
|
120
|
+
* equivalent rule patterns.
|
|
121
|
+
*/
|
|
122
|
+
function matchesInputField(
|
|
123
|
+
key: string,
|
|
124
|
+
pat: Pattern,
|
|
125
|
+
value: string,
|
|
126
|
+
event: ToolResultEvent,
|
|
127
|
+
getRefTexts: () => readonly string[] | null,
|
|
128
|
+
): boolean {
|
|
129
|
+
if (matchesPattern(pat, value)) return true;
|
|
130
|
+
|
|
131
|
+
// Wrapper-aware fallback: only for `command` on bash events. Other
|
|
132
|
+
// fields (path, content, …) don't have wrapper analogues — a
|
|
133
|
+
// file-path pattern has nothing to do with bash AST refs, so
|
|
134
|
+
// leaving them on the raw-string path is both correct and a perf
|
|
135
|
+
// guard against needless parsing on non-bash events.
|
|
136
|
+
if (key !== "command" || event.toolName !== "bash") return false;
|
|
137
|
+
|
|
138
|
+
const refTexts = getRefTexts();
|
|
139
|
+
if (refTexts === null) return false;
|
|
140
|
+
for (const text of refTexts) {
|
|
141
|
+
if (matchesPattern(pat, text)) return true;
|
|
142
|
+
}
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Extract per-ref flattened text (basename + args joined with spaces)
|
|
148
|
+
* from a bash tool_result's outer command, mirroring the evaluator's
|
|
149
|
+
* `prepareBashState` text projection so observer watch patterns match
|
|
150
|
+
* the same strings rule patterns see for the same command.
|
|
151
|
+
*
|
|
152
|
+
* Returns `null` when the event isn't a bash tool_result, the raw
|
|
153
|
+
* command is missing/non-string, or the walker throws while parsing
|
|
154
|
+
* (hard-to-parse command — fall back to raw-only matching without
|
|
155
|
+
* blowing up dispatch). Unlike the evaluator we don't walk trackers:
|
|
156
|
+
* observers don't receive `walkerState`, so the parse+extract+expand
|
|
157
|
+
* stages suffice.
|
|
158
|
+
*
|
|
159
|
+
* Exported so the production dispatcher can memoize the parse across
|
|
160
|
+
* observers on the same event (see `dispatchEventInner`'s
|
|
161
|
+
* `getRefTexts` cache). Chain-aware speculative-allow already
|
|
162
|
+
* synthesizes one event per prior ref and doesn't need memoization —
|
|
163
|
+
* it calls {@link matchesWatch} without a provider.
|
|
164
|
+
*/
|
|
165
|
+
export function extractRefTextsForBash(
|
|
166
|
+
event: ToolResultEvent,
|
|
167
|
+
): readonly string[] | null {
|
|
168
|
+
if (event.toolName !== "bash") return null;
|
|
169
|
+
const input =
|
|
170
|
+
typeof event.input === "object" && event.input !== null
|
|
171
|
+
? (event.input as { command?: unknown })
|
|
172
|
+
: undefined;
|
|
173
|
+
const command = input?.command;
|
|
174
|
+
if (typeof command !== "string" || command.length === 0) return null;
|
|
175
|
+
try {
|
|
176
|
+
const script = parseBash(command);
|
|
177
|
+
const extracted = extractAllCommandsFromAST(script, command);
|
|
178
|
+
const { commands: refs } = expandWrapperCommands(extracted);
|
|
179
|
+
return refs.map(refToText);
|
|
180
|
+
} catch {
|
|
181
|
+
// Don't let a parse error take down dispatch — a malformed
|
|
182
|
+
// command still deserves a raw-match chance. Returning null
|
|
183
|
+
// (as opposed to []) skips ref matching entirely for this event.
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function matchesExitCode(
|
|
189
|
+
code: number | undefined,
|
|
190
|
+
filter: number | "success" | "failure",
|
|
191
|
+
): boolean {
|
|
192
|
+
if (typeof filter === "number") {
|
|
193
|
+
// Numeric filter requires a concrete code; no-code events (non-bash)
|
|
194
|
+
// never match a numeric filter.
|
|
195
|
+
return code === filter;
|
|
196
|
+
}
|
|
197
|
+
if (filter === "success") return code === 0;
|
|
198
|
+
if (filter === "failure") return code !== undefined && code !== 0;
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Regression fence: verifies that JSDoc declared on
|
|
6
|
+
* `PiSteeringPredicates.<key>` (in a plugin's `declare global { ... }`
|
|
7
|
+
* block) propagates onto the synthesized fields of
|
|
8
|
+
* {@link TopLevelWhenClause} and {@link TopLevelWhenClauseNoRecurse},
|
|
9
|
+
* surfacing on hover for plugin-predicate authors.
|
|
10
|
+
*
|
|
11
|
+
* Hover resolution drives `LanguageService.getQuickInfoAtPosition` —
|
|
12
|
+
* the same code tsserver runs for IDE hover tooltips — so the
|
|
13
|
+
* assertion text is exactly what the user sees.
|
|
14
|
+
*
|
|
15
|
+
* Background: TypeScript's checker
|
|
16
|
+
* (`resolveMappedTypeMembers` in `src/compiler/checker.ts`) only
|
|
17
|
+
* links property declarations through a mapped type when the
|
|
18
|
+
* constraint AST is literally `keyof T` and the optional `as` clause
|
|
19
|
+
* is a Filter (returns `K | never`, assignable to `K`). The
|
|
20
|
+
* pre-computed alias `[K in PluginPredicateKey]` produces the same
|
|
21
|
+
* keyset but uses a `TypeReference` AST in place of `KeyOfKeyword` —
|
|
22
|
+
* `isMappedTypeWithKeyofConstraintDeclaration` returns false, the
|
|
23
|
+
* `modifiersProp.declarations` link is dropped, and JSDoc on the
|
|
24
|
+
* underlying interface member is no longer reachable from the
|
|
25
|
+
* synthesized symbol.
|
|
26
|
+
*
|
|
27
|
+
* Built-in non-registry leaves (`cwd:`, `happened:`, `condition:`)
|
|
28
|
+
* don't share this regression vector — their JSDoc propagates through
|
|
29
|
+
* the trivial `interface property → property symbol` path on
|
|
30
|
+
* `BuiltInWhenLeavesOuter` / `BuiltInWhenLeavesInner` and the
|
|
31
|
+
* homomorphism check doesn't apply.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import assert from "node:assert/strict";
|
|
35
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
36
|
+
import * as os from "node:os";
|
|
37
|
+
import * as path from "node:path";
|
|
38
|
+
import { describe, it } from "node:test";
|
|
39
|
+
import { fileURLToPath } from "node:url";
|
|
40
|
+
|
|
41
|
+
import * as ts from "typescript";
|
|
42
|
+
|
|
43
|
+
// Imported solely so the file-level JSDoc `@link` references resolve
|
|
44
|
+
// on hover; these are the types whose hover-propagation behavior the
|
|
45
|
+
// fences below pin.
|
|
46
|
+
import type {
|
|
47
|
+
TopLevelWhenClause,
|
|
48
|
+
TopLevelWhenClauseNoRecurse,
|
|
49
|
+
} from "./schema.ts";
|
|
50
|
+
|
|
51
|
+
void (0 as unknown as TopLevelWhenClause | TopLevelWhenClauseNoRecurse);
|
|
52
|
+
|
|
53
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
54
|
+
const PLUGIN_GIT_PATH = path.resolve(HERE, "plugins/git/index.ts");
|
|
55
|
+
const SCHEMA_PATH = path.resolve(HERE, "schema.ts");
|
|
56
|
+
const INDEX_PATH = path.resolve(HERE, "index.ts");
|
|
57
|
+
|
|
58
|
+
const COMPILER_OPTIONS: ts.CompilerOptions = {
|
|
59
|
+
target: ts.ScriptTarget.ES2022,
|
|
60
|
+
module: ts.ModuleKind.ES2022,
|
|
61
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
62
|
+
allowImportingTsExtensions: true,
|
|
63
|
+
skipLibCheck: true,
|
|
64
|
+
strict: true,
|
|
65
|
+
exactOptionalPropertyTypes: true,
|
|
66
|
+
noEmit: true,
|
|
67
|
+
types: [],
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolve hover-time JSDoc at the position of `propName:` inside the
|
|
72
|
+
* object literal whose source location follows `anchor`. Drives
|
|
73
|
+
* `LanguageService.getQuickInfoAtPosition` — the same code tsserver
|
|
74
|
+
* runs for IDE hover tooltips, so the returned text matches what the
|
|
75
|
+
* user sees on hover.
|
|
76
|
+
*/
|
|
77
|
+
function hoverDocsAt(
|
|
78
|
+
scratchDir: string,
|
|
79
|
+
source: string,
|
|
80
|
+
propName: string,
|
|
81
|
+
anchor: string,
|
|
82
|
+
): string {
|
|
83
|
+
mkdirSync(scratchDir, { recursive: true });
|
|
84
|
+
const scratchFile = path.join(scratchDir, "probe.ts");
|
|
85
|
+
writeFileSync(scratchFile, source);
|
|
86
|
+
|
|
87
|
+
const host: ts.LanguageServiceHost = {
|
|
88
|
+
getScriptFileNames: () => [scratchFile],
|
|
89
|
+
getScriptVersion: () => "1",
|
|
90
|
+
getScriptSnapshot: (name) => {
|
|
91
|
+
try {
|
|
92
|
+
return ts.ScriptSnapshot.fromString(ts.sys.readFile(name) ?? "");
|
|
93
|
+
} catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
getCurrentDirectory: () => scratchDir,
|
|
98
|
+
getCompilationSettings: () => COMPILER_OPTIONS,
|
|
99
|
+
getDefaultLibFileName: (opts) => ts.getDefaultLibFilePath(opts),
|
|
100
|
+
fileExists: ts.sys.fileExists,
|
|
101
|
+
readFile: ts.sys.readFile,
|
|
102
|
+
readDirectory: ts.sys.readDirectory,
|
|
103
|
+
directoryExists: ts.sys.directoryExists,
|
|
104
|
+
getDirectories: ts.sys.getDirectories,
|
|
105
|
+
};
|
|
106
|
+
const ls = ts.createLanguageService(host, ts.createDocumentRegistry());
|
|
107
|
+
const anchorIdx = source.indexOf(anchor);
|
|
108
|
+
assert(
|
|
109
|
+
anchorIdx >= 0,
|
|
110
|
+
`anchor ${JSON.stringify(anchor)} not found in source`,
|
|
111
|
+
);
|
|
112
|
+
const propIdx = source.indexOf(`${propName}:`, anchorIdx);
|
|
113
|
+
assert(
|
|
114
|
+
propIdx >= 0,
|
|
115
|
+
`prop ${JSON.stringify(propName)} not found after anchor`,
|
|
116
|
+
);
|
|
117
|
+
const qi = ls.getQuickInfoAtPosition(scratchFile, propIdx + 1);
|
|
118
|
+
assert(qi, "expected quickInfo to resolve at probe position");
|
|
119
|
+
return ts.displayPartsToString(qi.documentation ?? []);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function withScratch(suffix: string, fn: (scratchDir: string) => void): void {
|
|
123
|
+
const scratchDir = path.join(
|
|
124
|
+
os.tmpdir(),
|
|
125
|
+
`jsdoc-propagation-${suffix}-${process.pid}-${Date.now()}`,
|
|
126
|
+
);
|
|
127
|
+
try {
|
|
128
|
+
fn(scratchDir);
|
|
129
|
+
} finally {
|
|
130
|
+
rmSync(scratchDir, { recursive: true, force: true });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const IMPORT_HEADER = [
|
|
135
|
+
`import "${PLUGIN_GIT_PATH.replace(/\\/g, "\\\\")}";`,
|
|
136
|
+
`import type { Rule } from "${SCHEMA_PATH.replace(/\\/g, "\\\\")}";`,
|
|
137
|
+
"",
|
|
138
|
+
].join("\n");
|
|
139
|
+
|
|
140
|
+
const RULE_BOILERPLATE = [
|
|
141
|
+
'\tname: "x",',
|
|
142
|
+
'\ttool: "bash",',
|
|
143
|
+
'\tfield: "command",',
|
|
144
|
+
'\tpattern: "^x",',
|
|
145
|
+
'\treason: "x",',
|
|
146
|
+
].join("\n");
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* One row per plugin-augmented predicate key in `PiSteeringPredicates`.
|
|
150
|
+
* `value` is the bare-form RHS used inside `when: { ... }`; `contains`
|
|
151
|
+
* is a sentinel substring of the source-declared JSDoc on
|
|
152
|
+
* `plugins/git/index.ts`'s `interface PiSteeringPredicates`.
|
|
153
|
+
*/
|
|
154
|
+
const PREDICATE_CASES = [
|
|
155
|
+
{ key: "branch", value: "/main/", contains: "current git branch" },
|
|
156
|
+
{ key: "upstream", value: "/origin/", contains: "configured" },
|
|
157
|
+
{ key: "remote", value: "/github/", contains: "remote.origin.url" },
|
|
158
|
+
{ key: "isClean", value: "false", contains: "git status" },
|
|
159
|
+
{ key: "hasStagedChanges", value: "true", contains: "git diff --cached" },
|
|
160
|
+
{ key: "commitsAhead", value: "1", contains: "commits ahead" },
|
|
161
|
+
] as const;
|
|
162
|
+
|
|
163
|
+
describe("JSDoc propagation through TopLevelWhenClause mapped type", () => {
|
|
164
|
+
for (const { key, value, contains } of PREDICATE_CASES) {
|
|
165
|
+
it(`surfaces JSDoc on when.${key} via Rule annotation`, () => {
|
|
166
|
+
withScratch(`outer-${key}`, (scratchDir) => {
|
|
167
|
+
const source =
|
|
168
|
+
IMPORT_HEADER +
|
|
169
|
+
"const r: Rule = {\n" +
|
|
170
|
+
RULE_BOILERPLATE +
|
|
171
|
+
"\n" +
|
|
172
|
+
`\twhen: { ${key}: ${value} },\n` +
|
|
173
|
+
"};\n" +
|
|
174
|
+
"void r;\n";
|
|
175
|
+
const docs = hoverDocsAt(scratchDir, source, key, "when: {");
|
|
176
|
+
assert(
|
|
177
|
+
docs.includes(`when.${key}`),
|
|
178
|
+
`expected JSDoc to include 'when.${key}'; got: ${JSON.stringify(docs)}`,
|
|
179
|
+
);
|
|
180
|
+
assert(
|
|
181
|
+
docs.includes(contains),
|
|
182
|
+
`expected JSDoc to include ${JSON.stringify(contains)}; got: ${JSON.stringify(docs)}`,
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it(`surfaces JSDoc on when.not.${key} via TopLevelWhenClauseNoRecurse`, () => {
|
|
188
|
+
withScratch(`inner-${key}`, (scratchDir) => {
|
|
189
|
+
const source =
|
|
190
|
+
IMPORT_HEADER +
|
|
191
|
+
"const r: Rule = {\n" +
|
|
192
|
+
RULE_BOILERPLATE +
|
|
193
|
+
"\n" +
|
|
194
|
+
`\twhen: { not: { ${key}: ${value} } },\n` +
|
|
195
|
+
"};\n" +
|
|
196
|
+
"void r;\n";
|
|
197
|
+
const docs = hoverDocsAt(scratchDir, source, key, "not: {");
|
|
198
|
+
assert(
|
|
199
|
+
docs.includes(`when.${key}`),
|
|
200
|
+
`expected nested JSDoc to include 'when.${key}'; got: ${JSON.stringify(docs)}`,
|
|
201
|
+
);
|
|
202
|
+
assert(
|
|
203
|
+
docs.includes(contains),
|
|
204
|
+
`expected nested JSDoc to include ${JSON.stringify(contains)}; got: ${JSON.stringify(docs)}`,
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
it("inline defineConfig drops JSDoc — known limitation; factor rules out for hover-rich authoring", () => {
|
|
211
|
+
// `defineConfig({ rules: [{ ...inline... }] })` narrows the
|
|
212
|
+
// rule literal via its `const R extends readonly Rule[]`
|
|
213
|
+
// signature, bypassing the homomorphic mapped-type linkage.
|
|
214
|
+
// The canonical hover-rich pattern is to factor rules out into
|
|
215
|
+
// `as const satisfies Rule` (or `: Rule`) bindings — see the
|
|
216
|
+
// next test and `defineConfig`'s JSDoc.
|
|
217
|
+
withScratch("inline-defineconfig", (scratchDir) => {
|
|
218
|
+
const source = [
|
|
219
|
+
`import { defineConfig } from "${INDEX_PATH.replace(/\\/g, "\\\\")}";`,
|
|
220
|
+
`import "${PLUGIN_GIT_PATH.replace(/\\/g, "\\\\")}";`,
|
|
221
|
+
"",
|
|
222
|
+
"export default defineConfig({",
|
|
223
|
+
"\trules: [",
|
|
224
|
+
"\t\t{",
|
|
225
|
+
'\t\t\tname: "inline",',
|
|
226
|
+
'\t\t\ttool: "bash",',
|
|
227
|
+
'\t\t\tfield: "command",',
|
|
228
|
+
'\t\t\tpattern: "^x",',
|
|
229
|
+
'\t\t\treason: "x",',
|
|
230
|
+
"\t\t\twhen: { isClean: false },",
|
|
231
|
+
"\t\t},",
|
|
232
|
+
"\t],",
|
|
233
|
+
"});",
|
|
234
|
+
"",
|
|
235
|
+
].join("\n");
|
|
236
|
+
const docs = hoverDocsAt(scratchDir, source, "isClean", 'name: "inline"');
|
|
237
|
+
assert.equal(
|
|
238
|
+
docs,
|
|
239
|
+
"",
|
|
240
|
+
`inline-defineConfig path now SURFACES JSDoc — gap closed, update the docs (defineConfig JSDoc + examples README) to remove the factor-out recommendation. Got: ${JSON.stringify(docs)}`,
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("factored-out `as const satisfies Rule` preserves JSDoc through defineConfig", () => {
|
|
246
|
+
// The canonical hover-rich pattern documented in
|
|
247
|
+
// `defineConfig`'s JSDoc and `examples/dynamic-reason-runtime-
|
|
248
|
+
// cwd/steering.ts`. The factored-out binding annotates the
|
|
249
|
+
// literal with `Rule` (via `satisfies`), restoring the
|
|
250
|
+
// homomorphic mapped-type linkage that surfaces source JSDoc.
|
|
251
|
+
withScratch("factored-out", (scratchDir) => {
|
|
252
|
+
const source = [
|
|
253
|
+
`import { defineConfig, type Rule } from "${INDEX_PATH.replace(/\\/g, "\\\\")}";`,
|
|
254
|
+
`import "${PLUGIN_GIT_PATH.replace(/\\/g, "\\\\")}";`,
|
|
255
|
+
"",
|
|
256
|
+
"const myRule = {",
|
|
257
|
+
'\tname: "factored",',
|
|
258
|
+
'\ttool: "bash",',
|
|
259
|
+
'\tfield: "command",',
|
|
260
|
+
'\tpattern: "^x",',
|
|
261
|
+
'\treason: "x",',
|
|
262
|
+
"\twhen: { isClean: false },",
|
|
263
|
+
"} as const satisfies Rule;",
|
|
264
|
+
"",
|
|
265
|
+
"export default defineConfig({",
|
|
266
|
+
"\trules: [myRule],",
|
|
267
|
+
"});",
|
|
268
|
+
"",
|
|
269
|
+
].join("\n");
|
|
270
|
+
const docs = hoverDocsAt(scratchDir, source, "isClean", "myRule");
|
|
271
|
+
assert(
|
|
272
|
+
docs.includes("git status"),
|
|
273
|
+
`factored-out pattern should preserve JSDoc; got: ${JSON.stringify(docs)}`,
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
});
|