@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,99 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of the pi-steering work-item-plugin example.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `workItemFormat` — example plugin predicate.
|
|
6
|
+
*
|
|
7
|
+
* Demonstrates two things at once:
|
|
8
|
+
*
|
|
9
|
+
* 1. How to use `definePredicate<T>` for a typed arg shape.
|
|
10
|
+
* 2. How to read structured args via `input.args` (ADR §9).
|
|
11
|
+
*
|
|
12
|
+
* Accepted arg shape:
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* when: { workItemFormat: { pattern: /\[PROJ-\d+\]/ } }
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* Semantics:
|
|
19
|
+
*
|
|
20
|
+
* - Extracts the commit message from a `-m` flag in `input.args`.
|
|
21
|
+
* This is the preferred path because `input.args` preserves
|
|
22
|
+
* quoting via `Word.value` — `-m "feat: [PROJ-42]"` lands as a
|
|
23
|
+
* single Word with `value === "feat: [PROJ-42]"`.
|
|
24
|
+
* - If `-m` can't be found in `input.args` (the user authored a
|
|
25
|
+
* rule for a non-commit command, or args is absent for some
|
|
26
|
+
* reason), falls back to testing `input.command` directly. The
|
|
27
|
+
* fallback is conservative: it just checks if the pattern
|
|
28
|
+
* appears anywhere in the flattened command string.
|
|
29
|
+
*
|
|
30
|
+
* Fail-closed: if the predicate can't determine a message at all,
|
|
31
|
+
* returns `false` (rule does NOT fire) so authoring typos don't
|
|
32
|
+
* spuriously block every commit.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { Word } from "@cad0p/pi-steering";
|
|
36
|
+
import { definePredicate } from "@cad0p/pi-steering";
|
|
37
|
+
|
|
38
|
+
/** Argument shape the `when.workItemFormat` slot accepts. */
|
|
39
|
+
export interface WorkItemFormatArgs {
|
|
40
|
+
/** Required regex the commit message must match. */
|
|
41
|
+
pattern: RegExp;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Walk `args` looking for a `-m` / `--message` flag and return the
|
|
46
|
+
* message value (the Word that FOLLOWS the flag). Returns `null` if
|
|
47
|
+
* no `-m`-like flag is present.
|
|
48
|
+
*
|
|
49
|
+
* Notes:
|
|
50
|
+
* - `.value` is the quote-unwrapped lexical value (`feat: x`),
|
|
51
|
+
* `.text` is the raw source (`"feat: x"`). Rules care about
|
|
52
|
+
* lexical content.
|
|
53
|
+
* - The combined form `-m"msg"` is not parsed as a single Word by
|
|
54
|
+
* unbash (shell doesn't either — the quotes end the flag token
|
|
55
|
+
* prematurely). We treat `-m` and `--message` as space-separated
|
|
56
|
+
* only, which matches 99% of human usage.
|
|
57
|
+
*/
|
|
58
|
+
function extractMessage(args: readonly Word[]): string | null {
|
|
59
|
+
for (let i = 0; i < args.length; i++) {
|
|
60
|
+
const w = args[i];
|
|
61
|
+
if (w === undefined) continue;
|
|
62
|
+
if (w.value === "-m" || w.value === "--message") {
|
|
63
|
+
const next = args[i + 1];
|
|
64
|
+
if (next !== undefined) {
|
|
65
|
+
return next.value;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The predicate handler itself. See module doc for the arg shape and
|
|
74
|
+
* fallback policy.
|
|
75
|
+
*/
|
|
76
|
+
export const workItemFormat = definePredicate<WorkItemFormatArgs>(
|
|
77
|
+
(args, ctx) => {
|
|
78
|
+
if (!(args?.pattern instanceof RegExp)) return false;
|
|
79
|
+
|
|
80
|
+
// Preferred: structured args with quote-awareness.
|
|
81
|
+
if (ctx.input.args !== undefined) {
|
|
82
|
+
const msg = extractMessage(ctx.input.args);
|
|
83
|
+
if (msg !== null) {
|
|
84
|
+
return args.pattern.test(msg);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Fallback: match anywhere in the flattened command string.
|
|
89
|
+
// Conservative — a rule whose `pattern` already anchored on
|
|
90
|
+
// `git commit -m` has narrowed the scope, so arbitrary
|
|
91
|
+
// command content shouldn't match the ticket regex by
|
|
92
|
+
// accident.
|
|
93
|
+
if (ctx.input.command !== undefined) {
|
|
94
|
+
return args.pattern.test(ctx.input.command);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return false;
|
|
98
|
+
},
|
|
99
|
+
);
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of the pi-steering work-item-plugin example.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tests for `commit-description-check`. The critical path is the
|
|
6
|
+
* two-call sequence within one agent loop:
|
|
7
|
+
* 1. First commit blocks, onFire writes the self-mark.
|
|
8
|
+
* 2. Second commit in the same loop passes.
|
|
9
|
+
* 3. A commit in the NEXT agent loop blocks again.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { describe, it } from "node:test";
|
|
14
|
+
import {
|
|
15
|
+
createRecordingHost,
|
|
16
|
+
expectAllows,
|
|
17
|
+
loadHarness,
|
|
18
|
+
mockExtensionContext,
|
|
19
|
+
} from "@cad0p/pi-steering/testing";
|
|
20
|
+
import {
|
|
21
|
+
commitDescriptionCheck,
|
|
22
|
+
DESCRIPTION_REVIEWED_EVENT,
|
|
23
|
+
} from "./commit-description-check.ts";
|
|
24
|
+
|
|
25
|
+
describe("commit-description-check", () => {
|
|
26
|
+
it("blocks the first commit per loop, allows the second (self-mark)", async () => {
|
|
27
|
+
// Recording host + shared ExtensionContext: writes via
|
|
28
|
+
// host.appendEntry flow back into ctx.sessionManager.getEntries
|
|
29
|
+
// so later evaluate() calls see the self-mark the previous call
|
|
30
|
+
// wrote. Same plumbing the real pi runtime wires, minus the
|
|
31
|
+
// child-process bits.
|
|
32
|
+
const host = createRecordingHost();
|
|
33
|
+
const ctx = mockExtensionContext("/tmp/test", host.entries);
|
|
34
|
+
|
|
35
|
+
const harness = loadHarness({
|
|
36
|
+
config: { rules: [commitDescriptionCheck] },
|
|
37
|
+
host,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// First commit in loop 1 — blocks AND self-marks.
|
|
41
|
+
const first = await harness.evaluate(
|
|
42
|
+
{
|
|
43
|
+
type: "tool_call",
|
|
44
|
+
toolCallId: "tc1",
|
|
45
|
+
toolName: "bash",
|
|
46
|
+
input: { command: 'git commit -m "first"' },
|
|
47
|
+
} as unknown as Parameters<typeof harness.evaluate>[0],
|
|
48
|
+
ctx,
|
|
49
|
+
1,
|
|
50
|
+
);
|
|
51
|
+
assert.ok(
|
|
52
|
+
first !== undefined && first !== null,
|
|
53
|
+
"first commit should block",
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// `onFire` wrote the reminder entry.
|
|
57
|
+
assert.ok(
|
|
58
|
+
host.entries.some((e) => e.customType === DESCRIPTION_REVIEWED_EVENT),
|
|
59
|
+
"onFire did not self-mark the DESCRIPTION_REVIEWED_EVENT entry",
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// Second commit in the same loop — the reminder entry is now
|
|
63
|
+
// present, so `when.happened` no longer fires; the rule
|
|
64
|
+
// passes.
|
|
65
|
+
const second = await harness.evaluate(
|
|
66
|
+
{
|
|
67
|
+
type: "tool_call",
|
|
68
|
+
toolCallId: "tc2",
|
|
69
|
+
toolName: "bash",
|
|
70
|
+
input: { command: 'git commit -m "second"' },
|
|
71
|
+
} as unknown as Parameters<typeof harness.evaluate>[0],
|
|
72
|
+
ctx,
|
|
73
|
+
1,
|
|
74
|
+
);
|
|
75
|
+
assert.equal(
|
|
76
|
+
second,
|
|
77
|
+
undefined,
|
|
78
|
+
"second commit in the same loop should pass after self-mark",
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// New agent loop (index bumps) — the entry's
|
|
82
|
+
// `_agentLoopIndex` is 1, but this is loop 2, so
|
|
83
|
+
// `when.happened` filters it out and the reminder fires
|
|
84
|
+
// again.
|
|
85
|
+
const nextLoop = await harness.evaluate(
|
|
86
|
+
{
|
|
87
|
+
type: "tool_call",
|
|
88
|
+
toolCallId: "tc3",
|
|
89
|
+
toolName: "bash",
|
|
90
|
+
input: { command: 'git commit -m "new loop"' },
|
|
91
|
+
} as unknown as Parameters<typeof harness.evaluate>[0],
|
|
92
|
+
ctx,
|
|
93
|
+
2,
|
|
94
|
+
);
|
|
95
|
+
assert.ok(
|
|
96
|
+
nextLoop !== undefined && nextLoop !== null,
|
|
97
|
+
"new-loop commit should block — stale reminder doesn't carry over",
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("does NOT fire on a non-commit command", async () => {
|
|
102
|
+
const harness = loadHarness({
|
|
103
|
+
config: { rules: [commitDescriptionCheck] },
|
|
104
|
+
});
|
|
105
|
+
await expectAllows(harness, { command: "git status" });
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of the pi-steering work-item-plugin example.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `commit-description-check` — self-marking reminder rule.
|
|
6
|
+
*
|
|
7
|
+
* Demonstrates `Rule.onFire` + self-marking (ADR §6, §14):
|
|
8
|
+
*
|
|
9
|
+
* - Pattern `/^git\s+commit\b/` matches any commit command.
|
|
10
|
+
* - `when.happened: { event: DESCRIPTION_REVIEWED_EVENT, in: "agent_loop" }`
|
|
11
|
+
* fires when the reminder entry has NOT been written this loop.
|
|
12
|
+
* - `onFire: markDescriptionReviewed` writes that entry when the
|
|
13
|
+
* rule blocks. First commit per agent loop → blocks with the
|
|
14
|
+
* reminder, self-marks. Second commit in the same loop → the
|
|
15
|
+
* entry is now present, `when.happened` no longer fires, commit
|
|
16
|
+
* is allowed.
|
|
17
|
+
*
|
|
18
|
+
* ## Encapsulation (ADR §14)
|
|
19
|
+
*
|
|
20
|
+
* No corresponding observer ships the `DESCRIPTION_REVIEWED_EVENT`
|
|
21
|
+
* event — the rule is the producer and the consumer. The convention
|
|
22
|
+
* for that case is: the constant + helper live IN THE RULE FILE
|
|
23
|
+
* itself. Observer files own their constants; self-marking rule
|
|
24
|
+
* files own theirs.
|
|
25
|
+
*
|
|
26
|
+
* Override: allowed. Skipping the reminder is a workflow choice.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { PredicateContext, Rule } from "@cad0p/pi-steering";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Session-entry type written by this rule's `onFire`. Exported so
|
|
33
|
+
* tests (or any other rule / observer in the same plugin) can
|
|
34
|
+
* reference the same literal.
|
|
35
|
+
*/
|
|
36
|
+
export const DESCRIPTION_REVIEWED_EVENT =
|
|
37
|
+
"example-description-reviewed" as const;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Shape of the payload written on self-mark. Minimal — the rule just
|
|
41
|
+
* needs a presence marker; the timestamp the engine attaches via
|
|
42
|
+
* `findEntries` is enough for any downstream logic.
|
|
43
|
+
*/
|
|
44
|
+
interface DescriptionReviewedPayload {
|
|
45
|
+
/** Which commit command variant the reminder fired on. */
|
|
46
|
+
command: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Helper that writes the reminder entry. The ADR §14 pattern: both
|
|
51
|
+
* the constant and the writer live with the producing rule. If a
|
|
52
|
+
* future observer needed to ALSO write this type, we'd move both into
|
|
53
|
+
* an observer file; here, the rule is the sole writer so the rule
|
|
54
|
+
* file owns it.
|
|
55
|
+
*/
|
|
56
|
+
export function markDescriptionReviewed(
|
|
57
|
+
ctx: PredicateContext,
|
|
58
|
+
payload: DescriptionReviewedPayload = { command: "" },
|
|
59
|
+
): void {
|
|
60
|
+
ctx.appendEntry<DescriptionReviewedPayload>(
|
|
61
|
+
DESCRIPTION_REVIEWED_EVENT,
|
|
62
|
+
payload,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const commitDescriptionCheck = {
|
|
67
|
+
name: "commit-description-check",
|
|
68
|
+
tool: "bash",
|
|
69
|
+
field: "command",
|
|
70
|
+
pattern: /^git\s+commit\b/,
|
|
71
|
+
when: {
|
|
72
|
+
happened: { event: DESCRIPTION_REVIEWED_EVENT, in: "agent_loop" },
|
|
73
|
+
},
|
|
74
|
+
reason:
|
|
75
|
+
"Re-read the commit description before committing. This reminder fires once per agent loop — your next commit in this loop will go through.",
|
|
76
|
+
noOverride: false,
|
|
77
|
+
writes: [DESCRIPTION_REVIEWED_EVENT],
|
|
78
|
+
onFire: (ctx) => {
|
|
79
|
+
markDescriptionReviewed(ctx, {
|
|
80
|
+
command: ctx.input.command ?? "",
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
} as const satisfies Rule;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of the pi-steering work-item-plugin example.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Integration tests for `commit-requires-work-item`.
|
|
6
|
+
*
|
|
7
|
+
* Uses `loadHarness` + `expectBlocks` / `expectAllows` — same shape
|
|
8
|
+
* a real plugin's test would take. Builds a minimal config with just
|
|
9
|
+
* this rule + the `workItemFormat` predicate wired through a fake
|
|
10
|
+
* plugin. Keeps the test isolated from any upstream plugin behavior.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it } from "node:test";
|
|
14
|
+
import type { Plugin } from "@cad0p/pi-steering";
|
|
15
|
+
import { expectAllows, expectBlocks, loadHarness } from "@cad0p/pi-steering/testing";
|
|
16
|
+
import { workItemFormat } from "../predicates/work-item-format.ts";
|
|
17
|
+
import { commitRequiresWorkItem } from "./commit-requires-work-item.ts";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Minimal test plugin registering the predicate. We could use the
|
|
21
|
+
* whole `work-item` plugin default export here, but wiring the single
|
|
22
|
+
* predicate keeps each rule test focused on its own behavior without
|
|
23
|
+
* pulling in unrelated observers / rules.
|
|
24
|
+
*/
|
|
25
|
+
const testPlugin: Plugin = {
|
|
26
|
+
name: "test",
|
|
27
|
+
predicates: { workItemFormat },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe("commit-requires-work-item", () => {
|
|
31
|
+
const harness = loadHarness({
|
|
32
|
+
config: {
|
|
33
|
+
plugins: [testPlugin],
|
|
34
|
+
rules: [commitRequiresWorkItem],
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("blocks a commit missing the work-item tag", async () => {
|
|
39
|
+
await expectBlocks(
|
|
40
|
+
harness,
|
|
41
|
+
{ command: 'git commit -m "feat: add thing"' },
|
|
42
|
+
{ rule: "commit-requires-work-item" },
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("allows a commit containing [PROJ-N]", async () => {
|
|
47
|
+
await expectAllows(harness, {
|
|
48
|
+
command: 'git commit -m "feat: add thing [PROJ-42]"',
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("allows commits using --message long form", async () => {
|
|
53
|
+
await expectAllows(harness, {
|
|
54
|
+
command: 'git commit --message "fix [PROJ-1] bad thing"',
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('does NOT fire on git log --grep="commit"', async () => {
|
|
59
|
+
// Make sure the pattern doesn't spuriously match arbitrary
|
|
60
|
+
// commit-containing text.
|
|
61
|
+
await expectAllows(harness, {
|
|
62
|
+
command: 'git log --grep="commit"',
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("does NOT fire on `git commit` without -m (the rule's pattern requires -m)", async () => {
|
|
67
|
+
// The rule pattern anchors on `-m\s`, so this never even
|
|
68
|
+
// reaches the predicate.
|
|
69
|
+
await expectAllows(harness, { command: "git commit --amend" });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("blocks a commit whose [PROJ- tag uses the wrong format", async () => {
|
|
73
|
+
await expectBlocks(
|
|
74
|
+
harness,
|
|
75
|
+
{ command: 'git commit -m "feat [PROJXX] oops"' },
|
|
76
|
+
{ rule: "commit-requires-work-item" },
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of the pi-steering work-item-plugin example.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `commit-requires-work-item` — example rule.
|
|
6
|
+
*
|
|
7
|
+
* Demonstrates:
|
|
8
|
+
* - A plugin-registered predicate (`workItemFormat`) in use from a
|
|
9
|
+
* rule's `when.*` slot.
|
|
10
|
+
* - `when.not` for inversion — the predicate reports "the commit
|
|
11
|
+
* message MATCHES `[PROJ-N]`" (positive), so we wrap it in `not`
|
|
12
|
+
* to get "the message does NOT match" (the firing condition).
|
|
13
|
+
* - The typed-arg authoring pattern — `{ pattern: /\[PROJ-\d+\]/ }`.
|
|
14
|
+
* - A `pattern` anchored on the subcommand slot so `git log --grep
|
|
15
|
+
* "commit"` doesn't spuriously match.
|
|
16
|
+
*
|
|
17
|
+
* Semantics:
|
|
18
|
+
* - Fires on `git commit [...] -m <msg>` where `<msg>` does NOT
|
|
19
|
+
* contain a `[PROJ-N]` token.
|
|
20
|
+
* - The predicate's fallback path keeps us safe: if someone writes
|
|
21
|
+
* a commit with no `-m` reachable (e.g. `git commit
|
|
22
|
+
* --file /tmp/msg.txt`), the predicate falls back to
|
|
23
|
+
* pattern-matching the whole command — which will miss, so
|
|
24
|
+
* `not` makes this rule FIRE. That's the conservative default
|
|
25
|
+
* for a plugin whose whole purpose is "require a ticket".
|
|
26
|
+
*
|
|
27
|
+
* Override: allowed (workflow rule, not inherent-destructive). The
|
|
28
|
+
* engine's default is `noOverride: true`; rule authors opt out
|
|
29
|
+
* explicitly.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { Rule } from "@cad0p/pi-steering";
|
|
33
|
+
|
|
34
|
+
export const commitRequiresWorkItem = {
|
|
35
|
+
name: "commit-requires-work-item",
|
|
36
|
+
tool: "bash",
|
|
37
|
+
field: "command",
|
|
38
|
+
// Anchored on `git commit` — the pre-subcommand flag slot (`git -C
|
|
39
|
+
// /other commit …`) is intentionally omitted to keep the example
|
|
40
|
+
// compact. The git plugin's `no-main-commit` rule in the package
|
|
41
|
+
// shows the full slot pattern for production use.
|
|
42
|
+
pattern: /^git\s+commit\b.*-m\s/,
|
|
43
|
+
when: {
|
|
44
|
+
// Invert the predicate: fire when the work-item tag is MISSING.
|
|
45
|
+
not: {
|
|
46
|
+
// The plugin-registered `workItemFormat` predicate — see
|
|
47
|
+
// ../predicates/work-item-format.ts. `[PROJ-N]` is the
|
|
48
|
+
// placeholder — a real adopter replaces with e.g.
|
|
49
|
+
// `[PROJECT-\d+]` / `[JIRA-\d+]`.
|
|
50
|
+
workItemFormat: { pattern: /\[PROJ-\d+\]/ },
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
reason:
|
|
54
|
+
"Commit messages must reference a work item ticket, e.g., [PROJ-123].",
|
|
55
|
+
noOverride: false,
|
|
56
|
+
} as const satisfies Rule;
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of the pi-steering work-item-plugin example.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Integration tests for `push-requires-tests`.
|
|
6
|
+
*
|
|
7
|
+
* Exercises the observer → rule handoff end-to-end via `loadHarness`:
|
|
8
|
+
*
|
|
9
|
+
* 1. Push without a prior TEST_PASSED_EVENT entry → block.
|
|
10
|
+
* 2. Dispatch an `npm test` success event through the harness →
|
|
11
|
+
* the observer writes TEST_PASSED_EVENT into the session entries.
|
|
12
|
+
* 3. Push in the same agent loop → allow.
|
|
13
|
+
* 4. Dispatch a `git pull` → observer writes RETEST_REQUIRED_EVENT,
|
|
14
|
+
* stale-ing the test entry. Push now blocks again (PR §4 `since`).
|
|
15
|
+
* 5. Chain-aware: `npm test && git push` allowed pre-execution
|
|
16
|
+
* because the prior `&&` ref matches the npm-test observer (PR §4).
|
|
17
|
+
*
|
|
18
|
+
* Step (2) uses `harness.dispatch` — the same call production uses on
|
|
19
|
+
* `tool_result`. `createRecordingHost` + `mockExtensionContext` share
|
|
20
|
+
* a single entries array so writes via `harness.dispatch` flow back
|
|
21
|
+
* into `harness.evaluate`'s view of the session.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import assert from "node:assert/strict";
|
|
25
|
+
import { describe, it } from "node:test";
|
|
26
|
+
import type { Plugin } from "@cad0p/pi-steering";
|
|
27
|
+
import {
|
|
28
|
+
createRecordingHost,
|
|
29
|
+
expectAllows,
|
|
30
|
+
expectBlocks,
|
|
31
|
+
loadHarness,
|
|
32
|
+
mockExtensionContext,
|
|
33
|
+
} from "@cad0p/pi-steering/testing";
|
|
34
|
+
import {
|
|
35
|
+
npmTestTracker,
|
|
36
|
+
TEST_PASSED_EVENT,
|
|
37
|
+
} from "../observers/npm-test-tracker.ts";
|
|
38
|
+
import {
|
|
39
|
+
RETEST_REQUIRED_EVENT,
|
|
40
|
+
retestRequiredTracker,
|
|
41
|
+
} from "../observers/retest-required-tracker.ts";
|
|
42
|
+
import { pushRequiresTests } from "./push-requires-tests.ts";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Plugin bundling the two observers this rule interacts with. Keeps
|
|
46
|
+
* the test config focused on what's under exercise — no unrelated
|
|
47
|
+
* rules from the work-item plugin.
|
|
48
|
+
*/
|
|
49
|
+
const testPlugin: Plugin = {
|
|
50
|
+
name: "test",
|
|
51
|
+
observers: [npmTestTracker, retestRequiredTracker],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
describe("push-requires-tests", () => {
|
|
55
|
+
it("blocks git push when no TEST_PASSED entry exists", async () => {
|
|
56
|
+
const harness = loadHarness({
|
|
57
|
+
config: {
|
|
58
|
+
plugins: [testPlugin],
|
|
59
|
+
rules: [pushRequiresTests],
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
await expectBlocks(
|
|
63
|
+
harness,
|
|
64
|
+
{ command: "git push origin feat/x" },
|
|
65
|
+
{ rule: "push-requires-tests" },
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("does NOT fire on unrelated commands", async () => {
|
|
70
|
+
const harness = loadHarness({
|
|
71
|
+
config: {
|
|
72
|
+
plugins: [testPlugin],
|
|
73
|
+
rules: [pushRequiresTests],
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
await expectAllows(harness, { command: "git status" });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("allows git push after observer records a TEST_PASSED entry", async () => {
|
|
80
|
+
// Recording host + shared ExtensionContext: writes via
|
|
81
|
+
// host.appendEntry (from the dispatcher) flow into
|
|
82
|
+
// ctx.sessionManager.getEntries so the later evaluate() sees
|
|
83
|
+
// the TEST_PASSED entry the observer recorded. Mirrors the
|
|
84
|
+
// bridge the real pi runtime builds in `src/index.ts`.
|
|
85
|
+
const host = createRecordingHost();
|
|
86
|
+
const ctx = mockExtensionContext("/tmp/test", host.entries);
|
|
87
|
+
|
|
88
|
+
const harness = loadHarness({
|
|
89
|
+
config: {
|
|
90
|
+
plugins: [testPlugin],
|
|
91
|
+
rules: [pushRequiresTests],
|
|
92
|
+
},
|
|
93
|
+
host,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Simulate: pi emits a tool_result for a successful `npm test`.
|
|
97
|
+
await harness.dispatch(
|
|
98
|
+
{
|
|
99
|
+
type: "tool_result",
|
|
100
|
+
toolCallId: "tc1",
|
|
101
|
+
toolName: "bash",
|
|
102
|
+
input: { command: "npm test" },
|
|
103
|
+
content: [],
|
|
104
|
+
details: { exitCode: 0 },
|
|
105
|
+
} as unknown as Parameters<typeof harness.dispatch>[0],
|
|
106
|
+
ctx,
|
|
107
|
+
0,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// The observer should have written TEST_PASSED_EVENT.
|
|
111
|
+
assert.ok(
|
|
112
|
+
host.entries.some((e) => e.customType === TEST_PASSED_EVENT),
|
|
113
|
+
"observer did not record TEST_PASSED_EVENT",
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Now the push should be allowed — the rule's
|
|
117
|
+
// `when.happened` finds the entry in the current agent loop.
|
|
118
|
+
const result = await harness.evaluate(
|
|
119
|
+
{
|
|
120
|
+
type: "tool_call",
|
|
121
|
+
toolCallId: "tc2",
|
|
122
|
+
toolName: "bash",
|
|
123
|
+
input: { command: "git push origin feat/x" },
|
|
124
|
+
} as unknown as Parameters<typeof harness.evaluate>[0],
|
|
125
|
+
ctx,
|
|
126
|
+
0,
|
|
127
|
+
);
|
|
128
|
+
assert.equal(result, undefined);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("re-blocks push after `git pull` stale-s the test entry (happened.since)", async () => {
|
|
132
|
+
// Demonstrates the PR #4 `since` invalidation sentinel. The
|
|
133
|
+
// rule is gated on `TEST_PASSED_EVENT` but `since: RETEST_REQUIRED_EVENT`
|
|
134
|
+
// means a later pull stale-s the test state.
|
|
135
|
+
const host = createRecordingHost();
|
|
136
|
+
const ctx = mockExtensionContext("/tmp/test", host.entries);
|
|
137
|
+
|
|
138
|
+
const harness = loadHarness({
|
|
139
|
+
config: {
|
|
140
|
+
plugins: [testPlugin],
|
|
141
|
+
rules: [pushRequiresTests],
|
|
142
|
+
},
|
|
143
|
+
host,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// 1. Tests pass.
|
|
147
|
+
await harness.dispatch(
|
|
148
|
+
{
|
|
149
|
+
type: "tool_result",
|
|
150
|
+
toolCallId: "tc1",
|
|
151
|
+
toolName: "bash",
|
|
152
|
+
input: { command: "npm test" },
|
|
153
|
+
content: [],
|
|
154
|
+
details: { exitCode: 0 },
|
|
155
|
+
} as unknown as Parameters<typeof harness.dispatch>[0],
|
|
156
|
+
ctx,
|
|
157
|
+
0,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// 2. Pull stale-s the test state.
|
|
161
|
+
await harness.dispatch(
|
|
162
|
+
{
|
|
163
|
+
type: "tool_result",
|
|
164
|
+
toolCallId: "tc2",
|
|
165
|
+
toolName: "bash",
|
|
166
|
+
input: { command: "git pull" },
|
|
167
|
+
content: [],
|
|
168
|
+
details: { exitCode: 0 },
|
|
169
|
+
} as unknown as Parameters<typeof harness.dispatch>[0],
|
|
170
|
+
ctx,
|
|
171
|
+
0,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
assert.ok(
|
|
175
|
+
host.entries.some((e) => e.customType === RETEST_REQUIRED_EVENT),
|
|
176
|
+
"retest observer did not record RETEST_REQUIRED_EVENT",
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
// 3. Push blocked now — the most-recent TEST_PASSED_EVENT is
|
|
180
|
+
// older than the most-recent RETEST_REQUIRED_EVENT.
|
|
181
|
+
const result = await harness.evaluate(
|
|
182
|
+
{
|
|
183
|
+
type: "tool_call",
|
|
184
|
+
toolCallId: "tc3",
|
|
185
|
+
toolName: "bash",
|
|
186
|
+
input: { command: "git push origin feat/x" },
|
|
187
|
+
} as unknown as Parameters<typeof harness.evaluate>[0],
|
|
188
|
+
ctx,
|
|
189
|
+
0,
|
|
190
|
+
);
|
|
191
|
+
assert.ok(result && "block" in result, "post-pull push should block");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("`&&`-chain: `npm test && git push` is allowed pre-execution", async () => {
|
|
195
|
+
// Demonstrates the PR #4 speculative allow. The
|
|
196
|
+
// evaluator runs BEFORE the chain executes, so the observer
|
|
197
|
+
// hasn't written TEST_PASSED_EVENT yet — but because the prior
|
|
198
|
+
// `&&` ref matches the npm-test observer's watch, the engine
|
|
199
|
+
// speculatively allows. Safe: if `npm test` fails, `&&` short-
|
|
200
|
+
// circuits and `git push` never runs.
|
|
201
|
+
const harness = loadHarness({
|
|
202
|
+
config: {
|
|
203
|
+
plugins: [testPlugin],
|
|
204
|
+
rules: [pushRequiresTests],
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
await expectAllows(harness, {
|
|
208
|
+
command: "npm test && git push origin feat/x",
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("`&&`-chain: `git push && npm test` is NOT allowed (push is first)", async () => {
|
|
213
|
+
// Order matters — the speculative allow looks at PRIOR && refs
|
|
214
|
+
// only. Here push is first, so there's no prior observer to
|
|
215
|
+
// cite; the rule fires normally.
|
|
216
|
+
const harness = loadHarness({
|
|
217
|
+
config: {
|
|
218
|
+
plugins: [testPlugin],
|
|
219
|
+
rules: [pushRequiresTests],
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
await expectBlocks(
|
|
223
|
+
harness,
|
|
224
|
+
{ command: "git push origin feat/x && npm test" },
|
|
225
|
+
{ rule: "push-requires-tests" },
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("`&&`-chain: `npm test ; git push` still blocks (`;` does not qualify)", async () => {
|
|
230
|
+
// Only `&&` predecessors qualify for speculative allow. A
|
|
231
|
+
// `;`-joined prior doesn't short-circuit, so granting the
|
|
232
|
+
// allow would be unsafe.
|
|
233
|
+
const harness = loadHarness({
|
|
234
|
+
config: {
|
|
235
|
+
plugins: [testPlugin],
|
|
236
|
+
rules: [pushRequiresTests],
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
await expectBlocks(
|
|
240
|
+
harness,
|
|
241
|
+
{ command: "npm test ; git push origin feat/x" },
|
|
242
|
+
{ rule: "push-requires-tests" },
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
});
|