@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,705 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* End-to-end integration tests for the git plugin.
|
|
6
|
+
*
|
|
7
|
+
* These are the most valuable tests in Phase 4: they exercise the
|
|
8
|
+
* full wire-up from plugin registration through `resolvePlugins`,
|
|
9
|
+
* `buildEvaluator`, walker tracker composition, predicate dispatch,
|
|
10
|
+
* and override handling. Unit tests pin individual pieces; this
|
|
11
|
+
* suite pins that the pieces fit together.
|
|
12
|
+
*
|
|
13
|
+
* Scenarios covered:
|
|
14
|
+
*
|
|
15
|
+
* 1. Plugin resolution - predicates, rules, trackers, and
|
|
16
|
+
* trackerExtensions land in the resolved state.
|
|
17
|
+
* 2. `DEFAULT_RULES` still block basic force-push regardless of the
|
|
18
|
+
* plugin (sanity: plugin wiring hasn't broken the core).
|
|
19
|
+
* 3. Branch predicate against a fake git `exec` - fires on main,
|
|
20
|
+
* allows on feature.
|
|
21
|
+
* 4. `-C /other` cwd extension doesn't accidentally bypass the rule.
|
|
22
|
+
* 5. The WALKER-DRIVEN branch case: `git checkout main && git
|
|
23
|
+
* commit` - the branch tracker folds the checkout into the
|
|
24
|
+
* commit's state, so `no-main-commit` fires on the commit even
|
|
25
|
+
* though `exec` would see whatever the fake session is on.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import assert from "node:assert/strict";
|
|
29
|
+
import { describe, it } from "node:test";
|
|
30
|
+
import type {
|
|
31
|
+
BashToolCallEvent,
|
|
32
|
+
ExecResult as PiExecResult,
|
|
33
|
+
} from "@earendil-works/pi-coding-agent";
|
|
34
|
+
import { makeCtx, makeTrackedHost } from "../../__test-helpers__.ts";
|
|
35
|
+
import { DEFAULT_RULES } from "../../defaults.ts";
|
|
36
|
+
import { buildEvaluator } from "../../evaluator.ts";
|
|
37
|
+
import { resolvePlugins } from "../../plugin-merger.ts";
|
|
38
|
+
import type { SteeringConfig } from "../../schema.ts";
|
|
39
|
+
import gitPlugin from "./index.ts";
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Builders
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
function bashEvent(command: string): BashToolCallEvent {
|
|
46
|
+
return {
|
|
47
|
+
type: "tool_call",
|
|
48
|
+
toolCallId: "t1",
|
|
49
|
+
toolName: "bash",
|
|
50
|
+
input: { command },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build an evaluator that mirrors the realistic wiring: defaults +
|
|
56
|
+
* the git plugin + any extra user rules. Uses `makeTrackedHost` for
|
|
57
|
+
* a controllable `exec` stub.
|
|
58
|
+
*/
|
|
59
|
+
function buildRuntime(
|
|
60
|
+
config: SteeringConfig,
|
|
61
|
+
execStub?: (cmd: string, args: string[]) => Promise<PiExecResult>,
|
|
62
|
+
) {
|
|
63
|
+
const host = makeTrackedHost({
|
|
64
|
+
exec: async (cmd, args) => {
|
|
65
|
+
if (execStub) return execStub(cmd, args);
|
|
66
|
+
return { stdout: "", stderr: "", code: 1, killed: false };
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
const plugins = config.plugins ?? [];
|
|
70
|
+
// Pass `["cwd"]` as the known built-in tracker name so the git
|
|
71
|
+
// plugin's cwd extension (`--git-dir=` / `--work-tree=`) doesn't
|
|
72
|
+
// trigger an orphan warning and is preserved for the evaluator to
|
|
73
|
+
// compose onto the built-in `cwdTracker`. The pi extension runtime
|
|
74
|
+
// (`src/index.ts`) passes the same list.
|
|
75
|
+
const resolved = resolvePlugins(plugins, config, ["cwd"]);
|
|
76
|
+
const evaluator = buildEvaluator(
|
|
77
|
+
{ ...config, rules: config.rules ?? [...DEFAULT_RULES] },
|
|
78
|
+
resolved,
|
|
79
|
+
host,
|
|
80
|
+
);
|
|
81
|
+
return { evaluator, host, resolved };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Stub exec that reports a given branch name for `git branch
|
|
86
|
+
* --show-current`. Every other `git` call (including `git config
|
|
87
|
+
* --get remote.origin.url`) returns exit 1, so predicates fall back
|
|
88
|
+
* to their `onUnknown` policy. The github-flavored
|
|
89
|
+
* `noMainCommitGithub` specialization is more specific via
|
|
90
|
+
* `remote: { pattern: ..., onUnknown: "allow" }`; on the resulting
|
|
91
|
+
* no-origin signal the github rule skips and the engine cleanly
|
|
92
|
+
* falls through to the generic `noMainCommit` for tests in this
|
|
93
|
+
* scope.
|
|
94
|
+
*
|
|
95
|
+
* Tests that need to exercise the github-flavored rule (or its
|
|
96
|
+
* non-github fall-through) construct their own exec stub that
|
|
97
|
+
* additionally returns a github URL on the `git config` call.
|
|
98
|
+
*/
|
|
99
|
+
function branchExec(name: string) {
|
|
100
|
+
return async (cmd: string, args: string[]): Promise<PiExecResult> => {
|
|
101
|
+
if (cmd === "git" && args[0] === "branch" && args[1] === "--show-current") {
|
|
102
|
+
return { stdout: `${name}\n`, stderr: "", code: 0, killed: false };
|
|
103
|
+
}
|
|
104
|
+
return { stdout: "", stderr: "", code: 1, killed: false };
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// 1. Plugin resolution
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
describe("git plugin: registration + resolution", () => {
|
|
113
|
+
it("resolvePlugins surfaces all four plugin surfaces", () => {
|
|
114
|
+
const resolved = resolvePlugins([gitPlugin], {}, ["cwd"]);
|
|
115
|
+
// Predicates registered.
|
|
116
|
+
assert.ok("branch" in resolved.predicates);
|
|
117
|
+
assert.ok("upstream" in resolved.predicates);
|
|
118
|
+
assert.ok("commitsAhead" in resolved.predicates);
|
|
119
|
+
assert.ok("hasStagedChanges" in resolved.predicates);
|
|
120
|
+
assert.ok("isClean" in resolved.predicates);
|
|
121
|
+
assert.ok("remote" in resolved.predicates);
|
|
122
|
+
// Rules registered.
|
|
123
|
+
assert.ok(resolved.rules.some((r) => r.name === "no-main-commit"));
|
|
124
|
+
// Branch tracker registered.
|
|
125
|
+
assert.ok("branch" in resolved.trackers);
|
|
126
|
+
// Cwd extension captured: when the caller declares `"cwd"` as a
|
|
127
|
+
// known built-in tracker name, the merger keeps the extension in
|
|
128
|
+
// `trackerModifiers` (rather than dropping it as orphan) so the
|
|
129
|
+
// evaluator can compose it onto the built-in cwdTracker.
|
|
130
|
+
assert.ok("cwd" in resolved.trackerModifiers);
|
|
131
|
+
assert.ok(resolved.trackerModifiers["cwd"]?.["git"] !== undefined);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("without the knownBuiltinTrackers hint, cwd extension falls through with an orphan warning", () => {
|
|
135
|
+
// Callers that DON'T declare cwd as a built-in see an orphan
|
|
136
|
+
// warning + the extension is dropped. Pin this behavior so the
|
|
137
|
+
// semantic doesn't silently drift: the merger trusts the caller's
|
|
138
|
+
// declaration, not implicit knowledge of built-in trackers.
|
|
139
|
+
const resolved = resolvePlugins([gitPlugin], {});
|
|
140
|
+
assert.ok(
|
|
141
|
+
resolved.diagnostics.some((w) => w.kind === "extension-orphan"),
|
|
142
|
+
"expected orphan warning when cwd isn't declared as built-in",
|
|
143
|
+
);
|
|
144
|
+
assert.ok(
|
|
145
|
+
!("cwd" in resolved.trackerModifiers),
|
|
146
|
+
"expected the cwd extension to be dropped",
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// 2. DEFAULT_RULES still work with the plugin loaded
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
describe("git plugin: does not break DEFAULT_RULES", () => {
|
|
156
|
+
it("`git push --force` still blocks via no-force-push", async () => {
|
|
157
|
+
const { evaluator } = buildRuntime({ plugins: [gitPlugin] });
|
|
158
|
+
const res = await evaluator.evaluate(
|
|
159
|
+
bashEvent("git push --force origin main"),
|
|
160
|
+
makeCtx("/repo"),
|
|
161
|
+
0,
|
|
162
|
+
);
|
|
163
|
+
assert.ok(res && res.block === true);
|
|
164
|
+
assert.match(res.reason!, /\[steering:no-force-push@[^\]]+\]/);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// 3. Branch predicate via fake exec
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
describe("git plugin: no-main-commit via branch predicate", () => {
|
|
173
|
+
it("fires on main", async () => {
|
|
174
|
+
const { evaluator } = buildRuntime(
|
|
175
|
+
{
|
|
176
|
+
plugins: [gitPlugin],
|
|
177
|
+
rules: [], // only plugin-shipped rules, no DEFAULT_RULES to confuse
|
|
178
|
+
},
|
|
179
|
+
branchExec("main"),
|
|
180
|
+
);
|
|
181
|
+
const res = await evaluator.evaluate(
|
|
182
|
+
bashEvent("git commit -m 'x'"),
|
|
183
|
+
makeCtx("/repo"),
|
|
184
|
+
0,
|
|
185
|
+
);
|
|
186
|
+
assert.ok(res && res.block === true);
|
|
187
|
+
assert.match(res.reason!, /\[steering:no-main-commit@[^\]]+\]/);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("allows on feature", async () => {
|
|
191
|
+
const { evaluator } = buildRuntime(
|
|
192
|
+
{ plugins: [gitPlugin], rules: [] },
|
|
193
|
+
branchExec("feature-x"),
|
|
194
|
+
);
|
|
195
|
+
const res = await evaluator.evaluate(
|
|
196
|
+
bashEvent("git commit -m 'x'"),
|
|
197
|
+
makeCtx("/repo"),
|
|
198
|
+
0,
|
|
199
|
+
);
|
|
200
|
+
assert.equal(res, undefined);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// 4. `-C /other` doesn't bypass the rule
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
describe("git plugin: -C routing does not bypass no-main-commit", () => {
|
|
209
|
+
it("`git -C /other commit` still evaluates branch and fires on main", async () => {
|
|
210
|
+
// Stub reports main regardless of cwd - the predicate queries
|
|
211
|
+
// git at `ctx.cwd`, which for the `-C /other` ref is `/other`
|
|
212
|
+
// (walker cwd). Either way the stubbed branch is main.
|
|
213
|
+
const { evaluator } = buildRuntime(
|
|
214
|
+
{ plugins: [gitPlugin], rules: [] },
|
|
215
|
+
branchExec("main"),
|
|
216
|
+
);
|
|
217
|
+
const res = await evaluator.evaluate(
|
|
218
|
+
bashEvent("git -C /other commit -m 'x'"),
|
|
219
|
+
makeCtx("/repo"),
|
|
220
|
+
0,
|
|
221
|
+
);
|
|
222
|
+
assert.ok(res && res.block === true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("when.branch doesn't fire if predicate resolves non-main", async () => {
|
|
226
|
+
// Pair of the above: `-C /other commit` with exec reporting
|
|
227
|
+
// feature -> allow. Pins that branch predicate reads the stub
|
|
228
|
+
// every tool_call (not cached from a previous call).
|
|
229
|
+
const { evaluator } = buildRuntime(
|
|
230
|
+
{ plugins: [gitPlugin], rules: [] },
|
|
231
|
+
branchExec("feature"),
|
|
232
|
+
);
|
|
233
|
+
const res = await evaluator.evaluate(
|
|
234
|
+
bashEvent("git -C /other commit -m 'x'"),
|
|
235
|
+
makeCtx("/repo"),
|
|
236
|
+
0,
|
|
237
|
+
);
|
|
238
|
+
assert.equal(res, undefined);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("`git --git-dir=/other commit` forwards effective cwd through to the branch predicate", async () => {
|
|
242
|
+
// This pins the cwd-tracker-extension wire-up end-to-end: the
|
|
243
|
+
// `--git-dir=/other` flag is parsed by the plugin's cwd extension,
|
|
244
|
+
// producing walker cwd `/other` for that command ref. The branch
|
|
245
|
+
// predicate then runs `git branch --show-current` with `cwd:
|
|
246
|
+
// "/other"`. We assert that cwd on the exec call to pin the
|
|
247
|
+
// extension reached the evaluator (via the `knownBuiltinTrackers`
|
|
248
|
+
// hint and the evaluator's `composeBuiltinCwd` helper).
|
|
249
|
+
const { evaluator, host } = buildRuntime(
|
|
250
|
+
{ plugins: [gitPlugin], rules: [] },
|
|
251
|
+
branchExec("main"),
|
|
252
|
+
);
|
|
253
|
+
await evaluator.evaluate(
|
|
254
|
+
bashEvent("git --git-dir=/other commit -m 'x'"),
|
|
255
|
+
makeCtx("/repo"),
|
|
256
|
+
0,
|
|
257
|
+
);
|
|
258
|
+
const branchCall = host.execCalls.find(
|
|
259
|
+
(c) =>
|
|
260
|
+
c.cmd === "git" &&
|
|
261
|
+
c.args[0] === "branch" &&
|
|
262
|
+
c.args[1] === "--show-current",
|
|
263
|
+
);
|
|
264
|
+
assert.ok(
|
|
265
|
+
branchCall !== undefined,
|
|
266
|
+
"expected a `git branch --show-current` call",
|
|
267
|
+
);
|
|
268
|
+
assert.equal(
|
|
269
|
+
branchCall.cwd,
|
|
270
|
+
"/other",
|
|
271
|
+
"branch predicate should run in the cwd produced by the --git-dir= extension",
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
// 5. THE KEY TEST: walker-driven branch state
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
describe("git plugin: walker-driven branch state (the KEY test)", () => {
|
|
281
|
+
it("`git checkout main && git commit` - the second command is evaluated on branch=main", async () => {
|
|
282
|
+
// The fake session "current branch" is `feature-x` - a
|
|
283
|
+
// naive session-state predicate would see that and let the
|
|
284
|
+
// commit through. The branch TRACKER folds the in-chain
|
|
285
|
+
// `git checkout main` into the walker state seen by the
|
|
286
|
+
// second ref (`git commit`), so `no-main-commit` fires.
|
|
287
|
+
//
|
|
288
|
+
// The predicate prefers `ctx.walkerState.branch` over a shell
|
|
289
|
+
// call; the walker-resolved `main` wins over the stubbed
|
|
290
|
+
// `feature-x`. The `exec` stub is effectively unreachable
|
|
291
|
+
// here - if the predicate ever shelled out for this case it
|
|
292
|
+
// would incorrectly allow.
|
|
293
|
+
const { evaluator, host } = buildRuntime(
|
|
294
|
+
{ plugins: [gitPlugin], rules: [] },
|
|
295
|
+
branchExec("feature-x"),
|
|
296
|
+
);
|
|
297
|
+
const res = await evaluator.evaluate(
|
|
298
|
+
bashEvent("git checkout main && git commit -m 'x'"),
|
|
299
|
+
makeCtx("/repo"),
|
|
300
|
+
0,
|
|
301
|
+
);
|
|
302
|
+
assert.ok(res && res.block === true);
|
|
303
|
+
assert.match(res.reason!, /\[steering:no-main-commit@[^\]]+\]/);
|
|
304
|
+
// Regression guard: the branch predicate MUST NOT shell out
|
|
305
|
+
// when the walker already provided a concrete branch value.
|
|
306
|
+
assert.equal(
|
|
307
|
+
host.execCalls.filter(
|
|
308
|
+
(c) =>
|
|
309
|
+
c.cmd === "git" &&
|
|
310
|
+
c.args[0] === "branch" &&
|
|
311
|
+
c.args[1] === "--show-current",
|
|
312
|
+
).length,
|
|
313
|
+
0,
|
|
314
|
+
"branch predicate must read walkerState, not shell out",
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("`git checkout feature && git commit` - evaluated on branch=feature, allows", async () => {
|
|
319
|
+
// Pair test: walker folds the checkout in, lands on feature,
|
|
320
|
+
// rule skips. Exec stub would say `main` here - again
|
|
321
|
+
// walker-state wins.
|
|
322
|
+
const { evaluator } = buildRuntime(
|
|
323
|
+
{ plugins: [gitPlugin], rules: [] },
|
|
324
|
+
branchExec("main"),
|
|
325
|
+
);
|
|
326
|
+
const res = await evaluator.evaluate(
|
|
327
|
+
bashEvent("git checkout feature && git commit -m 'x'"),
|
|
328
|
+
makeCtx("/repo"),
|
|
329
|
+
0,
|
|
330
|
+
);
|
|
331
|
+
assert.equal(res, undefined);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("checkout in a subshell does NOT escape - outer `git commit` allowed on feature", async () => {
|
|
335
|
+
// `(git checkout main)` is subshell-isolated; the outer
|
|
336
|
+
// `git commit` inherits the pre-subshell branch state. No
|
|
337
|
+
// branch-changing modifier fired in the outer scope, so the
|
|
338
|
+
// walker threads the tracker's `NO_CHECKOUT_IN_CHAIN` initial
|
|
339
|
+
// sentinel (distinct from the `"unknown"` sentinel that
|
|
340
|
+
// signals a dynamic checkout). The predicate reads this as
|
|
341
|
+
// `missing` -> exec fallback to `git branch --show-current`,
|
|
342
|
+
// which the stub reports as `feature`. Rule doesn't fire.
|
|
343
|
+
const { evaluator } = buildRuntime(
|
|
344
|
+
{ plugins: [gitPlugin], rules: [] },
|
|
345
|
+
branchExec("feature"),
|
|
346
|
+
);
|
|
347
|
+
const res = await evaluator.evaluate(
|
|
348
|
+
bashEvent("(git checkout main) && git commit -m 'x'"),
|
|
349
|
+
makeCtx("/repo"),
|
|
350
|
+
0,
|
|
351
|
+
);
|
|
352
|
+
assert.equal(res, undefined);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("`git checkout $VAR && git commit` - walker unknown short-circuits to onUnknown, no exec fallback", async () => {
|
|
356
|
+
// When $VAR is not statically resolvable, the branch tracker
|
|
357
|
+
// collapses to its "unknown" sentinel. The predicate MUST
|
|
358
|
+
// short-circuit on this signal: a `git branch --show-current`
|
|
359
|
+
// exec fallback here would return the PRE-checkout branch,
|
|
360
|
+
// which is exactly the case the walker's in-chain tracking
|
|
361
|
+
// exists to catch. The predicate's `onUnknown: "block"`
|
|
362
|
+
// default then fires the rule.
|
|
363
|
+
//
|
|
364
|
+
// This pins U1: the exec stub reports "feature" (a non-
|
|
365
|
+
// protected branch). Pre-U1 the predicate treated walker
|
|
366
|
+
// "unknown" as "absent" and fell through to exec -> the rule
|
|
367
|
+
// would INCORRECTLY allow, defeating fail-closed. Post-U1:
|
|
368
|
+
// exec is not consulted, the rule correctly fires.
|
|
369
|
+
const { evaluator, host } = buildRuntime(
|
|
370
|
+
{ plugins: [gitPlugin], rules: [] },
|
|
371
|
+
branchExec("feature"),
|
|
372
|
+
);
|
|
373
|
+
const res = await evaluator.evaluate(
|
|
374
|
+
bashEvent("git checkout $VAR && git commit -m 'x'"),
|
|
375
|
+
makeCtx("/repo"),
|
|
376
|
+
0,
|
|
377
|
+
);
|
|
378
|
+
assert.ok(
|
|
379
|
+
res && res.block === true,
|
|
380
|
+
"unresolvable branch must fail-closed and fire no-main-commit",
|
|
381
|
+
);
|
|
382
|
+
// Regression guard: walker-unknown must NOT fall through to
|
|
383
|
+
// `git branch --show-current`. If this count is ever > 0 the
|
|
384
|
+
// U1 short-circuit has been re-broken.
|
|
385
|
+
assert.equal(
|
|
386
|
+
host.execCalls.filter(
|
|
387
|
+
(c) =>
|
|
388
|
+
c.cmd === "git" &&
|
|
389
|
+
c.args[0] === "branch" &&
|
|
390
|
+
c.args[1] === "--show-current",
|
|
391
|
+
).length,
|
|
392
|
+
0,
|
|
393
|
+
"walker-unknown short-circuits to onUnknown; predicate must not shell out",
|
|
394
|
+
);
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
// 6. no-main-commit-github + walker-unknown cwd
|
|
400
|
+
//
|
|
401
|
+
// The github-flavored specialization's reason fn has a dedicated
|
|
402
|
+
// walker-unknown branch — under `cd "$VAR" && git commit`, the engine
|
|
403
|
+
// Walker-unknown cwd: github-flavored rule has
|
|
404
|
+
// `remote: { pattern, onUnknown: "allow" }`. Under the trinary
|
|
405
|
+
// engine, `onUnknown: "allow"` at the leaf level genuinely means
|
|
406
|
+
// "skip the predicate when the value is unresolvable" — the
|
|
407
|
+
// `remote:` leaf surfaces trinary `"unknown"` (via the inline
|
|
408
|
+
// walker-unknown guard at the top of the handler body) and the
|
|
409
|
+
// engine's leaf adapter projects it to `false` (rule skips).
|
|
410
|
+
// The github-flavored rule therefore correctly defers to the
|
|
411
|
+
// generic `noMainCommit` rule under walker-unknown cwd, which
|
|
412
|
+
// consumes only the `branch:` predicate (no walker-unknown guard;
|
|
413
|
+
// stub returns "main\n") and fires fail-CLOSED via its default
|
|
414
|
+
// leaf-level `onUnknown: "block"`.
|
|
415
|
+
//
|
|
416
|
+
// What this test exercises (counterfactual rationale):
|
|
417
|
+
//
|
|
418
|
+
// 1. The `branch:` predicate matches `main` via the test stub
|
|
419
|
+
// (the stub returns "main\n" for `git branch --show-current`
|
|
420
|
+
// regardless of the runtime cwd that gets passed to it; in
|
|
421
|
+
// production the same exec at `cwd === "unknown"` would fail
|
|
422
|
+
// and the predicate would fall back to its `onUnknown:
|
|
423
|
+
// "block"` default — same firing verdict, different code
|
|
424
|
+
// path).
|
|
425
|
+
// 2. The `remote:` predicate inlines a walker-unknown-cwd guard
|
|
426
|
+
// at the top of its body and surfaces trinary `"unknown"`
|
|
427
|
+
// under `walkerState.cwd === "unknown"`. The leaf-level
|
|
428
|
+
// `onUnknown: "allow"` on the github rule's `remote:` arg
|
|
429
|
+
// then projects unknown → `false` — the github rule SKIPS.
|
|
430
|
+
// 3. The generic `noMainCommit` rule (with only `branch:`)
|
|
431
|
+
// fires next via its default `onUnknown: "block"`. Block
|
|
432
|
+
// verdict lands; the reason text is the generic
|
|
433
|
+
// protected-branch reason, not the github-specific one.
|
|
434
|
+
//
|
|
435
|
+
// Pinned: the steering tag is rendered, a block verdict lands,
|
|
436
|
+
// and the rule that fires is `no-main-commit` (generic), not
|
|
437
|
+
// `no-main-commit-github` — the github-flavored rule cleanly
|
|
438
|
+
// declines under walker-unknown cwd because its `remote:` leaf
|
|
439
|
+
// opts into "allow" semantics there.
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
describe("git plugin: no-main-commit-github walker-unknown cwd", () => {
|
|
443
|
+
it('`cd "$VAR" && git commit` on main + github remote → generic rule fires (github rule allows on walker-unknown via `onUnknown: "allow"` on its `remote:` leaf)', async () => {
|
|
444
|
+
// Explicit exec stubs make the test deterministic regardless
|
|
445
|
+
// of the runner's actual git state. Without the stubs, the
|
|
446
|
+
// `branch:` predicate (which has no inline walker-unknown-cwd
|
|
447
|
+
// guard — it tries the branch tracker first, then shells out)
|
|
448
|
+
// would shell out at the test runner's cwd — the test outcome
|
|
449
|
+
// would depend on whatever branch / remote that workspace
|
|
450
|
+
// happens to be on (flaky).
|
|
451
|
+
const { evaluator } = buildRuntime(
|
|
452
|
+
{ plugins: [gitPlugin], rules: [] },
|
|
453
|
+
async (cmd: string, args: string[]): Promise<PiExecResult> => {
|
|
454
|
+
if (
|
|
455
|
+
cmd === "git" &&
|
|
456
|
+
args[0] === "branch" &&
|
|
457
|
+
args[1] === "--show-current"
|
|
458
|
+
) {
|
|
459
|
+
return {
|
|
460
|
+
stdout: "main\n",
|
|
461
|
+
stderr: "",
|
|
462
|
+
code: 0,
|
|
463
|
+
killed: false,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
if (
|
|
467
|
+
cmd === "git" &&
|
|
468
|
+
args[0] === "config" &&
|
|
469
|
+
args[1] === "--get" &&
|
|
470
|
+
args[2] === "remote.origin.url"
|
|
471
|
+
) {
|
|
472
|
+
return {
|
|
473
|
+
stdout: "https://github.com/cad0p/repo.git\n",
|
|
474
|
+
stderr: "",
|
|
475
|
+
code: 0,
|
|
476
|
+
killed: false,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
return { stdout: "", stderr: "", code: 1, killed: false };
|
|
480
|
+
},
|
|
481
|
+
);
|
|
482
|
+
const res = await evaluator.evaluate(
|
|
483
|
+
bashEvent("cd \"$VAR\" && git commit -m 'x'"),
|
|
484
|
+
makeCtx("/repo"),
|
|
485
|
+
0,
|
|
486
|
+
);
|
|
487
|
+
assert.ok(
|
|
488
|
+
res && res.block === true,
|
|
489
|
+
'walker-unknown cwd must still fire a block (generic no-main-commit, after github-flavored rule allows via its `onUnknown: "allow"` on `remote:`)',
|
|
490
|
+
);
|
|
491
|
+
assert.match(
|
|
492
|
+
res.reason!,
|
|
493
|
+
/\[steering:no-main-commit@[^\]]+\]/,
|
|
494
|
+
'generic protected-branch rule fires (the github-flavored rule cleanly declines under walker-unknown via its `onUnknown: "allow"` on `remote:`)',
|
|
495
|
+
);
|
|
496
|
+
assert.doesNotMatch(
|
|
497
|
+
res.reason!,
|
|
498
|
+
/\[steering:no-main-commit-github@/,
|
|
499
|
+
'github-flavored rule must NOT fire when its `remote:` leaf opts into `onUnknown: "allow"` and walker can\'t resolve cwd',
|
|
500
|
+
);
|
|
501
|
+
// The reason text should be the generic protected-branch reason,
|
|
502
|
+
// not github-flavored PR-flow guidance, and must not contain
|
|
503
|
+
// walker-unknown-branch error text (the github-flavored rule's
|
|
504
|
+
// bespoke "could not verify the current branch" line lives
|
|
505
|
+
// behind its bespoke reason fn — it's the github rule, not the
|
|
506
|
+
// generic, that handles walker-unknown branch).
|
|
507
|
+
assert.doesNotMatch(
|
|
508
|
+
res.reason!,
|
|
509
|
+
/walker.*unknown/i,
|
|
510
|
+
"generic rule's reason text doesn't mention walker-unknown (that's the github rule's domain, and the github rule didn't fire here)",
|
|
511
|
+
);
|
|
512
|
+
assert.doesNotMatch(
|
|
513
|
+
res.reason!,
|
|
514
|
+
/open a PR|pull request/i,
|
|
515
|
+
"generic rule's reason text doesn't carry github-flavored PR-flow guidance",
|
|
516
|
+
);
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// ---------------------------------------------------------------------------
|
|
521
|
+
// 7. isClean spread-form engine end-to-end
|
|
522
|
+
//
|
|
523
|
+
// Pins the engine's leaf-onUnknown read + handler-dispatch contract
|
|
524
|
+
// for boolean predicates: the spread form `{ value: false,
|
|
525
|
+
// onUnknown: "allow" }` flows verbatim to the handler; the engine's
|
|
526
|
+
// `readLeafOnUnknown` reads the modifier and `projectVerdict` projects
|
|
527
|
+
// the handler's `"unknown"` returns under that policy. The handler
|
|
528
|
+
// itself treats `onUnknown:` as an opaque sibling field. This drives
|
|
529
|
+
// `evaluateWhen` → `readLeafOnUnknown` → `isClean` handler end-to-end.
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
describe("git plugin: isClean spread form drives through readLeafOnUnknown", () => {
|
|
533
|
+
it('`isClean: { value: false, onUnknown: "allow" }` fires when working tree is dirty (handler unwraps `value:` and ignores `onUnknown:`)', async () => {
|
|
534
|
+
// Stub `git status --porcelain` to report a dirty tree.
|
|
535
|
+
const { evaluator } = buildRuntime(
|
|
536
|
+
{
|
|
537
|
+
plugins: [gitPlugin],
|
|
538
|
+
rules: [
|
|
539
|
+
{
|
|
540
|
+
name: "deploy-requires-clean",
|
|
541
|
+
tool: "bash",
|
|
542
|
+
field: "command",
|
|
543
|
+
pattern: /^npm\s+run\s+deploy\b/,
|
|
544
|
+
reason: "Working tree dirty.",
|
|
545
|
+
when: { isClean: { value: false, onUnknown: "allow" } },
|
|
546
|
+
},
|
|
547
|
+
],
|
|
548
|
+
},
|
|
549
|
+
async (cmd: string, args: string[]): Promise<PiExecResult> => {
|
|
550
|
+
if (
|
|
551
|
+
cmd === "git" &&
|
|
552
|
+
args[0] === "status" &&
|
|
553
|
+
args[1] === "--porcelain"
|
|
554
|
+
) {
|
|
555
|
+
return {
|
|
556
|
+
stdout: " M file.ts\n",
|
|
557
|
+
stderr: "",
|
|
558
|
+
code: 0,
|
|
559
|
+
killed: false,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
return { stdout: "", stderr: "", code: 1, killed: false };
|
|
563
|
+
},
|
|
564
|
+
);
|
|
565
|
+
const res = await evaluator.evaluate(
|
|
566
|
+
bashEvent("npm run deploy"),
|
|
567
|
+
makeCtx("/repo"),
|
|
568
|
+
0,
|
|
569
|
+
);
|
|
570
|
+
assert.ok(
|
|
571
|
+
res && res.block === true,
|
|
572
|
+
"engine reads `onUnknown: 'allow'` via `readLeafOnUnknown`; handler receives the raw `{ value, onUnknown }` arg, unwraps `value: false`, sees dirty tree (boolean returns from a dirty tree are concrete false/true so `onUnknown` plays no role on this path), returns true → rule fires",
|
|
573
|
+
);
|
|
574
|
+
assert.match(res.reason!, /\[steering:deploy-requires-clean@[^\]]+\]/);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it('`isClean: { value: false, onUnknown: "allow" }` skips on walker-unknown cwd (handler surfaces `"unknown"` → leaf `"allow"` → false)', async () => {
|
|
578
|
+
// Pins the walker-unknown-cwd path: the inline guard at the
|
|
579
|
+
// handler's top surfaces `"unknown"`; the engine's leaf-level
|
|
580
|
+
// `onUnknown: "allow"` projects unknown → false, the rule skips.
|
|
581
|
+
const { evaluator } = buildRuntime(
|
|
582
|
+
{
|
|
583
|
+
plugins: [gitPlugin],
|
|
584
|
+
rules: [
|
|
585
|
+
{
|
|
586
|
+
name: "deploy-requires-clean",
|
|
587
|
+
tool: "bash",
|
|
588
|
+
field: "command",
|
|
589
|
+
pattern: /^npm\s+run\s+deploy\b/,
|
|
590
|
+
reason: "Working tree dirty.",
|
|
591
|
+
when: { isClean: { value: false, onUnknown: "allow" } },
|
|
592
|
+
},
|
|
593
|
+
],
|
|
594
|
+
},
|
|
595
|
+
async (): Promise<PiExecResult> => ({
|
|
596
|
+
stdout: " M file.ts\n",
|
|
597
|
+
stderr: "",
|
|
598
|
+
code: 0,
|
|
599
|
+
killed: false,
|
|
600
|
+
}),
|
|
601
|
+
);
|
|
602
|
+
const res = await evaluator.evaluate(
|
|
603
|
+
bashEvent('cd "$VAR" && npm run deploy'),
|
|
604
|
+
makeCtx("/repo"),
|
|
605
|
+
0,
|
|
606
|
+
);
|
|
607
|
+
assert.equal(
|
|
608
|
+
res,
|
|
609
|
+
undefined,
|
|
610
|
+
"walker-unknown cwd → handler returns 'unknown' → leaf-level 'allow' projects to false → rule skips",
|
|
611
|
+
);
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
// ---------------------------------------------------------------------------
|
|
616
|
+
// 8. isClean: false vs not: { isClean: true } — README equivalence pin
|
|
617
|
+
//
|
|
618
|
+
// The dynamic-reason-runtime-cwd example README documents that
|
|
619
|
+
// `isClean: false` and `not: { isClean: true }` agree on every truth-
|
|
620
|
+
// table row EXCEPT `walker-known + git fails`, where they diverge:
|
|
621
|
+
// - `isClean: false`: handler returns `false` on git failure → leaf
|
|
622
|
+
// verdict false → rule skips.
|
|
623
|
+
// - `not: { isClean: true }`: handler returns `false` → inner
|
|
624
|
+
// verdict false → Kleene-AND-false-absorbs → not-flip yields true
|
|
625
|
+
// → rule fires.
|
|
626
|
+
// This test pins both arms of the divergence so future engine drift
|
|
627
|
+
// trips the test alongside the README — the cross-link in the
|
|
628
|
+
// describe / test descriptions is intentional.
|
|
629
|
+
// ---------------------------------------------------------------------------
|
|
630
|
+
|
|
631
|
+
describe("git plugin: README equivalence — isClean: false vs not: { isClean: true } (walker-known + git fails row)", () => {
|
|
632
|
+
const gitFailsExec = async (
|
|
633
|
+
cmd: string,
|
|
634
|
+
args: string[],
|
|
635
|
+
): Promise<PiExecResult> => {
|
|
636
|
+
// `git status --porcelain` exits non-zero (git failure path the
|
|
637
|
+
// handler treats as `null` → boolean `false`).
|
|
638
|
+
if (cmd === "git" && args[0] === "status" && args[1] === "--porcelain") {
|
|
639
|
+
return {
|
|
640
|
+
stdout: "",
|
|
641
|
+
stderr: "fatal: not a git repository",
|
|
642
|
+
code: 128,
|
|
643
|
+
killed: false,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
return { stdout: "", stderr: "", code: 1, killed: false };
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
it("`when: { isClean: false }` does NOT fire on git failure (handler returns false → leaf false → rule skips)", async () => {
|
|
650
|
+
const { evaluator } = buildRuntime(
|
|
651
|
+
{
|
|
652
|
+
plugins: [gitPlugin],
|
|
653
|
+
rules: [
|
|
654
|
+
{
|
|
655
|
+
name: "deploy-requires-clean-positive",
|
|
656
|
+
tool: "bash",
|
|
657
|
+
field: "command",
|
|
658
|
+
pattern: /^npm\s+run\s+deploy\b/,
|
|
659
|
+
reason: "Working tree must be clean.",
|
|
660
|
+
when: { isClean: false },
|
|
661
|
+
},
|
|
662
|
+
],
|
|
663
|
+
},
|
|
664
|
+
gitFailsExec,
|
|
665
|
+
);
|
|
666
|
+
const res = await evaluator.evaluate(
|
|
667
|
+
bashEvent("npm run deploy"),
|
|
668
|
+
makeCtx("/repo"),
|
|
669
|
+
0,
|
|
670
|
+
);
|
|
671
|
+
assert.equal(
|
|
672
|
+
res,
|
|
673
|
+
undefined,
|
|
674
|
+
"git failure → handler returns false → leaf verdict false → rule skips",
|
|
675
|
+
);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it("`when: { not: { isClean: true } }` FIRES on git failure (handler returns false → Kleene-AND-false-absorbs → not-flip = true)", async () => {
|
|
679
|
+
const { evaluator } = buildRuntime(
|
|
680
|
+
{
|
|
681
|
+
plugins: [gitPlugin],
|
|
682
|
+
rules: [
|
|
683
|
+
{
|
|
684
|
+
name: "deploy-requires-clean-not",
|
|
685
|
+
tool: "bash",
|
|
686
|
+
field: "command",
|
|
687
|
+
pattern: /^npm\s+run\s+deploy\b/,
|
|
688
|
+
reason: "Working tree must be clean.",
|
|
689
|
+
when: { not: { isClean: true } },
|
|
690
|
+
},
|
|
691
|
+
],
|
|
692
|
+
},
|
|
693
|
+
gitFailsExec,
|
|
694
|
+
);
|
|
695
|
+
const res = await evaluator.evaluate(
|
|
696
|
+
bashEvent("npm run deploy"),
|
|
697
|
+
makeCtx("/repo"),
|
|
698
|
+
0,
|
|
699
|
+
);
|
|
700
|
+
assert.ok(
|
|
701
|
+
res && res.block === true,
|
|
702
|
+
"git failure → handler returns false → inner verdict false → Kleene-AND false absorbs → not(false) = true → rule fires",
|
|
703
|
+
);
|
|
704
|
+
});
|
|
705
|
+
});
|