@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,790 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tests for `resolvePlugins` — the plugin merger.
|
|
6
|
+
*
|
|
7
|
+
* Covers the collision semantics the ADR pins down: first-wins on soft
|
|
8
|
+
* collisions (predicate / observer / rule), hard error on tracker name
|
|
9
|
+
* collision, proper layering of trackerExtensions on top of registered
|
|
10
|
+
* trackers, and the config-level `disabledRules` / `disabledPlugins` filters.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { describe, it } from "node:test";
|
|
15
|
+
import type { Modifier, Tracker } from "@cad0p/unbash-walker";
|
|
16
|
+
import {
|
|
17
|
+
resolvePlugins,
|
|
18
|
+
validateName,
|
|
19
|
+
validateUserConfigNames,
|
|
20
|
+
} from "./plugin-merger.ts";
|
|
21
|
+
import type { Observer, Plugin, Rule } from "./schema.ts";
|
|
22
|
+
|
|
23
|
+
/** Build a minimal observer with a recognizable onResult. */
|
|
24
|
+
function mkObserver(name: string): Observer {
|
|
25
|
+
return { name, onResult: () => {} };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Build a minimal bash rule. */
|
|
29
|
+
function mkRule(name: string): Rule {
|
|
30
|
+
return {
|
|
31
|
+
name,
|
|
32
|
+
tool: "bash",
|
|
33
|
+
field: "command",
|
|
34
|
+
pattern: "^never$",
|
|
35
|
+
reason: `rule ${name}`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Build a minimal tracker with deterministic initial / unknown. */
|
|
40
|
+
function mkTracker(label: string): Tracker<string> {
|
|
41
|
+
return {
|
|
42
|
+
initial: `${label}:init`,
|
|
43
|
+
unknown: `${label}:unknown`,
|
|
44
|
+
modifiers: {},
|
|
45
|
+
subshellSemantics: "isolated",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A sentinel per-command modifier we can identify by identity. */
|
|
50
|
+
function mkModifier(tag: string): Modifier<string> {
|
|
51
|
+
return {
|
|
52
|
+
scope: "per-command",
|
|
53
|
+
apply: (_args, current) => `${current}+${tag}`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("resolvePlugins: empty input", () => {
|
|
58
|
+
it("returns an empty ResolvedPluginState for no plugins", () => {
|
|
59
|
+
const state = resolvePlugins([], {});
|
|
60
|
+
assert.deepEqual(state.predicates, {});
|
|
61
|
+
assert.deepEqual(state.observers, []);
|
|
62
|
+
assert.deepEqual(state.trackers, {});
|
|
63
|
+
assert.deepEqual(state.trackerModifiers, {});
|
|
64
|
+
assert.deepEqual(state.composedTrackers, {});
|
|
65
|
+
assert.deepEqual(state.rules, []);
|
|
66
|
+
assert.deepEqual(state.diagnostics, []);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("resolvePlugins: single plugin surface", () => {
|
|
71
|
+
it("propagates predicates / observers / trackers / rules unchanged", () => {
|
|
72
|
+
const tracker = mkTracker("t");
|
|
73
|
+
const obs = mkObserver("obs");
|
|
74
|
+
const rule = mkRule("r");
|
|
75
|
+
const predicate = () => true;
|
|
76
|
+
|
|
77
|
+
const plugin: Plugin = {
|
|
78
|
+
name: "p",
|
|
79
|
+
predicates: { foo: predicate },
|
|
80
|
+
observers: [obs],
|
|
81
|
+
trackers: { t: tracker as Tracker<unknown> },
|
|
82
|
+
rules: [rule],
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const state = resolvePlugins([plugin], {});
|
|
86
|
+
assert.equal(state.predicates["foo"], predicate);
|
|
87
|
+
assert.deepEqual(state.observers, [obs]);
|
|
88
|
+
assert.equal(state.trackers["t"], tracker);
|
|
89
|
+
// No extensions → composed tracker is identity-equal when no extras
|
|
90
|
+
// were layered on.
|
|
91
|
+
assert.equal(state.composedTrackers["t"], tracker);
|
|
92
|
+
assert.deepEqual(state.rules, [rule]);
|
|
93
|
+
assert.deepEqual(state.rulePluginOwners, { r: "p" });
|
|
94
|
+
assert.deepEqual(state.diagnostics, []);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("resolvePlugins: rulePluginOwners", () => {
|
|
99
|
+
it("maps each plugin rule name to its originating plugin", () => {
|
|
100
|
+
const p1: Plugin = { name: "plugin-a", rules: [mkRule("rule-a")] };
|
|
101
|
+
const p2: Plugin = { name: "plugin-b", rules: [mkRule("rule-b")] };
|
|
102
|
+
const state = resolvePlugins([p1, p2], {});
|
|
103
|
+
assert.deepEqual(state.rulePluginOwners, {
|
|
104
|
+
"rule-a": "plugin-a",
|
|
105
|
+
"rule-b": "plugin-b",
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("first-wins collision keeps the first owner", () => {
|
|
110
|
+
const p1: Plugin = { name: "first", rules: [mkRule("dup")] };
|
|
111
|
+
const p2: Plugin = { name: "second", rules: [mkRule("dup")] };
|
|
112
|
+
const state = resolvePlugins([p1, p2], {});
|
|
113
|
+
assert.deepEqual(state.rulePluginOwners, { dup: "first" });
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("resolvePlugins: predicate collision (soft)", () => {
|
|
118
|
+
it("keeps first-registered predicate, emits warning", () => {
|
|
119
|
+
const keptHandler = () => true;
|
|
120
|
+
const droppedHandler = () => false;
|
|
121
|
+
const p1: Plugin = { name: "first", predicates: { branch: keptHandler } };
|
|
122
|
+
const p2: Plugin = {
|
|
123
|
+
name: "second",
|
|
124
|
+
predicates: { branch: droppedHandler },
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const state = resolvePlugins([p1, p2], {});
|
|
128
|
+
assert.equal(state.predicates["branch"], keptHandler);
|
|
129
|
+
assert.equal(state.diagnostics.length, 1);
|
|
130
|
+
assert.equal(state.diagnostics[0]?.kind, "predicate-collision");
|
|
131
|
+
assert.match(state.diagnostics[0]?.message ?? "", /when\.branch/);
|
|
132
|
+
assert.match(state.diagnostics[0]?.message ?? "", /first/);
|
|
133
|
+
assert.match(state.diagnostics[0]?.message ?? "", /second/);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("resolvePlugins: observer collision (soft)", () => {
|
|
138
|
+
it("keeps first-registered observer, emits warning", () => {
|
|
139
|
+
const kept = mkObserver("sync-done");
|
|
140
|
+
const dropped = mkObserver("sync-done");
|
|
141
|
+
const p1: Plugin = { name: "first", observers: [kept] };
|
|
142
|
+
const p2: Plugin = { name: "second", observers: [dropped] };
|
|
143
|
+
|
|
144
|
+
const state = resolvePlugins([p1, p2], {});
|
|
145
|
+
assert.deepEqual(state.observers, [kept]);
|
|
146
|
+
assert.equal(state.observers[0], kept, "first-registered instance kept");
|
|
147
|
+
assert.equal(state.diagnostics.length, 1);
|
|
148
|
+
assert.equal(state.diagnostics[0]?.kind, "observer-collision");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("resolvePlugins: rule collision (soft)", () => {
|
|
153
|
+
it("keeps first-registered rule, emits warning", () => {
|
|
154
|
+
const kept = mkRule("shared-name");
|
|
155
|
+
const dropped = mkRule("shared-name");
|
|
156
|
+
const p1: Plugin = { name: "first", rules: [kept] };
|
|
157
|
+
const p2: Plugin = { name: "second", rules: [dropped] };
|
|
158
|
+
|
|
159
|
+
const state = resolvePlugins([p1, p2], {});
|
|
160
|
+
assert.equal(state.rules.length, 1);
|
|
161
|
+
assert.equal(state.rules[0], kept);
|
|
162
|
+
assert.equal(state.diagnostics.length, 1);
|
|
163
|
+
assert.equal(state.diagnostics[0]?.kind, "rule-collision");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("resolvePlugins: tracker collision (error-class diagnostic)", () => {
|
|
168
|
+
it("records an error-class tracker-name-collision diagnostic when two plugins register the same tracker name", () => {
|
|
169
|
+
const p1: Plugin = {
|
|
170
|
+
name: "first",
|
|
171
|
+
trackers: { branch: mkTracker("one") as Tracker<unknown> },
|
|
172
|
+
};
|
|
173
|
+
const p2: Plugin = {
|
|
174
|
+
name: "second",
|
|
175
|
+
trackers: { branch: mkTracker("two") as Tracker<unknown> },
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const state = resolvePlugins([p1, p2], {});
|
|
179
|
+
const hit = state.diagnostics.find(
|
|
180
|
+
(d) => d.kind === "tracker-name-collision",
|
|
181
|
+
);
|
|
182
|
+
assert.ok(
|
|
183
|
+
hit,
|
|
184
|
+
`expected a tracker-name-collision diagnostic; got: ${JSON.stringify(state.diagnostics)}`,
|
|
185
|
+
);
|
|
186
|
+
assert.equal(hit.type, "error");
|
|
187
|
+
assert.match(hit.message, /tracker name collision/);
|
|
188
|
+
assert.match(hit.message, /"first".*"second"/);
|
|
189
|
+
// Direct callers (those that bypass `buildConfig`) check
|
|
190
|
+
// `result.diagnostics.some(d => d.type === "error")` before using
|
|
191
|
+
// the resolved state — same contract as `loadHarness`.
|
|
192
|
+
assert.ok(
|
|
193
|
+
state.diagnostics.some((d) => d.type === "error"),
|
|
194
|
+
"expected at least one error-class diagnostic",
|
|
195
|
+
);
|
|
196
|
+
// The colliding tracker is dropped from the second plugin; the first
|
|
197
|
+
// plugin's registration wins (matches the loader's first-wins ordering).
|
|
198
|
+
assert.equal(
|
|
199
|
+
(state.trackers.branch as Tracker<string>).initial,
|
|
200
|
+
"one:init",
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("preserves warning-class diagnostics from earlier plugins alongside the tracker-name-collision error", () => {
|
|
205
|
+
// Convert-to-diagnostic guarantees: a tracker-name collision no
|
|
206
|
+
// longer aborts the merger. Earlier plugins' warning-class
|
|
207
|
+
// diagnostics (e.g. duplicate predicate names) survive on the
|
|
208
|
+
// returned `diagnostics` array, so direct callers of
|
|
209
|
+
// `resolvePlugins` see the full picture in one read instead of
|
|
210
|
+
// catching a throw and losing visibility into prior issues.
|
|
211
|
+
const p1: Plugin = {
|
|
212
|
+
name: "first",
|
|
213
|
+
predicates: { dup: () => true },
|
|
214
|
+
trackers: { branch: mkTracker("one") as Tracker<unknown> },
|
|
215
|
+
};
|
|
216
|
+
const p2: Plugin = {
|
|
217
|
+
name: "second",
|
|
218
|
+
predicates: { dup: () => false },
|
|
219
|
+
trackers: { branch: mkTracker("two") as Tracker<unknown> },
|
|
220
|
+
};
|
|
221
|
+
const state = resolvePlugins([p1, p2], {});
|
|
222
|
+
assert.ok(
|
|
223
|
+
state.diagnostics.some((d) => d.kind === "predicate-collision"),
|
|
224
|
+
"expected the predicate-collision warning to survive alongside the tracker error",
|
|
225
|
+
);
|
|
226
|
+
assert.ok(
|
|
227
|
+
state.diagnostics.some((d) => d.kind === "tracker-name-collision"),
|
|
228
|
+
"expected the tracker-name-collision error",
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('records an error-class diagnostic when a plugin registers the reserved tracker name "events"', () => {
|
|
233
|
+
// `walkerState.events` is written by the evaluator's speculative-
|
|
234
|
+
// entry synthesis pass (see evaluator.ts `prepareBashState`). A
|
|
235
|
+
// plugin-registered `events` tracker would be silently clobbered
|
|
236
|
+
// when the evaluator merges synthesized entries in, breaking
|
|
237
|
+
// `when.happened` with `in: "tool_call"`. Schema JSDoc promises
|
|
238
|
+
// rejection; this test holds the promise honest.
|
|
239
|
+
const p: Plugin = {
|
|
240
|
+
name: "broken",
|
|
241
|
+
trackers: { events: mkTracker("x") as Tracker<unknown> },
|
|
242
|
+
};
|
|
243
|
+
const state = resolvePlugins([p], {});
|
|
244
|
+
const hit = state.diagnostics.find(
|
|
245
|
+
(d) => d.kind === "reserved-tracker-name",
|
|
246
|
+
);
|
|
247
|
+
assert.ok(
|
|
248
|
+
hit,
|
|
249
|
+
`expected a reserved-tracker-name diagnostic; got: ${JSON.stringify(state.diagnostics)}`,
|
|
250
|
+
);
|
|
251
|
+
assert.equal(hit.type, "error");
|
|
252
|
+
assert.match(hit.message, /tracker name "events" is reserved/);
|
|
253
|
+
// The reserved tracker is dropped, not added to the trackers map.
|
|
254
|
+
assert.ok(!("events" in state.trackers));
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe("resolvePlugins: tracker extensions", () => {
|
|
259
|
+
it("layers extension modifiers on top of the declaring tracker", () => {
|
|
260
|
+
const origMod = mkModifier("orig");
|
|
261
|
+
const extraMod = mkModifier("extra");
|
|
262
|
+
const tracker: Tracker<unknown> = {
|
|
263
|
+
initial: "init",
|
|
264
|
+
unknown: "unknown",
|
|
265
|
+
modifiers: { git: origMod as Modifier<unknown> },
|
|
266
|
+
subshellSemantics: "isolated",
|
|
267
|
+
};
|
|
268
|
+
const owner: Plugin = { name: "owner", trackers: { cwd: tracker } };
|
|
269
|
+
const extender: Plugin = {
|
|
270
|
+
name: "extender",
|
|
271
|
+
trackerExtensions: {
|
|
272
|
+
cwd: { git: extraMod as Modifier<unknown> },
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const state = resolvePlugins([owner, extender], {});
|
|
277
|
+
assert.deepEqual(state.diagnostics, []);
|
|
278
|
+
assert.equal(state.trackers["cwd"], tracker, "raw tracker preserved");
|
|
279
|
+
const composed = state.composedTrackers["cwd"]!;
|
|
280
|
+
assert.notEqual(
|
|
281
|
+
composed,
|
|
282
|
+
tracker,
|
|
283
|
+
"composed tracker is a new object (non-mutating)",
|
|
284
|
+
);
|
|
285
|
+
const composedMods = composed.modifiers["git"];
|
|
286
|
+
assert.ok(Array.isArray(composedMods), "composed git entry is array");
|
|
287
|
+
const list = composedMods as Modifier<unknown>[];
|
|
288
|
+
assert.equal(list.length, 2);
|
|
289
|
+
assert.equal(list[0], origMod, "original modifier retained first");
|
|
290
|
+
assert.equal(list[1], extraMod, "extension modifier appended");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("warns and drops extensions targeting an unregistered tracker", () => {
|
|
294
|
+
const extender: Plugin = {
|
|
295
|
+
name: "extender",
|
|
296
|
+
trackerExtensions: {
|
|
297
|
+
nosuch: { git: mkModifier("x") as Modifier<unknown> },
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
const state = resolvePlugins([extender], {});
|
|
301
|
+
assert.equal(state.diagnostics.length, 1);
|
|
302
|
+
assert.equal(state.diagnostics[0]?.kind, "extension-orphan");
|
|
303
|
+
assert.match(state.diagnostics[0]?.message ?? "", /"nosuch"/);
|
|
304
|
+
assert.deepEqual(state.trackerModifiers, {});
|
|
305
|
+
assert.deepEqual(state.composedTrackers, {});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("keeps extensions targeting knownBuiltinTrackers (no warning, modifiers preserved)", () => {
|
|
309
|
+
// The caller declares `"cwd"` as a built-in tracker name. The
|
|
310
|
+
// merger still doesn't OWN the tracker (cwd isn't in any
|
|
311
|
+
// plugin's `trackers` map, so `composedTrackers.cwd` stays
|
|
312
|
+
// undefined), but the extension modifiers are preserved in
|
|
313
|
+
// `trackerModifiers.cwd` for the caller to layer onto its own
|
|
314
|
+
// built-in tracker. The evaluator uses this path for the
|
|
315
|
+
// walker's built-in `cwdTracker`.
|
|
316
|
+
const extender: Plugin = {
|
|
317
|
+
name: "extender",
|
|
318
|
+
trackerExtensions: {
|
|
319
|
+
cwd: { git: mkModifier("x") as Modifier<unknown> },
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
const state = resolvePlugins([extender], {}, ["cwd"]);
|
|
323
|
+
assert.equal(
|
|
324
|
+
state.diagnostics.filter((w) => w.kind === "extension-orphan").length,
|
|
325
|
+
0,
|
|
326
|
+
"no orphan warning when the tracker name is declared built-in",
|
|
327
|
+
);
|
|
328
|
+
assert.ok("cwd" in state.trackerModifiers);
|
|
329
|
+
assert.ok(state.trackerModifiers["cwd"]?.["git"] !== undefined);
|
|
330
|
+
// Still NOT composed - the caller is responsible for composing
|
|
331
|
+
// these modifiers onto its own built-in tracker.
|
|
332
|
+
assert.ok(!("cwd" in state.composedTrackers));
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("accepts the array form on trackerExtensions values", () => {
|
|
336
|
+
const tracker: Tracker<unknown> = {
|
|
337
|
+
initial: "x",
|
|
338
|
+
unknown: "?",
|
|
339
|
+
modifiers: {},
|
|
340
|
+
subshellSemantics: "isolated",
|
|
341
|
+
};
|
|
342
|
+
const m1 = mkModifier("a");
|
|
343
|
+
const m2 = mkModifier("b");
|
|
344
|
+
const owner: Plugin = { name: "owner", trackers: { cwd: tracker } };
|
|
345
|
+
const extender: Plugin = {
|
|
346
|
+
name: "extender",
|
|
347
|
+
trackerExtensions: {
|
|
348
|
+
cwd: {
|
|
349
|
+
git: [m1, m2] as readonly Modifier<unknown>[],
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const state = resolvePlugins([owner, extender], {});
|
|
355
|
+
const composed = state.composedTrackers["cwd"]!;
|
|
356
|
+
const list = composed.modifiers["git"] as Modifier<unknown>[];
|
|
357
|
+
assert.ok(Array.isArray(list));
|
|
358
|
+
assert.deepEqual(list, [m1, m2]);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("appends modifiers from multiple plugins in registration order", () => {
|
|
362
|
+
const tracker: Tracker<unknown> = {
|
|
363
|
+
initial: "x",
|
|
364
|
+
unknown: "?",
|
|
365
|
+
modifiers: {},
|
|
366
|
+
subshellSemantics: "isolated",
|
|
367
|
+
};
|
|
368
|
+
const m1 = mkModifier("one");
|
|
369
|
+
const m2 = mkModifier("two");
|
|
370
|
+
const owner: Plugin = { name: "owner", trackers: { cwd: tracker } };
|
|
371
|
+
const ext1: Plugin = {
|
|
372
|
+
name: "ext1",
|
|
373
|
+
trackerExtensions: { cwd: { git: m1 as Modifier<unknown> } },
|
|
374
|
+
};
|
|
375
|
+
const ext2: Plugin = {
|
|
376
|
+
name: "ext2",
|
|
377
|
+
trackerExtensions: { cwd: { git: m2 as Modifier<unknown> } },
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const state = resolvePlugins([owner, ext1, ext2], {});
|
|
381
|
+
const list = state.composedTrackers["cwd"]!.modifiers[
|
|
382
|
+
"git"
|
|
383
|
+
] as Modifier<unknown>[];
|
|
384
|
+
assert.deepEqual(list, [m1, m2]);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("leaves trackers unchanged when no extensions are registered", () => {
|
|
388
|
+
const tracker = mkTracker("cwd");
|
|
389
|
+
const plugin: Plugin = {
|
|
390
|
+
name: "only",
|
|
391
|
+
trackers: { cwd: tracker as Tracker<unknown> },
|
|
392
|
+
};
|
|
393
|
+
const state = resolvePlugins([plugin], {});
|
|
394
|
+
assert.equal(state.composedTrackers["cwd"], tracker);
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
describe("resolvePlugins: config filters", () => {
|
|
399
|
+
let origInfo: typeof console.info;
|
|
400
|
+
let infos: string[];
|
|
401
|
+
|
|
402
|
+
function captureInfos(): void {
|
|
403
|
+
origInfo = console.info;
|
|
404
|
+
infos = [];
|
|
405
|
+
console.info = (msg: unknown) => {
|
|
406
|
+
infos.push(String(msg));
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function restoreInfos(): void {
|
|
411
|
+
console.info = origInfo;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
it("applies config.disabledRules to plugin-shipped rules", () => {
|
|
415
|
+
const kept = mkRule("keep-me");
|
|
416
|
+
const dropped = mkRule("drop-me");
|
|
417
|
+
const plugin: Plugin = {
|
|
418
|
+
name: "p",
|
|
419
|
+
rules: [kept, dropped],
|
|
420
|
+
};
|
|
421
|
+
captureInfos();
|
|
422
|
+
try {
|
|
423
|
+
const state = resolvePlugins([plugin], { disabledRules: ["drop-me"] });
|
|
424
|
+
assert.equal(state.rules.length, 1);
|
|
425
|
+
assert.equal(state.rules[0]?.name, "keep-me");
|
|
426
|
+
// Disabling a plugin-shipped rule is by-design behavior, not a
|
|
427
|
+
// diagnostic-stream entry. Surfaced via console.info so authors
|
|
428
|
+
// debugging "why isn't my rule firing?" still get a breadcrumb.
|
|
429
|
+
assert.deepEqual(
|
|
430
|
+
state.diagnostics.filter((d) => d.message.includes("disabled")),
|
|
431
|
+
[],
|
|
432
|
+
);
|
|
433
|
+
assert.ok(
|
|
434
|
+
infos.some(
|
|
435
|
+
(m) =>
|
|
436
|
+
m.includes("[pi-steering]") &&
|
|
437
|
+
m.includes('rule "drop-me"') &&
|
|
438
|
+
m.includes("disabled via config.disabledRules"),
|
|
439
|
+
),
|
|
440
|
+
`expected a console.info breadcrumb for the disabled rule; got: ${JSON.stringify(infos)}`,
|
|
441
|
+
);
|
|
442
|
+
} finally {
|
|
443
|
+
restoreInfos();
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("applies config.disabledPlugins to skip an entire plugin", () => {
|
|
448
|
+
const p1: Plugin = {
|
|
449
|
+
name: "git",
|
|
450
|
+
predicates: { branch: () => true },
|
|
451
|
+
rules: [mkRule("no-main-commit")],
|
|
452
|
+
observers: [mkObserver("sync-done")],
|
|
453
|
+
};
|
|
454
|
+
const p2: Plugin = {
|
|
455
|
+
name: "kept",
|
|
456
|
+
predicates: { other: () => true },
|
|
457
|
+
};
|
|
458
|
+
captureInfos();
|
|
459
|
+
try {
|
|
460
|
+
const state = resolvePlugins([p1, p2], { disabledPlugins: ["git"] });
|
|
461
|
+
|
|
462
|
+
assert.deepEqual(state.observers, []);
|
|
463
|
+
assert.deepEqual(state.rules, []);
|
|
464
|
+
assert.ok(!("branch" in state.predicates));
|
|
465
|
+
assert.ok("other" in state.predicates);
|
|
466
|
+
// No diagnostic for the disabled plugin — by-design behavior.
|
|
467
|
+
assert.deepEqual(
|
|
468
|
+
state.diagnostics.filter((d) => d.message.includes("disabled")),
|
|
469
|
+
[],
|
|
470
|
+
);
|
|
471
|
+
assert.ok(
|
|
472
|
+
infos.some(
|
|
473
|
+
(m) =>
|
|
474
|
+
m.includes("[pi-steering]") &&
|
|
475
|
+
m.includes('plugin "git"') &&
|
|
476
|
+
m.includes("disabled via config.disabledPlugins"),
|
|
477
|
+
),
|
|
478
|
+
`expected a console.info breadcrumb for the disabled plugin; got: ${JSON.stringify(infos)}`,
|
|
479
|
+
);
|
|
480
|
+
} finally {
|
|
481
|
+
restoreInfos();
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("disabledPlugins covering a default plugin does NOT throw under strict default", () => {
|
|
486
|
+
// Regression for the case where `disabledPlugins: ["git"]`
|
|
487
|
+
// previously emitted a `plugin-disabled` warning that strict
|
|
488
|
+
// mode (the new default) escalated to a thrown error, breaking
|
|
489
|
+
// the most common opt-out path on first activation.
|
|
490
|
+
captureInfos();
|
|
491
|
+
try {
|
|
492
|
+
const state = resolvePlugins(
|
|
493
|
+
[{ name: "git", predicates: { branch: () => true } }],
|
|
494
|
+
{ disabledPlugins: ["git"] },
|
|
495
|
+
);
|
|
496
|
+
assert.deepEqual(state.diagnostics, []);
|
|
497
|
+
} finally {
|
|
498
|
+
restoreInfos();
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
describe("resolvePlugins: result immutability", () => {
|
|
504
|
+
it("doesn't mutate the input plugin array or its trackers", () => {
|
|
505
|
+
const origMods = { cd: mkModifier("cd") as Modifier<unknown> };
|
|
506
|
+
const tracker: Tracker<unknown> = {
|
|
507
|
+
initial: "/",
|
|
508
|
+
unknown: "unknown",
|
|
509
|
+
modifiers: origMods,
|
|
510
|
+
subshellSemantics: "isolated",
|
|
511
|
+
};
|
|
512
|
+
const owner: Plugin = { name: "owner", trackers: { cwd: tracker } };
|
|
513
|
+
const extender: Plugin = {
|
|
514
|
+
name: "extender",
|
|
515
|
+
trackerExtensions: {
|
|
516
|
+
cwd: { git: mkModifier("git") as Modifier<unknown> },
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
const plugins = [owner, extender];
|
|
520
|
+
|
|
521
|
+
const snapshot = JSON.stringify({
|
|
522
|
+
pluginsLen: plugins.length,
|
|
523
|
+
modifierKeys: Object.keys(tracker.modifiers),
|
|
524
|
+
});
|
|
525
|
+
resolvePlugins(plugins, {});
|
|
526
|
+
assert.equal(
|
|
527
|
+
JSON.stringify({
|
|
528
|
+
pluginsLen: plugins.length,
|
|
529
|
+
modifierKeys: Object.keys(tracker.modifiers),
|
|
530
|
+
}),
|
|
531
|
+
snapshot,
|
|
532
|
+
"plugins array / tracker.modifiers not mutated",
|
|
533
|
+
);
|
|
534
|
+
assert.equal(tracker.modifiers["cd"], origMods["cd"]);
|
|
535
|
+
assert.ok(
|
|
536
|
+
!("git" in tracker.modifiers),
|
|
537
|
+
"extension modifier did not bleed into original tracker",
|
|
538
|
+
);
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
// ---------------------------------------------------------------------------
|
|
543
|
+
// S3: name validation (rules, plugins, observers)
|
|
544
|
+
// ---------------------------------------------------------------------------
|
|
545
|
+
|
|
546
|
+
describe("S3: validateName", () => {
|
|
547
|
+
const okNames = [
|
|
548
|
+
"no-force-push",
|
|
549
|
+
"must_read_docs",
|
|
550
|
+
"rule1",
|
|
551
|
+
"1-critical",
|
|
552
|
+
"2026-release",
|
|
553
|
+
"A",
|
|
554
|
+
"pi-steering_git",
|
|
555
|
+
];
|
|
556
|
+
for (const n of okNames) {
|
|
557
|
+
it(`accepts ${JSON.stringify(n)}`, () => {
|
|
558
|
+
assert.equal(validateName("rule", n), undefined);
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const badNames: Array<[string, unknown]> = [
|
|
563
|
+
["empty string", ""],
|
|
564
|
+
["leading dash", "-bad"],
|
|
565
|
+
["leading underscore", "_bad"],
|
|
566
|
+
["contains space", "bad name"],
|
|
567
|
+
["contains tab", "bad\tname"],
|
|
568
|
+
["contains newline", "bad\nname"],
|
|
569
|
+
["contains ] (block-reason tag forge)", "phony] ALL CLEAR [real"],
|
|
570
|
+
["contains @", "rule@source"],
|
|
571
|
+
["contains .", "ns.rule"],
|
|
572
|
+
["contains /", "a/b"],
|
|
573
|
+
["contains colon", "steering:rule"],
|
|
574
|
+
["non-string (number)", 42],
|
|
575
|
+
["non-string (undefined)", undefined],
|
|
576
|
+
["non-string (null)", null],
|
|
577
|
+
];
|
|
578
|
+
for (const [label, value] of badNames) {
|
|
579
|
+
it(`rejects ${label}`, () => {
|
|
580
|
+
const d = validateName("rule", value);
|
|
581
|
+
assert.ok(d, `expected diagnostic for ${label}`);
|
|
582
|
+
assert.equal(d?.type, "error");
|
|
583
|
+
assert.equal(d?.kind, "invalid-name");
|
|
584
|
+
assert.match(d!.message, /contains disallowed characters/);
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
it("diagnostic message names the kind (rule / plugin / observer)", () => {
|
|
589
|
+
assert.match(
|
|
590
|
+
validateName("rule", "bad name")!.message,
|
|
591
|
+
/^rule name "bad name".*disallowed/,
|
|
592
|
+
);
|
|
593
|
+
assert.match(
|
|
594
|
+
validateName("plugin", "bad name")!.message,
|
|
595
|
+
/^plugin name "bad name".*disallowed/,
|
|
596
|
+
);
|
|
597
|
+
assert.match(
|
|
598
|
+
validateName("observer", "bad name")!.message,
|
|
599
|
+
/^observer name "bad name".*disallowed/,
|
|
600
|
+
);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it("diagnostic message includes the context hint when provided", () => {
|
|
604
|
+
assert.match(
|
|
605
|
+
validateName("rule", "bad name", 'plugin "git"')!.message,
|
|
606
|
+
/^rule name "bad name" \(plugin "git"\)/,
|
|
607
|
+
);
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
describe("S3: validateUserConfigNames", () => {
|
|
612
|
+
it("returns no diagnostics for a clean user-config", () => {
|
|
613
|
+
const out = validateUserConfigNames([
|
|
614
|
+
{
|
|
615
|
+
rules: [
|
|
616
|
+
{
|
|
617
|
+
name: "clean-rule",
|
|
618
|
+
tool: "bash",
|
|
619
|
+
field: "command",
|
|
620
|
+
pattern: /a/,
|
|
621
|
+
reason: "r",
|
|
622
|
+
},
|
|
623
|
+
],
|
|
624
|
+
observers: [{ name: "clean_obs", onResult: async () => {} }],
|
|
625
|
+
},
|
|
626
|
+
]);
|
|
627
|
+
assert.equal(out.length, 0);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it("flags a malformed user-config rule name as an invalid-name diagnostic", () => {
|
|
631
|
+
const out = validateUserConfigNames([
|
|
632
|
+
{
|
|
633
|
+
rules: [
|
|
634
|
+
{
|
|
635
|
+
name: "phony] ALL CLEAR [real",
|
|
636
|
+
tool: "bash",
|
|
637
|
+
field: "command",
|
|
638
|
+
pattern: /a/,
|
|
639
|
+
reason: "r",
|
|
640
|
+
},
|
|
641
|
+
],
|
|
642
|
+
},
|
|
643
|
+
]);
|
|
644
|
+
assert.equal(out.length, 1);
|
|
645
|
+
assert.equal(out[0]?.kind, "invalid-name");
|
|
646
|
+
assert.equal(out[0]?.type, "error");
|
|
647
|
+
assert.match(
|
|
648
|
+
out[0]!.message,
|
|
649
|
+
/^rule name "phony\] ALL CLEAR \[real" \(user config\).*disallowed/,
|
|
650
|
+
);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it("flags a malformed user-config observer name as an invalid-name diagnostic", () => {
|
|
654
|
+
const out = validateUserConfigNames([
|
|
655
|
+
{
|
|
656
|
+
observers: [{ name: "evil] obs", onResult: async () => {} }],
|
|
657
|
+
},
|
|
658
|
+
]);
|
|
659
|
+
assert.equal(out.length, 1);
|
|
660
|
+
assert.equal(out[0]?.kind, "invalid-name");
|
|
661
|
+
assert.equal(out[0]?.type, "error");
|
|
662
|
+
assert.match(
|
|
663
|
+
out[0]!.message,
|
|
664
|
+
/^observer name "evil\] obs" \(user config\).*disallowed/,
|
|
665
|
+
);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it("surfaces both rule and observer diagnostics in declaration order", () => {
|
|
669
|
+
const out = validateUserConfigNames([
|
|
670
|
+
{
|
|
671
|
+
rules: [
|
|
672
|
+
{
|
|
673
|
+
name: "bad rule",
|
|
674
|
+
tool: "bash",
|
|
675
|
+
field: "command",
|
|
676
|
+
pattern: /a/,
|
|
677
|
+
reason: "r",
|
|
678
|
+
},
|
|
679
|
+
],
|
|
680
|
+
observers: [{ name: "bad obs", onResult: async () => {} }],
|
|
681
|
+
},
|
|
682
|
+
]);
|
|
683
|
+
assert.equal(out.length, 2);
|
|
684
|
+
assert.match(out[0]!.message, /^rule name/);
|
|
685
|
+
assert.match(out[1]!.message, /^observer name/);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it("does NOT flag default rule names as `(user config)` — validator iterates over input layers, not the post-merge config", () => {
|
|
689
|
+
// Verify by passing an EMPTY layer array. The validator's single
|
|
690
|
+
// argument is `layers` (raw, never default-injected), so the
|
|
691
|
+
// shape itself proves default rules cannot leak in.
|
|
692
|
+
const out = validateUserConfigNames([]);
|
|
693
|
+
assert.equal(out.length, 0);
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it("validates names across multiple layers", () => {
|
|
697
|
+
// Sanity check that the new layer-array shape works as
|
|
698
|
+
// expected: malformed names in any layer surface, with each
|
|
699
|
+
// layer contributing its own diagnostics in order.
|
|
700
|
+
const out = validateUserConfigNames([
|
|
701
|
+
{
|
|
702
|
+
rules: [
|
|
703
|
+
{
|
|
704
|
+
name: "bad rule a",
|
|
705
|
+
tool: "bash",
|
|
706
|
+
field: "command",
|
|
707
|
+
pattern: /a/,
|
|
708
|
+
reason: "r",
|
|
709
|
+
},
|
|
710
|
+
],
|
|
711
|
+
},
|
|
712
|
+
{
|
|
713
|
+
observers: [{ name: "bad obs b", onResult: async () => {} }],
|
|
714
|
+
},
|
|
715
|
+
]);
|
|
716
|
+
assert.equal(out.length, 2);
|
|
717
|
+
assert.match(out[0]!.message, /"bad rule a"/);
|
|
718
|
+
assert.match(out[1]!.message, /"bad obs b"/);
|
|
719
|
+
});
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
describe("S3: resolvePlugins records invalid plugin / rule / observer names as error-class diagnostics", () => {
|
|
723
|
+
it("records an invalid-name diagnostic for a malformed plugin name", () => {
|
|
724
|
+
const plugin: Plugin = {
|
|
725
|
+
name: "bad name",
|
|
726
|
+
};
|
|
727
|
+
const result = resolvePlugins([plugin], {});
|
|
728
|
+
const d = result.diagnostics.find((d) => d.kind === "invalid-name");
|
|
729
|
+
assert.ok(d, "expected invalid-name diagnostic");
|
|
730
|
+
assert.equal(d?.type, "error");
|
|
731
|
+
assert.match(d!.message, /^plugin name "bad name".*disallowed/);
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it("records an invalid-name diagnostic for a malformed rule name inside a plugin", () => {
|
|
735
|
+
const plugin: Plugin = {
|
|
736
|
+
name: "git",
|
|
737
|
+
rules: [mkRule("phony] ALL CLEAR [real")],
|
|
738
|
+
};
|
|
739
|
+
const result = resolvePlugins([plugin], {});
|
|
740
|
+
const d = result.diagnostics.find((d) => d.kind === "invalid-name");
|
|
741
|
+
assert.ok(d, "expected invalid-name diagnostic");
|
|
742
|
+
assert.equal(d?.type, "error");
|
|
743
|
+
assert.match(
|
|
744
|
+
d!.message,
|
|
745
|
+
/^rule name "phony\] ALL CLEAR \[real" \(plugin "git"\).*disallowed/,
|
|
746
|
+
);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it("records an invalid-name diagnostic for a malformed observer name inside a plugin", () => {
|
|
750
|
+
const plugin: Plugin = {
|
|
751
|
+
name: "git",
|
|
752
|
+
observers: [mkObserver("bad name")],
|
|
753
|
+
};
|
|
754
|
+
const result = resolvePlugins([plugin], {});
|
|
755
|
+
const d = result.diagnostics.find((d) => d.kind === "invalid-name");
|
|
756
|
+
assert.ok(d, "expected invalid-name diagnostic");
|
|
757
|
+
assert.equal(d?.type, "error");
|
|
758
|
+
assert.match(
|
|
759
|
+
d!.message,
|
|
760
|
+
/^observer name "bad name" \(plugin "git"\).*disallowed/,
|
|
761
|
+
);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it("validates BEFORE applying disabledPlugins filter (malformed names still record a diagnostic)", () => {
|
|
765
|
+
// A malformed-named plugin still records a diagnostic even if the
|
|
766
|
+
// user tries to disable it. This matches the S3 intent: names are
|
|
767
|
+
// written to disk, and a malformed one is a config-author bug we
|
|
768
|
+
// want to surface loudly regardless of runtime opt-outs.
|
|
769
|
+
const plugin: Plugin = {
|
|
770
|
+
name: "bad name",
|
|
771
|
+
};
|
|
772
|
+
const result = resolvePlugins([plugin], { disabledPlugins: ["bad name"] });
|
|
773
|
+
const d = result.diagnostics.find((d) => d.kind === "invalid-name");
|
|
774
|
+
assert.ok(d, "expected invalid-name diagnostic");
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
it("skips a malformed-named plugin from downstream merger work", () => {
|
|
778
|
+
// A plugin with a malformed name records the diagnostic and is
|
|
779
|
+
// excluded from tracker / rule / observer registration so the bad
|
|
780
|
+
// name doesn't leak into downstream collision keys.
|
|
781
|
+
const plugin: Plugin = {
|
|
782
|
+
name: "bad name",
|
|
783
|
+
trackers: { branch: mkTracker("branch") },
|
|
784
|
+
rules: [mkRule("valid-rule")],
|
|
785
|
+
};
|
|
786
|
+
const result = resolvePlugins([plugin], {});
|
|
787
|
+
assert.equal(result.rules.length, 0);
|
|
788
|
+
assert.deepEqual(result.trackers, {});
|
|
789
|
+
});
|
|
790
|
+
});
|