@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,307 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Plugin-shipped rules for the git plugin.
|
|
6
|
+
*
|
|
7
|
+
* Rules here ship as SUGGESTED defaults - the plugin is opt-in (users
|
|
8
|
+
* must explicitly import and list it under `plugins: [...]`), so
|
|
9
|
+
* shipping a curated starter set matches the ADR's "distribution unit
|
|
10
|
+
* for rule packs" framing.
|
|
11
|
+
*
|
|
12
|
+
* Users who want the branch predicate but NOT `no-main-commit` can
|
|
13
|
+
* keep it by disabling the rule selectively:
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* defineConfig({
|
|
17
|
+
* plugins: [gitPlugin],
|
|
18
|
+
* disabledRules: ["no-main-commit"],
|
|
19
|
+
* });
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* Rules ride on the branch predicate registered in `./predicates.ts`
|
|
23
|
+
* and the branch tracker in `./branch-tracker.ts`. The tracker makes
|
|
24
|
+
* the rule bypass-proof against the `git checkout main && git commit`
|
|
25
|
+
* pattern: the walker folds the checkout into the branch seen by the
|
|
26
|
+
* commit, so the rule still fires.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { Rule } from "../../schema.ts";
|
|
30
|
+
import { NO_CHECKOUT_IN_CHAIN } from "./branch-tracker.ts";
|
|
31
|
+
import { walkerString } from "./predicates.ts";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Bash command pattern matching `git commit` (with optional pre-subcommand
|
|
35
|
+
* flag slots like `git -C /path commit ...`). Shared by `no-main-commit`
|
|
36
|
+
* and `no-main-commit-github` so the family stays byte-equal as the
|
|
37
|
+
* regex evolves; reorderings that touch one rule's pattern can't
|
|
38
|
+
* silently drift from the other.
|
|
39
|
+
*
|
|
40
|
+
* Exported so tests can pin each rule's `pattern` field against this
|
|
41
|
+
* constant by value (`noMainCommit.pattern === GIT_COMMIT_PATTERN`).
|
|
42
|
+
* That catches accidental divergence between the two rules' patterns
|
|
43
|
+
* (e.g. one drops a `\b`, the other doesn't) and removal/rename of
|
|
44
|
+
* the constant itself. It does NOT catch a future inlining of the
|
|
45
|
+
* literal at a rule's definition site with the SAME bytes — string
|
|
46
|
+
* primitives compare by value, so byte-equal copy-pasted literals
|
|
47
|
+
* pass `===`. Plugin authors who need true shared-reference pinning
|
|
48
|
+
* should use a `RegExp` (object) constant instead of a string source.
|
|
49
|
+
*/
|
|
50
|
+
export const GIT_COMMIT_PATTERN =
|
|
51
|
+
"^git\\b(?:\\s+-{1,2}[A-Za-z]\\S*(?:\\s+\\S+)?)*\\s+commit\\b";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Protected branch names that the gitPlugin's commit-on-main rules
|
|
55
|
+
* block by default (`main`, `master`, `mainline`, `trunk`). Shared by
|
|
56
|
+
* `no-main-commit` and `no-main-commit-github` so the protected-
|
|
57
|
+
* branch list stays uniform across the rule family — adding an alias
|
|
58
|
+
* here (e.g. a vendor-specific default-branch name) automatically
|
|
59
|
+
* propagates to both rules.
|
|
60
|
+
*
|
|
61
|
+
* `RegExp` (object) constant rather than a string source: that gives
|
|
62
|
+
* true shared-reference pinning at the test layer
|
|
63
|
+
* (`noMainCommit.when.branch === PROTECTED_BRANCH_PATTERN`), which
|
|
64
|
+
* also catches a future inline of the SAME bytes at a rule's
|
|
65
|
+
* definition site — something the string-source `GIT_COMMIT_PATTERN`
|
|
66
|
+
* pin can't do (see its JSDoc for the value-vs-reference tradeoff).
|
|
67
|
+
*/
|
|
68
|
+
export const PROTECTED_BRANCH_PATTERN = /^(main|master|mainline|trunk)$/;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `no-main-commit` - block direct commits to a protected branch
|
|
72
|
+
* (main / master / mainline / trunk).
|
|
73
|
+
*
|
|
74
|
+
* Fires on:
|
|
75
|
+
* - `git commit -m "..."` when the current branch is one of the
|
|
76
|
+
* protected names,
|
|
77
|
+
* - `git checkout main && git commit ...` (the branch tracker folds
|
|
78
|
+
* the checkout into the branch state for the commit),
|
|
79
|
+
* - `sh -c 'git commit ...'` (wrapper expansion),
|
|
80
|
+
* - `git -C /other commit ...` where the repo at `/other` is on
|
|
81
|
+
* main (the `branch` predicate queries git at the effective cwd).
|
|
82
|
+
*
|
|
83
|
+
* Does NOT fire on:
|
|
84
|
+
* - `git commit` while on a feature branch,
|
|
85
|
+
* - `git log --grep="commit"` (anchored to `git commit`, not
|
|
86
|
+
* arbitrary git subcommands),
|
|
87
|
+
* - `echo 'git commit -m "x"'` (extraction anchors to the
|
|
88
|
+
* basename).
|
|
89
|
+
*
|
|
90
|
+
* Fail-closed on unresolvable branch: if the branch predicate can't
|
|
91
|
+
* determine the current branch (detached HEAD, not a repo, or the
|
|
92
|
+
* tracker collapsed to `unknown` via `git checkout $VAR`), the rule
|
|
93
|
+
* fires by default. Authors who want the allow-through behavior
|
|
94
|
+
* supply the object form explicitly:
|
|
95
|
+
*
|
|
96
|
+
* `when: { branch: { pattern: /.../, onUnknown: "allow" } }`
|
|
97
|
+
*
|
|
98
|
+
* Reason text is dynamic via {@link ReasonFn}: when the branch
|
|
99
|
+
* tracker has resolved a concrete branch name for the guarded
|
|
100
|
+
* command (statically from a `git checkout <name>` earlier in the
|
|
101
|
+
* chain), the name is injected into the block message so the agent
|
|
102
|
+
* sees "You are on 'main'" instead of a generic reminder. The
|
|
103
|
+
* ReasonFn filters out the tracker's internal sentinels
|
|
104
|
+
* (`NO_CHECKOUT_IN_CHAIN` — no in-chain checkout, exec-fallback
|
|
105
|
+
* path; `"unknown"` — dynamic checkout the walker couldn't
|
|
106
|
+
* resolve) so those strings never leak into the agent-facing
|
|
107
|
+
* message; the static actionable tail still guides the agent to a
|
|
108
|
+
* feature branch in those cases.
|
|
109
|
+
*
|
|
110
|
+
* Pairs with {@link noMainCommitGithub} (specialization for
|
|
111
|
+
* github.com clones, placed BEFORE this rule in the rule array so
|
|
112
|
+
* first-match-wins routes the github-flavored guidance to github
|
|
113
|
+
* users; non-github contexts fall through to this generic rule).
|
|
114
|
+
*
|
|
115
|
+
* Override: allowed (the rule is overridable via a
|
|
116
|
+
* `# steering-override: no-main-commit` comment). This is a workflow
|
|
117
|
+
* rule, not an inherent-destructiveness rule - authors override when
|
|
118
|
+
* the commit is intentional (e.g. release process on `main`).
|
|
119
|
+
*/
|
|
120
|
+
export const noMainCommit = {
|
|
121
|
+
name: "no-main-commit",
|
|
122
|
+
tool: "bash",
|
|
123
|
+
field: "command",
|
|
124
|
+
pattern: GIT_COMMIT_PATTERN,
|
|
125
|
+
when: { branch: PROTECTED_BRANCH_PATTERN },
|
|
126
|
+
reason: (ctx) => {
|
|
127
|
+
// Delegate the sentinel classification to `walkerString` — the
|
|
128
|
+
// same three-way discrimination (value / unknown / missing)
|
|
129
|
+
// every other branch-consumer in this plugin uses. Single source
|
|
130
|
+
// of truth for tracker-sentinel semantics; future sentinel
|
|
131
|
+
// additions update one site (the classifier in predicates.ts),
|
|
132
|
+
// not this filter too. Empty-string remains filtered inline as
|
|
133
|
+
// a defensive check against future tracker contracts (detached
|
|
134
|
+
// HEAD or similar); the branch tracker doesn't emit it today.
|
|
135
|
+
const res = walkerString(ctx, "branch", NO_CHECKOUT_IN_CHAIN);
|
|
136
|
+
const branch =
|
|
137
|
+
res.kind === "value" && res.value !== "" ? res.value : undefined;
|
|
138
|
+
const onClause = branch !== undefined ? ` You are on '${branch}'.` : "";
|
|
139
|
+
return (
|
|
140
|
+
`Don't commit directly to a protected branch ` +
|
|
141
|
+
`(main / master / mainline / trunk).${onClause} ` +
|
|
142
|
+
`Create a feature branch first: \`git checkout -b feat/...\`.`
|
|
143
|
+
);
|
|
144
|
+
},
|
|
145
|
+
// Explicit override-OK: workflow rules are intentionally
|
|
146
|
+
// overridable.
|
|
147
|
+
noOverride: false,
|
|
148
|
+
} as const satisfies Rule;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* `no-main-commit-github` — block direct commits to a protected
|
|
152
|
+
* branch (main / master / mainline / trunk) on github.com clones.
|
|
153
|
+
* Specialization of {@link noMainCommit} that emits PR-flow guidance
|
|
154
|
+
* instead of the generic feature-branch reminder.
|
|
155
|
+
*
|
|
156
|
+
* Pairs with {@link noMainCommit}: this rule is more specific (adds
|
|
157
|
+
* `remote:` check), placed BEFORE `noMainCommit` in the plugin's
|
|
158
|
+
* rule array so first-match-wins routing surfaces the github-
|
|
159
|
+
* flavored reason on github clones. Non-github contexts (Brazil
|
|
160
|
+
* packages, vault paths, /tmp scratch repos with non-github remotes)
|
|
161
|
+
* fall through to the generic `noMainCommit`.
|
|
162
|
+
*
|
|
163
|
+
* Override: allowed (intentionally overridable for legitimate cases
|
|
164
|
+
* like release-process commits to main). User can:
|
|
165
|
+
* - Disable: `disabledRules: ["no-main-commit-github"]`
|
|
166
|
+
* - Per-invocation: `# steering-override: no-main-commit-github` comment
|
|
167
|
+
* - Customize: see gitPlugin's README "Customization" section
|
|
168
|
+
*
|
|
169
|
+
* @remarks Both `https://github.com/...` and `git@github.com:...`
|
|
170
|
+
* clone URLs are matched (the `remote:` regex's `[/:]`
|
|
171
|
+
* character class accepts both the HTTPS path separator
|
|
172
|
+
* and the SSH user-host separator).
|
|
173
|
+
*
|
|
174
|
+
* @remarks `remote:` is configured as `{ pattern, onUnknown: "allow" }`.
|
|
175
|
+
* The `onUnknown: "allow"` argument governs the case where
|
|
176
|
+
* the walker resolved cwd but the inner exec couldn't
|
|
177
|
+
* determine the remote URL — i.e. a known-cwd repo with no
|
|
178
|
+
* `origin` remote configured (fresh-init, repo with
|
|
179
|
+
* `upstream` but no `origin`, or other exec failure). In that
|
|
180
|
+
* case the predicate skips and the engine falls through to
|
|
181
|
+
* the generic `noMainCommit` (correct generic message).
|
|
182
|
+
* The branch predicate stays at default `onUnknown: "block"`
|
|
183
|
+
* (fail-closed) so protected-branch detection isn't weakened.
|
|
184
|
+
*
|
|
185
|
+
* Walker-unknown cwd: the `remote:` leaf surfaces trinary
|
|
186
|
+
* `"unknown"` (via the inline walker-unknown-cwd guard at the top of
|
|
187
|
+
* the handler) before the inner exec runs whenever
|
|
188
|
+
* `walkerState.cwd === "unknown"`. The engine's leaf-level
|
|
189
|
+
* `onUnknown:` policy reads the `"allow"` set on the leaf, projects
|
|
190
|
+
* `"unknown" → false`, and the github rule SKIPS. The engine then
|
|
191
|
+
* falls through to the generic {@link noMainCommit} (only `branch:`
|
|
192
|
+
* leaf), which fires fail-CLOSED and emits the generic
|
|
193
|
+
* protected-branch reason. The github-specific rule deliberately
|
|
194
|
+
* does NOT fire under walker-unknown cwd — a github-flavored reason
|
|
195
|
+
* would overstate what the engine has confirmed.
|
|
196
|
+
*
|
|
197
|
+
* Walker-unknown branch state: when `walkerState.cwd` is known but
|
|
198
|
+
* the branch tracker collapses to its `"unknown"` sentinel (dynamic
|
|
199
|
+
* checkout the walker couldn't resolve), the reason fn routes to a
|
|
200
|
+
* "could not verify the current branch" message rather than
|
|
201
|
+
* asserting a specific protected branch the engine hasn't
|
|
202
|
+
* confirmed.
|
|
203
|
+
*
|
|
204
|
+
* Pattern is shared with `noMainCommit` via the exported
|
|
205
|
+
* {@link GIT_COMMIT_PATTERN} constant so the two rules' bash-
|
|
206
|
+
* command applicability stays byte-equal as the family evolves.
|
|
207
|
+
*
|
|
208
|
+
* @see {@link noMainCommit}
|
|
209
|
+
*/
|
|
210
|
+
export const noMainCommitGithub = {
|
|
211
|
+
name: "no-main-commit-github",
|
|
212
|
+
tool: "bash",
|
|
213
|
+
field: "command",
|
|
214
|
+
pattern: GIT_COMMIT_PATTERN,
|
|
215
|
+
when: {
|
|
216
|
+
branch: PROTECTED_BRANCH_PATTERN,
|
|
217
|
+
// Intentional fail-OPEN — falls through to the generic
|
|
218
|
+
// `noMainCommit` rule when origin can't be resolved.
|
|
219
|
+
//
|
|
220
|
+
// `onUnknown: "allow"` posture: a github-specialized rule
|
|
221
|
+
// should only fire when github context is confirmed. The
|
|
222
|
+
// `"allow"` covers two opt-out paths:
|
|
223
|
+
// 1. Known-cwd no-`origin` case (fresh-init repo, repo with
|
|
224
|
+
// `upstream` but no `origin`, or other exec failure):
|
|
225
|
+
// handler returns `false`, leaf projects to false, github
|
|
226
|
+
// rule skips. Generic `noMainCommit` fires next — user
|
|
227
|
+
// gets the correct generic message rather than PR-flow
|
|
228
|
+
// guidance for a repo where there's no PR to open.
|
|
229
|
+
// 2. Walker-unknown cwd (`cd "$VAR" && git commit`): the
|
|
230
|
+
// handler's inline guard surfaces trinary `"unknown"`,
|
|
231
|
+
// the leaf-level `"allow"` projects to false, github rule
|
|
232
|
+
// skips. Generic `noMainCommit` fires fail-CLOSED via the
|
|
233
|
+
// branch predicate's default `onUnknown: "block"`.
|
|
234
|
+
// Both paths land on the same generic protected-branch reason,
|
|
235
|
+
// avoiding overstated github-specific claims when context is
|
|
236
|
+
// unverified.
|
|
237
|
+
remote: { pattern: /github\.com[/:]/, onUnknown: "allow" },
|
|
238
|
+
},
|
|
239
|
+
reason: (ctx) => {
|
|
240
|
+
// Walker-unknown branch: protected-branch unverified. Don't
|
|
241
|
+
// make a positive claim about which protected branch is
|
|
242
|
+
// involved.
|
|
243
|
+
//
|
|
244
|
+
// (CWD is always known in this rule's reason fn — `remote:`'s
|
|
245
|
+
// `onUnknown: "allow"` projects walker-unknown CWD to false at
|
|
246
|
+
// the leaf level, so the rule skips to the generic
|
|
247
|
+
// `noMainCommit` before this reason fn runs.)
|
|
248
|
+
const branchRes = walkerString(ctx, "branch", NO_CHECKOUT_IN_CHAIN);
|
|
249
|
+
if (branchRes.kind === "unknown") {
|
|
250
|
+
return (
|
|
251
|
+
`Could not verify the current branch — your command used a ` +
|
|
252
|
+
`dynamic checkout target (\`git checkout $VAR\`) that ` +
|
|
253
|
+
`couldn't be statically resolved. If you're on a protected ` +
|
|
254
|
+
`branch (main / master / mainline / trunk) on a github ` +
|
|
255
|
+
`clone, open a PR for review instead of committing directly.` +
|
|
256
|
+
`\n\n` +
|
|
257
|
+
`Safety: NEVER merge a PR or mark it ready-for-review unless ` +
|
|
258
|
+
`the user explicitly asks. Wait for explicit user instruction.`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
// Reuse the same walkerString-based branch interpolation idiom
|
|
262
|
+
// noMainCommit's reason fn uses (single source of truth for
|
|
263
|
+
// tracker-sentinel semantics).
|
|
264
|
+
const branch =
|
|
265
|
+
branchRes.kind === "value" && branchRes.value !== ""
|
|
266
|
+
? branchRes.value
|
|
267
|
+
: undefined;
|
|
268
|
+
const onClause = branch !== undefined ? ` You are on '${branch}'.` : "";
|
|
269
|
+
// Prose says "protected branch" rather than "main branch":
|
|
270
|
+
// the `when:` clause matches all four protected branch names
|
|
271
|
+
// (main / master / mainline / trunk). Mirrors
|
|
272
|
+
// `noMainCommit`'s wording, which lists them.
|
|
273
|
+
return (
|
|
274
|
+
`You're on a github clone's protected branch.${onClause} Open ` +
|
|
275
|
+
`a PR for review; \`gh pr merge\` lands the change after ` +
|
|
276
|
+
`approval. Direct commits to a protected branch bypass review ` +
|
|
277
|
+
`and break PR discipline.` +
|
|
278
|
+
`\n\n` +
|
|
279
|
+
`Safety: NEVER merge a PR or mark it ready-for-review unless ` +
|
|
280
|
+
`the user explicitly asks. Wait for explicit user instruction.`
|
|
281
|
+
);
|
|
282
|
+
},
|
|
283
|
+
// Explicit override-OK: workflow rules are intentionally
|
|
284
|
+
// overridable. Mirrors `noMainCommit`'s posture. Without this
|
|
285
|
+
// field the schema defaults to `defaultNoOverride: true`
|
|
286
|
+
// (fail-closed), making the rule non-overridable and contradicting
|
|
287
|
+
// the JSDoc above.
|
|
288
|
+
noOverride: false,
|
|
289
|
+
} as const satisfies Rule;
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Suggested rules for the git plugin.
|
|
293
|
+
*
|
|
294
|
+
* **Order matters — first-match-wins.** The github-specific rule
|
|
295
|
+
* (`no-main-commit-github`) is placed BEFORE the generic
|
|
296
|
+
* (`no-main-commit`) so on github clones + on main, the github
|
|
297
|
+
* rule's `remote:` predicate matches → fires first → user gets
|
|
298
|
+
* PR-flow guidance. On non-github contexts (Brazil packages, vault
|
|
299
|
+
* paths, scratch repos with non-github remotes) the github rule's
|
|
300
|
+
* `remote:` predicate doesn't match → the engine falls through to
|
|
301
|
+
* the generic `no-main-commit`. Reordering for stylistic reasons
|
|
302
|
+
* breaks this routing; pinned via a unit test in `./rules.test.ts`.
|
|
303
|
+
*/
|
|
304
|
+
export const rules = [
|
|
305
|
+
noMainCommitGithub,
|
|
306
|
+
noMainCommit,
|
|
307
|
+
] as const satisfies readonly Rule[];
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Part of pi-steering.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Runtime shape smoke tests for the v2 schema types.
|
|
6
|
+
*
|
|
7
|
+
* These tests construct object literals and assert their runtime shape —
|
|
8
|
+
* they do NOT currently exercise compile-time type enforcement via
|
|
9
|
+
* `@ts-expect-error` directives. Real type-level regression tests for
|
|
10
|
+
* `defineConfig` inference live in `define-config.test.ts`.
|
|
11
|
+
*
|
|
12
|
+
* The value here is: if the schema types drift incompatibly with their
|
|
13
|
+
* runtime contract (e.g., a field is renamed but the Rule interface
|
|
14
|
+
* isn't updated), these tests fail to compile even without explicit
|
|
15
|
+
* negative assertions. For richer type enforcement, extend with
|
|
16
|
+
* `@ts-expect-error` cases or move to `define-config.test.ts`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import { describe, it } from "node:test";
|
|
21
|
+
import type {
|
|
22
|
+
Observer,
|
|
23
|
+
Plugin,
|
|
24
|
+
PredicateContext,
|
|
25
|
+
PredicateFn,
|
|
26
|
+
PredicateHandler,
|
|
27
|
+
Rule,
|
|
28
|
+
SteeringConfig,
|
|
29
|
+
WhenClause,
|
|
30
|
+
} from "./schema.ts";
|
|
31
|
+
|
|
32
|
+
describe("schema: shape smoke tests", () => {
|
|
33
|
+
it("Rule accepts a minimal bash rule with a string pattern", () => {
|
|
34
|
+
const rule: Rule = {
|
|
35
|
+
name: "test",
|
|
36
|
+
tool: "bash",
|
|
37
|
+
field: "command",
|
|
38
|
+
pattern: "^git push --force",
|
|
39
|
+
reason: "nope",
|
|
40
|
+
};
|
|
41
|
+
assert.equal(rule.name, "test");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("Rule accepts RegExp in pattern / requires / unless", () => {
|
|
45
|
+
const rule: Rule = {
|
|
46
|
+
name: "regex-rule",
|
|
47
|
+
tool: "bash",
|
|
48
|
+
field: "command",
|
|
49
|
+
pattern: /^git push/,
|
|
50
|
+
requires: /--force/,
|
|
51
|
+
unless: /--force-with-lease/,
|
|
52
|
+
reason: "nope",
|
|
53
|
+
};
|
|
54
|
+
assert.ok(rule.pattern instanceof RegExp);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("Rule accepts PredicateFn in requires / unless", () => {
|
|
58
|
+
const always: PredicateFn = async (_ctx) => true;
|
|
59
|
+
const rule: Rule = {
|
|
60
|
+
name: "fn-rule",
|
|
61
|
+
tool: "bash",
|
|
62
|
+
field: "command",
|
|
63
|
+
pattern: "^git",
|
|
64
|
+
requires: always,
|
|
65
|
+
unless: async (ctx) => ctx.tool === "bash",
|
|
66
|
+
reason: "nope",
|
|
67
|
+
};
|
|
68
|
+
assert.equal(typeof rule.requires, "function");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("WhenClause accepts cwd as pattern or object with onUnknown", () => {
|
|
72
|
+
const w1: WhenClause = { cwd: "^/workplace" };
|
|
73
|
+
const w2: WhenClause = { cwd: /^\/workplace/ };
|
|
74
|
+
const w3: WhenClause = {
|
|
75
|
+
cwd: { pattern: /^\/workplace/, onUnknown: "block" },
|
|
76
|
+
};
|
|
77
|
+
assert.ok(w1.cwd !== undefined);
|
|
78
|
+
assert.ok(w2.cwd !== undefined);
|
|
79
|
+
assert.ok(w3.cwd !== undefined);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("WhenClause.condition accepts a PredicateFn", () => {
|
|
83
|
+
const w: WhenClause = {
|
|
84
|
+
condition: (ctx) => ctx.tool === "bash",
|
|
85
|
+
};
|
|
86
|
+
assert.equal(typeof w.condition, "function");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("WhenClause accepts plugin-registered custom keys", () => {
|
|
90
|
+
// Shape a plugin predicate registers. Consumers widen the
|
|
91
|
+
// index signature to carry arbitrary shapes.
|
|
92
|
+
const w: WhenClause = {
|
|
93
|
+
commitsAhead: { wrt: "origin/main", eq: 1 },
|
|
94
|
+
};
|
|
95
|
+
assert.ok("commitsAhead" in w);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("Observer accepts minimal shape (name + onResult)", () => {
|
|
99
|
+
const obs: Observer = {
|
|
100
|
+
name: "spy",
|
|
101
|
+
onResult: (_ev, _ctx) => {},
|
|
102
|
+
};
|
|
103
|
+
assert.equal(obs.name, "spy");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("Observer.watch accepts every documented filter", () => {
|
|
107
|
+
const obs: Observer = {
|
|
108
|
+
name: "filtered",
|
|
109
|
+
watch: {
|
|
110
|
+
toolName: "bash",
|
|
111
|
+
inputMatches: { command: /^git/ },
|
|
112
|
+
exitCode: "success",
|
|
113
|
+
},
|
|
114
|
+
onResult: () => {},
|
|
115
|
+
};
|
|
116
|
+
assert.equal(obs.watch?.exitCode, "success");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("Plugin accepts every documented extension point", () => {
|
|
120
|
+
const fakeHandler: PredicateHandler<{ wrt: string }> = (args, _ctx) =>
|
|
121
|
+
args.wrt.startsWith("origin/");
|
|
122
|
+
const plugin: Plugin = {
|
|
123
|
+
name: "fake",
|
|
124
|
+
// No cast on the typed `PredicateHandler<{ wrt: string }>` here —
|
|
125
|
+
// Item 2 of PR #5 switched `Plugin.predicates` to
|
|
126
|
+
// `Record<string, AnyPredicateHandler>`, so specifically-typed
|
|
127
|
+
// handlers assign into the registry slot directly.
|
|
128
|
+
predicates: { commitsAhead: fakeHandler },
|
|
129
|
+
rules: [
|
|
130
|
+
{
|
|
131
|
+
name: "r",
|
|
132
|
+
tool: "bash",
|
|
133
|
+
field: "command",
|
|
134
|
+
pattern: /^x/,
|
|
135
|
+
reason: "n",
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
observers: [{ name: "o", onResult: () => {} }],
|
|
139
|
+
// trackers and trackerExtensions left undefined — the
|
|
140
|
+
// schema-level type is just `Record<string, Tracker<unknown>>`,
|
|
141
|
+
// constructing a live tracker here would pull in walker
|
|
142
|
+
// runtime code we don't need for a shape test.
|
|
143
|
+
};
|
|
144
|
+
assert.equal(plugin.name, "fake");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("Plugin.predicates accepts typed PredicateHandler<A> values without a cast", () => {
|
|
148
|
+
// Pins the Item 2 fix: `Plugin.predicates` is
|
|
149
|
+
// `Record<string, AnyPredicateHandler>`, so a specifically-typed
|
|
150
|
+
// `PredicateHandler<FooArgs>` assigns cast-free at the registry
|
|
151
|
+
// slot. If this starts failing to compile, the alias was
|
|
152
|
+
// widened/narrowed incompatibly — every downstream plugin that
|
|
153
|
+
// registers a typed handler will break on the same change.
|
|
154
|
+
interface FooArgs {
|
|
155
|
+
threshold: number;
|
|
156
|
+
}
|
|
157
|
+
const typed: PredicateHandler<FooArgs> = (args) => args.threshold > 0;
|
|
158
|
+
const plugin = {
|
|
159
|
+
name: "typed-predicates",
|
|
160
|
+
predicates: { foo: typed },
|
|
161
|
+
} satisfies Plugin;
|
|
162
|
+
assert.ok(plugin.predicates);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("Rule.observer accepts inline Observer and string reference", () => {
|
|
166
|
+
const inline: Rule = {
|
|
167
|
+
name: "inline-obs",
|
|
168
|
+
tool: "bash",
|
|
169
|
+
field: "command",
|
|
170
|
+
pattern: /./,
|
|
171
|
+
reason: "r",
|
|
172
|
+
observer: { name: "o", onResult: () => {} },
|
|
173
|
+
};
|
|
174
|
+
const byName: Rule<"o"> = {
|
|
175
|
+
name: "by-name",
|
|
176
|
+
tool: "bash",
|
|
177
|
+
field: "command",
|
|
178
|
+
pattern: /./,
|
|
179
|
+
reason: "r",
|
|
180
|
+
observer: "o",
|
|
181
|
+
};
|
|
182
|
+
assert.equal(byName.observer, "o");
|
|
183
|
+
assert.ok(typeof inline.observer === "object");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("Rule accepts writes + onFire for self-marking patterns", () => {
|
|
187
|
+
const rule: Rule = {
|
|
188
|
+
name: "self-marker",
|
|
189
|
+
tool: "bash",
|
|
190
|
+
field: "command",
|
|
191
|
+
pattern: /./,
|
|
192
|
+
reason: "r",
|
|
193
|
+
writes: ["cr-attempted"],
|
|
194
|
+
onFire: (ctx) => {
|
|
195
|
+
ctx.appendEntry("cr-attempted", {});
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
assert.deepEqual(rule.writes, ["cr-attempted"]);
|
|
199
|
+
assert.equal(typeof rule.onFire, "function");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("Observer accepts writes declaration", () => {
|
|
203
|
+
const obs: Observer = {
|
|
204
|
+
name: "tracker",
|
|
205
|
+
writes: ["ws-sync-done", "ws-sync-failed"],
|
|
206
|
+
onResult: () => {},
|
|
207
|
+
};
|
|
208
|
+
assert.deepEqual(obs.writes, ["ws-sync-done", "ws-sync-failed"]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("WhenClause.happened accepts the { event, in } shape", () => {
|
|
212
|
+
const loop: WhenClause = {
|
|
213
|
+
happened: { event: "ws-sync-done", in: "agent_loop" },
|
|
214
|
+
};
|
|
215
|
+
const session: WhenClause = {
|
|
216
|
+
happened: { event: "welcome-shown", in: "session" },
|
|
217
|
+
};
|
|
218
|
+
assert.equal(loop.happened?.event, "ws-sync-done");
|
|
219
|
+
assert.equal(session.happened?.in, "session");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("SteeringConfig accepts every top-level field", () => {
|
|
223
|
+
const cfg: SteeringConfig = {
|
|
224
|
+
defaultNoOverride: true,
|
|
225
|
+
disabledRules: ["no-force-push"],
|
|
226
|
+
disabledPlugins: ["git"],
|
|
227
|
+
disableDefaults: false,
|
|
228
|
+
plugins: [],
|
|
229
|
+
rules: [],
|
|
230
|
+
observers: [],
|
|
231
|
+
};
|
|
232
|
+
assert.equal(cfg.defaultNoOverride, true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("PredicateContext exposes the documented surface", () => {
|
|
236
|
+
// Pure shape test — we never invoke the context.
|
|
237
|
+
const fake: PredicateContext = {
|
|
238
|
+
cwd: "/",
|
|
239
|
+
tool: "bash",
|
|
240
|
+
input: { tool: "bash", command: "echo" },
|
|
241
|
+
agentLoopIndex: 0,
|
|
242
|
+
exec: async () => ({ stdout: "", stderr: "", exitCode: 0 }),
|
|
243
|
+
appendEntry: () => {},
|
|
244
|
+
findEntries: () => [],
|
|
245
|
+
};
|
|
246
|
+
assert.equal(fake.cwd, "/");
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe("package-root exports", () => {
|
|
251
|
+
it("AGENT_LOOP_INDEX_KEY resolves to the on-disk JSONL tag string (C3)", async () => {
|
|
252
|
+
// Pins the public export path. Plugin authors inspecting raw
|
|
253
|
+
// session entries via `findEntries` import the constant instead
|
|
254
|
+
// of hardcoding the string — a future rename would then break at
|
|
255
|
+
// import time, not at runtime.
|
|
256
|
+
const rootExports = await import("./index.ts");
|
|
257
|
+
assert.equal(rootExports.AGENT_LOOP_INDEX_KEY, "_agentLoopIndex");
|
|
258
|
+
});
|
|
259
|
+
});
|