@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,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
|
+
});
|