@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,1001 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tests for the git plugin's shipped rules (`./rules.ts`).
|
|
6
|
+
*
|
|
7
|
+
* These verify the rule definitions themselves - pattern shape,
|
|
8
|
+
* `when` wiring, `noOverride` semantics - against the evaluator
|
|
9
|
+
* pipeline. Each test constructs a minimal config with the plugin
|
|
10
|
+
* loaded and runs a bash tool_call through `buildEvaluator`.
|
|
11
|
+
*
|
|
12
|
+
* End-to-end wiring (walker branch state driving predicate behavior,
|
|
13
|
+
* plugin registration) is in `./integration.test.ts`; this file
|
|
14
|
+
* focuses on the rule definition's static shape.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { describe, it } from "node:test";
|
|
19
|
+
import type {
|
|
20
|
+
BashToolCallEvent,
|
|
21
|
+
ExecResult as PiExecResult,
|
|
22
|
+
} from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { makeCtx, makeTrackedHost } from "../../__test-helpers__.ts";
|
|
24
|
+
import { buildEvaluator } from "../../evaluator.ts";
|
|
25
|
+
import { resolvePlugins } from "../../plugin-merger.ts";
|
|
26
|
+
import gitPlugin from "./index.ts";
|
|
27
|
+
import {
|
|
28
|
+
GIT_COMMIT_PATTERN,
|
|
29
|
+
noMainCommit,
|
|
30
|
+
noMainCommitGithub,
|
|
31
|
+
PROTECTED_BRANCH_PATTERN,
|
|
32
|
+
} from "./rules.ts";
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Harness
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
function bashEvent(command: string): BashToolCallEvent {
|
|
39
|
+
return {
|
|
40
|
+
type: "tool_call",
|
|
41
|
+
toolCallId: "t1",
|
|
42
|
+
toolName: "bash",
|
|
43
|
+
input: { command },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build an evaluator that includes the git plugin with a stub `exec`
|
|
49
|
+
* returning the given fake branch on `git branch --show-current`.
|
|
50
|
+
* Every other `git` call (including `git config --get
|
|
51
|
+
* remote.origin.url`) returns exit 1, so predicates fall back to
|
|
52
|
+
* their `onUnknown` policy. The github-flavored
|
|
53
|
+
* `noMainCommitGithub` specialization (more specific via
|
|
54
|
+
* `remote: { pattern: ..., onUnknown: "allow" }`) skips on the
|
|
55
|
+
* resulting no-origin signal, and tests in this scope cleanly
|
|
56
|
+
* exercise the generic `noMainCommit` rule.
|
|
57
|
+
*
|
|
58
|
+
* Tests that need to exercise the github-flavored rule (or the
|
|
59
|
+
* non-github fall-through path) use {@link buildWithBranchAndRemote}
|
|
60
|
+
* below — it parameterizes the origin URL.
|
|
61
|
+
*/
|
|
62
|
+
function buildWithBranch(branchName: string) {
|
|
63
|
+
const host = makeTrackedHost({
|
|
64
|
+
exec: async (cmd, args): Promise<PiExecResult> => {
|
|
65
|
+
if (
|
|
66
|
+
cmd === "git" &&
|
|
67
|
+
args[0] === "branch" &&
|
|
68
|
+
args[1] === "--show-current"
|
|
69
|
+
) {
|
|
70
|
+
return {
|
|
71
|
+
stdout: `${branchName}\n`,
|
|
72
|
+
stderr: "",
|
|
73
|
+
code: 0,
|
|
74
|
+
killed: false,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return { stdout: "", stderr: "", code: 1, killed: false };
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
const resolved = resolvePlugins([gitPlugin], {});
|
|
81
|
+
const evaluator = buildEvaluator({}, resolved, host);
|
|
82
|
+
return { evaluator, host };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// no-main-commit
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
describe("rules: no-main-commit shape", () => {
|
|
90
|
+
it("exists on the plugin with the expected name + pattern + overridable flag", () => {
|
|
91
|
+
const rule = gitPlugin.rules?.find((r) => r.name === "no-main-commit");
|
|
92
|
+
assert.ok(rule);
|
|
93
|
+
assert.equal(rule.tool, "bash");
|
|
94
|
+
assert.equal(rule.field, "command");
|
|
95
|
+
assert.equal(rule.noOverride, false);
|
|
96
|
+
// Accept both shapes: runtime value can be string | RegExp per the
|
|
97
|
+
// schema, even though the narrowed literal type is string-only after
|
|
98
|
+
// the `as const satisfies Rule` narrowing in ./rules.ts.
|
|
99
|
+
const pattern = rule.pattern as string | RegExp;
|
|
100
|
+
const patternSource =
|
|
101
|
+
typeof pattern === "string" ? pattern : pattern.source;
|
|
102
|
+
assert.ok(patternSource.includes("commit"));
|
|
103
|
+
assert.ok(
|
|
104
|
+
rule.when !== undefined &&
|
|
105
|
+
"branch" in (rule.when as Record<string, unknown>),
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("fires on `git commit` when branch predicate resolves to main", async () => {
|
|
110
|
+
const { evaluator } = buildWithBranch("main");
|
|
111
|
+
const res = await evaluator.evaluate(
|
|
112
|
+
bashEvent("git commit -m 'x'"),
|
|
113
|
+
makeCtx("/repo"),
|
|
114
|
+
0,
|
|
115
|
+
);
|
|
116
|
+
assert.ok(res && res.block === true);
|
|
117
|
+
assert.match(res.reason!, /\[steering:no-main-commit@[^\]]+\]/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("allows `git commit` on a feature branch", async () => {
|
|
121
|
+
const { evaluator } = buildWithBranch("feat-login");
|
|
122
|
+
const res = await evaluator.evaluate(
|
|
123
|
+
bashEvent("git commit -m 'x'"),
|
|
124
|
+
makeCtx("/repo"),
|
|
125
|
+
0,
|
|
126
|
+
);
|
|
127
|
+
assert.equal(res, undefined);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("fires on each protected-branch alias (master / mainline / trunk)", async () => {
|
|
131
|
+
for (const branchName of ["master", "mainline", "trunk"]) {
|
|
132
|
+
const { evaluator } = buildWithBranch(branchName);
|
|
133
|
+
const res = await evaluator.evaluate(
|
|
134
|
+
bashEvent("git commit -m 'x'"),
|
|
135
|
+
makeCtx("/repo"),
|
|
136
|
+
0,
|
|
137
|
+
);
|
|
138
|
+
assert.ok(
|
|
139
|
+
res && res.block === true,
|
|
140
|
+
`expected block for branch=${branchName}`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("does NOT fire on git log (non-commit subcommand) even on main", async () => {
|
|
146
|
+
const { evaluator } = buildWithBranch("main");
|
|
147
|
+
const res = await evaluator.evaluate(
|
|
148
|
+
bashEvent("git log --oneline"),
|
|
149
|
+
makeCtx("/repo"),
|
|
150
|
+
0,
|
|
151
|
+
);
|
|
152
|
+
assert.equal(res, undefined);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("catches `git -C /other commit` via pre-subcommand flag slot", async () => {
|
|
156
|
+
const { evaluator } = buildWithBranch("main");
|
|
157
|
+
const res = await evaluator.evaluate(
|
|
158
|
+
bashEvent("git -C /other commit -m 'x'"),
|
|
159
|
+
makeCtx("/repo"),
|
|
160
|
+
0,
|
|
161
|
+
);
|
|
162
|
+
assert.ok(res && res.block === true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("overridable via `# steering-override: no-main-commit` comment", async () => {
|
|
166
|
+
// noOverride: false on the rule - author-supplied override
|
|
167
|
+
// comment on the raw tool_call command is accepted, the event
|
|
168
|
+
// doesn't block, and the override is audit-logged.
|
|
169
|
+
const { evaluator, host } = buildWithBranch("main");
|
|
170
|
+
const res = await evaluator.evaluate(
|
|
171
|
+
bashEvent(
|
|
172
|
+
"git commit -m 'release' # steering-override: no-main-commit - release bump",
|
|
173
|
+
),
|
|
174
|
+
makeCtx("/repo"),
|
|
175
|
+
0,
|
|
176
|
+
);
|
|
177
|
+
assert.equal(res, undefined);
|
|
178
|
+
assert.ok(
|
|
179
|
+
host.appended.some((e) => e.type === "steering-override"),
|
|
180
|
+
"expected a steering-override audit entry",
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// no-main-commit: dynamic reason (Item 1 of PR #5 scope expansion)
|
|
187
|
+
//
|
|
188
|
+
// When the branch tracker has resolved the current branch statically
|
|
189
|
+
// (from a `git checkout <name>` earlier in the chain), the rule's
|
|
190
|
+
// reason text injects the branch name so the agent sees
|
|
191
|
+
// "You are on 'main'" instead of a generic reminder. When tracker
|
|
192
|
+
// state is missing (no checkout in chain, exec fallback) or the value
|
|
193
|
+
// is the walker's `"unknown"` sentinel (dynamic checkout), the
|
|
194
|
+
// dynamic clause is omitted — the static actionable tail still
|
|
195
|
+
// guides the agent to a feature branch.
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
describe("rules: no-main-commit dynamic reason", () => {
|
|
199
|
+
it("`git checkout main && git commit` - reason injects 'You are on main'", async () => {
|
|
200
|
+
// Walker folds the checkout into the branch state seen by the
|
|
201
|
+
// commit ref; the ReasonFn reads `ctx.walkerState.branch` and
|
|
202
|
+
// sees the concrete value `main`.
|
|
203
|
+
const { evaluator } = buildWithBranch("feature");
|
|
204
|
+
const res = await evaluator.evaluate(
|
|
205
|
+
bashEvent("git checkout main && git commit -m 'x'"),
|
|
206
|
+
makeCtx("/repo"),
|
|
207
|
+
0,
|
|
208
|
+
);
|
|
209
|
+
assert.ok(res && res.block === true);
|
|
210
|
+
assert.match(
|
|
211
|
+
res.reason!,
|
|
212
|
+
/You are on 'main'/,
|
|
213
|
+
"reason must include the walker-resolved branch name",
|
|
214
|
+
);
|
|
215
|
+
// Prefix and static tail still present - the dynamic clause
|
|
216
|
+
// is additive, not a replacement.
|
|
217
|
+
assert.match(res.reason!, /\[steering:no-main-commit@[^\]]+\]/);
|
|
218
|
+
assert.match(res.reason!, /Create a feature branch first/);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("`git checkout master && git commit` - injects the concrete protected branch name", async () => {
|
|
222
|
+
// Pin that the injected name is the tracker-resolved value,
|
|
223
|
+
// not a hardcoded "main" — master / trunk / mainline all get
|
|
224
|
+
// the same dynamic treatment.
|
|
225
|
+
const { evaluator } = buildWithBranch("feature");
|
|
226
|
+
const res = await evaluator.evaluate(
|
|
227
|
+
bashEvent("git checkout master && git commit -m 'x'"),
|
|
228
|
+
makeCtx("/repo"),
|
|
229
|
+
0,
|
|
230
|
+
);
|
|
231
|
+
assert.ok(res && res.block === true);
|
|
232
|
+
assert.match(res.reason!, /You are on 'master'/);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("no in-chain checkout + exec fallback - reason omits the dynamic clause", async () => {
|
|
236
|
+
// Exec reports `main` via `git branch --show-current`, so the
|
|
237
|
+
// rule fires — but the BRANCH TRACKER didn't see an in-chain
|
|
238
|
+
// checkout, so `ctx.walkerState.branch` is the tracker's
|
|
239
|
+
// `NO_CHECKOUT_IN_CHAIN` sentinel (not a real branch name).
|
|
240
|
+
// The ReasonFn must NOT leak that sentinel into the reason
|
|
241
|
+
// text; it falls back to the static form.
|
|
242
|
+
const { evaluator } = buildWithBranch("main");
|
|
243
|
+
const res = await evaluator.evaluate(
|
|
244
|
+
bashEvent("git commit -m 'x'"),
|
|
245
|
+
makeCtx("/repo"),
|
|
246
|
+
0,
|
|
247
|
+
);
|
|
248
|
+
assert.ok(res && res.block === true);
|
|
249
|
+
assert.doesNotMatch(
|
|
250
|
+
res.reason!,
|
|
251
|
+
/You are on '/,
|
|
252
|
+
"reason must not include the dynamic clause when walker state is missing",
|
|
253
|
+
);
|
|
254
|
+
// Static tail still present.
|
|
255
|
+
assert.match(res.reason!, /Create a feature branch first/);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("`git checkout $VAR && git commit` - walker-unknown branch - reason omits the dynamic clause", async () => {
|
|
259
|
+
// The branch tracker collapses `checkout $VAR` to its
|
|
260
|
+
// `"unknown"` sentinel. The predicate's onUnknown="block"
|
|
261
|
+
// default still fires, and the ReasonFn treats `"unknown"`
|
|
262
|
+
// as a non-concrete value — the dynamic clause is omitted
|
|
263
|
+
// rather than leaking the sentinel string into the message.
|
|
264
|
+
const { evaluator } = buildWithBranch("feature");
|
|
265
|
+
const res = await evaluator.evaluate(
|
|
266
|
+
bashEvent("git checkout $VAR && git commit -m 'x'"),
|
|
267
|
+
makeCtx("/repo"),
|
|
268
|
+
0,
|
|
269
|
+
);
|
|
270
|
+
assert.ok(res && res.block === true);
|
|
271
|
+
assert.doesNotMatch(
|
|
272
|
+
res.reason!,
|
|
273
|
+
/You are on '/,
|
|
274
|
+
"reason must not include the dynamic clause when walker state is 'unknown'",
|
|
275
|
+
);
|
|
276
|
+
assert.doesNotMatch(
|
|
277
|
+
res.reason!,
|
|
278
|
+
/unknown/,
|
|
279
|
+
"reason must not leak the walker sentinel string",
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// Harness extension for `no-main-commit-github` — stubs both the branch and
|
|
286
|
+
// the origin remote URL so non-github fall-through is exercised
|
|
287
|
+
// deterministically.
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Build an evaluator that includes the git plugin with a stub `exec`
|
|
292
|
+
* returning the given fake branch on `git branch --show-current` AND
|
|
293
|
+
* the given fake origin URL on `git config --get remote.origin.url`
|
|
294
|
+
* (when {@link remoteUrl} is non-null). Every other `git` call returns
|
|
295
|
+
* exit 1 so predicates fall back to their `onUnknown` policy.
|
|
296
|
+
*
|
|
297
|
+
* Pass `remoteUrl: null` to simulate a repo with no `origin` remote
|
|
298
|
+
* (the `git config --get remote.origin.url` call returns non-zero).
|
|
299
|
+
*/
|
|
300
|
+
function buildWithBranchAndRemote(
|
|
301
|
+
branchName: string,
|
|
302
|
+
remoteUrl: string | null,
|
|
303
|
+
) {
|
|
304
|
+
const host = makeTrackedHost({
|
|
305
|
+
exec: async (cmd, args): Promise<PiExecResult> => {
|
|
306
|
+
if (
|
|
307
|
+
cmd === "git" &&
|
|
308
|
+
args[0] === "branch" &&
|
|
309
|
+
args[1] === "--show-current"
|
|
310
|
+
) {
|
|
311
|
+
return {
|
|
312
|
+
stdout: `${branchName}\n`,
|
|
313
|
+
stderr: "",
|
|
314
|
+
code: 0,
|
|
315
|
+
killed: false,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
if (
|
|
319
|
+
cmd === "git" &&
|
|
320
|
+
args[0] === "config" &&
|
|
321
|
+
args[1] === "--get" &&
|
|
322
|
+
args[2] === "remote.origin.url" &&
|
|
323
|
+
remoteUrl !== null
|
|
324
|
+
) {
|
|
325
|
+
return {
|
|
326
|
+
stdout: `${remoteUrl}\n`,
|
|
327
|
+
stderr: "",
|
|
328
|
+
code: 0,
|
|
329
|
+
killed: false,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
return { stdout: "", stderr: "", code: 1, killed: false };
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
const resolved = resolvePlugins([gitPlugin], {});
|
|
336
|
+
const evaluator = buildEvaluator({}, resolved, host);
|
|
337
|
+
return { evaluator, host };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
// no-main-commit-github
|
|
342
|
+
//
|
|
343
|
+
// Specialization of `no-main-commit` that emits PR-flow guidance on
|
|
344
|
+
// github.com clones. The shared `GIT_COMMIT_PATTERN` constant +
|
|
345
|
+
// first-match-wins ordering in the rule array are LOAD-BEARING — these
|
|
346
|
+
// tests pin the routing so a future maintainer reordering for stylistic
|
|
347
|
+
// reasons trips the suite rather than silently regressing the user-
|
|
348
|
+
// facing message.
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
describe("rules: no-main-commit-github", () => {
|
|
352
|
+
it("github clone + on main → fires github rule with PR-flow guidance + safety reminder", async () => {
|
|
353
|
+
const { evaluator } = buildWithBranchAndRemote(
|
|
354
|
+
"main",
|
|
355
|
+
"https://github.com/cad0p/repo.git",
|
|
356
|
+
);
|
|
357
|
+
const res = await evaluator.evaluate(
|
|
358
|
+
bashEvent("git commit -m 'x'"),
|
|
359
|
+
makeCtx("/repo"),
|
|
360
|
+
0,
|
|
361
|
+
);
|
|
362
|
+
assert.ok(res && res.block === true);
|
|
363
|
+
assert.match(
|
|
364
|
+
res.reason!,
|
|
365
|
+
/\[steering:no-main-commit-github@[^\]]+\]/,
|
|
366
|
+
"github-flavored rule must fire (with the steering tag)",
|
|
367
|
+
);
|
|
368
|
+
assert.match(
|
|
369
|
+
res.reason!,
|
|
370
|
+
/github clone's protected branch/,
|
|
371
|
+
"reason must include the github-specific anchor (protected-branch wording mirrors the four-name when: clause)",
|
|
372
|
+
);
|
|
373
|
+
assert.match(
|
|
374
|
+
res.reason!,
|
|
375
|
+
/Open a PR for review/,
|
|
376
|
+
"reason must include the PR-flow guidance",
|
|
377
|
+
);
|
|
378
|
+
assert.match(
|
|
379
|
+
res.reason!,
|
|
380
|
+
/NEVER merge a PR or mark it ready-for-review/,
|
|
381
|
+
"reason must include the safety reminder",
|
|
382
|
+
);
|
|
383
|
+
// Multi-paragraph render-shape pin: the steering tag renders
|
|
384
|
+
// on its own line followed by a paragraph break, and the
|
|
385
|
+
// safety reminder stays a separate paragraph from the PR-flow
|
|
386
|
+
// body. Pins both the engine's paragraph-aware tag separator
|
|
387
|
+
// (in formatReason) AND this rule's `\n\n` body separator at
|
|
388
|
+
// one site. Counterfactual: a regression in either (e.g.,
|
|
389
|
+
// engine refactor that strips trailing/leading whitespace, or
|
|
390
|
+
// a future cleanup that changes `\n\n` → `\n` in the rule's
|
|
391
|
+
// reason text) would silently degrade rendering — the .match
|
|
392
|
+
// checks above don't catch newline drift.
|
|
393
|
+
assert.match(
|
|
394
|
+
res.reason!,
|
|
395
|
+
/^\[steering:no-main-commit-github@[^\]]+\]\n\nYou're on a github clone's protected branch/,
|
|
396
|
+
"tag must render on its own line followed by paragraph break",
|
|
397
|
+
);
|
|
398
|
+
assert.match(
|
|
399
|
+
res.reason!,
|
|
400
|
+
/\n\nSafety: NEVER merge a PR/,
|
|
401
|
+
"safety reminder must remain a separate paragraph from the PR-flow body",
|
|
402
|
+
);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("non-github remote + on main → falls through to generic no-main-commit", async () => {
|
|
406
|
+
const { evaluator } = buildWithBranchAndRemote(
|
|
407
|
+
"main",
|
|
408
|
+
"git@self-hosted.example.com:Foo/Bar.git",
|
|
409
|
+
);
|
|
410
|
+
const res = await evaluator.evaluate(
|
|
411
|
+
bashEvent("git commit -m 'x'"),
|
|
412
|
+
makeCtx("/repo"),
|
|
413
|
+
0,
|
|
414
|
+
);
|
|
415
|
+
assert.ok(res && res.block === true);
|
|
416
|
+
assert.match(
|
|
417
|
+
res.reason!,
|
|
418
|
+
/\[steering:no-main-commit@[^\]]+\]/,
|
|
419
|
+
"generic no-main-commit must fire on non-github remotes",
|
|
420
|
+
);
|
|
421
|
+
assert.doesNotMatch(
|
|
422
|
+
res.reason!,
|
|
423
|
+
/no-main-commit-github@/,
|
|
424
|
+
"github-flavored rule must NOT fire on non-github remotes",
|
|
425
|
+
);
|
|
426
|
+
assert.doesNotMatch(
|
|
427
|
+
res.reason!,
|
|
428
|
+
/github clone's protected branch/,
|
|
429
|
+
"reason must not claim github-specific context on non-github remotes",
|
|
430
|
+
);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("no origin configured + on main → falls through to generic no-main-commit", async () => {
|
|
434
|
+
// Pins the `onUnknown: "allow"` posture on the github rule's
|
|
435
|
+
// `remote:` predicate. With the default `onUnknown: "block"`, the
|
|
436
|
+
// github rule would fire fail-closed on a repo with no origin
|
|
437
|
+
// configured — emitting github-flavored PR-flow guidance and
|
|
438
|
+
// claiming a github clone the engine couldn't actually verify.
|
|
439
|
+
// `onUnknown: "allow"` makes the github rule cleanly skip on
|
|
440
|
+
// no-origin so the generic `noMainCommit` (which has no
|
|
441
|
+
// `remote:` clause) fires instead, with branch-only context the
|
|
442
|
+
// engine can confirm.
|
|
443
|
+
const { evaluator } = buildWithBranchAndRemote("main", null);
|
|
444
|
+
const res = await evaluator.evaluate(
|
|
445
|
+
bashEvent("git commit -m 'x'"),
|
|
446
|
+
makeCtx("/repo"),
|
|
447
|
+
0,
|
|
448
|
+
);
|
|
449
|
+
assert.ok(res && res.block === true);
|
|
450
|
+
assert.match(
|
|
451
|
+
res.reason!,
|
|
452
|
+
/\[steering:no-main-commit@[^\]]+\]/,
|
|
453
|
+
"generic no-main-commit must fire on no-origin repos",
|
|
454
|
+
);
|
|
455
|
+
assert.doesNotMatch(
|
|
456
|
+
res.reason!,
|
|
457
|
+
/no-main-commit-github@/,
|
|
458
|
+
"github-flavored rule must NOT fire on no-origin repos",
|
|
459
|
+
);
|
|
460
|
+
assert.doesNotMatch(
|
|
461
|
+
res.reason!,
|
|
462
|
+
/github clone's protected branch/,
|
|
463
|
+
"reason must not claim github-specific context on no-origin repos",
|
|
464
|
+
);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("github clone + on feature branch → does not fire", async () => {
|
|
468
|
+
const { evaluator } = buildWithBranchAndRemote(
|
|
469
|
+
"feat-x",
|
|
470
|
+
"https://github.com/cad0p/repo.git",
|
|
471
|
+
);
|
|
472
|
+
const res = await evaluator.evaluate(
|
|
473
|
+
bashEvent("git commit -m 'x'"),
|
|
474
|
+
makeCtx("/repo"),
|
|
475
|
+
0,
|
|
476
|
+
);
|
|
477
|
+
assert.equal(res, undefined);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("non-commit verb (`git push`) on main → does not fire (pattern doesn't match)", async () => {
|
|
481
|
+
const { evaluator } = buildWithBranchAndRemote(
|
|
482
|
+
"main",
|
|
483
|
+
"https://github.com/cad0p/repo.git",
|
|
484
|
+
);
|
|
485
|
+
const res = await evaluator.evaluate(
|
|
486
|
+
bashEvent("git push origin main"),
|
|
487
|
+
makeCtx("/repo"),
|
|
488
|
+
0,
|
|
489
|
+
);
|
|
490
|
+
assert.equal(
|
|
491
|
+
res,
|
|
492
|
+
undefined,
|
|
493
|
+
"shared GIT_COMMIT_PATTERN constant must only match `git commit` (not `git push`)",
|
|
494
|
+
);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("vault path + on main + github remote → fires github rule (gitPlugin has no built-in vault knowledge)", async () => {
|
|
498
|
+
// Counterfactual rationale: a future refactor that adds a
|
|
499
|
+
// cwd-based vault exemption to the rule (e.g.,
|
|
500
|
+
// `not: { cwd: VAULT_DIRS }` baked into the default `when:`)
|
|
501
|
+
// would silently change this behavior — vault paths would skip
|
|
502
|
+
// the rule. The README's Customization section is built on
|
|
503
|
+
// the contract that vault exemption is downstream-consumer
|
|
504
|
+
// responsibility, NOT a built-in default; this test pins that
|
|
505
|
+
// contract.
|
|
506
|
+
const { evaluator } = buildWithBranchAndRemote(
|
|
507
|
+
"main",
|
|
508
|
+
"https://github.com/user/Goldmine.git",
|
|
509
|
+
);
|
|
510
|
+
const res = await evaluator.evaluate(
|
|
511
|
+
bashEvent("git commit -m 'note'"),
|
|
512
|
+
makeCtx("/home/user/Goldmine/notes"),
|
|
513
|
+
0,
|
|
514
|
+
);
|
|
515
|
+
assert.ok(
|
|
516
|
+
res && res.block === true,
|
|
517
|
+
"vault path must NOT bypass the rule — gitPlugin doesn't ship vault awareness",
|
|
518
|
+
);
|
|
519
|
+
assert.match(
|
|
520
|
+
res.reason!,
|
|
521
|
+
/\[steering:no-main-commit-github@[^\]]+\]/,
|
|
522
|
+
"vault paths still get the github-flavored message; vault exemption is a downstream-consumer override",
|
|
523
|
+
);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("SSH-form github URL (`git@github.com:...`) → fires github rule", async () => {
|
|
527
|
+
// The `remote:` regex `/github\.com[/:]/` accepts both the
|
|
528
|
+
// HTTPS path separator (`github.com/`) and the SSH user-host
|
|
529
|
+
// separator (`github.com:`). Pins the broader character class
|
|
530
|
+
// against an accidental narrowing back to `/github\.com\//`.
|
|
531
|
+
const { evaluator } = buildWithBranchAndRemote(
|
|
532
|
+
"main",
|
|
533
|
+
"git@github.com:cad0p/repo.git",
|
|
534
|
+
);
|
|
535
|
+
const res = await evaluator.evaluate(
|
|
536
|
+
bashEvent("git commit -m 'x'"),
|
|
537
|
+
makeCtx("/repo"),
|
|
538
|
+
0,
|
|
539
|
+
);
|
|
540
|
+
assert.ok(res && res.block === true);
|
|
541
|
+
assert.match(
|
|
542
|
+
res.reason!,
|
|
543
|
+
/\[steering:no-main-commit-github@[^\]]+\]/,
|
|
544
|
+
"SSH-form github URL must route to the github-flavored message",
|
|
545
|
+
);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it("overridable via `# steering-override: no-main-commit-github` comment", async () => {
|
|
549
|
+
// Mirrors the override-comment test on the generic rule. The
|
|
550
|
+
// rule's `noOverride: false` field is set explicitly with a
|
|
551
|
+
// JSDoc rationale ("workflow rules are intentionally
|
|
552
|
+
// overridable") — this test exercises the engine's actual
|
|
553
|
+
// override-comment path on the github specialization, rather
|
|
554
|
+
// than relying on the rule-shape `assert.equal(noOverride,
|
|
555
|
+
// false)` pin alone. A future refactor that drops the field
|
|
556
|
+
// or flips the schema default would surface here as a failed
|
|
557
|
+
// override + a missing audit entry.
|
|
558
|
+
//
|
|
559
|
+
// Both `no-main-commit-github` and the generic
|
|
560
|
+
// `no-main-commit` fire on a github clone + main. Stacked
|
|
561
|
+
// override comments suppress both — the engine accepts
|
|
562
|
+
// multiple override markers on a single command (see
|
|
563
|
+
// `extractOverride`'s JSDoc on stacked overrides). The audit
|
|
564
|
+
// entry assertion specifically targets the github rule to pin
|
|
565
|
+
// the github-side override path was exercised.
|
|
566
|
+
const { evaluator, host } = buildWithBranchAndRemote(
|
|
567
|
+
"main",
|
|
568
|
+
"https://github.com/cad0p/repo.git",
|
|
569
|
+
);
|
|
570
|
+
const res = await evaluator.evaluate(
|
|
571
|
+
bashEvent(
|
|
572
|
+
"git commit -m 'release' " +
|
|
573
|
+
"# steering-override: no-main-commit-github - release process " +
|
|
574
|
+
"# steering-override: no-main-commit - release process",
|
|
575
|
+
),
|
|
576
|
+
makeCtx("/repo"),
|
|
577
|
+
0,
|
|
578
|
+
);
|
|
579
|
+
assert.equal(res, undefined);
|
|
580
|
+
assert.ok(
|
|
581
|
+
host.appended.some(
|
|
582
|
+
(e) =>
|
|
583
|
+
e.type === "steering-override" &&
|
|
584
|
+
(e.data as { rule?: string } | undefined)?.rule ===
|
|
585
|
+
"no-main-commit-github",
|
|
586
|
+
),
|
|
587
|
+
"expected a steering-override audit entry for no-main-commit-github (proves the github rule's override path was exercised)",
|
|
588
|
+
);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it("`git checkout $VAR && git commit` on github clone → walker-unknown branch routes to the bespoke 'could not verify' message", async () => {
|
|
592
|
+
// Pins the walker-unknown-branch early-return in the reason fn.
|
|
593
|
+
// Walker-unknown CWD takes a different path: `remote:` opts into
|
|
594
|
+
// `onUnknown: "allow"` and projects unknown→false BEFORE this
|
|
595
|
+
// reason fn runs (the rule skips, deferring to the generic
|
|
596
|
+
// noMainCommit). This test scopes to walker-unknown BRANCH (not
|
|
597
|
+
// cwd): github remote stubbed + dynamic checkout target in the
|
|
598
|
+
// chain so the branch tracker collapses to its `"unknown"`
|
|
599
|
+
// sentinel under known cwd. The branch predicate's default
|
|
600
|
+
// `onUnknown: "block"` fires fail-closed; the reason fn detects
|
|
601
|
+
// `branchRes.kind === "unknown"` and emits the bespoke "could not
|
|
602
|
+
// verify the current branch" message rather than falling through
|
|
603
|
+
// to a positive claim about the protected branch.
|
|
604
|
+
//
|
|
605
|
+
// Counterfactual rationale: without the walker-unknown-branch
|
|
606
|
+
// sibling early-return, the reason fn falls through to the
|
|
607
|
+
// known-branch body ("You're on a github clone's protected
|
|
608
|
+
// branch"). The walkerString-driven dynamic clause
|
|
609
|
+
// ` You are on '${branch}'.` correctly omits itself when
|
|
610
|
+
// `branchRes.kind === "unknown"` (the ternary at
|
|
611
|
+
// `rules.ts` returns undefined → `onClause = ""`), so the
|
|
612
|
+
// `"unknown"` sentinel cannot leak — but the static body still
|
|
613
|
+
// claims github-flavored protected-branch context the engine
|
|
614
|
+
// hasn't verified. The agent then sees github-specific PR-flow
|
|
615
|
+
// guidance for a context the rule didn't actually verify.
|
|
616
|
+
const { evaluator } = buildWithBranchAndRemote(
|
|
617
|
+
"feature",
|
|
618
|
+
"https://github.com/cad0p/repo.git",
|
|
619
|
+
);
|
|
620
|
+
const res = await evaluator.evaluate(
|
|
621
|
+
bashEvent("git checkout $VAR && git commit -m 'x'"),
|
|
622
|
+
makeCtx("/repo"),
|
|
623
|
+
0,
|
|
624
|
+
);
|
|
625
|
+
assert.ok(res && res.block === true);
|
|
626
|
+
assert.match(
|
|
627
|
+
res.reason!,
|
|
628
|
+
/\[steering:no-main-commit-github@[^\]]+\]/,
|
|
629
|
+
"github-flavored rule must fire on dynamic-checkout chain",
|
|
630
|
+
);
|
|
631
|
+
assert.match(
|
|
632
|
+
res.reason!,
|
|
633
|
+
/Could not verify the current branch/,
|
|
634
|
+
"reason must use the bespoke walker-unknown-branch message",
|
|
635
|
+
);
|
|
636
|
+
assert.doesNotMatch(
|
|
637
|
+
res.reason!,
|
|
638
|
+
/You're on a github clone's protected branch/,
|
|
639
|
+
"reason must NOT make an unverified positive claim about the protected branch",
|
|
640
|
+
);
|
|
641
|
+
assert.doesNotMatch(
|
|
642
|
+
res.reason!,
|
|
643
|
+
/You are on '/,
|
|
644
|
+
"reason must not include the dynamic branch-name clause when walker state is 'unknown'",
|
|
645
|
+
);
|
|
646
|
+
assert.doesNotMatch(
|
|
647
|
+
res.reason!,
|
|
648
|
+
/'unknown'/,
|
|
649
|
+
"reason must not leak the walker `unknown` sentinel into the agent-facing message",
|
|
650
|
+
);
|
|
651
|
+
// Cross-phase: paragraph-aware separator preserves the safety
|
|
652
|
+
// reminder as its own paragraph on the walker-unknown-branch
|
|
653
|
+
// body too.
|
|
654
|
+
assert.match(
|
|
655
|
+
res.reason!,
|
|
656
|
+
/\n\nSafety: NEVER merge a PR/,
|
|
657
|
+
"safety reminder must remain a separate paragraph on the walker-unknown-branch body",
|
|
658
|
+
);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it("`git checkout main && git commit` on github clone → reason injects 'You are on main'", async () => {
|
|
662
|
+
// Mirror of `noMainCommit`'s dynamic-clause pin for the github
|
|
663
|
+
// specialization: when the branch tracker has resolved a
|
|
664
|
+
// concrete protected-branch name from an in-chain checkout, the
|
|
665
|
+
// reason fn injects the name into the body. Pins the
|
|
666
|
+
// `walkerString`-driven interpolation against a regression that
|
|
667
|
+
// hardcodes "main".
|
|
668
|
+
const { evaluator } = buildWithBranchAndRemote(
|
|
669
|
+
"feature",
|
|
670
|
+
"https://github.com/cad0p/repo.git",
|
|
671
|
+
);
|
|
672
|
+
const res = await evaluator.evaluate(
|
|
673
|
+
bashEvent("git checkout main && git commit -m 'x'"),
|
|
674
|
+
makeCtx("/repo"),
|
|
675
|
+
0,
|
|
676
|
+
);
|
|
677
|
+
assert.ok(res && res.block === true);
|
|
678
|
+
assert.match(res.reason!, /\[steering:no-main-commit-github@[^\]]+\]/);
|
|
679
|
+
assert.match(
|
|
680
|
+
res.reason!,
|
|
681
|
+
/You are on 'main'/,
|
|
682
|
+
"reason must include the walker-resolved branch name",
|
|
683
|
+
);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it("`git checkout master && git commit` on github clone → injects the concrete protected branch name", async () => {
|
|
687
|
+
// Sibling pin to the `main` test: master / mainline / trunk all
|
|
688
|
+
// flow through the same tracker-driven interpolation. Catches a
|
|
689
|
+
// regression that hardcodes a single protected-branch literal
|
|
690
|
+
// instead of reading from `walkerString`.
|
|
691
|
+
const { evaluator } = buildWithBranchAndRemote(
|
|
692
|
+
"feature",
|
|
693
|
+
"https://github.com/cad0p/repo.git",
|
|
694
|
+
);
|
|
695
|
+
const res = await evaluator.evaluate(
|
|
696
|
+
bashEvent("git checkout master && git commit -m 'x'"),
|
|
697
|
+
makeCtx("/repo"),
|
|
698
|
+
0,
|
|
699
|
+
);
|
|
700
|
+
assert.ok(res && res.block === true);
|
|
701
|
+
assert.match(res.reason!, /You are on 'master'/);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it("`git checkout trunk && git commit` on github clone → injects 'trunk' (interpolation is tracker-driven)", async () => {
|
|
705
|
+
// Pin the third protected-branch alias to seal the
|
|
706
|
+
// tracker-driven contract: the injected name comes from
|
|
707
|
+
// `walkerString`, not a hardcoded set of `main` / `master`.
|
|
708
|
+
const { evaluator } = buildWithBranchAndRemote(
|
|
709
|
+
"feature",
|
|
710
|
+
"https://github.com/cad0p/repo.git",
|
|
711
|
+
);
|
|
712
|
+
const res = await evaluator.evaluate(
|
|
713
|
+
bashEvent("git checkout trunk && git commit -m 'x'"),
|
|
714
|
+
makeCtx("/repo"),
|
|
715
|
+
0,
|
|
716
|
+
);
|
|
717
|
+
assert.ok(res && res.block === true);
|
|
718
|
+
assert.match(res.reason!, /You are on 'trunk'/);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it("no in-chain checkout on github clone → reason omits the dynamic branch clause", async () => {
|
|
722
|
+
// Exec reports `main` via `git branch --show-current`, so the
|
|
723
|
+
// rule fires — but the BRANCH TRACKER didn't see an in-chain
|
|
724
|
+
// checkout, so `walkerString` returns the
|
|
725
|
+
// `NO_CHECKOUT_IN_CHAIN` sentinel (not a real branch name).
|
|
726
|
+
// The reason fn must NOT leak the sentinel into the body and
|
|
727
|
+
// must omit the dynamic clause; the static github-flavored
|
|
728
|
+
// guidance still fires.
|
|
729
|
+
const { evaluator } = buildWithBranchAndRemote(
|
|
730
|
+
"main",
|
|
731
|
+
"https://github.com/cad0p/repo.git",
|
|
732
|
+
);
|
|
733
|
+
const res = await evaluator.evaluate(
|
|
734
|
+
bashEvent("git commit -m 'x'"),
|
|
735
|
+
makeCtx("/repo"),
|
|
736
|
+
0,
|
|
737
|
+
);
|
|
738
|
+
assert.ok(res && res.block === true);
|
|
739
|
+
assert.match(res.reason!, /\[steering:no-main-commit-github@[^\]]+\]/);
|
|
740
|
+
assert.match(res.reason!, /You're on a github clone's protected branch/);
|
|
741
|
+
assert.doesNotMatch(
|
|
742
|
+
res.reason!,
|
|
743
|
+
/You are on '/,
|
|
744
|
+
"reason must not include the dynamic clause when no in-chain checkout was tracked",
|
|
745
|
+
);
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it("rule-shape pin: `noOverride: false` + `when:` includes both branch + remote", () => {
|
|
749
|
+
const rule = gitPlugin.rules?.find(
|
|
750
|
+
(r) => r.name === "no-main-commit-github",
|
|
751
|
+
);
|
|
752
|
+
assert.ok(rule);
|
|
753
|
+
assert.equal(rule.tool, "bash");
|
|
754
|
+
assert.equal(rule.field, "command");
|
|
755
|
+
assert.equal(
|
|
756
|
+
rule.noOverride,
|
|
757
|
+
false,
|
|
758
|
+
"workflow rule must stay overridable via `# steering-override:` comment",
|
|
759
|
+
);
|
|
760
|
+
assert.ok(
|
|
761
|
+
rule.when !== undefined &&
|
|
762
|
+
"branch" in (rule.when as Record<string, unknown>) &&
|
|
763
|
+
"remote" in (rule.when as Record<string, unknown>),
|
|
764
|
+
"when: must include both branch + remote (specialization shape)",
|
|
765
|
+
);
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it("`disabledRules: ['no-main-commit-github']` only + github clone → generic fires (clean fall-through)", async () => {
|
|
769
|
+
// Pins the canonical "swap to generic message" customization:
|
|
770
|
+
// disable just the github specialization, generic stays
|
|
771
|
+
// active, user gets the generic feature-branch reminder
|
|
772
|
+
// instead of PR-flow guidance. This is the user-facing
|
|
773
|
+
// behavior README's Customization section advertises for the
|
|
774
|
+
// disable-only path — pinned so a regression in the
|
|
775
|
+
// disabledRules filter (e.g., name-prefix-matching that
|
|
776
|
+
// accidentally disables both rules) surfaces here as a
|
|
777
|
+
// missing block.
|
|
778
|
+
const host = makeTrackedHost({
|
|
779
|
+
exec: async (cmd, args): Promise<PiExecResult> => {
|
|
780
|
+
if (
|
|
781
|
+
cmd === "git" &&
|
|
782
|
+
args[0] === "branch" &&
|
|
783
|
+
args[1] === "--show-current"
|
|
784
|
+
) {
|
|
785
|
+
return {
|
|
786
|
+
stdout: "main\n",
|
|
787
|
+
stderr: "",
|
|
788
|
+
code: 0,
|
|
789
|
+
killed: false,
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
if (
|
|
793
|
+
cmd === "git" &&
|
|
794
|
+
args[0] === "config" &&
|
|
795
|
+
args[1] === "--get" &&
|
|
796
|
+
args[2] === "remote.origin.url"
|
|
797
|
+
) {
|
|
798
|
+
return {
|
|
799
|
+
stdout: "https://github.com/cad0p/repo.git\n",
|
|
800
|
+
stderr: "",
|
|
801
|
+
code: 0,
|
|
802
|
+
killed: false,
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
return { stdout: "", stderr: "", code: 1, killed: false };
|
|
806
|
+
},
|
|
807
|
+
});
|
|
808
|
+
const resolved = resolvePlugins([gitPlugin], {
|
|
809
|
+
disabledRules: ["no-main-commit-github"],
|
|
810
|
+
});
|
|
811
|
+
const evaluator = buildEvaluator({}, resolved, host);
|
|
812
|
+
const res = await evaluator.evaluate(
|
|
813
|
+
bashEvent("git commit -m 'x'"),
|
|
814
|
+
makeCtx("/repo"),
|
|
815
|
+
0,
|
|
816
|
+
);
|
|
817
|
+
assert.ok(
|
|
818
|
+
res && res.block === true,
|
|
819
|
+
"generic rule must still fire on protected branch when only the github rule is disabled",
|
|
820
|
+
);
|
|
821
|
+
assert.match(
|
|
822
|
+
res.reason!,
|
|
823
|
+
/\[steering:no-main-commit@[^\]]+\]/,
|
|
824
|
+
"generic no-main-commit fires on github clones too (its when: is just branch-based)",
|
|
825
|
+
);
|
|
826
|
+
assert.doesNotMatch(
|
|
827
|
+
res.reason!,
|
|
828
|
+
/no-main-commit-github@/,
|
|
829
|
+
"github-flavored rule must NOT fire when listed in disabledRules",
|
|
830
|
+
);
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it("`disabledRules: ['no-main-commit-github']` only + non-github → generic fires (disable doesn't affect generic routing)", async () => {
|
|
834
|
+
// Sibling pin to the above: disabling the github rule must
|
|
835
|
+
// not affect the generic rule's routing on non-github
|
|
836
|
+
// remotes. A regression where `disabledRules` accidentally
|
|
837
|
+
// name-prefix-matched (`startsWith` instead of equality)
|
|
838
|
+
// would disable both rules here and surface as `res ===
|
|
839
|
+
// undefined`.
|
|
840
|
+
const host = makeTrackedHost({
|
|
841
|
+
exec: async (cmd, args): Promise<PiExecResult> => {
|
|
842
|
+
if (
|
|
843
|
+
cmd === "git" &&
|
|
844
|
+
args[0] === "branch" &&
|
|
845
|
+
args[1] === "--show-current"
|
|
846
|
+
) {
|
|
847
|
+
return {
|
|
848
|
+
stdout: "main\n",
|
|
849
|
+
stderr: "",
|
|
850
|
+
code: 0,
|
|
851
|
+
killed: false,
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
return { stdout: "", stderr: "", code: 1, killed: false };
|
|
855
|
+
},
|
|
856
|
+
});
|
|
857
|
+
const resolved = resolvePlugins([gitPlugin], {
|
|
858
|
+
disabledRules: ["no-main-commit-github"],
|
|
859
|
+
});
|
|
860
|
+
const evaluator = buildEvaluator({}, resolved, host);
|
|
861
|
+
const res = await evaluator.evaluate(
|
|
862
|
+
bashEvent("git commit -m 'x'"),
|
|
863
|
+
makeCtx("/repo"),
|
|
864
|
+
0,
|
|
865
|
+
);
|
|
866
|
+
assert.ok(
|
|
867
|
+
res && res.block === true,
|
|
868
|
+
"generic rule fires on non-github too — disable name-equality must scope cleanly",
|
|
869
|
+
);
|
|
870
|
+
assert.match(res.reason!, /\[steering:no-main-commit@[^\]]+\]/);
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
it("`disabledRules: ['no-main-commit-github', 'no-main-commit']` → no commit-on-main rule fires", async () => {
|
|
874
|
+
// Sanity-check pin for the both-disabled case. With both
|
|
875
|
+
// rules disabled, the engine has no commit-on-main guard at
|
|
876
|
+
// all → block does not surface. Useful as a regression seal
|
|
877
|
+
// against an accidental third commit-on-main rule being
|
|
878
|
+
// added without being listed here.
|
|
879
|
+
const host = makeTrackedHost({
|
|
880
|
+
exec: async (cmd, args): Promise<PiExecResult> => {
|
|
881
|
+
if (
|
|
882
|
+
cmd === "git" &&
|
|
883
|
+
args[0] === "branch" &&
|
|
884
|
+
args[1] === "--show-current"
|
|
885
|
+
) {
|
|
886
|
+
return {
|
|
887
|
+
stdout: "main\n",
|
|
888
|
+
stderr: "",
|
|
889
|
+
code: 0,
|
|
890
|
+
killed: false,
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
if (
|
|
894
|
+
cmd === "git" &&
|
|
895
|
+
args[0] === "config" &&
|
|
896
|
+
args[1] === "--get" &&
|
|
897
|
+
args[2] === "remote.origin.url"
|
|
898
|
+
) {
|
|
899
|
+
return {
|
|
900
|
+
stdout: "https://github.com/cad0p/repo.git\n",
|
|
901
|
+
stderr: "",
|
|
902
|
+
code: 0,
|
|
903
|
+
killed: false,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
return { stdout: "", stderr: "", code: 1, killed: false };
|
|
907
|
+
},
|
|
908
|
+
});
|
|
909
|
+
const resolved = resolvePlugins([gitPlugin], {
|
|
910
|
+
disabledRules: ["no-main-commit-github", "no-main-commit"],
|
|
911
|
+
});
|
|
912
|
+
const evaluator = buildEvaluator({}, resolved, host);
|
|
913
|
+
const res = await evaluator.evaluate(
|
|
914
|
+
bashEvent("git commit -m 'x'"),
|
|
915
|
+
makeCtx("/repo"),
|
|
916
|
+
0,
|
|
917
|
+
);
|
|
918
|
+
assert.equal(
|
|
919
|
+
res,
|
|
920
|
+
undefined,
|
|
921
|
+
"both rules disabled: no commit-on-main rule should fire",
|
|
922
|
+
);
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
it("both rules' pattern fields equal the exported GIT_COMMIT_PATTERN constant", () => {
|
|
926
|
+
// Value-equality pin against the exported constant. The pattern
|
|
927
|
+
// is a string primitive, so this is byte-equality, NOT shared-
|
|
928
|
+
// reference identity — a future maintainer who inlines the
|
|
929
|
+
// literal at one rule's definition site with the SAME bytes
|
|
930
|
+
// would not trip this assertion. What it DOES catch:
|
|
931
|
+
// - Either rule's pattern accidentally diverging from the
|
|
932
|
+
// exported constant (e.g. one drops `\b`, one anchors
|
|
933
|
+
// differently).
|
|
934
|
+
// - The constant itself getting renamed away or removed.
|
|
935
|
+
// True shared-reference factoring would need a `RegExp` (object)
|
|
936
|
+
// constant; today's design uses a string source so the regex-
|
|
937
|
+
// compile cache in the engine can dedupe across both rules
|
|
938
|
+
// without per-rule allocation.
|
|
939
|
+
assert.equal(
|
|
940
|
+
noMainCommit.pattern,
|
|
941
|
+
GIT_COMMIT_PATTERN,
|
|
942
|
+
"noMainCommit.pattern must equal the exported GIT_COMMIT_PATTERN constant",
|
|
943
|
+
);
|
|
944
|
+
assert.equal(
|
|
945
|
+
noMainCommitGithub.pattern,
|
|
946
|
+
GIT_COMMIT_PATTERN,
|
|
947
|
+
"noMainCommitGithub.pattern must equal the exported GIT_COMMIT_PATTERN constant",
|
|
948
|
+
);
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
it("both rules' branch fields share-reference the exported PROTECTED_BRANCH_PATTERN constant", () => {
|
|
952
|
+
// Shared-reference pin (`===` on the RegExp object), STRICTLY
|
|
953
|
+
// stronger than the byte-equality pin above for `pattern`. The
|
|
954
|
+
// protected-branch list is a `RegExp` (object) constant rather
|
|
955
|
+
// than a string source, so identity comparison is meaningful:
|
|
956
|
+
// any future maintainer who inlines `/^(main|master|mainline|trunk)$/`
|
|
957
|
+
// at a rule's definition site — even with byte-identical contents
|
|
958
|
+
// — trips this assertion because the inlined literal compiles to
|
|
959
|
+
// a fresh `RegExp` instance. That catches the failure mode the
|
|
960
|
+
// `GIT_COMMIT_PATTERN` value-equality pin cannot: silent
|
|
961
|
+
// re-inlining that re-introduces the duplication this constant
|
|
962
|
+
// was extracted to eliminate.
|
|
963
|
+
//
|
|
964
|
+
// Without the shared constant, the protected-branch list could
|
|
965
|
+
// drift between the two rules (one rule adds a vendor-specific
|
|
966
|
+
// default-branch alias, the other doesn't); pinning shared-
|
|
967
|
+
// reference forces both rules to pick up the alias from a single
|
|
968
|
+
// edit site.
|
|
969
|
+
assert.strictEqual(
|
|
970
|
+
noMainCommit.when.branch,
|
|
971
|
+
PROTECTED_BRANCH_PATTERN,
|
|
972
|
+
"noMainCommit.when.branch must reference the exported PROTECTED_BRANCH_PATTERN constant",
|
|
973
|
+
);
|
|
974
|
+
// `noMainCommitGithub.when` is the object form (with `remote:`);
|
|
975
|
+
// the `branch:` field still points directly at the regex.
|
|
976
|
+
assert.strictEqual(
|
|
977
|
+
noMainCommitGithub.when.branch,
|
|
978
|
+
PROTECTED_BRANCH_PATTERN,
|
|
979
|
+
"noMainCommitGithub.when.branch must reference the exported PROTECTED_BRANCH_PATTERN constant",
|
|
980
|
+
);
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
it("first-match-wins ordering: github rule appears BEFORE no-main-commit in the plugin's rule array", () => {
|
|
984
|
+
// Load-bearing ordering. On a github clone + on main, BOTH rules'
|
|
985
|
+
// `when:` clauses match — first-match-wins routes the github-
|
|
986
|
+
// flavored guidance to github users. Reordering for stylistic
|
|
987
|
+
// reasons (alphabetical, etc.) silently regresses user-facing
|
|
988
|
+
// behavior; this assertion makes that regression visible.
|
|
989
|
+
const rules = gitPlugin.rules ?? [];
|
|
990
|
+
const githubIdx = rules.findIndex(
|
|
991
|
+
(r) => r.name === "no-main-commit-github",
|
|
992
|
+
);
|
|
993
|
+
const genericIdx = rules.findIndex((r) => r.name === "no-main-commit");
|
|
994
|
+
assert.notEqual(githubIdx, -1, "no-main-commit-github must be registered");
|
|
995
|
+
assert.notEqual(genericIdx, -1, "no-main-commit must be registered");
|
|
996
|
+
assert.ok(
|
|
997
|
+
githubIdx < genericIdx,
|
|
998
|
+
`expected no-main-commit-github (idx ${githubIdx}) BEFORE no-main-commit (idx ${genericIdx})`,
|
|
999
|
+
);
|
|
1000
|
+
});
|
|
1001
|
+
});
|