@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,431 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tests for {@link fromJSON} — v1 JSON → v2 {@link SteeringConfig}
|
|
6
|
+
* import helper.
|
|
7
|
+
*
|
|
8
|
+
* Covers:
|
|
9
|
+
* - Happy paths: top-level fields, rule fields, `when.cwd`.
|
|
10
|
+
* - Golden conversions: the v1 JSON fixtures in
|
|
11
|
+
* `../loader.test.ts`'s source pass through with the expected
|
|
12
|
+
* output.
|
|
13
|
+
* - Rejection paths: every v2-only construct (plugins, observers,
|
|
14
|
+
* function predicates, custom `when` keys).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { describe, it } from "node:test";
|
|
19
|
+
import { FromJSONError, fromJSON } from "./compat.ts";
|
|
20
|
+
|
|
21
|
+
describe("compat: fromJSON happy paths", () => {
|
|
22
|
+
it("empty object produces empty config", () => {
|
|
23
|
+
assert.deepEqual(fromJSON({}), {});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("preserves `disable` list (renames to `disabledRules` in v2)", () => {
|
|
27
|
+
const out = fromJSON({ disable: ["rule-a", "rule-b"] });
|
|
28
|
+
assert.deepEqual(out.disabledRules, ["rule-a", "rule-b"]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("preserves `defaultNoOverride`", () => {
|
|
32
|
+
assert.equal(fromJSON({ defaultNoOverride: true }).defaultNoOverride, true);
|
|
33
|
+
assert.equal(
|
|
34
|
+
fromJSON({ defaultNoOverride: false }).defaultNoOverride,
|
|
35
|
+
false,
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("converts a minimal rule", () => {
|
|
40
|
+
const out = fromJSON({
|
|
41
|
+
rules: [
|
|
42
|
+
{
|
|
43
|
+
name: "no-foo",
|
|
44
|
+
tool: "bash",
|
|
45
|
+
field: "command",
|
|
46
|
+
pattern: "^foo",
|
|
47
|
+
reason: "foo forbidden",
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
assert.deepEqual(out.rules, [
|
|
52
|
+
{
|
|
53
|
+
name: "no-foo",
|
|
54
|
+
tool: "bash",
|
|
55
|
+
field: "command",
|
|
56
|
+
pattern: "^foo",
|
|
57
|
+
reason: "foo forbidden",
|
|
58
|
+
},
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("carries forward `requires` / `unless` / `noOverride` / `when.cwd`", () => {
|
|
63
|
+
const out = fromJSON({
|
|
64
|
+
rules: [
|
|
65
|
+
{
|
|
66
|
+
name: "rich",
|
|
67
|
+
tool: "write",
|
|
68
|
+
field: "path",
|
|
69
|
+
pattern: "secret",
|
|
70
|
+
requires: "\\.env$",
|
|
71
|
+
unless: "\\.env\\.example$",
|
|
72
|
+
noOverride: true,
|
|
73
|
+
reason: "no secrets in tracked files",
|
|
74
|
+
when: { cwd: "^/workplace" },
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
});
|
|
78
|
+
const rule = out.rules?.[0];
|
|
79
|
+
assert.ok(rule);
|
|
80
|
+
assert.equal(rule.requires, "\\.env$");
|
|
81
|
+
assert.equal(rule.unless, "\\.env\\.example$");
|
|
82
|
+
assert.equal(rule.noOverride, true);
|
|
83
|
+
assert.equal(rule.when?.cwd, "^/workplace");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("golden: example fixture used by the v1 JSON loader tests round-trips", () => {
|
|
87
|
+
// Mirrors the shape of the fixture at
|
|
88
|
+
// `src/loader.test.ts` — using a
|
|
89
|
+
// generic name so this test doesn't grow an import dependency
|
|
90
|
+
// on the legacy fixture file.
|
|
91
|
+
const fixture = {
|
|
92
|
+
disable: ["noisy-rule"],
|
|
93
|
+
defaultNoOverride: true,
|
|
94
|
+
rules: [
|
|
95
|
+
{
|
|
96
|
+
name: "block-dangerous",
|
|
97
|
+
tool: "bash",
|
|
98
|
+
field: "command",
|
|
99
|
+
pattern: "^rm\\s+-rf",
|
|
100
|
+
reason: "rm -rf is catastrophic",
|
|
101
|
+
noOverride: true,
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: "warn-in-workplace",
|
|
105
|
+
tool: "bash",
|
|
106
|
+
field: "command",
|
|
107
|
+
pattern: "^git\\s+push",
|
|
108
|
+
reason: "don't push from workplace paths",
|
|
109
|
+
when: { cwd: "^/workplace" },
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
};
|
|
113
|
+
const out = fromJSON(fixture);
|
|
114
|
+
assert.deepEqual(out.disabledRules, ["noisy-rule"]);
|
|
115
|
+
assert.equal(out.defaultNoOverride, true);
|
|
116
|
+
assert.equal(out.rules?.length, 2);
|
|
117
|
+
assert.equal(out.rules?.[0]?.noOverride, true);
|
|
118
|
+
assert.equal(out.rules?.[1]?.when?.cwd, "^/workplace");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("compat: fromJSON rejections", () => {
|
|
123
|
+
it("rejects non-object top-level", () => {
|
|
124
|
+
assert.throws(() => fromJSON(null), FromJSONError);
|
|
125
|
+
assert.throws(() => fromJSON(42), FromJSONError);
|
|
126
|
+
assert.throws(() => fromJSON("string"), FromJSONError);
|
|
127
|
+
assert.throws(() => fromJSON([]), FromJSONError);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("rejects v2-only top-level keys with a clear error", () => {
|
|
131
|
+
for (const key of [
|
|
132
|
+
"plugins",
|
|
133
|
+
"observers",
|
|
134
|
+
"disablePlugins",
|
|
135
|
+
"disabledPlugins",
|
|
136
|
+
"disabledRules",
|
|
137
|
+
"disableDefaults",
|
|
138
|
+
]) {
|
|
139
|
+
let caught: unknown;
|
|
140
|
+
try {
|
|
141
|
+
fromJSON({ [key]: [] });
|
|
142
|
+
} catch (e) {
|
|
143
|
+
caught = e;
|
|
144
|
+
}
|
|
145
|
+
assert.ok(
|
|
146
|
+
caught instanceof FromJSONError,
|
|
147
|
+
`expected FromJSONError for ${key}`,
|
|
148
|
+
);
|
|
149
|
+
assert.ok(
|
|
150
|
+
caught.message.includes(key),
|
|
151
|
+
`error should mention the forbidden key, got: ${caught.message}`,
|
|
152
|
+
);
|
|
153
|
+
assert.equal(caught.path, `<root>.${key}`);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("rejects malformed `disable`", () => {
|
|
158
|
+
assert.throws(() => fromJSON({ disable: "not-an-array" }), FromJSONError);
|
|
159
|
+
assert.throws(() => fromJSON({ disable: [1, 2] }), FromJSONError);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("rejects malformed `defaultNoOverride`", () => {
|
|
163
|
+
assert.throws(() => fromJSON({ defaultNoOverride: "true" }), FromJSONError);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("rejects missing required rule fields", () => {
|
|
167
|
+
// missing name
|
|
168
|
+
assert.throws(
|
|
169
|
+
() =>
|
|
170
|
+
fromJSON({
|
|
171
|
+
rules: [
|
|
172
|
+
{ tool: "bash", field: "command", pattern: "p", reason: "r" },
|
|
173
|
+
],
|
|
174
|
+
}),
|
|
175
|
+
FromJSONError,
|
|
176
|
+
);
|
|
177
|
+
// missing pattern
|
|
178
|
+
assert.throws(
|
|
179
|
+
() =>
|
|
180
|
+
fromJSON({
|
|
181
|
+
rules: [{ name: "n", tool: "bash", field: "command", reason: "r" }],
|
|
182
|
+
}),
|
|
183
|
+
FromJSONError,
|
|
184
|
+
);
|
|
185
|
+
// wrong tool value
|
|
186
|
+
assert.throws(
|
|
187
|
+
() =>
|
|
188
|
+
fromJSON({
|
|
189
|
+
rules: [
|
|
190
|
+
{
|
|
191
|
+
name: "n",
|
|
192
|
+
tool: "shell", // not one of bash/write/edit
|
|
193
|
+
field: "command",
|
|
194
|
+
pattern: "p",
|
|
195
|
+
reason: "r",
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
}),
|
|
199
|
+
FromJSONError,
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("rejects invalid (tool, field) combinations per the Rule union", () => {
|
|
204
|
+
// bash rules must use `field: "command"`; `path` / `content` are
|
|
205
|
+
// silently-wrong combos the discriminated TS Rule union now
|
|
206
|
+
// rejects. compat.ts mirrors the check at JSON parse time.
|
|
207
|
+
assert.throws(
|
|
208
|
+
() =>
|
|
209
|
+
fromJSON({
|
|
210
|
+
rules: [
|
|
211
|
+
{
|
|
212
|
+
name: "n",
|
|
213
|
+
tool: "bash",
|
|
214
|
+
field: "path",
|
|
215
|
+
pattern: "p",
|
|
216
|
+
reason: "r",
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
}),
|
|
220
|
+
FromJSONError,
|
|
221
|
+
);
|
|
222
|
+
assert.throws(
|
|
223
|
+
() =>
|
|
224
|
+
fromJSON({
|
|
225
|
+
rules: [
|
|
226
|
+
{
|
|
227
|
+
name: "n",
|
|
228
|
+
tool: "bash",
|
|
229
|
+
field: "content",
|
|
230
|
+
pattern: "p",
|
|
231
|
+
reason: "r",
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
}),
|
|
235
|
+
FromJSONError,
|
|
236
|
+
);
|
|
237
|
+
// write / edit rules test `path` or `content`, never `command`.
|
|
238
|
+
assert.throws(
|
|
239
|
+
() =>
|
|
240
|
+
fromJSON({
|
|
241
|
+
rules: [
|
|
242
|
+
{
|
|
243
|
+
name: "n",
|
|
244
|
+
tool: "write",
|
|
245
|
+
field: "command",
|
|
246
|
+
pattern: "p",
|
|
247
|
+
reason: "r",
|
|
248
|
+
},
|
|
249
|
+
],
|
|
250
|
+
}),
|
|
251
|
+
FromJSONError,
|
|
252
|
+
);
|
|
253
|
+
assert.throws(
|
|
254
|
+
() =>
|
|
255
|
+
fromJSON({
|
|
256
|
+
rules: [
|
|
257
|
+
{
|
|
258
|
+
name: "n",
|
|
259
|
+
tool: "edit",
|
|
260
|
+
field: "command",
|
|
261
|
+
pattern: "p",
|
|
262
|
+
reason: "r",
|
|
263
|
+
},
|
|
264
|
+
],
|
|
265
|
+
}),
|
|
266
|
+
FromJSONError,
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("rejects non-string `pattern`", () => {
|
|
271
|
+
assert.throws(
|
|
272
|
+
() =>
|
|
273
|
+
fromJSON({
|
|
274
|
+
rules: [
|
|
275
|
+
{
|
|
276
|
+
name: "n",
|
|
277
|
+
tool: "bash",
|
|
278
|
+
field: "command",
|
|
279
|
+
pattern: 42,
|
|
280
|
+
reason: "r",
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
}),
|
|
284
|
+
FromJSONError,
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("rejects function-shaped rule fields (they can't appear in JSON, but errors help hand-editing humans)", () => {
|
|
289
|
+
assert.throws(
|
|
290
|
+
() =>
|
|
291
|
+
fromJSON({
|
|
292
|
+
rules: [
|
|
293
|
+
{
|
|
294
|
+
name: "n",
|
|
295
|
+
tool: "bash",
|
|
296
|
+
field: "command",
|
|
297
|
+
pattern: "p",
|
|
298
|
+
reason: "r",
|
|
299
|
+
requires: 42,
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
}),
|
|
303
|
+
FromJSONError,
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("rejects plugin-registered `when` keys", () => {
|
|
308
|
+
let caught: unknown;
|
|
309
|
+
try {
|
|
310
|
+
fromJSON({
|
|
311
|
+
rules: [
|
|
312
|
+
{
|
|
313
|
+
name: "n",
|
|
314
|
+
tool: "bash",
|
|
315
|
+
field: "command",
|
|
316
|
+
pattern: "p",
|
|
317
|
+
reason: "r",
|
|
318
|
+
when: { branch: "^main$" },
|
|
319
|
+
},
|
|
320
|
+
],
|
|
321
|
+
});
|
|
322
|
+
} catch (e) {
|
|
323
|
+
caught = e;
|
|
324
|
+
}
|
|
325
|
+
assert.ok(caught instanceof FromJSONError);
|
|
326
|
+
assert.ok(caught.message.includes("when.branch"));
|
|
327
|
+
assert.ok(caught.path.endsWith("when.branch"));
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("rejects `when.not` / `when.condition` (v2-only)", () => {
|
|
331
|
+
assert.throws(
|
|
332
|
+
() =>
|
|
333
|
+
fromJSON({
|
|
334
|
+
rules: [
|
|
335
|
+
{
|
|
336
|
+
name: "n",
|
|
337
|
+
tool: "bash",
|
|
338
|
+
field: "command",
|
|
339
|
+
pattern: "p",
|
|
340
|
+
reason: "r",
|
|
341
|
+
when: { not: {} },
|
|
342
|
+
},
|
|
343
|
+
],
|
|
344
|
+
}),
|
|
345
|
+
FromJSONError,
|
|
346
|
+
);
|
|
347
|
+
assert.throws(
|
|
348
|
+
() =>
|
|
349
|
+
fromJSON({
|
|
350
|
+
rules: [
|
|
351
|
+
{
|
|
352
|
+
name: "n",
|
|
353
|
+
tool: "bash",
|
|
354
|
+
field: "command",
|
|
355
|
+
pattern: "p",
|
|
356
|
+
reason: "r",
|
|
357
|
+
when: { condition: "noop" },
|
|
358
|
+
},
|
|
359
|
+
],
|
|
360
|
+
}),
|
|
361
|
+
FromJSONError,
|
|
362
|
+
);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("rejects `observer` key on a rule", () => {
|
|
366
|
+
assert.throws(
|
|
367
|
+
() =>
|
|
368
|
+
fromJSON({
|
|
369
|
+
rules: [
|
|
370
|
+
{
|
|
371
|
+
name: "n",
|
|
372
|
+
tool: "bash",
|
|
373
|
+
field: "command",
|
|
374
|
+
pattern: "p",
|
|
375
|
+
reason: "r",
|
|
376
|
+
observer: "some-name",
|
|
377
|
+
},
|
|
378
|
+
],
|
|
379
|
+
}),
|
|
380
|
+
FromJSONError,
|
|
381
|
+
);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("rejects non-string `when.cwd`", () => {
|
|
385
|
+
assert.throws(
|
|
386
|
+
() =>
|
|
387
|
+
fromJSON({
|
|
388
|
+
rules: [
|
|
389
|
+
{
|
|
390
|
+
name: "n",
|
|
391
|
+
tool: "bash",
|
|
392
|
+
field: "command",
|
|
393
|
+
pattern: "p",
|
|
394
|
+
reason: "r",
|
|
395
|
+
when: { cwd: 42 },
|
|
396
|
+
},
|
|
397
|
+
],
|
|
398
|
+
}),
|
|
399
|
+
FromJSONError,
|
|
400
|
+
);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
describe("compat: FromJSONError", () => {
|
|
405
|
+
it("carries a path pointing at the offending location", () => {
|
|
406
|
+
try {
|
|
407
|
+
fromJSON({
|
|
408
|
+
rules: [
|
|
409
|
+
{
|
|
410
|
+
name: "ok",
|
|
411
|
+
tool: "bash",
|
|
412
|
+
field: "command",
|
|
413
|
+
pattern: "p",
|
|
414
|
+
reason: "r",
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
name: "bad",
|
|
418
|
+
tool: "bash",
|
|
419
|
+
field: "command",
|
|
420
|
+
pattern: 42,
|
|
421
|
+
reason: "r",
|
|
422
|
+
},
|
|
423
|
+
],
|
|
424
|
+
});
|
|
425
|
+
assert.fail("expected throw");
|
|
426
|
+
} catch (err) {
|
|
427
|
+
assert.ok(err instanceof FromJSONError);
|
|
428
|
+
assert.equal(err.path, "<root>.rules[1].pattern");
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
});
|
package/src/compat.ts
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `fromJSON` — convert the v1 JSON config shape to a v2
|
|
6
|
+
* {@link SteeringConfig}.
|
|
7
|
+
*
|
|
8
|
+
* Per the accepted ADR ("Design → File layout and loader behavior"):
|
|
9
|
+
* JSON is not a first-class config format in v2. `fromJSON` exists so
|
|
10
|
+
* `.pi/steering.json` files authored against the PoC shape can be
|
|
11
|
+
* loaded programmatically — either as a library call (this module)
|
|
12
|
+
* or via the `pi-steering import-json` CLI wrapping it.
|
|
13
|
+
*
|
|
14
|
+
* Scope:
|
|
15
|
+
*
|
|
16
|
+
* - Top-level v1 fields: `disable`, `defaultNoOverride`, `rules`.
|
|
17
|
+
* - Rule fields: `name`, `tool`, `field`, `pattern` (stays a string —
|
|
18
|
+
* v2 accepts `string | RegExp`), `requires`, `unless`, `reason`,
|
|
19
|
+
* `noOverride`, `when.cwd` (string pattern).
|
|
20
|
+
*
|
|
21
|
+
* Rejected (throws):
|
|
22
|
+
*
|
|
23
|
+
* - Plugins (JSON can't express function-typed handlers).
|
|
24
|
+
* - Observers (ditto — `onResult` is a function).
|
|
25
|
+
* - Function-valued fields on rules (ditto).
|
|
26
|
+
* - `when.<customKey>` — plugin-registered predicates have no
|
|
27
|
+
* JSON-expressible binding.
|
|
28
|
+
* - `when.not` or `when.condition` (also function-shaped or recursive).
|
|
29
|
+
*
|
|
30
|
+
* Callers that hit one of the rejection cases should author the
|
|
31
|
+
* offending rule / plugin directly in TypeScript; `fromJSON` is only
|
|
32
|
+
* for the trivial pattern-string path.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { Rule, SteeringConfig, WhenClause } from "./schema.ts";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Error thrown when the input JSON uses a feature the v1 → v2 helper
|
|
39
|
+
* can't represent. Carries a `path` pointing at the offending location
|
|
40
|
+
* (e.g. `rules[2].when.branch`) so callers can point the user at the
|
|
41
|
+
* rule to rewrite in TypeScript.
|
|
42
|
+
*/
|
|
43
|
+
export class FromJSONError extends Error {
|
|
44
|
+
readonly path: string;
|
|
45
|
+
|
|
46
|
+
constructor(message: string, path: string) {
|
|
47
|
+
super(`${message} (at ${path})`);
|
|
48
|
+
this.name = "FromJSONError";
|
|
49
|
+
this.path = path;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Narrow `unknown` to a plain (non-array) object. */
|
|
54
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
55
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Convert v1 JSON into a v2 {@link SteeringConfig}.
|
|
60
|
+
*
|
|
61
|
+
* The input is typed `unknown` deliberately — this helper accepts
|
|
62
|
+
* whatever came out of `JSON.parse`, validates the shape, and throws
|
|
63
|
+
* {@link FromJSONError} on anything it can't represent.
|
|
64
|
+
*/
|
|
65
|
+
export function fromJSON(json: unknown): SteeringConfig {
|
|
66
|
+
if (!isPlainObject(json)) {
|
|
67
|
+
throw new FromJSONError(
|
|
68
|
+
"expected a JSON object at the top level",
|
|
69
|
+
"<root>",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Pre-flight: loudly reject v2-only keys that shouldn't appear in
|
|
74
|
+
// v1 JSON. If a user lands here by mistake (e.g. calling fromJSON
|
|
75
|
+
// on an already-v2 object), we'd rather tell them than silently
|
|
76
|
+
// drop their plugins / observers.
|
|
77
|
+
for (const forbidden of [
|
|
78
|
+
"plugins",
|
|
79
|
+
"observers",
|
|
80
|
+
"disablePlugins",
|
|
81
|
+
"disabledPlugins",
|
|
82
|
+
"disabledRules",
|
|
83
|
+
"disableDefaults",
|
|
84
|
+
] as const) {
|
|
85
|
+
if (forbidden in json) {
|
|
86
|
+
throw new FromJSONError(
|
|
87
|
+
`JSON config cannot express \`${forbidden}\`; ` +
|
|
88
|
+
`author that configuration directly in TypeScript instead`,
|
|
89
|
+
`<root>.${forbidden}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const out: SteeringConfig = {};
|
|
95
|
+
|
|
96
|
+
if ("disable" in json) {
|
|
97
|
+
const disable = json["disable"];
|
|
98
|
+
if (
|
|
99
|
+
!Array.isArray(disable) ||
|
|
100
|
+
!disable.every((x) => typeof x === "string")
|
|
101
|
+
) {
|
|
102
|
+
throw new FromJSONError(
|
|
103
|
+
"`disable` must be a string array",
|
|
104
|
+
"<root>.disable",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
// v1 JSON's `disable` → v2 TS `disabledRules`. The JSON key is
|
|
108
|
+
// preserved for legacy on-disk configs; the TS shape uses the
|
|
109
|
+
// past-participle form.
|
|
110
|
+
out.disabledRules = [...disable];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if ("defaultNoOverride" in json) {
|
|
114
|
+
const v = json["defaultNoOverride"];
|
|
115
|
+
if (typeof v !== "boolean") {
|
|
116
|
+
throw new FromJSONError(
|
|
117
|
+
"`defaultNoOverride` must be a boolean",
|
|
118
|
+
"<root>.defaultNoOverride",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
out.defaultNoOverride = v;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if ("rules" in json) {
|
|
125
|
+
const rules = json["rules"];
|
|
126
|
+
if (!Array.isArray(rules)) {
|
|
127
|
+
throw new FromJSONError("`rules` must be an array", "<root>.rules");
|
|
128
|
+
}
|
|
129
|
+
out.rules = rules.map((rule, i) => convertRule(rule, `<root>.rules[${i}]`));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Convert a single v1 JSON rule, scoping errors with `path`. */
|
|
136
|
+
function convertRule(raw: unknown, path: string): Rule {
|
|
137
|
+
if (!isPlainObject(raw)) {
|
|
138
|
+
throw new FromJSONError("rule must be an object", path);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Required: name, tool, field, pattern, reason.
|
|
142
|
+
const name = raw["name"];
|
|
143
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
144
|
+
throw new FromJSONError(
|
|
145
|
+
"`name` must be a non-empty string",
|
|
146
|
+
`${path}.name`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
const tool = raw["tool"];
|
|
150
|
+
if (tool !== "bash" && tool !== "write" && tool !== "edit") {
|
|
151
|
+
throw new FromJSONError(
|
|
152
|
+
"`tool` must be one of 'bash' | 'write' | 'edit'",
|
|
153
|
+
`${path}.tool`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
const field = raw["field"];
|
|
157
|
+
if (field !== "command" && field !== "path" && field !== "content") {
|
|
158
|
+
throw new FromJSONError(
|
|
159
|
+
"`field` must be one of 'command' | 'path' | 'content'",
|
|
160
|
+
`${path}.field`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
// Validate the (tool, field) combination per the discriminated
|
|
164
|
+
// Rule union: bash rules test against `command`; write / edit
|
|
165
|
+
// rules test against `path` or `content`.
|
|
166
|
+
if (tool === "bash" && field !== "command") {
|
|
167
|
+
throw new FromJSONError(
|
|
168
|
+
`bash rules must use \`field: "command"\` (got "${field}")`,
|
|
169
|
+
`${path}.field`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
if ((tool === "write" || tool === "edit") && field === "command") {
|
|
173
|
+
throw new FromJSONError(
|
|
174
|
+
`${tool} rules must use \`field: "path"\` or \`field: "content"\` ` +
|
|
175
|
+
`(got "command")`,
|
|
176
|
+
`${path}.field`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
const pattern = raw["pattern"];
|
|
180
|
+
if (typeof pattern !== "string") {
|
|
181
|
+
throw new FromJSONError(
|
|
182
|
+
"JSON rules must use a string `pattern` (v2 accepts RegExp in TS)",
|
|
183
|
+
`${path}.pattern`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
const reason = raw["reason"];
|
|
187
|
+
if (typeof reason !== "string") {
|
|
188
|
+
throw new FromJSONError("`reason` must be a string", `${path}.reason`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const rule: Rule =
|
|
192
|
+
tool === "bash"
|
|
193
|
+
? { name, tool, field: "command", pattern, reason }
|
|
194
|
+
: {
|
|
195
|
+
name,
|
|
196
|
+
tool,
|
|
197
|
+
// Narrowed by the (tool, field) check above.
|
|
198
|
+
field: field as "path" | "content",
|
|
199
|
+
pattern,
|
|
200
|
+
reason,
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// Optional: requires / unless (string patterns only).
|
|
204
|
+
if ("requires" in raw) {
|
|
205
|
+
const v = raw["requires"];
|
|
206
|
+
if (typeof v !== "string") {
|
|
207
|
+
throw new FromJSONError(
|
|
208
|
+
"JSON rules must use string `requires` (functions aren't JSON-expressible)",
|
|
209
|
+
`${path}.requires`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
rule.requires = v;
|
|
213
|
+
}
|
|
214
|
+
if ("unless" in raw) {
|
|
215
|
+
const v = raw["unless"];
|
|
216
|
+
if (typeof v !== "string") {
|
|
217
|
+
throw new FromJSONError(
|
|
218
|
+
"JSON rules must use string `unless` (functions aren't JSON-expressible)",
|
|
219
|
+
`${path}.unless`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
rule.unless = v;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Optional: noOverride.
|
|
226
|
+
if ("noOverride" in raw) {
|
|
227
|
+
const v = raw["noOverride"];
|
|
228
|
+
if (typeof v !== "boolean") {
|
|
229
|
+
throw new FromJSONError(
|
|
230
|
+
"`noOverride` must be a boolean",
|
|
231
|
+
`${path}.noOverride`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
rule.noOverride = v;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Optional: when. V1 JSON supports only `when.cwd` (string pattern).
|
|
238
|
+
// Every other key under `when` is plugin-specific and can't be
|
|
239
|
+
// expressed in JSON.
|
|
240
|
+
if ("when" in raw) {
|
|
241
|
+
rule.when = convertWhen(raw["when"], `${path}.when`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// V1 JSON has no `observer` field — observers are v2-only. But if
|
|
245
|
+
// someone hand-edits `observer: "name"` into their JSON, warn loudly.
|
|
246
|
+
if ("observer" in raw) {
|
|
247
|
+
throw new FromJSONError(
|
|
248
|
+
"JSON rules cannot reference observers; observers are v2-only (TS)",
|
|
249
|
+
`${path}.observer`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return rule;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Convert the v1 `when` object into a v2 {@link WhenClause}. */
|
|
257
|
+
function convertWhen(raw: unknown, path: string): WhenClause {
|
|
258
|
+
if (!isPlainObject(raw)) {
|
|
259
|
+
throw new FromJSONError("`when` must be an object", path);
|
|
260
|
+
}
|
|
261
|
+
const out: WhenClause = {};
|
|
262
|
+
for (const key of Object.keys(raw)) {
|
|
263
|
+
if (key !== "cwd") {
|
|
264
|
+
throw new FromJSONError(
|
|
265
|
+
`JSON config cannot express \`when.${key}\`; ` +
|
|
266
|
+
`plugin-registered predicates and \`when.not\` / \`when.condition\` are v2-only (TS)`,
|
|
267
|
+
`${path}.${key}`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if ("cwd" in raw) {
|
|
272
|
+
const v = raw["cwd"];
|
|
273
|
+
if (typeof v !== "string") {
|
|
274
|
+
throw new FromJSONError(
|
|
275
|
+
"JSON `when.cwd` must be a string pattern",
|
|
276
|
+
`${path}.cwd`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
out.cwd = v;
|
|
280
|
+
}
|
|
281
|
+
return out;
|
|
282
|
+
}
|