@erclx/canon 4.19.0 → 4.19.2
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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-autoship/SKILL.md +1 -1
- package/claude/skills/git-pr/references/labels.md +1 -1
- package/docs/agents/key-changes.md +3 -1
- package/docs/agents/label-coverage.md +7 -5
- package/docs/agents/test-order.md +7 -5
- package/package.json +1 -1
- package/src/commands/pr.ts +1 -1
- package/src/git-files.ts +15 -11
- package/src/gov/test-order.ts +12 -9
- package/src/labels/audit.ts +6 -2
- package/src/labels/phase.ts +60 -25
- package/standards/publish.md +1 -1
|
@@ -11,7 +11,7 @@ Chain the post-plan pipeline in a single run. Every step has a stop condition. S
|
|
|
11
11
|
## Guards
|
|
12
12
|
|
|
13
13
|
- All `.canon/plans/` and `.canon/review/` reads resolve at the main worktree root, not the current worktree. Resolve that root the way `claude-worktree` does.
|
|
14
|
-
- Derive `<slug>` per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`. This skill takes the stop rather than the `latest` fallback, since it commits and opens a pull request. If empty, stop: `❌ Detached HEAD. Checkout the feature branch first.` Every later step keys its output on
|
|
14
|
+
- Derive `<slug>` per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`. This skill takes the stop rather than the `latest` fallback, since it commits and opens a pull request. If empty, stop: `❌ Detached HEAD. Checkout the feature branch first.` This slug is provisional. It is superseded once `claude-worktree` runs, whether at Step 0 or before this chain began. Every later step keys its output on the slug that run resolves, being the worktree, the review receipt, the branch, and the memory proposal, regardless of which plan Step 1 reads.
|
|
15
15
|
- Resolve `<plan>` in Step 1, ahead of any other read.
|
|
16
16
|
- If the working tree has uncommitted changes unrelated to the plan, stop: `❌ Uncommitted changes outside the plan. Commit or stash before autoshipping.`
|
|
17
17
|
|
|
@@ -61,7 +61,7 @@ What it leaves unmeasured is a prefix reaching no path, so a row left behind by
|
|
|
61
61
|
|
|
62
62
|
## Matching
|
|
63
63
|
|
|
64
|
-
- Take the changed set from `git diff --name-only <base
|
|
64
|
+
- Take the changed set from `git diff --name-only <base>...HEAD`, resolved against the same base as the diff the body is written from. The three dots hold whether `<base>` arrives already resolved to a merge base or as a bare ref name, where the two-dot form is correct only in the first case and hands the branch somebody else's merged paths in the second
|
|
65
65
|
- A path earns a label when the path starts with one of that label's prefixes
|
|
66
66
|
- Collect the distinct labels across the whole set, ordered as the map declares them, so two runs over one branch produce one string
|
|
67
67
|
- Pass the result as a single comma-separated value. An empty result runs no labelling step.
|
|
@@ -18,12 +18,14 @@ The positional is the pull request to read, defaulting to the one open on this b
|
|
|
18
18
|
| Option | Behavior |
|
|
19
19
|
| --------------- | ------------------------------------------------------------------ |
|
|
20
20
|
| `--body <path>` | Read the body from a file, taking the changed set from git instead |
|
|
21
|
-
| `--base <ref>` |
|
|
21
|
+
| `--base <ref>` | Ref the range runs back to when `--body` supplies the body |
|
|
22
22
|
| `--root <path>` | Repository to read, defaulting to the cwd |
|
|
23
23
|
| `--json` | Add a machine-readable record on stdout, keeping the frame |
|
|
24
24
|
|
|
25
25
|
`--body` decides where both halves come from, so a number passed beside it is never read. The body comes off disk and the changed set from the local range, which is the shape a fixture and a body still being drafted both need.
|
|
26
26
|
|
|
27
|
+
`--base` names the far side of that range and never the commit the comparison runs against directly. The reader resolves the merge base between `HEAD` and the ref, so `--base origin/main` measures what the branch wrote rather than what the trunk gained while the branch was open. Passing the trunk as it stands today is therefore safe on a branch of any age, and a ref sharing no history with `HEAD` refuses as `bad-base`.
|
|
28
|
+
|
|
27
29
|
Without `--body` the body, the file list, and the head commit come back from one `gh pr view` call, because the three have to describe the same commit and reading them apart leaves a window where a push between them compares a body against another head's files.
|
|
28
30
|
|
|
29
31
|
That view caps its file list at 100 rows and says nothing about having done so, which was measured against `#1250`: the pull request carries 101 files and the view reports 100. A pull request at the cap therefore takes a second read through the paginated endpoint, and a failure there refuses with `gh-truncated` rather than comparing against a set known to be short. A set silently one file short is the worst input this takes, since the missing file is exactly what a correct bullet would then be accused of inventing.
|
|
@@ -14,11 +14,13 @@ canon labels audit --base origin/main
|
|
|
14
14
|
canon labels audit src/cli.ts docs/index.md --json
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
| Option | Behavior
|
|
18
|
-
| --------------- |
|
|
19
|
-
| `--base <ref>` |
|
|
20
|
-
| `--root <path>` | Repository to read, defaulting to the current directory
|
|
21
|
-
| `--json` | Add a machine-readable record on stdout, keeping the frame
|
|
17
|
+
| Option | Behavior |
|
|
18
|
+
| --------------- | ---------------------------------------------------------- |
|
|
19
|
+
| `--base <ref>` | Ref the range runs back to, defaulting to the trunk |
|
|
20
|
+
| `--root <path>` | Repository to read, defaulting to the current directory |
|
|
21
|
+
| `--json` | Add a machine-readable record on stdout, keeping the frame |
|
|
22
|
+
|
|
23
|
+
`--base` names the far side of the range rather than the commit the diff runs against. The audit resolves the merge base between `HEAD` and the ref before reading anything, so `--base origin/main` on a branch the trunk has moved past still earns labels for the paths that branch wrote. Reading the ref literally is what once handed a long-open branch the labels for somebody else's merge, and it named those paths as uncovered surfaces on top.
|
|
22
24
|
|
|
23
25
|
Positional paths replace the range entirely. A caller holding a changed set already passes it rather than paying for a second git read, and the record then omits `base` to say the range was never resolved.
|
|
24
26
|
|
|
@@ -13,11 +13,11 @@ canon gov test-order --base origin/main --json
|
|
|
13
13
|
canon gov test-order --root ../my-app
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
| Option | Behavior
|
|
17
|
-
| --------------- |
|
|
18
|
-
| `--base <ref>` |
|
|
19
|
-
| `--root <path>` | Repository to read, defaulting to the current directory
|
|
20
|
-
| `--json` | Add a machine-readable record on stdout, keeping the frame
|
|
16
|
+
| Option | Behavior |
|
|
17
|
+
| --------------- | ---------------------------------------------------------- |
|
|
18
|
+
| `--base <ref>` | Ref the range runs back to, defaulting to the trunk |
|
|
19
|
+
| `--root <path>` | Repository to read, defaulting to the current directory |
|
|
20
|
+
| `--json` | Add a machine-readable record on stdout, keeping the frame |
|
|
21
21
|
|
|
22
22
|
Under `--json` the record holds stdout alone and the frame still renders on stderr, refusals included, which is the split `output-shape.md` fixes for every mode. A consumer reading stdout sees pure data, and an operator reading the terminal sees why a run refused rather than a command that appeared to do nothing.
|
|
23
23
|
|
|
@@ -27,6 +27,8 @@ The default range is the branch against the trunk, resolved as the merge base ag
|
|
|
27
27
|
|
|
28
28
|
Reading all history on every run measures work nobody is reviewing and buries the finding that matters under the ones already merged. `--base` widens or narrows it when a reader wants a different window.
|
|
29
29
|
|
|
30
|
+
A ref passed there is resolved the same way, through its merge base with `HEAD`, so it names the far side of the range and not the commit the range starts from. `--base origin/main` therefore reports the same window whether the trunk has moved since the branch left or not. Naming a commit the branch already carries still measures from that commit, since the merge base of `HEAD` and an ancestor is the ancestor, and a ref sharing no history with `HEAD` refuses as `bad-base`.
|
|
31
|
+
|
|
30
32
|
Only history is read. A file sitting in the working tree and in no commit is invisible here, which is the point: the verification run sees a tree at one moment, and the ordering exists nowhere but history.
|
|
31
33
|
|
|
32
34
|
## How a pair is decided
|
package/package.json
CHANGED
package/src/commands/pr.ts
CHANGED
|
@@ -66,7 +66,7 @@ const REFUSALS: Record<Refusal, string> = {
|
|
|
66
66
|
'git could not list this repository, so no path could be judged whole rather than partial.',
|
|
67
67
|
'no-base': 'No base resolves against the trunk. Fetch origin or pass --base.',
|
|
68
68
|
'bad-base':
|
|
69
|
-
'The ref passed to --base resolves to no commit
|
|
69
|
+
'The ref passed to --base shares no history with HEAD here, either because it resolves to no commit or because it sits on an unrelated root. Pass a ref this branch was taken from.',
|
|
70
70
|
'unreadable-changes':
|
|
71
71
|
'git could not list what this branch changed, so the set is unknown.',
|
|
72
72
|
'no-section': `This body carries no ## ${KEY_CHANGES} section, so it claims nothing to compare.`,
|
package/src/git-files.ts
CHANGED
|
@@ -18,24 +18,28 @@ async function git(
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* The far side of a branch range: the
|
|
22
|
-
* against the first trunk this repository carries.
|
|
21
|
+
* The far side of a branch range: the merge base against the ref a caller
|
|
22
|
+
* named, or against the first trunk this repository carries.
|
|
23
23
|
*
|
|
24
|
-
* A named ref
|
|
25
|
-
*
|
|
24
|
+
* A named ref names the far side rather than the comparison point, so it
|
|
25
|
+
* resolves through the merge base exactly as the trunk below does. Reading it
|
|
26
|
+
* literally is what let a trunk moving under an open branch report every path
|
|
27
|
+
* merged in between as one the branch had written. A caller naming an ancestor
|
|
28
|
+
* still gets that commit back, since the merge base of `HEAD` and an ancestor
|
|
29
|
+
* is the ancestor.
|
|
30
|
+
*
|
|
31
|
+
* A named ref that produces no merge base refuses rather than falling back,
|
|
32
|
+
* since measuring the trunk range instead would answer a question nobody
|
|
33
|
+
* asked. That covers a ref resolving to nothing and a ref sharing no history
|
|
34
|
+
* with `HEAD` alike, which no caller can separate.
|
|
26
35
|
*/
|
|
27
36
|
export async function resolveBaseRef(
|
|
28
37
|
root: string,
|
|
29
38
|
ref?: string,
|
|
30
39
|
): Promise<string | undefined> {
|
|
31
40
|
if (ref !== undefined) {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
'--verify',
|
|
35
|
-
'--quiet',
|
|
36
|
-
`${ref}^{commit}`,
|
|
37
|
-
])
|
|
38
|
-
return resolved === undefined || resolved === '' ? undefined : resolved
|
|
41
|
+
const merged = await git(root, ['merge-base', 'HEAD', ref])
|
|
42
|
+
return merged === undefined || merged === '' ? undefined : merged
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
for (const trunk of TRUNK_REFS) {
|
package/src/gov/test-order.ts
CHANGED
|
@@ -335,11 +335,14 @@ export function readTestOrder(
|
|
|
335
335
|
}
|
|
336
336
|
|
|
337
337
|
/**
|
|
338
|
-
* The far side of the range. A ref the caller named
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
338
|
+
* The far side of the range. A ref the caller named resolves through the merge
|
|
339
|
+
* base against `head`, matching the no-ref branch below rather than taking the
|
|
340
|
+
* ref as the comparison point, so a trunk that has moved under the branch does
|
|
341
|
+
* not pull other people's merged commits into the range. A ref producing no
|
|
342
|
+
* merge base has to refuse, since falling back to the trunk there would measure
|
|
343
|
+
* a range nobody asked for. With no ref named, the merge base against the trunk
|
|
344
|
+
* scopes the run to the branch, and a repository carrying no trunk falls back
|
|
345
|
+
* to the root commit rather than refusing.
|
|
343
346
|
*/
|
|
344
347
|
function resolveBase(
|
|
345
348
|
root: string,
|
|
@@ -347,15 +350,15 @@ function resolveBase(
|
|
|
347
350
|
head: string,
|
|
348
351
|
): string | { kind: 'unreadable'; reason: TestOrderRefusal; message: string } {
|
|
349
352
|
if (ref !== undefined) {
|
|
350
|
-
const
|
|
351
|
-
if (
|
|
353
|
+
const merged = git(root, ['merge-base', head, ref])
|
|
354
|
+
if (merged === undefined || merged === '') {
|
|
352
355
|
return {
|
|
353
356
|
kind: 'unreadable',
|
|
354
357
|
reason: 'bad-base',
|
|
355
|
-
message: `Ref ${ref}
|
|
358
|
+
message: `Ref ${ref} shares no history with HEAD in ${root}. Pass a ref this branch was taken from.`,
|
|
356
359
|
}
|
|
357
360
|
}
|
|
358
|
-
return
|
|
361
|
+
return merged
|
|
359
362
|
}
|
|
360
363
|
|
|
361
364
|
for (const trunk of TRUNK_REFS) {
|
package/src/labels/audit.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { type MapRefusal, readLabelMap } from '@/labels/map'
|
|
|
10
10
|
* could not get, which is a broken invocation rather than a project that
|
|
11
11
|
* declared nothing.
|
|
12
12
|
*
|
|
13
|
-
* A named ref
|
|
13
|
+
* A named ref producing no merge base is its own reason. Folding it into
|
|
14
14
|
* `no-base` sends the caller who already passed `--base` a message telling
|
|
15
15
|
* them to pass `--base`.
|
|
16
16
|
*/
|
|
@@ -31,7 +31,11 @@ export type LabelAudit =
|
|
|
31
31
|
| { readonly kind: 'refused'; readonly reason: LabelAuditRefusal }
|
|
32
32
|
|
|
33
33
|
export interface LabelAuditOptions {
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Ref naming the far side of the range, which resolves through its merge
|
|
36
|
+
* base with `HEAD` rather than being compared against literally. Defaults to
|
|
37
|
+
* the trunk.
|
|
38
|
+
*/
|
|
35
39
|
readonly base?: string
|
|
36
40
|
/** A changed set the caller already holds, which skips git entirely. */
|
|
37
41
|
readonly paths?: readonly string[]
|
package/src/labels/phase.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { linesOutsideFences, maskCodeSpans } from '@/markdown/scan'
|
|
2
|
-
import { RECORD_ENTRIES, RECORD_ROOTS
|
|
2
|
+
import { RECORD_ENTRIES, RECORD_ROOTS } from '@/record-root'
|
|
3
|
+
import type { RecordRoot } from '@/record-root'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The two version namespaces `standards/versioning.md` keeps apart, and why a
|
|
@@ -70,13 +71,15 @@ function escapeLiteral(text: string): string {
|
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
/**
|
|
73
|
-
* What is ignored under
|
|
74
|
+
* What is ignored under the tracked root beyond the entries the record move
|
|
75
|
+
* relocated.
|
|
74
76
|
*
|
|
75
77
|
* `RECORD_ENTRIES` answers which folders that move carried across, and this
|
|
76
|
-
* check asks which paths a reader on a remote cannot open
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
78
|
+
* check asks which paths a reader on a remote cannot open under `.claude/`,
|
|
79
|
+
* the one root still read by entry name below. The two questions differ by
|
|
80
|
+
* exactly one entry: the worktrees folder is ignored and stays out of
|
|
81
|
+
* `RECORD_ENTRIES` deliberately, since the harness creates a worktree there
|
|
82
|
+
* and requires its target to sit there, so adding it upstream would tell the
|
|
80
83
|
* migration to relocate a folder the harness pins.
|
|
81
84
|
*
|
|
82
85
|
* It is also the entry a worker announcement names most often, which is what
|
|
@@ -85,29 +88,61 @@ function escapeLiteral(text: string): string {
|
|
|
85
88
|
const IGNORED_BEYOND_RECORDS: readonly string[] = ['worktrees']
|
|
86
89
|
|
|
87
90
|
/**
|
|
88
|
-
*
|
|
89
|
-
* entries
|
|
91
|
+
* Which reading each record root takes: `whole` for a root one `.gitignore`
|
|
92
|
+
* line covers entirely, `entries` for a root kept narrow by name.
|
|
90
93
|
*
|
|
91
|
-
* `.
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
94
|
+
* `.canon/` takes `whole`: the ignore file excludes the folder outright, and
|
|
95
|
+
* git does not descend into an excluded directory, so no entry list is ever
|
|
96
|
+
* wider than the root itself. `.claude/` takes `entries` because it is
|
|
97
|
+
* tracked. A `Record` over `RecordRoot` rather than a filtered list of the
|
|
98
|
+
* roots read one way, so a root added to `RECORD_ROOTS` fails to typecheck
|
|
99
|
+
* here until this map says which reading it takes, rather than falling
|
|
100
|
+
* through a filter into the entry-list branch unnoticed.
|
|
101
|
+
*/
|
|
102
|
+
const ROOT_READING: Record<RecordRoot, 'whole' | 'entries'> = {
|
|
103
|
+
'.canon': 'whole',
|
|
104
|
+
'.claude': 'entries',
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A path a reader on a remote cannot open.
|
|
109
|
+
*
|
|
110
|
+
* The two roots differ by why they are unreadable rather than by which one
|
|
111
|
+
* they are. A root reading `whole` matches on the root alone, since one
|
|
112
|
+
* ignore line covers everything beneath it and no entry list can ever be
|
|
113
|
+
* narrower than that. A root reading `entries` keeps the entry-list reading:
|
|
114
|
+
* `.claude/` is tracked and holds `rules`, `skills`, `hooks`, and `context`,
|
|
115
|
+
* so a rule path resolves in any clone and is not a board reference, and only
|
|
116
|
+
* the entries the record move relocated are unreadable there. Reading the
|
|
117
|
+
* roots and the relocated entries from `src/record-root.ts` is what makes a
|
|
118
|
+
* folder added there matched here without an edit, and the list above is what
|
|
119
|
+
* covers the one thing that module deliberately does not carry.
|
|
120
|
+
*
|
|
121
|
+
* The root-alone branch requires at least one tail character ahead of the
|
|
122
|
+
* shared tail capture, so a root written bare, such as the ignore line naming
|
|
123
|
+
* it as a concept, reports nothing: there is no path there for an author to
|
|
124
|
+
* remove. The entry-list branch instead bounds the entry name on the right
|
|
125
|
+
* with a lookahead, so `plans` does not also match the prefix of a longer
|
|
126
|
+
* word; that lookahead has to sit inside the entry branch rather than after
|
|
127
|
+
* the whole alternation; the character right after a bare root is an ordinary
|
|
128
|
+
* word character, and the same lookahead there would reject every root-alone
|
|
129
|
+
* match.
|
|
97
130
|
*
|
|
98
|
-
* The tail runs to the first whitespace or closing delimiter, so a report
|
|
99
|
-
* the whole path an author has to remove rather than the prefix that
|
|
131
|
+
* The tail runs to the first whitespace or closing delimiter, so a report
|
|
132
|
+
* names the whole path an author has to remove rather than the prefix that
|
|
133
|
+
* matched.
|
|
100
134
|
*/
|
|
101
135
|
const RECORD_PATH = new RegExp(
|
|
102
|
-
`(?<![\\w./-])(?:${RECORD_ROOTS.map(
|
|
103
|
-
|
|
104
|
-
`${escapeLiteral(root)}/(
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
136
|
+
`(?<![\\w./-])(?:${RECORD_ROOTS.map((root) =>
|
|
137
|
+
ROOT_READING[root] === 'whole'
|
|
138
|
+
? `${escapeLiteral(root)}/(?=[^\\s\`)\\]])`
|
|
139
|
+
: `${escapeLiteral(root)}/(?:${[
|
|
140
|
+
...RECORD_ENTRIES,
|
|
141
|
+
...IGNORED_BEYOND_RECORDS,
|
|
142
|
+
]
|
|
143
|
+
.map((entry) => escapeLiteral(entry))
|
|
144
|
+
.join('|')})(?![\\w-])`,
|
|
145
|
+
).join('|')})[^\\s\`)\\]]*`,
|
|
111
146
|
'g',
|
|
112
147
|
)
|
|
113
148
|
|
package/standards/publish.md
CHANGED
|
@@ -40,7 +40,7 @@ This check is one of the two the destination rule above scopes. The reader insid
|
|
|
40
40
|
|
|
41
41
|
A phase label is one way text names the board, and a path under a record root is the other. Both resolve for a reader holding this checkout and neither resolves for anyone else, so this check is the second one the destination rule scopes.
|
|
42
42
|
|
|
43
|
-
Two shapes get past a reader scanning for a bare label. A code span quoting a label is still the label, so read a span whose whole content is one as a hit and leave a longer token inside a span alone, which is a fixture name rather than a reference. The second shape is a path under a record root, gitignored and therefore absent from every clone, so `.canon/review/feedback/` names a folder the remote's reader cannot open. Under
|
|
43
|
+
Two shapes get past a reader scanning for a bare label. A code span quoting a label is still the label, so read a span whose whole content is one as a hit and leave a longer token inside a span alone, which is a fixture name rather than a reference. The second shape is a path under a record root, gitignored and therefore absent from every clone, so `.canon/review/feedback/` names a folder the remote's reader cannot open. Under `.claude/`'s own tracked folders there is no hit, since `.claude/rules/core/005-behavior.md` resolves everywhere. `.canon/` carries no such carve-out: one ignore line covers the root whole, so every path beneath it is a hit regardless of which folder names it.
|
|
44
44
|
|
|
45
45
|
Rewrite a hit to name what the reader can reach rather than deleting it. A row's subject stated plainly replaces its label, and what a record folder holds, said in a sentence, replaces its path.
|
|
46
46
|
|