@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.
Files changed (246) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +29 -0
  3. package/README.md +920 -0
  4. package/dist/__test-helpers__.d.ts +95 -0
  5. package/dist/__test-helpers__.d.ts.map +1 -0
  6. package/dist/__test-helpers__.js +174 -0
  7. package/dist/__test-helpers__.js.map +1 -0
  8. package/dist/bin/pi-steering.d.ts +9 -0
  9. package/dist/bin/pi-steering.d.ts.map +1 -0
  10. package/dist/bin/pi-steering.js +506 -0
  11. package/dist/bin/pi-steering.js.map +1 -0
  12. package/dist/compat.d.ts +50 -0
  13. package/dist/compat.d.ts.map +1 -0
  14. package/dist/compat.js +180 -0
  15. package/dist/compat.js.map +1 -0
  16. package/dist/defaults.d.ts +61 -0
  17. package/dist/defaults.d.ts.map +1 -0
  18. package/dist/defaults.js +106 -0
  19. package/dist/defaults.js.map +1 -0
  20. package/dist/define-config.d.ts +235 -0
  21. package/dist/define-config.d.ts.map +1 -0
  22. package/dist/define-config.js +118 -0
  23. package/dist/define-config.js.map +1 -0
  24. package/dist/define-predicate.d.ts +49 -0
  25. package/dist/define-predicate.d.ts.map +1 -0
  26. package/dist/define-predicate.js +35 -0
  27. package/dist/define-predicate.js.map +1 -0
  28. package/dist/evaluator-internals/context.d.ts +186 -0
  29. package/dist/evaluator-internals/context.d.ts.map +1 -0
  30. package/dist/evaluator-internals/context.js +219 -0
  31. package/dist/evaluator-internals/context.js.map +1 -0
  32. package/dist/evaluator-internals/override.d.ts +19 -0
  33. package/dist/evaluator-internals/override.d.ts.map +1 -0
  34. package/dist/evaluator-internals/override.js +79 -0
  35. package/dist/evaluator-internals/override.js.map +1 -0
  36. package/dist/evaluator-internals/predicates.d.ts +152 -0
  37. package/dist/evaluator-internals/predicates.d.ts.map +1 -0
  38. package/dist/evaluator-internals/predicates.js +849 -0
  39. package/dist/evaluator-internals/predicates.js.map +1 -0
  40. package/dist/evaluator-internals/speculative-synthesis.d.ts +102 -0
  41. package/dist/evaluator-internals/speculative-synthesis.d.ts.map +1 -0
  42. package/dist/evaluator-internals/speculative-synthesis.js +101 -0
  43. package/dist/evaluator-internals/speculative-synthesis.js.map +1 -0
  44. package/dist/evaluator.d.ts +101 -0
  45. package/dist/evaluator.d.ts.map +1 -0
  46. package/dist/evaluator.js +714 -0
  47. package/dist/evaluator.js.map +1 -0
  48. package/dist/helpers/walker-unknown-cwd-reason.d.ts +68 -0
  49. package/dist/helpers/walker-unknown-cwd-reason.d.ts.map +1 -0
  50. package/dist/helpers/walker-unknown-cwd-reason.js +73 -0
  51. package/dist/helpers/walker-unknown-cwd-reason.js.map +1 -0
  52. package/dist/index.d.ts +42 -0
  53. package/dist/index.d.ts.map +1 -0
  54. package/dist/index.js +93 -0
  55. package/dist/index.js.map +1 -0
  56. package/dist/internal/drop-unused-observers.d.ts +43 -0
  57. package/dist/internal/drop-unused-observers.d.ts.map +1 -0
  58. package/dist/internal/drop-unused-observers.js +50 -0
  59. package/dist/internal/drop-unused-observers.js.map +1 -0
  60. package/dist/internal/finalize-plugin-state.d.ts +22 -0
  61. package/dist/internal/finalize-plugin-state.d.ts.map +1 -0
  62. package/dist/internal/finalize-plugin-state.js +14 -0
  63. package/dist/internal/finalize-plugin-state.js.map +1 -0
  64. package/dist/internal/merge-observers.d.ts +30 -0
  65. package/dist/internal/merge-observers.d.ts.map +1 -0
  66. package/dist/internal/merge-observers.js +27 -0
  67. package/dist/internal/merge-observers.js.map +1 -0
  68. package/dist/internal/pattern-utils.d.ts +33 -0
  69. package/dist/internal/pattern-utils.d.ts.map +1 -0
  70. package/dist/internal/pattern-utils.js +20 -0
  71. package/dist/internal/pattern-utils.js.map +1 -0
  72. package/dist/internal/ref-text.d.ts +11 -0
  73. package/dist/internal/ref-text.d.ts.map +1 -0
  74. package/dist/internal/ref-text.js +15 -0
  75. package/dist/internal/ref-text.js.map +1 -0
  76. package/dist/internal/session-runtime.d.ts +44 -0
  77. package/dist/internal/session-runtime.d.ts.map +1 -0
  78. package/dist/internal/session-runtime.js +143 -0
  79. package/dist/internal/session-runtime.js.map +1 -0
  80. package/dist/internal/watch-matcher.d.ts +56 -0
  81. package/dist/internal/watch-matcher.d.ts.map +1 -0
  82. package/dist/internal/watch-matcher.js +179 -0
  83. package/dist/internal/watch-matcher.js.map +1 -0
  84. package/dist/loader.d.ts +97 -0
  85. package/dist/loader.d.ts.map +1 -0
  86. package/dist/loader.js +527 -0
  87. package/dist/loader.js.map +1 -0
  88. package/dist/observer-dispatcher.d.ts +69 -0
  89. package/dist/observer-dispatcher.d.ts.map +1 -0
  90. package/dist/observer-dispatcher.js +184 -0
  91. package/dist/observer-dispatcher.js.map +1 -0
  92. package/dist/plugin-merger.d.ts +175 -0
  93. package/dist/plugin-merger.d.ts.map +1 -0
  94. package/dist/plugin-merger.js +450 -0
  95. package/dist/plugin-merger.js.map +1 -0
  96. package/dist/plugins/git/branch-tracker.d.ts +85 -0
  97. package/dist/plugins/git/branch-tracker.d.ts.map +1 -0
  98. package/dist/plugins/git/branch-tracker.js +181 -0
  99. package/dist/plugins/git/branch-tracker.js.map +1 -0
  100. package/dist/plugins/git/cwd-extensions.d.ts +10 -0
  101. package/dist/plugins/git/cwd-extensions.d.ts.map +1 -0
  102. package/dist/plugins/git/cwd-extensions.js +155 -0
  103. package/dist/plugins/git/cwd-extensions.js.map +1 -0
  104. package/dist/plugins/git/git-ops.d.ts +89 -0
  105. package/dist/plugins/git/git-ops.d.ts.map +1 -0
  106. package/dist/plugins/git/git-ops.js +107 -0
  107. package/dist/plugins/git/git-ops.js.map +1 -0
  108. package/dist/plugins/git/index.d.ts +225 -0
  109. package/dist/plugins/git/index.d.ts.map +1 -0
  110. package/dist/plugins/git/index.js +52 -0
  111. package/dist/plugins/git/index.js.map +1 -0
  112. package/dist/plugins/git/predicates.d.ts +312 -0
  113. package/dist/plugins/git/predicates.d.ts.map +1 -0
  114. package/dist/plugins/git/predicates.js +580 -0
  115. package/dist/plugins/git/predicates.js.map +1 -0
  116. package/dist/plugins/git/rules.d.ts +209 -0
  117. package/dist/plugins/git/rules.d.ts.map +1 -0
  118. package/dist/plugins/git/rules.js +267 -0
  119. package/dist/plugins/git/rules.js.map +1 -0
  120. package/dist/schema.d.ts +1799 -0
  121. package/dist/schema.d.ts.map +1 -0
  122. package/dist/schema.js +4 -0
  123. package/dist/schema.js.map +1 -0
  124. package/dist/testing/index.d.ts +621 -0
  125. package/dist/testing/index.d.ts.map +1 -0
  126. package/dist/testing/index.js +747 -0
  127. package/dist/testing/index.js.map +1 -0
  128. package/examples/README.md +83 -0
  129. package/examples/combined-git-discipline/README.md +52 -0
  130. package/examples/combined-git-discipline/node_modules/.bin/pi-steering +43 -0
  131. package/examples/combined-git-discipline/package.json +18 -0
  132. package/examples/combined-git-discipline/steering.json +27 -0
  133. package/examples/combined-git-discipline/steering.test.ts +42 -0
  134. package/examples/combined-git-discipline/steering.ts +64 -0
  135. package/examples/combined-git-discipline/tsconfig.json +7 -0
  136. package/examples/draft-prs-only/README.md +42 -0
  137. package/examples/draft-prs-only/node_modules/.bin/pi-steering +43 -0
  138. package/examples/draft-prs-only/package.json +18 -0
  139. package/examples/draft-prs-only/steering.json +12 -0
  140. package/examples/draft-prs-only/steering.test.ts +48 -0
  141. package/examples/draft-prs-only/steering.ts +36 -0
  142. package/examples/draft-prs-only/tsconfig.json +7 -0
  143. package/examples/dynamic-reason-runtime-cwd/README.md +148 -0
  144. package/examples/dynamic-reason-runtime-cwd/node_modules/.bin/pi-steering +43 -0
  145. package/examples/dynamic-reason-runtime-cwd/package.json +18 -0
  146. package/examples/dynamic-reason-runtime-cwd/steering.test.ts +130 -0
  147. package/examples/dynamic-reason-runtime-cwd/steering.ts +82 -0
  148. package/examples/dynamic-reason-runtime-cwd/tsconfig.json +7 -0
  149. package/examples/force-push-strict/README.md +75 -0
  150. package/examples/force-push-strict/node_modules/.bin/pi-steering +43 -0
  151. package/examples/force-push-strict/package.json +18 -0
  152. package/examples/force-push-strict/steering.json +12 -0
  153. package/examples/force-push-strict/steering.test.ts +51 -0
  154. package/examples/force-push-strict/steering.ts +54 -0
  155. package/examples/force-push-strict/tsconfig.json +7 -0
  156. package/examples/no-amend/README.md +52 -0
  157. package/examples/no-amend/node_modules/.bin/pi-steering +43 -0
  158. package/examples/no-amend/package.json +18 -0
  159. package/examples/no-amend/steering.cwd-scoped.json +12 -0
  160. package/examples/no-amend/steering.json +11 -0
  161. package/examples/no-amend/steering.test.ts +49 -0
  162. package/examples/no-amend/steering.ts +38 -0
  163. package/examples/no-amend/tsconfig.json +7 -0
  164. package/examples/work-item-plugin/README.md +103 -0
  165. package/examples/work-item-plugin/node_modules/.bin/pi-steering +43 -0
  166. package/examples/work-item-plugin/package.json +19 -0
  167. package/examples/work-item-plugin/src/index.test.ts +168 -0
  168. package/examples/work-item-plugin/src/index.ts +122 -0
  169. package/examples/work-item-plugin/src/observers/npm-test-tracker.test.ts +95 -0
  170. package/examples/work-item-plugin/src/observers/npm-test-tracker.ts +88 -0
  171. package/examples/work-item-plugin/src/observers/retest-required-tracker.test.ts +81 -0
  172. package/examples/work-item-plugin/src/observers/retest-required-tracker.ts +81 -0
  173. package/examples/work-item-plugin/src/predicates/work-item-format.test.ts +157 -0
  174. package/examples/work-item-plugin/src/predicates/work-item-format.ts +99 -0
  175. package/examples/work-item-plugin/src/rules/commit-description-check.test.ts +107 -0
  176. package/examples/work-item-plugin/src/rules/commit-description-check.ts +83 -0
  177. package/examples/work-item-plugin/src/rules/commit-requires-work-item.test.ts +79 -0
  178. package/examples/work-item-plugin/src/rules/commit-requires-work-item.ts +56 -0
  179. package/examples/work-item-plugin/src/rules/push-requires-tests.test.ts +245 -0
  180. package/examples/work-item-plugin/src/rules/push-requires-tests.ts +62 -0
  181. package/examples/work-item-plugin/tsconfig.json +8 -0
  182. package/package.json +88 -0
  183. package/skills/steering-authoring/SKILL.md +102 -0
  184. package/src/INVARIANTS.md +116 -0
  185. package/src/__test-helpers__.ts +243 -0
  186. package/src/bin/pi-steering.test.ts +920 -0
  187. package/src/bin/pi-steering.ts +584 -0
  188. package/src/compat.test.ts +431 -0
  189. package/src/compat.ts +282 -0
  190. package/src/defaults.test.ts +585 -0
  191. package/src/defaults.ts +148 -0
  192. package/src/define-config.test.ts +888 -0
  193. package/src/define-config.ts +375 -0
  194. package/src/define-predicate.test.ts +70 -0
  195. package/src/define-predicate.ts +57 -0
  196. package/src/evaluator-internals/context.ts +290 -0
  197. package/src/evaluator-internals/override.test.ts +99 -0
  198. package/src/evaluator-internals/override.ts +83 -0
  199. package/src/evaluator-internals/predicates.ts +1037 -0
  200. package/src/evaluator-internals/speculative-synthesis.test.ts +411 -0
  201. package/src/evaluator-internals/speculative-synthesis.ts +199 -0
  202. package/src/evaluator.test.ts +6296 -0
  203. package/src/evaluator.ts +1106 -0
  204. package/src/factory-time-load.test.ts +630 -0
  205. package/src/helpers/walker-unknown-cwd-reason.test.ts +73 -0
  206. package/src/helpers/walker-unknown-cwd-reason.ts +83 -0
  207. package/src/index.test.ts +836 -0
  208. package/src/index.ts +229 -0
  209. package/src/internal/drop-unused-observers.test.ts +141 -0
  210. package/src/internal/drop-unused-observers.ts +81 -0
  211. package/src/internal/finalize-plugin-state.ts +43 -0
  212. package/src/internal/merge-observers.ts +51 -0
  213. package/src/internal/pattern-utils.test.ts +52 -0
  214. package/src/internal/pattern-utils.ts +38 -0
  215. package/src/internal/ref-text.test.ts +54 -0
  216. package/src/internal/ref-text.ts +17 -0
  217. package/src/internal/session-runtime.test.ts +629 -0
  218. package/src/internal/session-runtime.ts +201 -0
  219. package/src/internal/watch-matcher.test.ts +326 -0
  220. package/src/internal/watch-matcher.ts +200 -0
  221. package/src/jsdoc-propagation.test.ts +277 -0
  222. package/src/loader.test.ts +1098 -0
  223. package/src/loader.ts +581 -0
  224. package/src/not-block-onunknown.test.ts +554 -0
  225. package/src/observer-dispatcher.test.ts +939 -0
  226. package/src/observer-dispatcher.ts +290 -0
  227. package/src/per-predicate-typing.test.ts +361 -0
  228. package/src/plugin-merger.test.ts +790 -0
  229. package/src/plugin-merger.ts +598 -0
  230. package/src/plugins/git/README.md +485 -0
  231. package/src/plugins/git/branch-tracker.test.ts +281 -0
  232. package/src/plugins/git/branch-tracker.ts +187 -0
  233. package/src/plugins/git/cwd-extensions.test.ts +230 -0
  234. package/src/plugins/git/cwd-extensions.ts +160 -0
  235. package/src/plugins/git/git-ops.test.ts +397 -0
  236. package/src/plugins/git/git-ops.ts +173 -0
  237. package/src/plugins/git/index.ts +253 -0
  238. package/src/plugins/git/integration.test.ts +705 -0
  239. package/src/plugins/git/predicates.test.ts +1401 -0
  240. package/src/plugins/git/predicates.ts +693 -0
  241. package/src/plugins/git/rules.test.ts +1001 -0
  242. package/src/plugins/git/rules.ts +307 -0
  243. package/src/schema.test.ts +259 -0
  244. package/src/schema.ts +1963 -0
  245. package/src/testing/index.test.ts +1696 -0
  246. 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
+ });