@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,411 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Unit tests for {@link synthesizeSpeculativeEntries}.
6
+ *
7
+ * Table-driven across (watch, joiner) shapes per PR #5 spec. These
8
+ * tests pin the behaviour the evaluator relies on when threading the
9
+ * synthesis output into `walkerState.events`; the integration-level
10
+ * `&&`-chain tests in `evaluator.test.ts` still drive end-to-end
11
+ * behaviour through the built-in `happened` predicate.
12
+ */
13
+
14
+ import assert from "node:assert";
15
+ import { describe, it } from "node:test";
16
+ import {
17
+ type CommandRef,
18
+ expandWrapperCommands,
19
+ extractAllCommandsFromAST,
20
+ parse as parseBash,
21
+ } from "@cad0p/unbash-walker";
22
+ import type { Observer } from "../schema.ts";
23
+ import {
24
+ SPECULATIVE_BASELINE,
25
+ type SyntheticEntry,
26
+ synthesizeSpeculativeEntries,
27
+ } from "./speculative-synthesis.ts";
28
+
29
+ function refsFor(command: string): readonly CommandRef[] {
30
+ const script = parseBash(command);
31
+ const extracted = extractAllCommandsFromAST(script, command);
32
+ return expandWrapperCommands(extracted).commands;
33
+ }
34
+
35
+ const SYNC_DONE = "sync-done" as const;
36
+ const UPSTREAM_FAILED = "upstream-failed" as const;
37
+
38
+ function syncObserver(overrides: Partial<Observer> = {}): Observer {
39
+ return {
40
+ name: "sync-tracker",
41
+ writes: [SYNC_DONE],
42
+ watch: {
43
+ toolName: "bash",
44
+ inputMatches: { command: /^sync\b/ },
45
+ exitCode: "success",
46
+ },
47
+ onResult: () => {},
48
+ ...overrides,
49
+ };
50
+ }
51
+
52
+ describe("synthesizeSpeculativeEntries: joiner reachability", () => {
53
+ it("empty refs → empty result", () => {
54
+ const out = synthesizeSpeculativeEntries([], [syncObserver()]);
55
+ assert.equal(out.size, 0);
56
+ });
57
+
58
+ it("`sync && cr` — cr sees sync's synthetic entry", () => {
59
+ const refs = refsFor("sync && cr --review");
60
+ const out = synthesizeSpeculativeEntries(refs, [syncObserver()]);
61
+ const syncView = out.get(refs[0]!);
62
+ const crView = out.get(refs[1]!);
63
+ assert.deepEqual(syncView, {}, "first ref sees empty chain");
64
+ assert.ok(crView?.[SYNC_DONE], "cr sees sync's synthetic entry");
65
+ assert.equal(crView[SYNC_DONE].length, 1);
66
+ assert.deepEqual(crView[SYNC_DONE][0], {
67
+ data: {},
68
+ timestamp: SPECULATIVE_BASELINE + 1, // baseline + 1 + ref index 0
69
+ speculative: true,
70
+ });
71
+ });
72
+
73
+ it("`cr && sync` — cr has no prior && producer", () => {
74
+ const refs = refsFor("cr --review && sync");
75
+ const out = synthesizeSpeculativeEntries(refs, [syncObserver()]);
76
+ assert.deepEqual(out.get(refs[0]!), {}, "cr sees empty");
77
+ // sync is after cr in source order, so sync sees whatever cr
78
+ // produced — but cr doesn't match the observer, so sync sees empty.
79
+ assert.deepEqual(out.get(refs[1]!), {});
80
+ });
81
+
82
+ it("`sync ; cr` — `;` does NOT propagate synthesis across", () => {
83
+ const refs = refsFor("sync ; cr --review");
84
+ const out = synthesizeSpeculativeEntries(refs, [syncObserver()]);
85
+ assert.deepEqual(out.get(refs[1]!), {}, "cr after `;` sees no chain");
86
+ });
87
+
88
+ it("`sync || cr` — `||` breaks the chain", () => {
89
+ const refs = refsFor("sync || cr --review");
90
+ const out = synthesizeSpeculativeEntries(refs, [syncObserver()]);
91
+ assert.deepEqual(out.get(refs[1]!), {});
92
+ });
93
+
94
+ it("`lint || sync && cr` — `||` early kills reachability", () => {
95
+ // lint.joiner=||, sync.joiner=&&, cr.joiner=undef. Because
96
+ // `lint` is followed by `||`, reachability clears. `sync`'s
97
+ // `&&` happens on an unreachable segment → sync is not an
98
+ // eligible producer for cr.
99
+ const refs = refsFor("lint || sync && cr --review");
100
+ const out = synthesizeSpeculativeEntries(refs, [syncObserver()]);
101
+ assert.deepEqual(out.get(refs[2]!), {});
102
+ });
103
+
104
+ it("`cd /x ; sync && cr` — `;` restores reachability", () => {
105
+ const refs = refsFor("cd /x ; sync && cr --review");
106
+ const out = synthesizeSpeculativeEntries(refs, [syncObserver()]);
107
+ const crView = out.get(refs[refs.length - 1]!);
108
+ assert.ok(
109
+ crView?.[SYNC_DONE],
110
+ "after `;`, sync && cr grants a synthetic entry for cr",
111
+ );
112
+ });
113
+
114
+ it("`echo foo && sync && cr` — transitive chain", () => {
115
+ const refs = refsFor("echo foo && sync && cr --review");
116
+ const out = synthesizeSpeculativeEntries(refs, [syncObserver()]);
117
+ const crView = out.get(refs[2]!);
118
+ assert.ok(crView?.[SYNC_DONE], "cr inherits the && chain");
119
+ });
120
+ });
121
+
122
+ describe("synthesizeSpeculativeEntries: observer watch gating", () => {
123
+ it("observer without inputMatches.command → no synthesis", () => {
124
+ const looseObs: Observer = {
125
+ name: "loose",
126
+ writes: [SYNC_DONE],
127
+ watch: { toolName: "bash" },
128
+ onResult: () => {},
129
+ };
130
+ const refs = refsFor("sync && cr --review");
131
+ const out = synthesizeSpeculativeEntries(refs, [looseObs]);
132
+ assert.deepEqual(out.get(refs[1]!), {});
133
+ });
134
+
135
+ it("observer with non-bash toolName → no synthesis", () => {
136
+ const readObs: Observer = {
137
+ name: "read-obs",
138
+ writes: [SYNC_DONE],
139
+ watch: {
140
+ toolName: "read",
141
+ inputMatches: { command: /^sync\b/ },
142
+ },
143
+ onResult: () => {},
144
+ };
145
+ const refs = refsFor("sync && cr --review");
146
+ const out = synthesizeSpeculativeEntries(refs, [readObs]);
147
+ assert.deepEqual(out.get(refs[1]!), {});
148
+ });
149
+
150
+ it("observer gated on exitCode: 'failure' → no synthesis (success event)", () => {
151
+ // The synthesized event is `exitCode: 0`; a watch demanding
152
+ // failure rejects it via `matchesWatch`. Delegation to
153
+ // matchesWatch — no subset re-impl here.
154
+ const failObs = syncObserver({
155
+ name: "fail-gated",
156
+ watch: {
157
+ toolName: "bash",
158
+ inputMatches: { command: /^sync\b/ },
159
+ exitCode: "failure",
160
+ },
161
+ });
162
+ const refs = refsFor("sync && cr --review");
163
+ const out = synthesizeSpeculativeEntries(refs, [failObs]);
164
+ assert.deepEqual(out.get(refs[1]!), {});
165
+ });
166
+
167
+ it("observer with inputMatches pattern not hitting the ref → no synthesis", () => {
168
+ const tightObs = syncObserver({
169
+ watch: {
170
+ toolName: "bash",
171
+ inputMatches: { command: /^sync --lock\b/ },
172
+ exitCode: "success",
173
+ },
174
+ });
175
+ const refs = refsFor("sync && cr --review");
176
+ const out = synthesizeSpeculativeEntries(refs, [tightObs]);
177
+ assert.deepEqual(out.get(refs[1]!), {});
178
+ });
179
+
180
+ it("observer without writes[] → no synthesis", () => {
181
+ const noWritesObs: Observer = {
182
+ name: "no-writes",
183
+ watch: {
184
+ toolName: "bash",
185
+ inputMatches: { command: /^sync\b/ },
186
+ },
187
+ onResult: () => {},
188
+ };
189
+ const refs = refsFor("sync && cr --review");
190
+ const out = synthesizeSpeculativeEntries(refs, [noWritesObs]);
191
+ assert.deepEqual(out.get(refs[1]!), {});
192
+ });
193
+
194
+ it("multiple observers on same type — first match wins, no duplicate entries", () => {
195
+ const obsA = syncObserver({
196
+ name: "obs-a",
197
+ watch: {
198
+ toolName: "bash",
199
+ inputMatches: { command: /^never-match\b/ },
200
+ },
201
+ });
202
+ const obsB = syncObserver({
203
+ name: "obs-b",
204
+ watch: {
205
+ toolName: "bash",
206
+ inputMatches: { command: /^sync\b/ },
207
+ },
208
+ });
209
+ const refs = refsFor("sync && cr --review");
210
+ const out = synthesizeSpeculativeEntries(refs, [obsA, obsB]);
211
+ const crView = out.get(refs[1]!);
212
+ assert.equal(
213
+ crView?.[SYNC_DONE]?.length,
214
+ 1,
215
+ "one synthetic entry per (ref, customType) — dedup across observers",
216
+ );
217
+ });
218
+ });
219
+
220
+ describe("synthesizeSpeculativeEntries: timestamp convention", () => {
221
+ it("speculative timestamp uses fixed reserved baseline + 1 + astIndex", () => {
222
+ const refs = refsFor("sync && cr --review");
223
+ const out = synthesizeSpeculativeEntries(refs, [syncObserver()]);
224
+ const entry = out.get(refs[1]!)?.[SYNC_DONE]?.[0];
225
+ // sync is at index 0, baseline + 1 + 0
226
+ assert.equal(entry?.timestamp, SPECULATIVE_BASELINE + 1);
227
+ });
228
+
229
+ it("baseline is strictly greater than any realistic epoch-ms timestamp", () => {
230
+ // Date.now() is < 2^48 for any date through ~year 10,890 AD. Our
231
+ // baseline is 2^52. The gap is the headroom that makes
232
+ // speculative > real trivially.
233
+ assert.ok(SPECULATIVE_BASELINE > Date.now() * 100);
234
+ });
235
+
236
+ it("AST-order monotonic — later ref has later speculative timestamp", () => {
237
+ // `echo foo && sync && cr`: echo.idx=0, sync.idx=1. Only sync
238
+ // matches the observer. Its timestamp: baseline + 1 + 1.
239
+ const refs = refsFor("echo foo && sync && cr --review");
240
+ const out = synthesizeSpeculativeEntries(refs, [syncObserver()]);
241
+ const entry = out.get(refs[2]!)?.[SYNC_DONE]?.[0];
242
+ assert.equal(entry?.timestamp, SPECULATIVE_BASELINE + 2);
243
+ });
244
+
245
+ it("two speculative writes in same chain — later ref has later timestamp", () => {
246
+ // `A && B && cr` where A writes X and B writes Y. AST order:
247
+ // A.idx=0, B.idx=1. Timestamps: X=SPECULATIVE_BASELINE+1, Y=SPECULATIVE_BASELINE+2.
248
+ // Downstream `when.happened: { event: X, since: Y }` reads X
249
+ // as stale — the two-speculative-writes-with-since-invalidator
250
+ // correctness case.
251
+ const EVENT_X = "event-x";
252
+ const EVENT_Y = "event-y";
253
+ const obsA: Observer = {
254
+ name: "a-tracker",
255
+ writes: [EVENT_X],
256
+ watch: {
257
+ toolName: "bash",
258
+ inputMatches: { command: /^a\b/ },
259
+ },
260
+ onResult: () => {},
261
+ };
262
+ const obsB: Observer = {
263
+ name: "b-tracker",
264
+ writes: [EVENT_Y],
265
+ watch: {
266
+ toolName: "bash",
267
+ inputMatches: { command: /^b\b/ },
268
+ },
269
+ onResult: () => {},
270
+ };
271
+ const refs = refsFor("a && b && cr --review");
272
+ const out = synthesizeSpeculativeEntries(refs, [obsA, obsB]);
273
+ const crView = out.get(refs[2]!);
274
+ assert.equal(
275
+ crView?.[EVENT_X]?.[0]?.timestamp,
276
+ SPECULATIVE_BASELINE + 1,
277
+ "X ts = baseline + 1 + 0",
278
+ );
279
+ assert.equal(
280
+ crView?.[EVENT_Y]?.[0]?.timestamp,
281
+ SPECULATIVE_BASELINE + 2,
282
+ "Y ts = baseline + 1 + 1",
283
+ );
284
+ // Invariant: X older than Y in the speculative timeline.
285
+ assert.ok(
286
+ crView![EVENT_X]![0]!.timestamp < crView![EVENT_Y]![0]!.timestamp,
287
+ "AST order preserved among speculatives",
288
+ );
289
+ });
290
+ });
291
+
292
+ describe("synthesizeSpeculativeEntries: flag + identity", () => {
293
+ it("all synthetic entries carry `speculative: true`", () => {
294
+ const refs = refsFor("sync && cr --review");
295
+ const out = synthesizeSpeculativeEntries(refs, [syncObserver()]);
296
+ const entry = out.get(refs[1]!)?.[SYNC_DONE]?.[0];
297
+ assert.equal(entry?.speculative, true);
298
+ });
299
+
300
+ it("output is keyed by ref identity, not index", () => {
301
+ const refs = refsFor("sync && cr --review");
302
+ const out = synthesizeSpeculativeEntries(refs, [syncObserver()]);
303
+ for (const ref of refs) {
304
+ assert.ok(out.has(ref), `result has ref ${ref.node.name?.text ?? ""}`);
305
+ }
306
+ });
307
+
308
+ it("single ref with no joiner → empty view", () => {
309
+ const refs = refsFor("cr --review");
310
+ const out = synthesizeSpeculativeEntries(refs, [syncObserver()]);
311
+ assert.deepEqual(out.get(refs[0]!), {});
312
+ });
313
+ });
314
+
315
+ describe("synthesizeSpeculativeEntries: subshell coverage", () => {
316
+ it("`(sync) && cr` — subshell refs participate in the chain", () => {
317
+ const refs = refsFor("(sync) && cr --review");
318
+ const out = synthesizeSpeculativeEntries(refs, [syncObserver()]);
319
+ // cr is the last ref; sync's synthetic entry must be visible.
320
+ const crView = out.get(refs[refs.length - 1]!);
321
+ assert.ok(crView?.[SYNC_DONE], "subshell sync feeds cr's chain");
322
+ });
323
+
324
+ it("`(echo hi && sync) && cr` — multi-ref subshell", () => {
325
+ // GAP-01 regression fence. Both refs inside `(...)` participate
326
+ // in cr's prior chain; sync matches the observer.
327
+ const refs = refsFor("(echo hi && sync) && cr --review");
328
+ const out = synthesizeSpeculativeEntries(refs, [syncObserver()]);
329
+ const crView = out.get(refs[refs.length - 1]!);
330
+ assert.ok(crView?.[SYNC_DONE], "multi-ref subshell: sync feeds cr");
331
+ });
332
+
333
+ it("`foo && (bar ; sync) && cr` — `;` inside subshell clears chain", () => {
334
+ // GAP-02 conservative-under fence. After the `;`, only sync is
335
+ // in cr's prior chain; foo is dropped. Observer matches ONLY
336
+ // foo → no synthesis for cr.
337
+ const fooObs = syncObserver({
338
+ name: "foo-only",
339
+ watch: {
340
+ toolName: "bash",
341
+ inputMatches: { command: /^foo\b/ },
342
+ exitCode: "success",
343
+ },
344
+ });
345
+ const refs = refsFor("foo && (bar ; sync) && cr --review");
346
+ const out = synthesizeSpeculativeEntries(refs, [fooObs]);
347
+ const crView = out.get(refs[refs.length - 1]!);
348
+ assert.deepEqual(
349
+ crView,
350
+ {},
351
+ "conservative-under: foo dropped from cr's chain by `;`",
352
+ );
353
+ });
354
+ });
355
+
356
+ describe("synthesizeSpeculativeEntries: per-ref isolation", () => {
357
+ it("earlier refs do not see later producers", () => {
358
+ // Load-bearing safety property. `sync && cr`: sync (idx 0)
359
+ // must NOT see its own synthetic entry. Only cr (idx 1) does.
360
+ const refs = refsFor("sync && cr --review");
361
+ const out = synthesizeSpeculativeEntries(refs, [syncObserver()]);
362
+ assert.deepEqual(
363
+ out.get(refs[0]!),
364
+ {},
365
+ "sync cannot see entries it (would) produce",
366
+ );
367
+ });
368
+
369
+ it("map entries are distinct per ref (no cross-ref mutation)", () => {
370
+ const refs = refsFor("echo foo && sync && cr --review");
371
+ const out = synthesizeSpeculativeEntries(refs, [syncObserver()]);
372
+ const echoView = out.get(refs[0]!);
373
+ const syncView = out.get(refs[1]!);
374
+ const crView = out.get(refs[2]!);
375
+ // echo, sync should both see empty (sync hasn't produced yet
376
+ // for itself). cr should see sync's entry.
377
+ assert.deepEqual(echoView, {});
378
+ assert.deepEqual(syncView, {});
379
+ assert.ok(crView?.[SYNC_DONE]);
380
+ });
381
+ });
382
+
383
+ describe("synthesizeSpeculativeEntries: multi-type coexistence", () => {
384
+ it("writes by distinct observers for different customTypes coexist", () => {
385
+ const OTHER = "other-event";
386
+ const otherObs: Observer = {
387
+ name: "other",
388
+ writes: [OTHER],
389
+ watch: {
390
+ toolName: "bash",
391
+ inputMatches: { command: /^sync\b/ },
392
+ },
393
+ onResult: () => {},
394
+ };
395
+ const refs = refsFor("sync && cr --review");
396
+ const out = synthesizeSpeculativeEntries(refs, [syncObserver(), otherObs]);
397
+ const crView = out.get(refs[1]!);
398
+ assert.ok(crView?.[SYNC_DONE]);
399
+ assert.ok(crView?.[OTHER]);
400
+ });
401
+
402
+ it("custom type with no observers remains unrepresented", () => {
403
+ const refs = refsFor("sync && cr --review");
404
+ const out = synthesizeSpeculativeEntries(refs, [syncObserver()]);
405
+ const crView = out.get(refs[1]!) as Record<
406
+ string,
407
+ readonly SyntheticEntry[]
408
+ >;
409
+ assert.equal(crView[UPSTREAM_FAILED], undefined);
410
+ });
411
+ });
@@ -0,0 +1,199 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Unified speculative-entry synthesis for `when.happened`'s tool_call-
6
+ * scope speculative allow.
7
+ *
8
+ * Replaces the pre-PR-5 specialized speculative-allow path (a pair of
9
+ * helpers on the evaluator that matched observers against prior `&&`
10
+ * refs on a per-call boolean basis). For every bash command
11
+ * ref in an unconditionally-`&&`-reachable segment, for every observer
12
+ * writing an event AND matching the ref via the shared
13
+ * {@link matchesWatch} contract, we produce a synthetic entry that
14
+ * later `happened` evaluations merge with real entries via timestamp
15
+ * ordering.
16
+ *
17
+ * Pure function of `(refs, observers)`. The evaluator wires the
18
+ * output into per-ref `walkerState.events` before running predicates.
19
+ *
20
+ * ## Timestamp convention
21
+ *
22
+ * speculativeTimestamp(ref_j) = SPECULATIVE_BASELINE + 1 + j
23
+ *
24
+ * where `SPECULATIVE_BASELINE = 2^52` — a literal chosen far above
25
+ * any epoch-ms timestamp pi writes on real entries (`Date.now()`
26
+ * returns < 2^48 for any date through ~year 10,890 AD). The speculative
27
+ * timestamp is thus strictly greater than ANY real entry's timestamp,
28
+ * regardless of type or scope. So a speculative `sync-done` at ts
29
+ * `2^52 + 1` beats a real `upstream-failed` at ts `Date.now()` in
30
+ * the since-invalidator comparison.
31
+ *
32
+ * Two properties follow (see the `unification-reconsider` review's
33
+ * "Timestamp reframing" section for the full walk-through):
34
+ *
35
+ * 1. **Strictly newer than every real entry across all types and
36
+ * scopes.** The reserved-range literal sidesteps a per-type
37
+ * max-plus-one approach — which would fail on a two-writes
38
+ * scenario where the since-invalidator's latest real entry is
39
+ * newer than the event type's latest real entry. A speculative
40
+ * entry newer than ALL real entries is newer than any subset.
41
+ *
42
+ * 2. **Relative ordering among multiple speculative writes follows
43
+ * AST order.** Ref at index `j` gets `BASELINE + 1 + j`; a later
44
+ * ref at `k > j` gets `BASELINE + 1 + k`. So
45
+ * `when.happened: { event: X, since: Y }` correctly reads X as
46
+ * stale when Y is written later — the two-speculative-writes-
47
+ * with-since-invalidator correctness case pinned by the
48
+ * `A && B && cr` test in this commit pack.
49
+ *
50
+ * ## Safety
51
+ *
52
+ * **Chain reachability**: only refs with joiner `&&` in an
53
+ * unconditionally-reached segment qualify as eligible producers. The
54
+ * `&&` short-circuit guarantees any later ref that runs saw the
55
+ * producer complete successfully first.
56
+ *
57
+ * **Observer eligibility**: observers must declare
58
+ * `watch.inputMatches.command`. Any-bash-event observers would grant
59
+ * allow for every `foo && cr` regardless of what `foo` does. Non-bash
60
+ * `toolName` watches are rejected early (prior `&&` refs always
61
+ * originate from bash). Other filter fields are delegated to
62
+ * {@link matchesWatch} against a synthesized success event — the
63
+ * single-source-of-truth filter contract gates future new `watch`
64
+ * fields automatically.
65
+ *
66
+ * @internal — not part of the public pi-steering surface.
67
+ */
68
+
69
+ import type { CommandRef } from "@cad0p/unbash-walker";
70
+ import { refToText } from "../internal/ref-text.ts";
71
+ import { matchesWatch } from "../internal/watch-matcher.ts";
72
+ import type { Observer } from "../schema.ts";
73
+
74
+ /**
75
+ * Speculative baseline timestamp. All synthetic entries carry
76
+ * `SPECULATIVE_BASELINE + 1 + astIndex` so they order strictly above
77
+ * any real entry's `timestamp` (real entries are epoch-ms from
78
+ * `Date.now()`, well under `2^48` for any realistic date). See the
79
+ * file-level JSDoc's "Timestamp convention" section for the full
80
+ * rationale and the acknowledged numerical-convention caveat.
81
+ *
82
+ * @internal
83
+ */
84
+ export const SPECULATIVE_BASELINE = 2 ** 52;
85
+
86
+ /**
87
+ * Speculative session entry. Structurally a superset of real entries
88
+ * (`{ data, timestamp }`) plus a `speculative: true` marker so the
89
+ * built-in `happened` predicate and plugin filters over
90
+ * `walkerState.events` can distinguish synthetic writes from real
91
+ * ones. Default direction (include speculative) matches what
92
+ * `happened` wants; plugins wanting pure historical semantics filter
93
+ * with `.filter(e => !e.speculative)`.
94
+ */
95
+ export interface SyntheticEntry<T = unknown> {
96
+ readonly data: T;
97
+ readonly timestamp: number;
98
+ readonly speculative: true;
99
+ }
100
+
101
+ /** Per-ref view: `walkerState.events[customType] → SyntheticEntry[]`. */
102
+ type SyntheticEventsByType = Readonly<
103
+ Record<string, readonly SyntheticEntry[]>
104
+ >;
105
+
106
+ /** Per-ref output keyed by {@link CommandRef} identity. */
107
+ export type SpeculativeEventsByRef = ReadonlyMap<
108
+ CommandRef,
109
+ SyntheticEventsByType
110
+ >;
111
+
112
+ /**
113
+ * Compute per-ref speculative events. See file-level JSDoc for the
114
+ * timestamp convention, reachability model, and observer eligibility.
115
+ */
116
+ export function synthesizeSpeculativeEntries(
117
+ refs: readonly CommandRef[],
118
+ observers: readonly Observer[],
119
+ ): SpeculativeEventsByRef {
120
+ const result = new Map<CommandRef, SyntheticEventsByType>();
121
+
122
+ // Reverse index `customType -> observers writing it`, pre-filtered
123
+ // to eligible observers. Stricter than `matchesWatch` on purpose —
124
+ // layered on top, not a reimplementation.
125
+ const observersByWrite = new Map<string, Observer[]>();
126
+ for (const obs of observers) {
127
+ const watch = obs.watch;
128
+ if (!obs.writes || !watch) continue;
129
+ if (watch.toolName !== undefined && watch.toolName !== "bash") continue;
130
+ if (watch.inputMatches?.["command"] === undefined) continue;
131
+ for (const event of obs.writes) {
132
+ const bucket = observersByWrite.get(event);
133
+ if (bucket) bucket.push(obs);
134
+ else observersByWrite.set(event, [obs]);
135
+ }
136
+ }
137
+
138
+ // Fast path: no producing observers → every ref gets empty events.
139
+ if (observersByWrite.size === 0) {
140
+ for (const ref of refs) result.set(ref, EMPTY_EVENTS);
141
+ return result;
142
+ }
143
+
144
+ // Single left-to-right walk. Each ref sees the active `&&`-chain's
145
+ // speculative events; the switch below carries the joiner semantics.
146
+ let chainEvents: Record<string, readonly SyntheticEntry[]> = {};
147
+ let reachable = true;
148
+ for (let i = 0; i < refs.length; i++) {
149
+ const ref = refs[i]!;
150
+ result.set(
151
+ ref,
152
+ Object.keys(chainEvents).length === 0 ? EMPTY_EVENTS : chainEvents,
153
+ );
154
+
155
+ if (ref.joiner === ";") {
156
+ chainEvents = {};
157
+ reachable = true;
158
+ continue;
159
+ }
160
+ if (ref.joiner !== "&&" || !reachable) {
161
+ chainEvents = {};
162
+ reachable = false;
163
+ continue;
164
+ }
165
+
166
+ // Eligible producer: test every (customType, observer) against
167
+ // the shared `matchesWatch` with a synthesized success event.
168
+ // First matching observer per customType wins — a second match
169
+ // is redundant for `happened`'s presence + latest-timestamp
170
+ // verdict. Clone the chain before extending: earlier consumer
171
+ // refs hold frozen references to the old map.
172
+ const refText = refToText(ref);
173
+ let next: Record<string, readonly SyntheticEntry[]> | null = null;
174
+ for (const [customType, observersForType] of observersByWrite) {
175
+ for (const obs of observersForType) {
176
+ const matched = matchesWatch(obs.watch, {
177
+ toolName: "bash",
178
+ input: { command: refText },
179
+ output: undefined,
180
+ exitCode: 0,
181
+ });
182
+ if (!matched) continue;
183
+ if (next === null) next = { ...chainEvents };
184
+ const entry: SyntheticEntry = {
185
+ data: {},
186
+ timestamp: SPECULATIVE_BASELINE + 1 + i,
187
+ speculative: true,
188
+ };
189
+ const existing = next[customType];
190
+ next[customType] = existing ? [...existing, entry] : [entry];
191
+ break;
192
+ }
193
+ }
194
+ if (next !== null) chainEvents = next;
195
+ }
196
+ return result;
197
+ }
198
+
199
+ const EMPTY_EVENTS: SyntheticEventsByType = Object.freeze({});