@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,554 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Type + runtime tests for the not-block onUnknown semantics +
|
|
6
|
+
* per-predicate typing introduced alongside the trinary
|
|
7
|
+
* `PredicateHandler` widening.
|
|
8
|
+
*
|
|
9
|
+
* Two surfaces under test:
|
|
10
|
+
*
|
|
11
|
+
* 1. Reserved-key registration error \u2014 plugin-merger throws at
|
|
12
|
+
* config-resolve time when a plugin attempts to register a
|
|
13
|
+
* predicate with a name reserved by the schema (currently
|
|
14
|
+
* `"not"`, `"onUnknown"` \u2014 the runtime mirror of
|
|
15
|
+
* {@link ReservedPredicateKey}).
|
|
16
|
+
*
|
|
17
|
+
* 2. Type-vs-runtime sync \u2014 the runtime constant
|
|
18
|
+
* `RESERVED_PREDICATE_KEYS` and the type-level
|
|
19
|
+
* `ReservedPredicateKey` derive lockstep from
|
|
20
|
+
* `OperatorField | keyof PredicateModifiers`. A future maintainer
|
|
21
|
+
* who adds a modifier key (or operator field) without updating
|
|
22
|
+
* both surfaces is caught by the type-level
|
|
23
|
+
* `_RESERVED_PREDICATE_KEYS_COVERS_TYPE` assertion in
|
|
24
|
+
* `evaluator-internals/predicates.ts`. This test additionally
|
|
25
|
+
* pins the runtime constant's contents so a typo or accidental
|
|
26
|
+
* reorder gets a fixture-level diff.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import assert from "node:assert/strict";
|
|
30
|
+
import { describe, it } from "node:test";
|
|
31
|
+
import { makeTrackedHost } from "./__test-helpers__.ts";
|
|
32
|
+
import { buildEvaluator } from "./evaluator.ts";
|
|
33
|
+
import {
|
|
34
|
+
isReservedPredicateKey,
|
|
35
|
+
MODIFIER_KEYS,
|
|
36
|
+
RESERVED_PREDICATE_KEYS,
|
|
37
|
+
validateWhenClauseShape,
|
|
38
|
+
} from "./evaluator-internals/predicates.ts";
|
|
39
|
+
import { resolvePlugins } from "./plugin-merger.ts";
|
|
40
|
+
import type {
|
|
41
|
+
BuiltInWhenLeaves,
|
|
42
|
+
BuiltInWhenLeavesInner,
|
|
43
|
+
BuiltInWhenLeavesOuter,
|
|
44
|
+
Plugin,
|
|
45
|
+
PredicateModifiers,
|
|
46
|
+
PredicateShape,
|
|
47
|
+
ReservedPredicateKey,
|
|
48
|
+
Rule,
|
|
49
|
+
TopLevelWhenClause,
|
|
50
|
+
TopLevelWhenClauseNoRecurse,
|
|
51
|
+
} from "./schema.ts";
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Reserved-key registration check (plugin-merger records error-class diagnostic)
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
describe("plugin-merger: reserved predicate key registration check", () => {
|
|
58
|
+
it("records an error-class diagnostic when a plugin registers `not` as a predicate name", () => {
|
|
59
|
+
const plugin: Plugin = {
|
|
60
|
+
name: "evil",
|
|
61
|
+
predicates: {
|
|
62
|
+
// Direct collision with the `not?:` operator field on
|
|
63
|
+
// TopLevelWhenClause.
|
|
64
|
+
not: () => true,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
const state = resolvePlugins([plugin], {});
|
|
68
|
+
const hit = state.diagnostics.find(
|
|
69
|
+
(d) => d.kind === "reserved-predicate-key",
|
|
70
|
+
);
|
|
71
|
+
assert.ok(
|
|
72
|
+
hit,
|
|
73
|
+
`expected a reserved-predicate-key diagnostic; got: ${JSON.stringify(state.diagnostics)}`,
|
|
74
|
+
);
|
|
75
|
+
assert.equal(hit.type, "error");
|
|
76
|
+
assert.match(hit.message, /reserved predicate key "not"/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("records an error-class diagnostic when a plugin registers `onUnknown` as a predicate name", () => {
|
|
80
|
+
const plugin: Plugin = {
|
|
81
|
+
name: "evil",
|
|
82
|
+
predicates: {
|
|
83
|
+
// Direct collision with the `onUnknown?:` modifier on
|
|
84
|
+
// PredicateModifiers (consumed by the leaf adapter at the
|
|
85
|
+
// outer level and by the not-block evaluator inside `not:`).
|
|
86
|
+
onUnknown: () => true,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
const state = resolvePlugins([plugin], {});
|
|
90
|
+
const hit = state.diagnostics.find(
|
|
91
|
+
(d) => d.kind === "reserved-predicate-key",
|
|
92
|
+
);
|
|
93
|
+
assert.ok(hit);
|
|
94
|
+
assert.equal(hit.type, "error");
|
|
95
|
+
assert.match(hit.message, /reserved predicate key "onUnknown"/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("`onUnknown` collision suggests `unknownPolicy` (modifier-collision suggestion convention)", () => {
|
|
99
|
+
// Pins the modifier-domain-flavored suggestion for `onUnknown:`
|
|
100
|
+
// collisions (per the convention documented on plugin-merger.ts:
|
|
101
|
+
// modifier collisions prefer alternatives including the modifier's
|
|
102
|
+
// domain). The generic `"isNot", "negate"` operator-collision
|
|
103
|
+
// suggestion isn't relevant for an `onUnknown:` collision; the
|
|
104
|
+
// suggestion text must point at `unknownPolicy` /
|
|
105
|
+
// `walkerUnknownPolicy` so a plugin author lands at a semantically
|
|
106
|
+
// related alternative.
|
|
107
|
+
const plugin: Plugin = {
|
|
108
|
+
name: "evil",
|
|
109
|
+
predicates: { onUnknown: () => true },
|
|
110
|
+
};
|
|
111
|
+
const state = resolvePlugins([plugin], {});
|
|
112
|
+
const hit = state.diagnostics.find(
|
|
113
|
+
(d) => d.kind === "reserved-predicate-key",
|
|
114
|
+
);
|
|
115
|
+
assert.ok(hit);
|
|
116
|
+
assert.match(hit.message, /unknownPolicy/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("diagnostic message includes the offending plugin name and a suggested alternative", () => {
|
|
120
|
+
const plugin: Plugin = {
|
|
121
|
+
name: "my-plugin",
|
|
122
|
+
predicates: { not: () => true },
|
|
123
|
+
};
|
|
124
|
+
const state = resolvePlugins([plugin], {});
|
|
125
|
+
const hit = state.diagnostics.find(
|
|
126
|
+
(d) => d.kind === "reserved-predicate-key",
|
|
127
|
+
);
|
|
128
|
+
assert.ok(hit);
|
|
129
|
+
assert.match(hit.message, /Plugin "my-plugin"/);
|
|
130
|
+
assert.match(hit.message, /isNot/); // suggested alternative
|
|
131
|
+
assert.match(hit.message, /not, onUnknown/); // full reserved set listed
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("accepts non-reserved names \u2014 sanity check that the throw is targeted", () => {
|
|
135
|
+
const plugin: Plugin = {
|
|
136
|
+
name: "good",
|
|
137
|
+
predicates: {
|
|
138
|
+
myCustomPredicate: () => true,
|
|
139
|
+
isClean: () => true, // built-in name is fine \u2014 it's a normal collision
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
assert.doesNotThrow(() => resolvePlugins([plugin], {}));
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Type \u2194 runtime sync pin
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
describe("RESERVED_PREDICATE_KEYS: type \u2194 runtime sync pin", () => {
|
|
151
|
+
it("matches the {OperatorField, modifier keys} fixture so additions get a loud diff", () => {
|
|
152
|
+
// Pinned fixture so adding a new modifier or operator without
|
|
153
|
+
// updating the runtime list (or the matching type union) gets a
|
|
154
|
+
// loud test-level diff. The type-level assertion in
|
|
155
|
+
// `evaluator-internals/predicates.ts` already catches the
|
|
156
|
+
// drift in the OPPOSITE direction (runtime list missing a type
|
|
157
|
+
// member); this test catches the direction the type-level
|
|
158
|
+
// assertion can't (fixture pinning the literal contents).
|
|
159
|
+
assert.deepEqual(
|
|
160
|
+
[...RESERVED_PREDICATE_KEYS].sort(),
|
|
161
|
+
["not", "onUnknown"].sort(),
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("isReservedPredicateKey covers every entry in RESERVED_PREDICATE_KEYS", () => {
|
|
166
|
+
for (const key of RESERVED_PREDICATE_KEYS) {
|
|
167
|
+
assert.equal(
|
|
168
|
+
isReservedPredicateKey(key),
|
|
169
|
+
true,
|
|
170
|
+
`isReservedPredicateKey("${key}") should be true`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("isReservedPredicateKey returns false for non-reserved keys", () => {
|
|
176
|
+
assert.equal(isReservedPredicateKey("cwd"), false);
|
|
177
|
+
assert.equal(isReservedPredicateKey("branch"), false);
|
|
178
|
+
assert.equal(isReservedPredicateKey("commitsAhead"), false);
|
|
179
|
+
assert.equal(isReservedPredicateKey("happened"), false);
|
|
180
|
+
assert.equal(isReservedPredicateKey("condition"), false);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("type assignment: runtime entries are assignable to ReservedPredicateKey", () => {
|
|
184
|
+
// Compile-time pin via assignment. If a future maintainer
|
|
185
|
+
// adds a runtime entry that drifts from the type union, this
|
|
186
|
+
// assignment fails to compile.
|
|
187
|
+
const _checked: readonly ReservedPredicateKey[] = RESERVED_PREDICATE_KEYS;
|
|
188
|
+
assert.ok(_checked.length > 0);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// MODIFIER_KEYS ↔ keyof PredicateModifiers sync pin
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
describe("MODIFIER_KEYS: type ↔ runtime sync pin", () => {
|
|
197
|
+
it("validateWhenClauseShape strips `onUnknown` when counting leaves (modifier-only outer block throws)", () => {
|
|
198
|
+
// If `onUnknown` ever drops out of MODIFIER_KEYS, an outer
|
|
199
|
+
// block with only `onUnknown:` would count it as a leaf and
|
|
200
|
+
// the validator would PASS — masking the empty-clause foot-gun.
|
|
201
|
+
// This tests the runtime side of the sync pin.
|
|
202
|
+
assert.throws(
|
|
203
|
+
() =>
|
|
204
|
+
validateWhenClauseShape(
|
|
205
|
+
{ onUnknown: "block" } as unknown as TopLevelWhenClause<string>,
|
|
206
|
+
'rule "r".when',
|
|
207
|
+
),
|
|
208
|
+
/contains no predicate leaves/,
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("MODIFIER_KEYS runtime constant matches `keyof PredicateModifiers` exactly", () => {
|
|
213
|
+
// Runtime + compile-time pin: the runtime list and the type-level
|
|
214
|
+
// modifier surface must agree. The type-level
|
|
215
|
+
// `_MODIFIER_KEYS_COVERS_TYPE` constant in
|
|
216
|
+
// `evaluator-internals/predicates.ts` fails compilation when
|
|
217
|
+
// `keyof PredicateModifiers` extends to a key the runtime list
|
|
218
|
+
// missed; this fixture pins the literal contents so a typo or
|
|
219
|
+
// reorder gets a test-level diff. Adding a future modifier
|
|
220
|
+
// (e.g., v0.2 `priority?:`) requires updating both surfaces.
|
|
221
|
+
const expected: readonly (keyof PredicateModifiers)[] = ["onUnknown"];
|
|
222
|
+
assert.deepEqual([...MODIFIER_KEYS].sort(), [...expected].sort());
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// Rule.when wired to TopLevelWhenClause<Writes>
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
describe("Rule.when: registry-driven mapped type wireup", () => {
|
|
231
|
+
it("NonNullable<Rule['when']> is structurally identical to TopLevelWhenClause<string>", () => {
|
|
232
|
+
// Compile-only assertion. If `BaseRule.when` ever regresses to
|
|
233
|
+
// the legacy `WhenClause` (loose index signature), this fails to
|
|
234
|
+
// typecheck — the registry-driven mapped type's narrow domain is
|
|
235
|
+
// not assignable to a wider permissive interface.
|
|
236
|
+
type RuleWhenIsTopLevel =
|
|
237
|
+
NonNullable<Rule["when"]> extends TopLevelWhenClause<string>
|
|
238
|
+
? TopLevelWhenClause<string> extends NonNullable<Rule["when"]>
|
|
239
|
+
? true
|
|
240
|
+
: false
|
|
241
|
+
: false;
|
|
242
|
+
const _identity: RuleWhenIsTopLevel = true;
|
|
243
|
+
void _identity;
|
|
244
|
+
assert.ok(true);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("rule-level onUnknown: is forbidden against the actual Rule type", () => {
|
|
248
|
+
const _r: Rule = {
|
|
249
|
+
name: "r",
|
|
250
|
+
tool: "bash",
|
|
251
|
+
field: "command",
|
|
252
|
+
pattern: "^git",
|
|
253
|
+
reason: "r",
|
|
254
|
+
when: {
|
|
255
|
+
cwd: /work/,
|
|
256
|
+
// @ts-expect-error — rule-level onUnknown: is not on TopLevelWhenClause.
|
|
257
|
+
onUnknown: "block",
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
assert.ok(_r.name === "r");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("leaf-level onUnknown: inside not: is forbidden against the actual Rule type", () => {
|
|
264
|
+
const _r: Rule = {
|
|
265
|
+
name: "r",
|
|
266
|
+
tool: "bash",
|
|
267
|
+
field: "command",
|
|
268
|
+
pattern: "^git",
|
|
269
|
+
reason: "r",
|
|
270
|
+
when: {
|
|
271
|
+
not: {
|
|
272
|
+
// @ts-expect-error — InnerValue<"branch"> excludes leaf-level modifiers.
|
|
273
|
+
branch: { pattern: /main/, onUnknown: "allow" },
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
assert.ok(_r.name === "r");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("not: not: recursion is forbidden against the actual Rule type", () => {
|
|
281
|
+
const _r: Rule = {
|
|
282
|
+
name: "r",
|
|
283
|
+
tool: "bash",
|
|
284
|
+
field: "command",
|
|
285
|
+
pattern: "^git",
|
|
286
|
+
reason: "r",
|
|
287
|
+
when: {
|
|
288
|
+
not: {
|
|
289
|
+
// @ts-expect-error — TopLevelWhenClauseNoRecurse has no `not?:` field.
|
|
290
|
+
not: { cwd: /work/ },
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
assert.ok(_r.name === "r");
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// Runtime nested-not: guard (catches JSON / `as any` escape hatches)
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
describe("validateWhenClauseShape: nested-not: rejection", () => {
|
|
303
|
+
it("throws on `when: { not: { not: { cwd: P } } }` directly", () => {
|
|
304
|
+
assert.throws(
|
|
305
|
+
() =>
|
|
306
|
+
validateWhenClauseShape(
|
|
307
|
+
{
|
|
308
|
+
not: { not: { cwd: /work/ } },
|
|
309
|
+
} as unknown as TopLevelWhenClause<string>,
|
|
310
|
+
'rule "r".when',
|
|
311
|
+
),
|
|
312
|
+
/contains a nested 'not:' key/,
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("throws at buildEvaluator config-resolve time when a rule slips a nested not: through `as any`", () => {
|
|
317
|
+
const rule: Rule = {
|
|
318
|
+
name: "nested-not-via-cast",
|
|
319
|
+
tool: "bash",
|
|
320
|
+
field: "command",
|
|
321
|
+
pattern: "^git",
|
|
322
|
+
reason: "r",
|
|
323
|
+
when: { not: { not: { cwd: /work/ } } } as unknown as NonNullable<
|
|
324
|
+
Rule["when"]
|
|
325
|
+
>,
|
|
326
|
+
};
|
|
327
|
+
assert.throws(
|
|
328
|
+
() =>
|
|
329
|
+
buildEvaluator(
|
|
330
|
+
{ rules: [rule] },
|
|
331
|
+
resolvePlugins([], {}),
|
|
332
|
+
makeTrackedHost(),
|
|
333
|
+
),
|
|
334
|
+
/contains a nested 'not:' key/,
|
|
335
|
+
);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("the error message names the path and explains why nested not: is forbidden", () => {
|
|
339
|
+
assert.throws(
|
|
340
|
+
() =>
|
|
341
|
+
validateWhenClauseShape(
|
|
342
|
+
{
|
|
343
|
+
not: { not: { cwd: /work/ } },
|
|
344
|
+
} as unknown as TopLevelWhenClause<string>,
|
|
345
|
+
'rule "my-rule".when',
|
|
346
|
+
),
|
|
347
|
+
(err: Error) => {
|
|
348
|
+
assert.match(err.message, /'rule "my-rule"\.when\.not'/);
|
|
349
|
+
assert.match(err.message, /Use a single 'not:' wrapper/);
|
|
350
|
+
return true;
|
|
351
|
+
},
|
|
352
|
+
);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("throws on a JSON-deserialized nested-not (the JSON / `as any` escape hatch the type-level ban can't catch)", () => {
|
|
356
|
+
// Pins the runtime-guard's primary justification: a config
|
|
357
|
+
// loaded from JSON or hand-typed via an `as any` cast bypasses
|
|
358
|
+
// the type-level ban on `TopLevelWhenClauseNoRecurse.not`. The
|
|
359
|
+
// runtime check in validateWhenClauseShape is the only line of
|
|
360
|
+
// defense for that path.
|
|
361
|
+
const fromJson = JSON.parse(
|
|
362
|
+
'{"not": {"not": {"cwd": "/work/"}}}',
|
|
363
|
+
) as unknown;
|
|
364
|
+
assert.throws(
|
|
365
|
+
() =>
|
|
366
|
+
validateWhenClauseShape(
|
|
367
|
+
fromJson as TopLevelWhenClause<string>,
|
|
368
|
+
'rule "r".when',
|
|
369
|
+
),
|
|
370
|
+
/contains a nested 'not:' key/,
|
|
371
|
+
);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("throws on `when: { not: { not: { not: P } } }` (depth-3 recursion stays rejected)", () => {
|
|
375
|
+
// Pins the recursion contract beyond depth-2. The validator
|
|
376
|
+
// recurses into the not-block and runs the same nested-not
|
|
377
|
+
// check; depth-3 must reject at the second level (the inner
|
|
378
|
+
// `not.not` of the outer block).
|
|
379
|
+
assert.throws(
|
|
380
|
+
() =>
|
|
381
|
+
validateWhenClauseShape(
|
|
382
|
+
{
|
|
383
|
+
not: { not: { not: { cwd: /work/ } } },
|
|
384
|
+
} as unknown as TopLevelWhenClause<string>,
|
|
385
|
+
'rule "r".when',
|
|
386
|
+
),
|
|
387
|
+
/contains a nested 'not:' key/,
|
|
388
|
+
);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
// BuiltInWhenLeaves shape pin
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
describe("BuiltInWhenLeaves: shape pin", () => {
|
|
397
|
+
it("BuiltInWhenLeaves contains exactly { happened, condition, cwd }", () => {
|
|
398
|
+
// Compile-only assertion. If a future change adds a new built-in
|
|
399
|
+
// non-registry leaf (e.g., `tool?:`) without updating the pin, this
|
|
400
|
+
// fails to typecheck — forces a deliberate decision rather than
|
|
401
|
+
// silently widening the surface that ships with the engine itself.
|
|
402
|
+
type _BuiltInShape = keyof BuiltInWhenLeaves extends
|
|
403
|
+
| "happened"
|
|
404
|
+
| "condition"
|
|
405
|
+
| "cwd"
|
|
406
|
+
? true
|
|
407
|
+
: false;
|
|
408
|
+
const _b: _BuiltInShape = true;
|
|
409
|
+
void _b;
|
|
410
|
+
assert.ok(true);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("BuiltInWhenLeavesOuter and BuiltInWhenLeavesInner share the same key set", () => {
|
|
414
|
+
// Outer/Inner split formalizes the leaf-level `onUnknown:` ban
|
|
415
|
+
// inside `not:` (parity with registry-driven inner predicates).
|
|
416
|
+
// Both flavors carry `happened?:`, `condition?:`, `cwd?:`; only
|
|
417
|
+
// `cwd:`'s spread shape differs.
|
|
418
|
+
type _OuterKeys = keyof BuiltInWhenLeavesOuter extends
|
|
419
|
+
| "happened"
|
|
420
|
+
| "condition"
|
|
421
|
+
| "cwd"
|
|
422
|
+
? true
|
|
423
|
+
: false;
|
|
424
|
+
type _InnerKeys = keyof BuiltInWhenLeavesInner extends
|
|
425
|
+
| "happened"
|
|
426
|
+
| "condition"
|
|
427
|
+
| "cwd"
|
|
428
|
+
? true
|
|
429
|
+
: false;
|
|
430
|
+
const _outer: _OuterKeys = true;
|
|
431
|
+
const _inner: _InnerKeys = true;
|
|
432
|
+
void _outer;
|
|
433
|
+
void _inner;
|
|
434
|
+
assert.ok(true);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("BuiltInWhenLeavesInner.cwd spread shape forbids leaf-level onUnknown", () => {
|
|
438
|
+
// The asymmetry: outer `cwd:` allows `{ pattern, onUnknown? }`;
|
|
439
|
+
// inner `cwd:` (inside `not:`) drops `onUnknown?:` because the
|
|
440
|
+
// engine reads the block-level modifier inside `not:` (default
|
|
441
|
+
// `"block"` = fail-CLOSED). A leaf-level `onUnknown:` inside
|
|
442
|
+
// `not:` would silently lose, masking a fail-OPEN authoring
|
|
443
|
+
// error. Pin the constraint with a positive Inner spread (no
|
|
444
|
+
// `onUnknown:`) and an Outer spread (with `onUnknown:`).
|
|
445
|
+
const innerSpread: NonNullable<BuiltInWhenLeavesInner["cwd"]> = {
|
|
446
|
+
pattern: /work/,
|
|
447
|
+
};
|
|
448
|
+
const outerSpread: NonNullable<BuiltInWhenLeavesOuter["cwd"]> = {
|
|
449
|
+
pattern: /work/,
|
|
450
|
+
onUnknown: "allow",
|
|
451
|
+
};
|
|
452
|
+
assert.ok(innerSpread !== undefined);
|
|
453
|
+
assert.ok(outerSpread !== undefined);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("Rule.when rejects leaf-level onUnknown on cwd inside not:", () => {
|
|
457
|
+
// Empirical type-pin: the silent fail-OPEN shape
|
|
458
|
+
// `not: { cwd: { pattern, onUnknown: "allow" } }` must NOT
|
|
459
|
+
// typecheck under the new Outer/Inner split. The `not:` body
|
|
460
|
+
// resolves to `TopLevelWhenClauseNoRecurse` which intersects with
|
|
461
|
+
// `BuiltInWhenLeavesInner` (where `cwd:`'s spread drops the
|
|
462
|
+
// `onUnknown?:` field). Block-level `onUnknown:` lives on the
|
|
463
|
+
// outer `not:` block via `& PredicateModifiers` and is the
|
|
464
|
+
// canonical placement.
|
|
465
|
+
const _ban: Rule = {
|
|
466
|
+
name: "x",
|
|
467
|
+
tool: "bash",
|
|
468
|
+
field: "command",
|
|
469
|
+
pattern: "^x",
|
|
470
|
+
reason: "x",
|
|
471
|
+
when: {
|
|
472
|
+
not: {
|
|
473
|
+
cwd: {
|
|
474
|
+
pattern: /work/,
|
|
475
|
+
// @ts-expect-error: leaf-level onUnknown forbidden inside not: (parity with registry predicates)
|
|
476
|
+
onUnknown: "allow",
|
|
477
|
+
},
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
};
|
|
481
|
+
void _ban;
|
|
482
|
+
|
|
483
|
+
// Sibling positive cases: bare cwd inside not:, and block-level
|
|
484
|
+
// onUnknown on the not: block, both must typecheck cleanly.
|
|
485
|
+
const _bareInsideNot: Rule = {
|
|
486
|
+
name: "x",
|
|
487
|
+
tool: "bash",
|
|
488
|
+
field: "command",
|
|
489
|
+
pattern: "^x",
|
|
490
|
+
reason: "x",
|
|
491
|
+
when: { not: { cwd: /work/ } },
|
|
492
|
+
};
|
|
493
|
+
void _bareInsideNot;
|
|
494
|
+
|
|
495
|
+
const _blockLevel: Rule = {
|
|
496
|
+
name: "x",
|
|
497
|
+
tool: "bash",
|
|
498
|
+
field: "command",
|
|
499
|
+
pattern: "^x",
|
|
500
|
+
reason: "x",
|
|
501
|
+
when: { not: { cwd: /work/, onUnknown: "block" } },
|
|
502
|
+
};
|
|
503
|
+
void _blockLevel;
|
|
504
|
+
|
|
505
|
+
assert.ok(true);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("Rule.when rejects spread shape on outer condition: (bare PredicateFn only)", () => {
|
|
509
|
+
// Negative type-pin: `condition?:` is bare-`PredicateFn`-typed at
|
|
510
|
+
// every placement (outer + inner). Future widening to a
|
|
511
|
+
// {@link PredicateShape} that admits a leaf-level `onUnknown:`
|
|
512
|
+
// modifier would silently bypass the engine's outer
|
|
513
|
+
// `condition:` contract (default `"block"` policy hard-coded;
|
|
514
|
+
// authors needing fail-OPEN wrap inside `not: { condition: fn,
|
|
515
|
+
// onUnknown: "allow" }`). Pin the constraint at the authoring
|
|
516
|
+
// surface so any future shape-widening trips the typecheck gate
|
|
517
|
+
// instead of regressing the contract silently.
|
|
518
|
+
const _banSpread: Rule = {
|
|
519
|
+
name: "x",
|
|
520
|
+
tool: "bash",
|
|
521
|
+
field: "command",
|
|
522
|
+
pattern: "^x",
|
|
523
|
+
reason: "x",
|
|
524
|
+
when: {
|
|
525
|
+
// @ts-expect-error: condition?: is bare PredicateFn — spread shape forbidden
|
|
526
|
+
condition: { value: () => true, onUnknown: "allow" },
|
|
527
|
+
},
|
|
528
|
+
};
|
|
529
|
+
void _banSpread;
|
|
530
|
+
|
|
531
|
+
// Sibling positive cases: bare callback at outer + inside `not:`.
|
|
532
|
+
const _bareOuter: Rule = {
|
|
533
|
+
name: "x",
|
|
534
|
+
tool: "bash",
|
|
535
|
+
field: "command",
|
|
536
|
+
pattern: "^x",
|
|
537
|
+
reason: "x",
|
|
538
|
+
when: { condition: () => true },
|
|
539
|
+
};
|
|
540
|
+
void _bareOuter;
|
|
541
|
+
|
|
542
|
+
const _bareInner: Rule = {
|
|
543
|
+
name: "x",
|
|
544
|
+
tool: "bash",
|
|
545
|
+
field: "command",
|
|
546
|
+
pattern: "^x",
|
|
547
|
+
reason: "x",
|
|
548
|
+
when: { not: { condition: () => true } },
|
|
549
|
+
};
|
|
550
|
+
void _bareInner;
|
|
551
|
+
|
|
552
|
+
assert.ok(true);
|
|
553
|
+
});
|
|
554
|
+
});
|