@erclx/canon 4.15.1 → 4.16.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/claude/.claude-plugin/plugin.json +1 -1
- package/docs/agents/commands.md +1 -1
- package/package.json +1 -1
- package/src/commands/labels.ts +25 -4
- package/src/labels/phase.ts +142 -7
- package/standards/pr.md +1 -0
- package/standards/publish.md +13 -1
package/docs/agents/commands.md
CHANGED
|
@@ -68,7 +68,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
|
|
|
68
68
|
| `canon secrets scan` | Report credential-shaped values in the tree the package ships, keyed on issued values rather than on words (`--json`) |
|
|
69
69
|
| `canon deps audit` | Report published advisories against the resolved dependency set, refusing rather than reporting clean when the index is unreachable (`--json`) |
|
|
70
70
|
| `canon labels audit` | Report the labels a changed set earns from the pull request label map and the paths no row reaches (`--json`) |
|
|
71
|
-
| `canon labels scan` | Fail a pull request whose title or body
|
|
71
|
+
| `canon labels scan` | Fail a pull request whose title or body names the board, by a phase label, a label a code span quotes, or a gitignored record path (`--event`, `--json`) |
|
|
72
72
|
| `canon autoship classify` | Decide whether a changed set needs the review pass, naming the file and the test that decided it (`--json`) |
|
|
73
73
|
| `canon pr key-changes` | Compare the files a pull request body's Key Changes names against its own diff, in both directions (`--body`, `--base`, `--json`) |
|
|
74
74
|
| `canon repo metadata propose` | Compare a description, homepage, and topic set computed from the README and `package.json` against what the remote carries, writing nothing (`--root`, `--json`) |
|
package/package.json
CHANGED
package/src/commands/labels.ts
CHANGED
|
@@ -90,7 +90,7 @@ export function register(program: Command): void {
|
|
|
90
90
|
labels
|
|
91
91
|
.command('scan')
|
|
92
92
|
.description(
|
|
93
|
-
'Fail a pull request whose title or body carries a phase label',
|
|
93
|
+
'Fail a pull request whose title or body carries a phase label or a board identifier',
|
|
94
94
|
)
|
|
95
95
|
.helpOption('-h, --help', 'Show this help message')
|
|
96
96
|
.option(
|
|
@@ -112,10 +112,16 @@ export function register(program: Command): void {
|
|
|
112
112
|
'other pull request may carry neither, so any token found there is a',
|
|
113
113
|
'leaked phase label.',
|
|
114
114
|
'',
|
|
115
|
+
'It reports a board identifier beside that, being text naming the task',
|
|
116
|
+
'board rather than the change: a version token a code span quotes, and a',
|
|
117
|
+
'path under a record root, both of which a reader on the remote holds no',
|
|
118
|
+
'copy of. A path under a tracked folder is left alone, so a rule or a',
|
|
119
|
+
'skill any clone resolves is not reported.',
|
|
120
|
+
'',
|
|
115
121
|
'Exit codes:',
|
|
116
|
-
' 0 no phase label found',
|
|
122
|
+
' 0 no phase label and no board identifier found',
|
|
117
123
|
' 1 refused, with the reason on stderr or in the JSON record',
|
|
118
|
-
' 2 the title or body carries a phase label',
|
|
124
|
+
' 2 the title or body carries a phase label or a board identifier',
|
|
119
125
|
'',
|
|
120
126
|
'Examples:',
|
|
121
127
|
' canon labels scan --event "$GITHUB_EVENT_PATH"',
|
|
@@ -345,6 +351,18 @@ async function runScan(opts: ScanOptions): Promise<number> {
|
|
|
345
351
|
for (const label of result.phaseLabels) logWarn(label)
|
|
346
352
|
}
|
|
347
353
|
|
|
354
|
+
logStep(
|
|
355
|
+
result.boardReferences.length === 0 ? 'Clean' : 'Board identifier found',
|
|
356
|
+
)
|
|
357
|
+
if (result.boardReferences.length === 0) {
|
|
358
|
+
logInfo('no quoted label or record path in the title or body')
|
|
359
|
+
} else {
|
|
360
|
+
logWarn(
|
|
361
|
+
`${plural(result.boardReferences.length, 'board identifier')} in the title or body. Name what a reader on the remote can open, since a record path is gitignored there and a quoted label reads as one only from the board.`,
|
|
362
|
+
)
|
|
363
|
+
for (const reference of result.boardReferences) logWarn(reference)
|
|
364
|
+
}
|
|
365
|
+
|
|
348
366
|
outro()
|
|
349
367
|
|
|
350
368
|
if (emitJson) {
|
|
@@ -353,9 +371,12 @@ async function runScan(opts: ScanOptions): Promise<number> {
|
|
|
353
371
|
cutsRelease: result.cutsRelease,
|
|
354
372
|
phaseLabels: result.phaseLabels,
|
|
355
373
|
semverTags: result.semverTags,
|
|
374
|
+
boardReferences: result.boardReferences,
|
|
356
375
|
})}\n`,
|
|
357
376
|
)
|
|
358
377
|
}
|
|
359
378
|
|
|
360
|
-
return result.phaseLabels.length === 0
|
|
379
|
+
return result.phaseLabels.length === 0 && result.boardReferences.length === 0
|
|
380
|
+
? 0
|
|
381
|
+
: 2
|
|
361
382
|
}
|
package/src/labels/phase.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { linesOutsideFences, maskCodeSpans } from '@/markdown/scan'
|
|
2
|
+
import { RECORD_ENTRIES, RECORD_ROOTS, spell } from '@/record-root'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* The two version namespaces `standards/versioning.md` keeps apart, and why a
|
|
@@ -22,10 +23,81 @@ export interface PhaseScanResult {
|
|
|
22
23
|
readonly cutsRelease: boolean
|
|
23
24
|
readonly phaseLabels: readonly string[]
|
|
24
25
|
readonly semverTags: readonly string[]
|
|
26
|
+
/**
|
|
27
|
+
* Text naming the board rather than the change: a version token a code span
|
|
28
|
+
* quotes, and a path under a record root.
|
|
29
|
+
*
|
|
30
|
+
* It sits beside the two namespaces rather than inside either, because a
|
|
31
|
+
* record path is version-shaped in neither and a quoted token is one the
|
|
32
|
+
* reading below has already declined to sort. Both are one defect at the
|
|
33
|
+
* destination, which is a reader on a remote holding neither the task board
|
|
34
|
+
* nor the gitignored folder a path names.
|
|
35
|
+
*/
|
|
36
|
+
readonly boardReferences: readonly string[]
|
|
25
37
|
}
|
|
26
38
|
|
|
27
39
|
const VERSION_TOKEN = /\bv\d+(?:\.\d+){1,2}\b/g
|
|
28
40
|
|
|
41
|
+
/**
|
|
42
|
+
* A code span holding a version token and nothing else.
|
|
43
|
+
*
|
|
44
|
+
* The closing delimiter refers back to the opening one, so a span opened on two
|
|
45
|
+
* backticks closes on two, which is the rule `maskCodeSpans` reads a span by.
|
|
46
|
+
* Content is the token alone rather than a token found inside longer content,
|
|
47
|
+
* which is the whole of what separates a quoted phase label from `#1208`.
|
|
48
|
+
*/
|
|
49
|
+
const VERSION_SPAN = /(`+)(v\d+(?:\.\d+){1,2})\1/g
|
|
50
|
+
|
|
51
|
+
/** Sentence punctuation a path picks up at the end of a clause. */
|
|
52
|
+
const TRAILING_PUNCTUATION = /[.,;]+$/
|
|
53
|
+
|
|
54
|
+
/** Escapes a literal so it can sit inside a constructed pattern. */
|
|
55
|
+
function escapeLiteral(text: string): string {
|
|
56
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What is ignored under a root beyond the entries the record move relocated.
|
|
61
|
+
*
|
|
62
|
+
* `RECORD_ENTRIES` answers which folders that move carried across, and this
|
|
63
|
+
* check asks which paths a reader on a remote cannot open. The two questions
|
|
64
|
+
* differ by exactly one entry: the worktrees folder is ignored and stays out of
|
|
65
|
+
* that list deliberately, since the harness creates a worktree there and
|
|
66
|
+
* requires its target to sit there, so adding it upstream would tell the
|
|
67
|
+
* migration to relocate a folder the harness pins.
|
|
68
|
+
*
|
|
69
|
+
* It is also the entry a worker announcement names most often, which is what
|
|
70
|
+
* makes the gap a live class rather than a theoretical one.
|
|
71
|
+
*/
|
|
72
|
+
const IGNORED_BEYOND_RECORDS: readonly string[] = ['worktrees']
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A path a reader on a remote cannot open, which is a root plus one of the
|
|
76
|
+
* entries that root ignores rather than the root alone.
|
|
77
|
+
*
|
|
78
|
+
* `.claude/` is tracked and holds `rules`, `skills`, `hooks`, and `context`, so
|
|
79
|
+
* a rule path resolves in any clone and is not a board reference. The scratch
|
|
80
|
+
* folder goes through `spell` because it is the one entry whose name differs by
|
|
81
|
+
* root. Reading the roots and the relocated entries from `src/record-root.ts`
|
|
82
|
+
* is what makes a folder added there matched here without an edit, and the list
|
|
83
|
+
* above is what covers the one thing that module deliberately does not carry.
|
|
84
|
+
*
|
|
85
|
+
* The tail runs to the first whitespace or closing delimiter, so a report names
|
|
86
|
+
* the whole path an author has to remove rather than the prefix that matched.
|
|
87
|
+
*/
|
|
88
|
+
const RECORD_PATH = new RegExp(
|
|
89
|
+
`(?<![\\w./-])(?:${RECORD_ROOTS.map(
|
|
90
|
+
(root) =>
|
|
91
|
+
`${escapeLiteral(root)}/(?:${[
|
|
92
|
+
...RECORD_ENTRIES,
|
|
93
|
+
...IGNORED_BEYOND_RECORDS,
|
|
94
|
+
]
|
|
95
|
+
.map((entry) => escapeLiteral(spell(root, entry)))
|
|
96
|
+
.join('|')})`,
|
|
97
|
+
).join('|')})(?![\\w-])[^\\s\`)\\]]*`,
|
|
98
|
+
'g',
|
|
99
|
+
)
|
|
100
|
+
|
|
29
101
|
/**
|
|
30
102
|
* The head branch release-please opens every release pull request under.
|
|
31
103
|
*
|
|
@@ -53,6 +125,15 @@ function isReleasePullRequest(input: PhaseScanInput): boolean {
|
|
|
53
125
|
)
|
|
54
126
|
}
|
|
55
127
|
|
|
128
|
+
/** Replaces a version span's delimiters with spaces, holding the line's width. */
|
|
129
|
+
function unquoteVersionSpans(line: string): string {
|
|
130
|
+
return line.replace(
|
|
131
|
+
VERSION_SPAN,
|
|
132
|
+
(_span, ticks: string, token: string) =>
|
|
133
|
+
`${' '.repeat(ticks.length)}${token}${' '.repeat(ticks.length)}`,
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
56
137
|
/**
|
|
57
138
|
* Drops a fenced block outright and blanks a code span inside what remains,
|
|
58
139
|
* so a token quoted rather than written is read the way a reader reads it:
|
|
@@ -69,9 +150,34 @@ function isReleasePullRequest(input: PhaseScanInput): boolean {
|
|
|
69
150
|
* compare link's URL, and masking it would empty `semverTags` on the one
|
|
70
151
|
* pull request this check exists to pass, trading the corpus's one code-span
|
|
71
152
|
* leak for a hole in every release.
|
|
153
|
+
*
|
|
154
|
+
* `keepVersionSpans` widens that reading for the board-reference pass alone. A
|
|
155
|
+
* span whose whole content is a version token survives it, which is the shape
|
|
156
|
+
* the leak reached the remote through, and longer content stays blanked, which
|
|
157
|
+
* is what holds `#1208` closed. Two passes over one source rather than one pass
|
|
158
|
+
* sorting its own output, because a token is a board reference by virtue of the
|
|
159
|
+
* span it came out of and nothing downstream of the match can see that.
|
|
72
160
|
*/
|
|
73
|
-
function readable(text: string): string {
|
|
74
|
-
return linesOutsideFences(text)
|
|
161
|
+
function readable(text: string, keepVersionSpans = false): string {
|
|
162
|
+
return linesOutsideFences(text)
|
|
163
|
+
.map((line) =>
|
|
164
|
+
maskCodeSpans(keepVersionSpans ? unquoteVersionSpans(line) : line),
|
|
165
|
+
)
|
|
166
|
+
.join('\n')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function versionTokens(text: string): string[] {
|
|
170
|
+
return [...new Set(text.match(VERSION_TOKEN) ?? [])]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function recordPaths(text: string): string[] {
|
|
174
|
+
return [
|
|
175
|
+
...new Set(
|
|
176
|
+
(text.match(RECORD_PATH) ?? []).map((path) =>
|
|
177
|
+
path.replace(TRAILING_PUNCTUATION, ''),
|
|
178
|
+
),
|
|
179
|
+
),
|
|
180
|
+
]
|
|
75
181
|
}
|
|
76
182
|
|
|
77
183
|
/**
|
|
@@ -83,13 +189,42 @@ function readable(text: string): string {
|
|
|
83
189
|
* legitimately carries, and every other pull request's tokens are read as
|
|
84
190
|
* leaked phase labels, which is what `standards/versioning.md` names the
|
|
85
191
|
* defect this exists to catch.
|
|
192
|
+
*
|
|
193
|
+
* A release pull request reports no board reference either, and the ground is
|
|
194
|
+
* coverage rather than exemption. Release-please generates that body from
|
|
195
|
+
* merged history, and every commit in that history came through a pull request
|
|
196
|
+
* this same check already scanned, so a board reference cannot reach a release
|
|
197
|
+
* body without passing the gate on its own. That its author has nothing to
|
|
198
|
+
* rewrite is true as well and is the weaker half, since it would leave the
|
|
199
|
+
* reference standing and unresolvable.
|
|
86
200
|
*/
|
|
87
201
|
export function scanPhaseLabels(input: PhaseScanInput): PhaseScanResult {
|
|
88
|
-
const
|
|
89
|
-
const tokens =
|
|
202
|
+
const source = `${input.title}\n${input.body}`
|
|
203
|
+
const tokens = versionTokens(readable(source))
|
|
90
204
|
const cutsRelease = isReleasePullRequest(input)
|
|
91
205
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
206
|
+
if (cutsRelease) {
|
|
207
|
+
return {
|
|
208
|
+
cutsRelease,
|
|
209
|
+
phaseLabels: [],
|
|
210
|
+
semverTags: tokens,
|
|
211
|
+
boardReferences: [],
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Dropped where the same token is also written bare, since the phase-label
|
|
216
|
+
// half already names it and reporting it twice asks for one removal twice.
|
|
217
|
+
const quoted = versionTokens(readable(source, true)).filter(
|
|
218
|
+
(token) => !tokens.includes(token),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
cutsRelease,
|
|
223
|
+
phaseLabels: tokens,
|
|
224
|
+
semverTags: [],
|
|
225
|
+
boardReferences: [
|
|
226
|
+
...quoted,
|
|
227
|
+
...recordPaths(linesOutsideFences(source).join('\n')),
|
|
228
|
+
],
|
|
229
|
+
}
|
|
95
230
|
}
|
package/standards/pr.md
CHANGED
|
@@ -14,6 +14,7 @@ Does not govern:
|
|
|
14
14
|
- Commit subject format, which shares the title form: `commit.md`
|
|
15
15
|
- Branch naming: `branch.md`
|
|
16
16
|
- Whether a phase label or a semver tag may appear in a title or body: `versioning.md`
|
|
17
|
+
- Whether a quoted label or a gitignored record path may appear in a title or body, which `canon labels scan` fails on: `publish.md`
|
|
17
18
|
- Voice, rhythm, and sentence construction in pull request prose: the `write-human` skill
|
|
18
19
|
- Punctuation, formatting, and banned words in pull request prose: `markdown.md`
|
|
19
20
|
|
package/standards/publish.md
CHANGED
|
@@ -22,6 +22,8 @@ Wherever text leaves through a channel no automated check covers, the author is
|
|
|
22
22
|
|
|
23
23
|
Run the scan as an explicit step against the finished text. Having read the underlying rules before drafting does not cover it, because the check has to happen after the text exists.
|
|
24
24
|
|
|
25
|
+
Scope every check below by destination. Text published to a remote takes all of them. Text staying in the repository takes the character bans alone, since those hold wherever the text lands, and skips whichever check depends on the reader holding something only this checkout carries. The two checks below that depend on it say so under their own headings.
|
|
26
|
+
|
|
25
27
|
## Banned characters
|
|
26
28
|
|
|
27
29
|
`markdown.md` holds the character bans and the banned words alike. Read it at scan time rather than working the sets from memory, then scan the drafted text and rewrite each occurrence.
|
|
@@ -32,7 +34,17 @@ Restructure the sentence rather than substituting the character. A semicolon swa
|
|
|
32
34
|
|
|
33
35
|
`versioning.md` holds the label rule and the table of surfaces. Read it at scan time rather than working the format from memory.
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
This check is one of the two the destination rule above scopes. The reader inside the repository has the task board and the reader on a remote does not.
|
|
38
|
+
|
|
39
|
+
## Board identifiers
|
|
40
|
+
|
|
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
|
+
|
|
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 a tracked folder there is no hit, since `.claude/rules/core/005-behavior.md` resolves everywhere.
|
|
44
|
+
|
|
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
|
+
|
|
47
|
+
`canon labels scan` runs this check and the phase-label one over a pull request title and body. It reads that pair alone, so every other channel is the author's own scan.
|
|
36
48
|
|
|
37
49
|
## Cross-reference form
|
|
38
50
|
|