@cad0p/pi-steering 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/LICENSE +29 -0
- package/README.md +920 -0
- package/dist/__test-helpers__.d.ts +95 -0
- package/dist/__test-helpers__.d.ts.map +1 -0
- package/dist/__test-helpers__.js +174 -0
- package/dist/__test-helpers__.js.map +1 -0
- package/dist/bin/pi-steering.d.ts +9 -0
- package/dist/bin/pi-steering.d.ts.map +1 -0
- package/dist/bin/pi-steering.js +506 -0
- package/dist/bin/pi-steering.js.map +1 -0
- package/dist/compat.d.ts +50 -0
- package/dist/compat.d.ts.map +1 -0
- package/dist/compat.js +180 -0
- package/dist/compat.js.map +1 -0
- package/dist/defaults.d.ts +61 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +106 -0
- package/dist/defaults.js.map +1 -0
- package/dist/define-config.d.ts +235 -0
- package/dist/define-config.d.ts.map +1 -0
- package/dist/define-config.js +118 -0
- package/dist/define-config.js.map +1 -0
- package/dist/define-predicate.d.ts +49 -0
- package/dist/define-predicate.d.ts.map +1 -0
- package/dist/define-predicate.js +35 -0
- package/dist/define-predicate.js.map +1 -0
- package/dist/evaluator-internals/context.d.ts +186 -0
- package/dist/evaluator-internals/context.d.ts.map +1 -0
- package/dist/evaluator-internals/context.js +219 -0
- package/dist/evaluator-internals/context.js.map +1 -0
- package/dist/evaluator-internals/override.d.ts +19 -0
- package/dist/evaluator-internals/override.d.ts.map +1 -0
- package/dist/evaluator-internals/override.js +79 -0
- package/dist/evaluator-internals/override.js.map +1 -0
- package/dist/evaluator-internals/predicates.d.ts +152 -0
- package/dist/evaluator-internals/predicates.d.ts.map +1 -0
- package/dist/evaluator-internals/predicates.js +849 -0
- package/dist/evaluator-internals/predicates.js.map +1 -0
- package/dist/evaluator-internals/speculative-synthesis.d.ts +102 -0
- package/dist/evaluator-internals/speculative-synthesis.d.ts.map +1 -0
- package/dist/evaluator-internals/speculative-synthesis.js +101 -0
- package/dist/evaluator-internals/speculative-synthesis.js.map +1 -0
- package/dist/evaluator.d.ts +101 -0
- package/dist/evaluator.d.ts.map +1 -0
- package/dist/evaluator.js +714 -0
- package/dist/evaluator.js.map +1 -0
- package/dist/helpers/walker-unknown-cwd-reason.d.ts +68 -0
- package/dist/helpers/walker-unknown-cwd-reason.d.ts.map +1 -0
- package/dist/helpers/walker-unknown-cwd-reason.js +73 -0
- package/dist/helpers/walker-unknown-cwd-reason.js.map +1 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +93 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/drop-unused-observers.d.ts +43 -0
- package/dist/internal/drop-unused-observers.d.ts.map +1 -0
- package/dist/internal/drop-unused-observers.js +50 -0
- package/dist/internal/drop-unused-observers.js.map +1 -0
- package/dist/internal/finalize-plugin-state.d.ts +22 -0
- package/dist/internal/finalize-plugin-state.d.ts.map +1 -0
- package/dist/internal/finalize-plugin-state.js +14 -0
- package/dist/internal/finalize-plugin-state.js.map +1 -0
- package/dist/internal/merge-observers.d.ts +30 -0
- package/dist/internal/merge-observers.d.ts.map +1 -0
- package/dist/internal/merge-observers.js +27 -0
- package/dist/internal/merge-observers.js.map +1 -0
- package/dist/internal/pattern-utils.d.ts +33 -0
- package/dist/internal/pattern-utils.d.ts.map +1 -0
- package/dist/internal/pattern-utils.js +20 -0
- package/dist/internal/pattern-utils.js.map +1 -0
- package/dist/internal/ref-text.d.ts +11 -0
- package/dist/internal/ref-text.d.ts.map +1 -0
- package/dist/internal/ref-text.js +15 -0
- package/dist/internal/ref-text.js.map +1 -0
- package/dist/internal/session-runtime.d.ts +44 -0
- package/dist/internal/session-runtime.d.ts.map +1 -0
- package/dist/internal/session-runtime.js +143 -0
- package/dist/internal/session-runtime.js.map +1 -0
- package/dist/internal/watch-matcher.d.ts +56 -0
- package/dist/internal/watch-matcher.d.ts.map +1 -0
- package/dist/internal/watch-matcher.js +179 -0
- package/dist/internal/watch-matcher.js.map +1 -0
- package/dist/loader.d.ts +97 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +527 -0
- package/dist/loader.js.map +1 -0
- package/dist/observer-dispatcher.d.ts +69 -0
- package/dist/observer-dispatcher.d.ts.map +1 -0
- package/dist/observer-dispatcher.js +184 -0
- package/dist/observer-dispatcher.js.map +1 -0
- package/dist/plugin-merger.d.ts +175 -0
- package/dist/plugin-merger.d.ts.map +1 -0
- package/dist/plugin-merger.js +450 -0
- package/dist/plugin-merger.js.map +1 -0
- package/dist/plugins/git/branch-tracker.d.ts +85 -0
- package/dist/plugins/git/branch-tracker.d.ts.map +1 -0
- package/dist/plugins/git/branch-tracker.js +181 -0
- package/dist/plugins/git/branch-tracker.js.map +1 -0
- package/dist/plugins/git/cwd-extensions.d.ts +10 -0
- package/dist/plugins/git/cwd-extensions.d.ts.map +1 -0
- package/dist/plugins/git/cwd-extensions.js +155 -0
- package/dist/plugins/git/cwd-extensions.js.map +1 -0
- package/dist/plugins/git/git-ops.d.ts +89 -0
- package/dist/plugins/git/git-ops.d.ts.map +1 -0
- package/dist/plugins/git/git-ops.js +107 -0
- package/dist/plugins/git/git-ops.js.map +1 -0
- package/dist/plugins/git/index.d.ts +225 -0
- package/dist/plugins/git/index.d.ts.map +1 -0
- package/dist/plugins/git/index.js +52 -0
- package/dist/plugins/git/index.js.map +1 -0
- package/dist/plugins/git/predicates.d.ts +312 -0
- package/dist/plugins/git/predicates.d.ts.map +1 -0
- package/dist/plugins/git/predicates.js +580 -0
- package/dist/plugins/git/predicates.js.map +1 -0
- package/dist/plugins/git/rules.d.ts +209 -0
- package/dist/plugins/git/rules.d.ts.map +1 -0
- package/dist/plugins/git/rules.js +267 -0
- package/dist/plugins/git/rules.js.map +1 -0
- package/dist/schema.d.ts +1799 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +4 -0
- package/dist/schema.js.map +1 -0
- package/dist/testing/index.d.ts +621 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +747 -0
- package/dist/testing/index.js.map +1 -0
- package/examples/README.md +83 -0
- package/examples/combined-git-discipline/README.md +52 -0
- package/examples/combined-git-discipline/node_modules/.bin/pi-steering +43 -0
- package/examples/combined-git-discipline/package.json +18 -0
- package/examples/combined-git-discipline/steering.json +27 -0
- package/examples/combined-git-discipline/steering.test.ts +42 -0
- package/examples/combined-git-discipline/steering.ts +64 -0
- package/examples/combined-git-discipline/tsconfig.json +7 -0
- package/examples/draft-prs-only/README.md +42 -0
- package/examples/draft-prs-only/node_modules/.bin/pi-steering +43 -0
- package/examples/draft-prs-only/package.json +18 -0
- package/examples/draft-prs-only/steering.json +12 -0
- package/examples/draft-prs-only/steering.test.ts +48 -0
- package/examples/draft-prs-only/steering.ts +36 -0
- package/examples/draft-prs-only/tsconfig.json +7 -0
- package/examples/dynamic-reason-runtime-cwd/README.md +148 -0
- package/examples/dynamic-reason-runtime-cwd/node_modules/.bin/pi-steering +43 -0
- package/examples/dynamic-reason-runtime-cwd/package.json +18 -0
- package/examples/dynamic-reason-runtime-cwd/steering.test.ts +130 -0
- package/examples/dynamic-reason-runtime-cwd/steering.ts +82 -0
- package/examples/dynamic-reason-runtime-cwd/tsconfig.json +7 -0
- package/examples/force-push-strict/README.md +75 -0
- package/examples/force-push-strict/node_modules/.bin/pi-steering +43 -0
- package/examples/force-push-strict/package.json +18 -0
- package/examples/force-push-strict/steering.json +12 -0
- package/examples/force-push-strict/steering.test.ts +51 -0
- package/examples/force-push-strict/steering.ts +54 -0
- package/examples/force-push-strict/tsconfig.json +7 -0
- package/examples/no-amend/README.md +52 -0
- package/examples/no-amend/node_modules/.bin/pi-steering +43 -0
- package/examples/no-amend/package.json +18 -0
- package/examples/no-amend/steering.cwd-scoped.json +12 -0
- package/examples/no-amend/steering.json +11 -0
- package/examples/no-amend/steering.test.ts +49 -0
- package/examples/no-amend/steering.ts +38 -0
- package/examples/no-amend/tsconfig.json +7 -0
- package/examples/work-item-plugin/README.md +103 -0
- package/examples/work-item-plugin/node_modules/.bin/pi-steering +43 -0
- package/examples/work-item-plugin/package.json +19 -0
- package/examples/work-item-plugin/src/index.test.ts +168 -0
- package/examples/work-item-plugin/src/index.ts +122 -0
- package/examples/work-item-plugin/src/observers/npm-test-tracker.test.ts +95 -0
- package/examples/work-item-plugin/src/observers/npm-test-tracker.ts +88 -0
- package/examples/work-item-plugin/src/observers/retest-required-tracker.test.ts +81 -0
- package/examples/work-item-plugin/src/observers/retest-required-tracker.ts +81 -0
- package/examples/work-item-plugin/src/predicates/work-item-format.test.ts +157 -0
- package/examples/work-item-plugin/src/predicates/work-item-format.ts +99 -0
- package/examples/work-item-plugin/src/rules/commit-description-check.test.ts +107 -0
- package/examples/work-item-plugin/src/rules/commit-description-check.ts +83 -0
- package/examples/work-item-plugin/src/rules/commit-requires-work-item.test.ts +79 -0
- package/examples/work-item-plugin/src/rules/commit-requires-work-item.ts +56 -0
- package/examples/work-item-plugin/src/rules/push-requires-tests.test.ts +245 -0
- package/examples/work-item-plugin/src/rules/push-requires-tests.ts +62 -0
- package/examples/work-item-plugin/tsconfig.json +8 -0
- package/package.json +88 -0
- package/skills/steering-authoring/SKILL.md +102 -0
- package/src/INVARIANTS.md +116 -0
- package/src/__test-helpers__.ts +243 -0
- package/src/bin/pi-steering.test.ts +920 -0
- package/src/bin/pi-steering.ts +584 -0
- package/src/compat.test.ts +431 -0
- package/src/compat.ts +282 -0
- package/src/defaults.test.ts +585 -0
- package/src/defaults.ts +148 -0
- package/src/define-config.test.ts +888 -0
- package/src/define-config.ts +375 -0
- package/src/define-predicate.test.ts +70 -0
- package/src/define-predicate.ts +57 -0
- package/src/evaluator-internals/context.ts +290 -0
- package/src/evaluator-internals/override.test.ts +99 -0
- package/src/evaluator-internals/override.ts +83 -0
- package/src/evaluator-internals/predicates.ts +1037 -0
- package/src/evaluator-internals/speculative-synthesis.test.ts +411 -0
- package/src/evaluator-internals/speculative-synthesis.ts +199 -0
- package/src/evaluator.test.ts +6296 -0
- package/src/evaluator.ts +1106 -0
- package/src/factory-time-load.test.ts +630 -0
- package/src/helpers/walker-unknown-cwd-reason.test.ts +73 -0
- package/src/helpers/walker-unknown-cwd-reason.ts +83 -0
- package/src/index.test.ts +836 -0
- package/src/index.ts +229 -0
- package/src/internal/drop-unused-observers.test.ts +141 -0
- package/src/internal/drop-unused-observers.ts +81 -0
- package/src/internal/finalize-plugin-state.ts +43 -0
- package/src/internal/merge-observers.ts +51 -0
- package/src/internal/pattern-utils.test.ts +52 -0
- package/src/internal/pattern-utils.ts +38 -0
- package/src/internal/ref-text.test.ts +54 -0
- package/src/internal/ref-text.ts +17 -0
- package/src/internal/session-runtime.test.ts +629 -0
- package/src/internal/session-runtime.ts +201 -0
- package/src/internal/watch-matcher.test.ts +326 -0
- package/src/internal/watch-matcher.ts +200 -0
- package/src/jsdoc-propagation.test.ts +277 -0
- package/src/loader.test.ts +1098 -0
- package/src/loader.ts +581 -0
- package/src/not-block-onunknown.test.ts +554 -0
- package/src/observer-dispatcher.test.ts +939 -0
- package/src/observer-dispatcher.ts +290 -0
- package/src/per-predicate-typing.test.ts +361 -0
- package/src/plugin-merger.test.ts +790 -0
- package/src/plugin-merger.ts +598 -0
- package/src/plugins/git/README.md +485 -0
- package/src/plugins/git/branch-tracker.test.ts +281 -0
- package/src/plugins/git/branch-tracker.ts +187 -0
- package/src/plugins/git/cwd-extensions.test.ts +230 -0
- package/src/plugins/git/cwd-extensions.ts +160 -0
- package/src/plugins/git/git-ops.test.ts +397 -0
- package/src/plugins/git/git-ops.ts +173 -0
- package/src/plugins/git/index.ts +253 -0
- package/src/plugins/git/integration.test.ts +705 -0
- package/src/plugins/git/predicates.test.ts +1401 -0
- package/src/plugins/git/predicates.ts +693 -0
- package/src/plugins/git/rules.test.ts +1001 -0
- package/src/plugins/git/rules.ts +307 -0
- package/src/schema.test.ts +259 -0
- package/src/schema.ts +1963 -0
- package/src/testing/index.test.ts +1696 -0
- package/src/testing/index.ts +1472 -0
|
@@ -0,0 +1,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({});
|