@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,201 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Internal module — not part of the package's public API.
6
+ *
7
+ * This module holds the wiring that the bridge factory in `index.ts`
8
+ * uses to spin up an evaluator + observer dispatcher from a walk-up
9
+ * steering config. It is intentionally NOT re-exported from
10
+ * `index.ts` or any other public entry point; consumers building
11
+ * their own extensions should go through `loadHarness` (subpath
12
+ * `@cad0p/pi-steering/testing`) or call `buildEvaluator` /
13
+ * `buildObserverDispatcher` directly.
14
+ *
15
+ * The runtime owns the strict-mode contract: diagnostics produced
16
+ * by the loader (per-layer import failures, dual-form coexistence,
17
+ * stray files, cross-layer + within-layer collisions) and by the
18
+ * plugin merger (predicate / observer / rule / extension-orphan /
19
+ * reserved-name / invalid-name diagnostics) are aggregated here.
20
+ * Any error-class diagnostic always escalates to a thrown error;
21
+ * warning-class diagnostics escalate when `failOnWarnings !== false`
22
+ * on the merged config (default: true). Otherwise warnings are
23
+ * emitted to `console.warn` for legacy fail-soft semantics.
24
+ *
25
+ * The bridge calls `buildSessionRuntime` once at extension factory
26
+ * time. A thrown factory propagates through pi's extension loader
27
+ * into pi's `[Extension issues]` diagnostic block (which survives
28
+ * `/reload`); the bridge does not catch.
29
+ */
30
+
31
+ import { DEFAULT_PLUGINS, DEFAULT_RULES } from "../defaults.ts";
32
+ import {
33
+ buildEvaluator,
34
+ EVALUATOR_BUILTIN_TRACKERS,
35
+ type EvaluatorHost,
36
+ type EvaluatorRuntime,
37
+ } from "../evaluator.ts";
38
+ import { buildConfig, loadConfigs, mergeBool } from "../loader.ts";
39
+ import {
40
+ buildObserverDispatcher,
41
+ type ObserverDispatcher,
42
+ } from "../observer-dispatcher.ts";
43
+ import {
44
+ type ResolvedPluginState,
45
+ resolvePlugins,
46
+ validateUserConfigNames,
47
+ } from "../plugin-merger.ts";
48
+ import type { SteeringConfig, SteeringDiagnostic } from "../schema.ts";
49
+ import { finalizePluginState } from "./finalize-plugin-state.ts";
50
+
51
+ /**
52
+ * Run `buildConfig` then `resolvePlugins` over the raw layer list,
53
+ * short-circuiting before `resolvePlugins` if any merge-side
54
+ * diagnostic is error-class. Avoids double-emitting
55
+ * `tracker-name-collision` (O2 in INVARIANTS.md).
56
+ * `validateUserConfigNames` runs unconditionally so user-config name
57
+ * issues surface alongside merge errors.
58
+ */
59
+ export function runMergerPipeline(
60
+ layers: readonly SteeringConfig[],
61
+ defaults: SteeringConfig | undefined,
62
+ builtinTrackers: readonly string[],
63
+ ): {
64
+ merged: SteeringConfig;
65
+ resolved: ResolvedPluginState | null;
66
+ diagnostics: SteeringDiagnostic[];
67
+ } {
68
+ const { config: merged, diagnostics: mergeDiagnostics } = buildConfig(
69
+ layers,
70
+ defaults,
71
+ );
72
+ const userConfigNameDiagnostics = validateUserConfigNames(layers);
73
+ if (mergeDiagnostics.some((d) => d.type === "error")) {
74
+ return {
75
+ merged,
76
+ resolved: null,
77
+ diagnostics: [...mergeDiagnostics, ...userConfigNameDiagnostics],
78
+ };
79
+ }
80
+ const resolved = resolvePlugins(
81
+ merged.plugins ?? [],
82
+ merged,
83
+ builtinTrackers,
84
+ );
85
+ return {
86
+ merged,
87
+ resolved,
88
+ diagnostics: [
89
+ ...mergeDiagnostics,
90
+ ...userConfigNameDiagnostics,
91
+ ...resolved.diagnostics,
92
+ ],
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Render a diagnostics array into a single multi-line message
98
+ * suitable for use as a thrown Error's `message`. See {@link
99
+ * SteeringDiagnostic} render-format matrix for the canonical shape;
100
+ * the `formatAggregatedDiagnostics: rule-based spec` describe block in
101
+ * `internal/session-runtime.test.ts` pins the rules.
102
+ */
103
+ export function formatAggregatedDiagnostics(
104
+ diagnostics: readonly SteeringDiagnostic[],
105
+ ): string {
106
+ const errors = diagnostics.filter((d) => d.type === "error");
107
+ const warnings = diagnostics.filter((d) => d.type === "warning");
108
+ const ordered = [...errors, ...warnings];
109
+ const count = ordered.length;
110
+ const noun = count === 1 ? "issue" : "issues";
111
+ const lines = ordered.map((d) => {
112
+ const pathPrefix = d.path !== undefined ? `${d.path}: ` : "";
113
+ return ` - [${d.type}] ${pathPrefix}${d.message}`;
114
+ });
115
+ return `${count} config ${noun}:\n${lines.join("\n")}`;
116
+ }
117
+
118
+ /**
119
+ * Single-line render of one diagnostic; see {@link SteeringDiagnostic}
120
+ * render-format matrix for the canonical contract.
121
+ */
122
+ export function formatSingleLineDiagnostic(d: SteeringDiagnostic): string {
123
+ const pathPrefix = d.path !== undefined ? `${d.path}: ` : "";
124
+ return `[pi-steering] [${d.type}] ${pathPrefix}${d.message}`;
125
+ }
126
+
127
+ /**
128
+ * Build the per-session evaluator + observer dispatcher from the walk-
129
+ * up config rooted at `cwd`. Honors `disableDefaults` via inner-wins
130
+ * peek before injecting `DEFAULT_*`. Throws on any error-class
131
+ * diagnostic and on warning-class diagnostics when
132
+ * `failOnWarnings !== false`; otherwise emits surviving warnings via
133
+ * `console.warn`. See {@link runMergerPipeline} for the merge contract
134
+ * and `finalizePluginState` for observer-drop.
135
+ */
136
+ export async function buildSessionRuntime(
137
+ cwd: string,
138
+ host: EvaluatorHost,
139
+ ): Promise<{
140
+ evaluator: EvaluatorRuntime;
141
+ dispatcher: ObserverDispatcher;
142
+ }> {
143
+ const aggregated: SteeringDiagnostic[] = [];
144
+
145
+ const { layers: rawLayers, diagnostics: loaderDiagnostics } =
146
+ await loadConfigs(cwd);
147
+ aggregated.push(...loaderDiagnostics);
148
+
149
+ const disableDefaults = mergeBool(rawLayers, "disableDefaults") === true;
150
+ const defaults: SteeringConfig | undefined = disableDefaults
151
+ ? undefined
152
+ : { rules: DEFAULT_RULES, plugins: DEFAULT_PLUGINS };
153
+
154
+ const {
155
+ merged,
156
+ resolved,
157
+ diagnostics: mergeAndResolveDiagnostics,
158
+ } = runMergerPipeline(rawLayers, defaults, EVALUATOR_BUILTIN_TRACKERS);
159
+ aggregated.push(...mergeAndResolveDiagnostics);
160
+
161
+ const failOnWarnings = merged.failOnWarnings;
162
+ const treatWarningsAsErrors = failOnWarnings !== false;
163
+
164
+ const hasError = aggregated.some((d) => d.type === "error");
165
+ const hasWarning = aggregated.some((d) => d.type === "warning");
166
+ if (hasError || (treatWarningsAsErrors && hasWarning)) {
167
+ throw new Error(formatAggregatedDiagnostics(aggregated));
168
+ }
169
+ if (hasWarning) {
170
+ for (const d of aggregated) {
171
+ console.warn(formatSingleLineDiagnostic(d));
172
+ }
173
+ }
174
+
175
+ if (resolved === null) {
176
+ throw new Error("internal: resolved null without error diagnostic");
177
+ }
178
+
179
+ const disabled = new Set(merged.disabledRules ?? []);
180
+ const filteredConfig: SteeringConfig = { ...merged };
181
+ if (merged.rules !== undefined) {
182
+ const kept = merged.rules.filter((r) => !disabled.has(r.name));
183
+ if (kept.length > 0) filteredConfig.rules = kept;
184
+ else delete filteredConfig.rules;
185
+ }
186
+
187
+ const { pluginKept, userKept } = finalizePluginState(
188
+ filteredConfig.rules ?? [],
189
+ resolved.rules,
190
+ filteredConfig.observers ?? [],
191
+ resolved.observers,
192
+ );
193
+ const filteredResolved = {
194
+ ...resolved,
195
+ observers: [...pluginKept],
196
+ };
197
+
198
+ const evaluator = buildEvaluator(filteredConfig, filteredResolved, host);
199
+ const dispatcher = buildObserverDispatcher(filteredResolved, userKept, host);
200
+ return { evaluator, dispatcher };
201
+ }
@@ -0,0 +1,326 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Parity regression test for the shared observer-watch filter
6
+ * contract.
7
+ *
8
+ * Two consumers need the same answer to "would this observer fire on
9
+ * a successful bash tool_result with command=X?":
10
+ *
11
+ * 1. The production dispatcher ({@link matchesWatch} applied to a
12
+ * real `ToolResultEvent` arriving from pi).
13
+ * 2. The evaluator's `when.happened` with `in: "tool_call"` speculative-allow
14
+ * (synthesizes a minimal successful bash event from a prior
15
+ * `&&` ref and calls {@link matchesWatch}).
16
+ *
17
+ * Before commit 12, path 2 hand-rolled a SUBSET of the filter. Three
18
+ * reviewer-caught bugs (reachability aside) were all "drift between
19
+ * the hand-rolled subset and the dispatcher's real filter":
20
+ *
21
+ * - medium-001: exitCode / toolName checks missing in the subset.
22
+ * - FIND-3: dedup missing (not a filter drift, fixed separately).
23
+ * - future-drift: any new `watch` field would need parallel updates.
24
+ *
25
+ * Commit 12 collapsed path 2 onto {@link matchesWatch} via a
26
+ * synthesized event. This test pins that the two paths agree on every
27
+ * combination of `watch` field against representative ref-text inputs.
28
+ * If a future commit adds a new `watch` field to {@link matchesWatch}
29
+ * and forgets to exercise it in the synthesized event, the mismatch
30
+ * shows up here first.
31
+ *
32
+ * Semantic layer note: tool_call-scope speculative allow ALSO layers an
33
+ * extra "observer must declare `inputMatches.command`" gate on top of
34
+ * the shared filter (to prevent allow on command-agnostic watches —
35
+ * not a filter-semantic question, but a speculative-synthesis safety
36
+ * requirement). That gate is covered end-to-end in the evaluator tests
37
+ * ("observer without inputMatches.command → no speculative allow"). It
38
+ * is intentionally OUT OF SCOPE here: this file verifies that when
39
+ * both paths see the same event + watch, they compute the same filter
40
+ * answer.
41
+ */
42
+
43
+ import assert from "node:assert/strict";
44
+ import { describe, it } from "node:test";
45
+ import type { ObserverWatch, ToolResultEvent } from "../schema.ts";
46
+ import { matchesWatch } from "./watch-matcher.ts";
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Synthetic-event constructor
50
+ // ---------------------------------------------------------------------------
51
+ //
52
+ // The exact synthesis the evaluator's speculative-entry producer
53
+ // (see `evaluator-internals/speculative-synthesis.ts`) uses for a
54
+ // prior `&&` ref. Kept in lockstep with the real one: if the
55
+ // evaluator's synthesis grows a field, mirror it here so the test
56
+ // exercises the same shape.
57
+
58
+ function synthesizedSuccessBashEvent(refText: string): ToolResultEvent {
59
+ return {
60
+ toolName: "bash",
61
+ input: { command: refText },
62
+ output: undefined,
63
+ exitCode: 0,
64
+ };
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Fixture matrix
69
+ // ---------------------------------------------------------------------------
70
+
71
+ interface WatchCase {
72
+ readonly name: string;
73
+ readonly watch: ObserverWatch | undefined;
74
+ }
75
+
76
+ const WATCH_CASES: readonly WatchCase[] = [
77
+ { name: "undefined watch → fires on everything", watch: undefined },
78
+ { name: "empty watch object", watch: {} },
79
+ { name: "toolName: bash", watch: { toolName: "bash" } },
80
+ { name: "toolName: read", watch: { toolName: "read" } },
81
+ {
82
+ name: "inputMatches.command (RegExp)",
83
+ watch: { inputMatches: { command: /^sync\b/ } },
84
+ },
85
+ {
86
+ name: "inputMatches.command (string)",
87
+ watch: { inputMatches: { command: "^sync\\b" } },
88
+ },
89
+ {
90
+ name: "inputMatches.command — no match",
91
+ watch: { inputMatches: { command: /^never-match\b/ } },
92
+ },
93
+ {
94
+ name: "inputMatches multi-key (path absent on bash event)",
95
+ watch: { inputMatches: { command: /^sync\b/, path: /^\// } },
96
+ },
97
+ { name: "exitCode: success", watch: { exitCode: "success" } },
98
+ { name: "exitCode: failure", watch: { exitCode: "failure" } },
99
+ { name: "exitCode: any", watch: { exitCode: "any" } },
100
+ { name: "exitCode: 0 (numeric)", watch: { exitCode: 0 } },
101
+ { name: "exitCode: 1 (numeric non-zero)", watch: { exitCode: 1 } },
102
+ {
103
+ name: "toolName+inputMatches+exitCode combo (all satisfied)",
104
+ watch: {
105
+ toolName: "bash",
106
+ inputMatches: { command: /^sync\b/ },
107
+ exitCode: "success",
108
+ },
109
+ },
110
+ {
111
+ name: "toolName+inputMatches+exitCode combo (exitCode fails)",
112
+ watch: {
113
+ toolName: "bash",
114
+ inputMatches: { command: /^sync\b/ },
115
+ exitCode: "failure",
116
+ },
117
+ },
118
+ {
119
+ name: "toolName+inputMatches+exitCode combo (toolName fails)",
120
+ watch: {
121
+ toolName: "read",
122
+ inputMatches: { command: /^sync\b/ },
123
+ exitCode: "success",
124
+ },
125
+ },
126
+ ];
127
+
128
+ interface DispatcherEventCase {
129
+ readonly name: string;
130
+ readonly refText: string;
131
+ /** Real dispatcher-facing event for the same ref text. */
132
+ readonly dispatcherEvent: ToolResultEvent;
133
+ }
134
+
135
+ /**
136
+ * For each ref text we build two events:
137
+ * - the speculative SYNTHESIS (constructed above),
138
+ * - the dispatcher equivalent: the same bash tool_result the
139
+ * dispatcher would see after a successful run of `refText`.
140
+ *
141
+ * Both events carry `toolName: "bash"`, `input.command = refText`,
142
+ * and `exitCode: 0`. The only field-level difference is that the
143
+ * dispatcher event has an `output` array (pi's `content`) whereas the
144
+ * synthesis leaves `output` undefined. {@link matchesWatch} does not
145
+ * inspect `output` today; this parity test also pins that invariant.
146
+ */
147
+ const EVENT_CASES: readonly DispatcherEventCase[] = [
148
+ {
149
+ name: "sync (matches /^sync\\b/)",
150
+ refText: "sync",
151
+ dispatcherEvent: {
152
+ toolName: "bash",
153
+ input: { command: "sync" },
154
+ output: [{ type: "text", text: "" }],
155
+ exitCode: 0,
156
+ },
157
+ },
158
+ {
159
+ name: "sync --lock (still matches /^sync\\b/)",
160
+ refText: "sync --lock",
161
+ dispatcherEvent: {
162
+ toolName: "bash",
163
+ input: { command: "sync --lock" },
164
+ output: [{ type: "text", text: "" }],
165
+ exitCode: 0,
166
+ },
167
+ },
168
+ {
169
+ name: "git push (no match for /^sync\\b/)",
170
+ refText: "git push",
171
+ dispatcherEvent: {
172
+ toolName: "bash",
173
+ input: { command: "git push" },
174
+ output: [{ type: "text", text: "" }],
175
+ exitCode: 0,
176
+ },
177
+ },
178
+ {
179
+ name: "empty-args ref",
180
+ refText: "pwd",
181
+ dispatcherEvent: {
182
+ toolName: "bash",
183
+ input: { command: "pwd" },
184
+ output: [{ type: "text", text: "" }],
185
+ exitCode: 0,
186
+ },
187
+ },
188
+ ];
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Parity assertion
192
+ // ---------------------------------------------------------------------------
193
+
194
+ describe("matchesWatch: synthesized-event parity with dispatcher event", () => {
195
+ // Cross-product: every watch × every (refText, dispatcher event)
196
+ // combination exercises both paths with the same semantic inputs.
197
+ // If they disagree for ANY combination, the subset has drifted.
198
+ for (const wc of WATCH_CASES) {
199
+ for (const ec of EVENT_CASES) {
200
+ it(`watch "${wc.name}" on event "${ec.name}" — both paths agree`, () => {
201
+ const synthetic = synthesizedSuccessBashEvent(ec.refText);
202
+ const syntheticResult = matchesWatch(wc.watch, synthetic);
203
+ const dispatcherResult = matchesWatch(wc.watch, ec.dispatcherEvent);
204
+ assert.equal(
205
+ syntheticResult,
206
+ dispatcherResult,
207
+ `Synthesized-event path returned ${syntheticResult} but ` +
208
+ `dispatcher event returned ${dispatcherResult} for the ` +
209
+ `same watch/command. Synthesis must carry every field ` +
210
+ `matchesWatch inspects for a successful bash event.`,
211
+ );
212
+ });
213
+ }
214
+ }
215
+ });
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Direct ground-truth tests
219
+ // ---------------------------------------------------------------------------
220
+ //
221
+ // The parity matrix above pins that both paths AGREE — but "both
222
+ // always return false" is also agreement. These tests pin the actual
223
+ // filter semantics per ADR so a refactor that breaks the ground truth
224
+ // (e.g. exitCode: "failure" now passes on success) doesn't slip
225
+ // through under the parity check.
226
+
227
+ describe("matchesWatch: ground-truth semantics", () => {
228
+ it("undefined watch fires on everything", () => {
229
+ assert.equal(
230
+ matchesWatch(undefined, synthesizedSuccessBashEvent("anything")),
231
+ true,
232
+ );
233
+ });
234
+
235
+ it("toolName: 'bash' accepts a bash event", () => {
236
+ assert.equal(
237
+ matchesWatch({ toolName: "bash" }, synthesizedSuccessBashEvent("sync")),
238
+ true,
239
+ );
240
+ });
241
+
242
+ it("toolName: 'read' rejects a bash event", () => {
243
+ assert.equal(
244
+ matchesWatch({ toolName: "read" }, synthesizedSuccessBashEvent("sync")),
245
+ false,
246
+ );
247
+ });
248
+
249
+ it("inputMatches.command matches ref text", () => {
250
+ assert.equal(
251
+ matchesWatch(
252
+ { inputMatches: { command: /^sync\b/ } },
253
+ synthesizedSuccessBashEvent("sync"),
254
+ ),
255
+ true,
256
+ );
257
+ });
258
+
259
+ it("inputMatches.command rejects non-matching ref text", () => {
260
+ assert.equal(
261
+ matchesWatch(
262
+ { inputMatches: { command: /^sync\b/ } },
263
+ synthesizedSuccessBashEvent("git push"),
264
+ ),
265
+ false,
266
+ );
267
+ });
268
+
269
+ it("exitCode: 'success' accepts synthesized (exit 0)", () => {
270
+ assert.equal(
271
+ matchesWatch(
272
+ { exitCode: "success" },
273
+ synthesizedSuccessBashEvent("sync"),
274
+ ),
275
+ true,
276
+ );
277
+ });
278
+
279
+ it("exitCode: 'failure' rejects synthesized (exit 0)", () => {
280
+ // This pins the medium-001 regression: the pre-refactor subset
281
+ // ignored exitCode entirely and would have let a failure-gated
282
+ // observer grant speculative-allow on a successful `&&` chain.
283
+ assert.equal(
284
+ matchesWatch(
285
+ { exitCode: "failure" },
286
+ synthesizedSuccessBashEvent("sync"),
287
+ ),
288
+ false,
289
+ );
290
+ });
291
+
292
+ it("exitCode: 1 rejects synthesized (exit 0)", () => {
293
+ assert.equal(
294
+ matchesWatch({ exitCode: 1 }, synthesizedSuccessBashEvent("sync")),
295
+ false,
296
+ );
297
+ });
298
+
299
+ it("exitCode: 0 accepts synthesized (exit 0)", () => {
300
+ assert.equal(
301
+ matchesWatch({ exitCode: 0 }, synthesizedSuccessBashEvent("sync")),
302
+ true,
303
+ );
304
+ });
305
+
306
+ it("exitCode: 'any' accepts synthesized", () => {
307
+ assert.equal(
308
+ matchesWatch({ exitCode: "any" }, synthesizedSuccessBashEvent("sync")),
309
+ true,
310
+ );
311
+ });
312
+
313
+ it("multi-key inputMatches with absent key fails closed", () => {
314
+ // The synthesized event has `input: { command: refText }` —
315
+ // `path` is absent. ADR fail-closed: absent key → whole filter
316
+ // fails. Pins that the synthesis doesn't accidentally pass
317
+ // filters it shouldn't.
318
+ assert.equal(
319
+ matchesWatch(
320
+ { inputMatches: { command: /^sync\b/, path: /^\// } },
321
+ synthesizedSuccessBashEvent("sync"),
322
+ ),
323
+ false,
324
+ );
325
+ });
326
+ });