@erclx/canon 5.7.0 → 5.9.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/README.md +4 -2
- package/claude/.claude-plugin/plugin.json +2 -2
- package/claude/skills/auto-ship/SKILL.md +2 -0
- package/claude/skills/draft-and-pick/REQUIREMENT.md +2 -1
- package/claude/skills/draft-and-pick/SKILL.md +9 -6
- package/claude/skills/git-followup/SKILL.md +1 -1
- package/claude/skills/git-pr/references/evidence.md +1 -1
- package/claude/skills/git-ship/REQUIREMENT.md +3 -2
- package/claude/skills/git-ship/SKILL.md +25 -2
- package/claude/skills/review-address/SKILL.md +6 -4
- package/claude/skills/review-craft/REQUIREMENT.md +8 -2
- package/claude/skills/review-craft/SKILL.md +63 -7
- package/claude/skills/review-craft/references/adopted.md +49 -0
- package/docs/agents/commands.md +2 -0
- package/docs/agents/hooks.md +56 -0
- package/docs/agents/index.md +1 -0
- package/docs/agents/install-and-sync.md +1 -1
- package/docs/agents/markdown-audit.md +1 -1
- package/docs/agents/pr-evidence.md +33 -17
- package/docs/agents/records-push.md +1 -1
- package/docs/agents/records-validate.md +1 -1
- package/docs/agents/scripting.md +6 -0
- package/docs/agents/tasks-archive.md +4 -2
- package/docs/agents/tasks.md +1 -1
- package/docs/agents/worktrees.md +2 -2
- package/docs/target/scenarios.md +2 -2
- package/package.json +2 -2
- package/src/claude/skills-audit.ts +1 -0
- package/src/cli.ts +2 -0
- package/src/commands/claude.ts +1 -1
- package/src/commands/docs.ts +51 -11
- package/src/commands/hooks.ts +67 -0
- package/src/commands/pr.ts +146 -76
- package/src/commands/standards.ts +33 -2
- package/src/commands/tasks.ts +45 -0
- package/src/commands/tooling.ts +3 -3
- package/src/commands/upgrade.ts +3 -2
- package/src/commands/worktrees.ts +4 -3
- package/src/docs/list.ts +103 -0
- package/src/docs/read.ts +63 -19
- package/src/gov/stacks.ts +1 -1
- package/src/help.ts +2 -0
- package/src/hooks/post-merge.ts +331 -0
- package/src/pr/evidence.ts +21 -0
- package/src/records/validate.ts +1 -38
- package/src/standards/list.ts +33 -0
- package/src/standards/read.ts +13 -0
- package/src/standards/scope.ts +56 -0
- package/src/sync/check.ts +38 -4
- package/src/tasks/archive.ts +168 -3
- package/src/tooling/inject.ts +4 -3
- package/src/tooling/manifest.ts +62 -8
- package/src/tooling/scan.ts +4 -3
- package/src/tooling/stamp.ts +11 -2
- package/src/tooling/subfolder.ts +7 -3
- package/tooling/astro/reference.md +1 -1
- package/tooling/base/configs/scripts/verify.json +3 -0
- package/tooling/base/configs/scripts/verify.sh +1 -110
- package/tooling/base/configs/scripts/verify.ts +378 -0
- package/tooling/base/manifest.toml +1 -0
- package/tooling/base/reference.md +5 -3
- package/tooling/go/configs/scripts/verify.json +10 -0
- package/tooling/go/reference.md +5 -4
- package/tooling/php/configs/scripts/verify.json +7 -0
- package/tooling/php/reference.md +5 -4
- package/tooling/python/configs/scripts/verify.json +4 -0
- package/tooling/python/reference.md +3 -3
- package/tooling/web/configs/scripts/verify.json +3 -0
- package/tooling/web/reference.md +3 -3
- package/scripts/docs/list.sh +0 -184
- package/scripts/lib/frontmatter.sh +0 -30
- package/scripts/standards/list.sh +0 -115
- package/src/commands/pass-through.ts +0 -39
- package/tooling/go/configs/scripts/verify.sh +0 -142
- package/tooling/php/configs/scripts/verify.sh +0 -147
- package/tooling/python/configs/scripts/verify.sh +0 -141
- package/tooling/web/configs/scripts/verify.sh +0 -153
package/src/docs/read.ts
CHANGED
|
@@ -12,10 +12,11 @@ const INDEX_TOPIC = 'index'
|
|
|
12
12
|
* The context folder is read at every surface root, so a checkout the surface
|
|
13
13
|
* move has not reached still resolves its own entries.
|
|
14
14
|
*/
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
export const DOCS_ROOT = 'docs'
|
|
16
|
+
export const CONTEXT_ROOTS: readonly string[] = SURFACE_ROOTS.map((root) =>
|
|
17
|
+
join(root, 'context'),
|
|
18
|
+
)
|
|
19
|
+
const ROOTS: readonly string[] = [DOCS_ROOT, ...CONTEXT_ROOTS]
|
|
19
20
|
|
|
20
21
|
export interface ResolvedTopic {
|
|
21
22
|
readonly path: string
|
|
@@ -96,14 +97,22 @@ function catalogedFolders(dir: string): string[] {
|
|
|
96
97
|
* `resolveTopic` answers for and nothing else.
|
|
97
98
|
*/
|
|
98
99
|
export function listTopics(root: string): string[] {
|
|
99
|
-
const
|
|
100
|
-
const leaves = ROOTS.map((dir) => collectLeaves(root, dir))
|
|
100
|
+
const walked = ROOTS.map((dir) => walkEntries(root, dir))
|
|
101
101
|
|
|
102
|
-
const taken = new Set(
|
|
102
|
+
const taken = new Set(
|
|
103
|
+
walked.flatMap((entries) =>
|
|
104
|
+
entries.filter((entry) => entry.kind !== 'leaf').map((e) => e.name),
|
|
105
|
+
),
|
|
106
|
+
)
|
|
103
107
|
const claimed = new Set<string>()
|
|
104
108
|
|
|
105
|
-
return
|
|
106
|
-
const
|
|
109
|
+
return walked.flatMap((entries) => {
|
|
110
|
+
const named = entries
|
|
111
|
+
.filter((entry) => entry.kind !== 'leaf')
|
|
112
|
+
.map((entry) => entry.name)
|
|
113
|
+
const here = entries
|
|
114
|
+
.filter((entry) => entry.kind === 'leaf')
|
|
115
|
+
.map((entry) => entry.name)
|
|
107
116
|
const reachable = [...new Set(here)].filter(
|
|
108
117
|
(name) =>
|
|
109
118
|
!taken.has(name) &&
|
|
@@ -112,25 +121,60 @@ export function listTopics(root: string): string[] {
|
|
|
112
121
|
)
|
|
113
122
|
for (const name of here) claimed.add(name)
|
|
114
123
|
|
|
115
|
-
return [...
|
|
124
|
+
return [...named, ...reachable].sort()
|
|
116
125
|
})
|
|
117
126
|
}
|
|
118
127
|
|
|
119
|
-
/**
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
128
|
+
/**
|
|
129
|
+
* How an entry is named. A `file` is a sibling `<name>.md`, a `folder` is a
|
|
130
|
+
* split domain named by its folder and carried by its `index.md`, and a `leaf`
|
|
131
|
+
* is a sub-area file one level down, named by its bare filename.
|
|
132
|
+
*/
|
|
133
|
+
export type EntryKind = 'file' | 'folder' | 'leaf'
|
|
123
134
|
|
|
124
|
-
|
|
135
|
+
export interface WalkedEntry {
|
|
136
|
+
readonly name: string
|
|
137
|
+
readonly kind: EntryKind
|
|
138
|
+
/** The path under the project root, which is the catalog's `target`. */
|
|
139
|
+
readonly rel: string
|
|
125
140
|
}
|
|
126
141
|
|
|
127
|
-
/**
|
|
128
|
-
|
|
142
|
+
/**
|
|
143
|
+
* Every entry one root offers, in the three kinds above, with a leaf counted
|
|
144
|
+
* once per occurrence so a name a folder pair both carry stays visible as a
|
|
145
|
+
* collision. A root that is absent offers nothing. This is the one reading of
|
|
146
|
+
* the tree, so `listTopics` and the docs catalog cannot disagree on membership.
|
|
147
|
+
*/
|
|
148
|
+
export function walkEntries(root: string, dir: string): WalkedEntry[] {
|
|
129
149
|
const cwd = join(root, dir)
|
|
150
|
+
if (!existsSync(cwd)) return []
|
|
130
151
|
|
|
131
|
-
|
|
132
|
-
|
|
152
|
+
const files = markdownNames(cwd).map(
|
|
153
|
+
(name): WalkedEntry => ({
|
|
154
|
+
name,
|
|
155
|
+
kind: 'file',
|
|
156
|
+
rel: join(dir, `${name}.md`),
|
|
157
|
+
}),
|
|
133
158
|
)
|
|
159
|
+
const folders = catalogedFolders(cwd)
|
|
160
|
+
const split = folders.map(
|
|
161
|
+
(folder): WalkedEntry => ({
|
|
162
|
+
name: folder,
|
|
163
|
+
kind: 'folder',
|
|
164
|
+
rel: join(dir, folder, `${INDEX_TOPIC}.md`),
|
|
165
|
+
}),
|
|
166
|
+
)
|
|
167
|
+
const leaves = folders.flatMap((folder) =>
|
|
168
|
+
markdownNames(join(cwd, folder)).map(
|
|
169
|
+
(name): WalkedEntry => ({
|
|
170
|
+
name,
|
|
171
|
+
kind: 'leaf',
|
|
172
|
+
rel: join(dir, folder, `${name}.md`),
|
|
173
|
+
}),
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return [...files, ...split, ...leaves]
|
|
134
178
|
}
|
|
135
179
|
|
|
136
180
|
function markdownNames(dir: string): string[] {
|
package/src/gov/stacks.ts
CHANGED
|
@@ -94,7 +94,7 @@ export function expandStackEntry(root: string, entry: string): string[] {
|
|
|
94
94
|
/**
|
|
95
95
|
* Walks `extends` ancestors first, then the stack's own rules, deduped by
|
|
96
96
|
* first appearance. Tooling's `resolveChain` returns full manifests nearest
|
|
97
|
-
* first and carries `skipStack`
|
|
97
|
+
* first and carries `skipStack` restriction, so the two walks stay separate
|
|
98
98
|
* rather than fitting one shape to both.
|
|
99
99
|
*
|
|
100
100
|
* Dedupe runs on expanded names rather than on the entries, so a stack naming
|
package/src/help.ts
CHANGED
|
@@ -56,6 +56,7 @@ export const COMMAND_GROUPS: { title: string; rows: CommandRow[] }[] = [
|
|
|
56
56
|
['records [cmd]', 'Session records (validate, size, push, pull)'],
|
|
57
57
|
['sessions [cmd]', 'Resolve live sessions to worktree and branch'],
|
|
58
58
|
['worktrees [cmd]', 'Reclaim worktrees whose branches merged'],
|
|
59
|
+
['hooks [cmd]', 'Run the steps a git hook drives (post-merge)'],
|
|
59
60
|
['autoship [cmd]', 'Decide whether a changed set needs review'],
|
|
60
61
|
['pr [cmd]', 'Read a pull request (key-changes, head, checks)'],
|
|
61
62
|
['feedback', 'Write toolkit feedback from stdin'],
|
|
@@ -122,6 +123,7 @@ const EXAMPLES = [
|
|
|
122
123
|
'canon sessions list --json',
|
|
123
124
|
'canon worktrees list --json',
|
|
124
125
|
'canon worktrees reclaim --dry-run',
|
|
126
|
+
'canon hooks post-merge',
|
|
125
127
|
'canon secrets scan --json',
|
|
126
128
|
'canon deps audit --json',
|
|
127
129
|
'canon labels audit --json',
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { execa } from 'execa'
|
|
2
|
+
import { gitEnv } from '@/git-env'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One child `canon` call. Each step runs as its own process rather than
|
|
6
|
+
* in-process, so the plugin update after a reinstall runs the binary the
|
|
7
|
+
* upgrade just installed, and every verb keeps its own gates.
|
|
8
|
+
*/
|
|
9
|
+
export interface StepCall {
|
|
10
|
+
readonly args: readonly string[]
|
|
11
|
+
readonly cwd: string
|
|
12
|
+
readonly env: Record<string, string>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface StepResult {
|
|
16
|
+
readonly stdout: string
|
|
17
|
+
readonly exitCode: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type StepRunner = (call: StepCall) => Promise<StepResult>
|
|
21
|
+
|
|
22
|
+
export interface PostMergeInput {
|
|
23
|
+
/** The main worktree root, where the board and the records live. */
|
|
24
|
+
readonly root: string
|
|
25
|
+
/** The worktree the pull happened in, which the reclaim refuses to remove. */
|
|
26
|
+
readonly cwd: string
|
|
27
|
+
readonly subjects: readonly string[]
|
|
28
|
+
/** Read for the `CANON_SKIP_*` switches. */
|
|
29
|
+
readonly env: Readonly<Record<string, string | undefined>>
|
|
30
|
+
readonly run: StepRunner
|
|
31
|
+
readonly write: (text: string) => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The child verb each step runs, in run order. The command's help lists these
|
|
36
|
+
* and the landing page reads that help to say what a merge does, so the
|
|
37
|
+
* figure follows the steps this module actually runs.
|
|
38
|
+
*/
|
|
39
|
+
export const STEP_VERBS = {
|
|
40
|
+
archive: ['tasks', 'archive'],
|
|
41
|
+
push: ['records', 'push'],
|
|
42
|
+
reclaim: ['worktrees', 'reclaim'],
|
|
43
|
+
upgrade: ['upgrade'],
|
|
44
|
+
pluginUpdate: ['claude', 'plugin-update'],
|
|
45
|
+
} as const satisfies Record<string, readonly string[]>
|
|
46
|
+
|
|
47
|
+
type Fields = Record<string, unknown>
|
|
48
|
+
|
|
49
|
+
const GIT_TIMEOUT_MS = 10_000
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Every pull request number the merge brought in. A squash carries `(#NNN)` at
|
|
53
|
+
* the end of its subject, and a release or a merge naming none yields nothing,
|
|
54
|
+
* which is what keeps the archive quiet on runs it has nothing to say about.
|
|
55
|
+
*/
|
|
56
|
+
export function pullRequestNumbers(subjects: readonly string[]): number[] {
|
|
57
|
+
return subjects.flatMap((subject) => {
|
|
58
|
+
const match = /\(#(\d+)\)\s*$/.exec(subject)
|
|
59
|
+
return match ? [Number(match[1])] : []
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Every subject the pull brought in, not just the tip. One pull routinely
|
|
65
|
+
* fast-forwards over several merges, and reading HEAD alone would archive the
|
|
66
|
+
* last one's task and strand the rest. `git pull` sets ORIG_HEAD to the
|
|
67
|
+
* pre-merge tip, so the range is exactly what arrived, and a merge leaving no
|
|
68
|
+
* ORIG_HEAD falls back to the tip.
|
|
69
|
+
*
|
|
70
|
+
* A hook exports `GIT_DIR` and friends, which beat `-C`, so every read goes
|
|
71
|
+
* through `gitEnv()` to answer for the checkout `cwd` names.
|
|
72
|
+
*/
|
|
73
|
+
export async function readMergedSubjects(cwd: string): Promise<string[]> {
|
|
74
|
+
const options = {
|
|
75
|
+
reject: false,
|
|
76
|
+
timeout: GIT_TIMEOUT_MS,
|
|
77
|
+
env: gitEnv(),
|
|
78
|
+
extendEnv: false,
|
|
79
|
+
} as const
|
|
80
|
+
|
|
81
|
+
const origHead = await execa(
|
|
82
|
+
'git',
|
|
83
|
+
['-C', cwd, 'rev-parse', '--verify', '--quiet', 'ORIG_HEAD'],
|
|
84
|
+
options,
|
|
85
|
+
)
|
|
86
|
+
const range = origHead.exitCode === 0 ? ['ORIG_HEAD..HEAD'] : ['-1', 'HEAD']
|
|
87
|
+
const log = await execa(
|
|
88
|
+
'git',
|
|
89
|
+
['-C', cwd, 'log', ...range, '--pretty=%s', '--'],
|
|
90
|
+
options,
|
|
91
|
+
)
|
|
92
|
+
if (log.exitCode !== 0) return []
|
|
93
|
+
|
|
94
|
+
return log.stdout.split('\n').filter((line) => line.length > 0)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const runCanon: StepRunner = async (call) => {
|
|
98
|
+
const result = await execa('canon', [...call.args], {
|
|
99
|
+
cwd: call.cwd,
|
|
100
|
+
env: call.env,
|
|
101
|
+
extendEnv: false,
|
|
102
|
+
reject: false,
|
|
103
|
+
})
|
|
104
|
+
return { stdout: result.stdout, exitCode: result.exitCode ?? 1 }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A child producing no parseable record is an older global binary carrying no
|
|
109
|
+
* such subcommand or flag, which stays quiet until a release lands.
|
|
110
|
+
*/
|
|
111
|
+
function parseRecord(stdout: string): Fields | undefined {
|
|
112
|
+
try {
|
|
113
|
+
const parsed: unknown = JSON.parse(stdout.trim())
|
|
114
|
+
return parsed !== null && typeof parsed === 'object'
|
|
115
|
+
? (parsed as Fields)
|
|
116
|
+
: undefined
|
|
117
|
+
} catch {
|
|
118
|
+
return undefined
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function text(fields: Fields, key: string): string | undefined {
|
|
123
|
+
const value = fields[key]
|
|
124
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function count(fields: Fields, key: string): number {
|
|
128
|
+
const value = fields[key]
|
|
129
|
+
return typeof value === 'number' ? value : 0
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function archiveLines(
|
|
133
|
+
number: number,
|
|
134
|
+
result: StepResult,
|
|
135
|
+
fields: Fields,
|
|
136
|
+
): string | undefined {
|
|
137
|
+
if (result.exitCode === 0) {
|
|
138
|
+
const task = text(fields, 'task')
|
|
139
|
+
if (task === undefined) return undefined
|
|
140
|
+
|
|
141
|
+
// A binary predating the relink pass carries no `relinkFailed`, which
|
|
142
|
+
// reads as empty.
|
|
143
|
+
const failed = Array.isArray(fields.relinkFailed)
|
|
144
|
+
? fields.relinkFailed.flatMap((entry: unknown) =>
|
|
145
|
+
entry !== null &&
|
|
146
|
+
typeof entry === 'object' &&
|
|
147
|
+
typeof (entry as Fields).file === 'string'
|
|
148
|
+
? [(entry as Fields).file as string]
|
|
149
|
+
: [],
|
|
150
|
+
)
|
|
151
|
+
: []
|
|
152
|
+
const relink =
|
|
153
|
+
failed.length > 0
|
|
154
|
+
? `🔗 Links not repointed in: ${failed.join(',')}\n`
|
|
155
|
+
: ''
|
|
156
|
+
return `\n📦 Archived ${task}, closed by #${number}.\n${relink}\n`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// A refusal is ordinary here, since most merges close no task.
|
|
160
|
+
const reason = text(fields, 'reason')
|
|
161
|
+
if (reason === undefined || reason === 'no-match' || reason === 'no-board') {
|
|
162
|
+
return undefined
|
|
163
|
+
}
|
|
164
|
+
return (
|
|
165
|
+
`\n📋 Task not archived for #${number}: ${text(fields, 'message') ?? reason}\n` +
|
|
166
|
+
'Run /task-board to resolve it by hand.\n\n'
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function pushLines(result: StepResult, fields: Fields): string | undefined {
|
|
171
|
+
if (result.exitCode === 0) {
|
|
172
|
+
const changed = count(fields, 'changed')
|
|
173
|
+
return changed > 0
|
|
174
|
+
? `\n🗄️ Backed up ${changed} record path(s).\n\n`
|
|
175
|
+
: undefined
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// No records history on this machine, which is every checkout that never
|
|
179
|
+
// ran the one-time setup.
|
|
180
|
+
const reason = text(fields, 'reason') ?? 'unknown'
|
|
181
|
+
if (reason === 'no-repository') return undefined
|
|
182
|
+
const hint =
|
|
183
|
+
reason === 'unsafe-payload'
|
|
184
|
+
? 'Run canon records push to see the blocked paths.'
|
|
185
|
+
: 'Run canon records push when the remote is reachable.'
|
|
186
|
+
return `\n🗄️ Records not backed up: ${reason}\n${hint}\n\n`
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The exit decides nothing here and the fields decide everything. A run that
|
|
191
|
+
* removes one worktree and fails on another exits 1 with a directory already
|
|
192
|
+
* deleted, so a report keyed on the exit would never name the removal.
|
|
193
|
+
*/
|
|
194
|
+
function reclaimLines(fields: Fields): string | undefined {
|
|
195
|
+
let lines = ''
|
|
196
|
+
const removed = count(fields, 'removed')
|
|
197
|
+
if (removed > 0) {
|
|
198
|
+
lines += `\n🧹 Reclaimed ${removed} worktree(s) whose pull request merged.\n\n`
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// `gh-missing` answers on every merge forever on a machine without `gh`, so
|
|
202
|
+
// the hook header carries that gap rather than a line nobody acts on twice.
|
|
203
|
+
const reason = text(fields, 'reason')
|
|
204
|
+
const failed = count(fields, 'failed')
|
|
205
|
+
if (reason !== undefined && reason !== 'gh-missing') {
|
|
206
|
+
lines +=
|
|
207
|
+
`\n🧹 Worktrees not reclaimed: ${reason}\n` +
|
|
208
|
+
'Run canon worktrees list to read the state by hand.\n\n'
|
|
209
|
+
} else if (failed > 0) {
|
|
210
|
+
lines +=
|
|
211
|
+
`\n🧹 ${failed} worktree(s) could not be removed.\n` +
|
|
212
|
+
'Run canon worktrees reclaim to read which step failed.\n\n'
|
|
213
|
+
}
|
|
214
|
+
return lines.length > 0 ? lines : undefined
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Shared by the binary reinstall and the plugin update. `current` is the
|
|
219
|
+
* ordinary outcome on most merges and stays quiet, and `quiet` names the
|
|
220
|
+
* refusal reasons that describe a permanent condition on the machine. `icon`
|
|
221
|
+
* carries its own trailing spacing, since the two glyphs render at different
|
|
222
|
+
* widths.
|
|
223
|
+
*/
|
|
224
|
+
function moveLines(
|
|
225
|
+
result: StepResult,
|
|
226
|
+
fields: Fields,
|
|
227
|
+
icon: string,
|
|
228
|
+
failure: string,
|
|
229
|
+
quiet: readonly string[],
|
|
230
|
+
): string | undefined {
|
|
231
|
+
const message = text(fields, 'message')
|
|
232
|
+
if (message === undefined) return undefined
|
|
233
|
+
|
|
234
|
+
if (result.exitCode === 0) {
|
|
235
|
+
return fields.state === 'current' ? undefined : `\n${icon}${message}\n\n`
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const reason = text(fields, 'reason')
|
|
239
|
+
if (reason !== undefined && quiet.includes(reason)) return undefined
|
|
240
|
+
return `\n${icon}${failure}: ${message}\n\n`
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function step(
|
|
244
|
+
input: PostMergeInput,
|
|
245
|
+
call: StepCall,
|
|
246
|
+
render: (result: StepResult, fields: Fields) => string | undefined,
|
|
247
|
+
): Promise<void> {
|
|
248
|
+
let result: StepResult
|
|
249
|
+
try {
|
|
250
|
+
result = await input.run(call)
|
|
251
|
+
} catch {
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const fields = parseRecord(result.stdout)
|
|
256
|
+
if (fields === undefined) return
|
|
257
|
+
|
|
258
|
+
const lines = render(result, fields)
|
|
259
|
+
if (lines !== undefined) input.write(lines)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Runs the five post-merge steps in order: archive, records push, reclaim,
|
|
264
|
+
* upgrade, plugin update. The reclaim sits before the upgrade, since the
|
|
265
|
+
* upgrade reinstalls the binary every later child runs under, and the two
|
|
266
|
+
* network-bound moves sit last so a slow registry delays nothing else.
|
|
267
|
+
*
|
|
268
|
+
* Never throws and reports nothing on the ordinary merge, since nobody is
|
|
269
|
+
* watching and a hook failing aborts nothing useful.
|
|
270
|
+
*/
|
|
271
|
+
export async function runPostMerge(input: PostMergeInput): Promise<void> {
|
|
272
|
+
const env = gitEnv()
|
|
273
|
+
const at = (args: string[], extra: Record<string, string> = {}) => ({
|
|
274
|
+
args,
|
|
275
|
+
cwd: input.cwd,
|
|
276
|
+
env: { ...env, ...extra },
|
|
277
|
+
})
|
|
278
|
+
const isSkipped = (name: string) => (input.env[name] ?? '').length > 0
|
|
279
|
+
|
|
280
|
+
for (const number of pullRequestNumbers(input.subjects)) {
|
|
281
|
+
await step(
|
|
282
|
+
input,
|
|
283
|
+
at([
|
|
284
|
+
...STEP_VERBS.archive,
|
|
285
|
+
'--pull-request',
|
|
286
|
+
String(number),
|
|
287
|
+
'--root',
|
|
288
|
+
input.root,
|
|
289
|
+
'--json',
|
|
290
|
+
]),
|
|
291
|
+
(result, fields) => archiveLines(number, result, fields),
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
await step(
|
|
296
|
+
input,
|
|
297
|
+
at([...STEP_VERBS.push, '--root', input.root, '--json']),
|
|
298
|
+
pushLines,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
// No `--root` here, and that is load-bearing. The verb reads its own working
|
|
302
|
+
// directory to refuse the worktree it stands in, so a root would turn the
|
|
303
|
+
// running worktree into an ordinary candidate and let a pull inside a
|
|
304
|
+
// linked worktree delete the ground under itself.
|
|
305
|
+
if (!isSkipped('CANON_SKIP_RECLAIM')) {
|
|
306
|
+
await step(input, at([...STEP_VERBS.reclaim, '--json']), (_, fields) =>
|
|
307
|
+
reclaimLines(fields),
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (!isSkipped('CANON_SKIP_UPGRADE')) {
|
|
312
|
+
await step(
|
|
313
|
+
input,
|
|
314
|
+
at([...STEP_VERBS.upgrade, '--json'], { CANON_NON_INTERACTIVE: '1' }),
|
|
315
|
+
(result, fields) =>
|
|
316
|
+
moveLines(result, fields, '⬆️ ', 'CLI not upgraded', []),
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (!isSkipped('CANON_SKIP_PLUGIN_UPDATE')) {
|
|
321
|
+
await step(
|
|
322
|
+
input,
|
|
323
|
+
at([...STEP_VERBS.pluginUpdate, '--json']),
|
|
324
|
+
(result, fields) =>
|
|
325
|
+
moveLines(result, fields, '🔌 ', 'Plugin not updated', [
|
|
326
|
+
'no-claude',
|
|
327
|
+
'no-plugin',
|
|
328
|
+
]),
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
}
|
package/src/pr/evidence.ts
CHANGED
|
@@ -189,6 +189,19 @@ export function renderEvidenceBody(
|
|
|
189
189
|
].join('\n')
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
/**
|
|
193
|
+
* How many case rows a rendered body carries, read off the same row shape
|
|
194
|
+
* `renderEvidenceBody` writes. A row always ends in a head image, which a
|
|
195
|
+
* checklist line or a preview address never does.
|
|
196
|
+
*/
|
|
197
|
+
export function countEvidenceCases(body: string): number {
|
|
198
|
+
return (
|
|
199
|
+
body.match(
|
|
200
|
+
/^\| .+ \| (?:\*\(new\)\*|!\[\]\([^)]*\)) \| !\[\]\([^)]*\) \|$/gm,
|
|
201
|
+
)?.length ?? 0
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
192
205
|
export interface EvidenceComment {
|
|
193
206
|
readonly url?: string
|
|
194
207
|
readonly body: string
|
|
@@ -219,6 +232,14 @@ export function findEvidenceCommentId(
|
|
|
219
232
|
return undefined
|
|
220
233
|
}
|
|
221
234
|
|
|
235
|
+
/** How many cases the marked comment already shows, zero when none is marked. */
|
|
236
|
+
export function findEvidenceCaseCount(
|
|
237
|
+
comments: readonly EvidenceComment[],
|
|
238
|
+
): number {
|
|
239
|
+
const marked = comments.find((comment) => hasEvidenceMarker(comment.body))
|
|
240
|
+
return marked === undefined ? 0 : countEvidenceCases(marked.body)
|
|
241
|
+
}
|
|
242
|
+
|
|
222
243
|
/**
|
|
223
244
|
* The preview address the marked comment already opens with. A re-render
|
|
224
245
|
* after a later push passes no address of its own, and carrying this one
|
package/src/records/validate.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
recordDir as resolveRecordDir,
|
|
8
8
|
recordDirs as resolveRecordDirs,
|
|
9
9
|
} from '@/record-root'
|
|
10
|
+
import { ATTRIBUTE_MARKER, governedPaths, readScope } from '@/standards/scope'
|
|
10
11
|
import {
|
|
11
12
|
TEACH_GLOSSARY,
|
|
12
13
|
TEACH_MISSION,
|
|
@@ -1044,45 +1045,7 @@ function checkMemoryBody(
|
|
|
1044
1045
|
const STANDARD_INDEX = 'index.md'
|
|
1045
1046
|
const STANDARD_FIELDS = ['title', 'description'] as const
|
|
1046
1047
|
|
|
1047
|
-
const SCOPE_HEADING = /^##[ \t]+Scope[ \t]*$/
|
|
1048
|
-
const ANY_HEADING = /^#{1,6}[ \t]+\S/
|
|
1049
1048
|
const DOES_NOT_GOVERN = 'Does not govern:'
|
|
1050
|
-
const ATTRIBUTE_MARKER = 'attribute standard'
|
|
1051
|
-
const CODE_SPAN = /`([^`]+)`/g
|
|
1052
|
-
|
|
1053
|
-
interface Scope {
|
|
1054
|
-
/** The first non-blank line under the heading, which is the statement. */
|
|
1055
|
-
readonly statement: string
|
|
1056
|
-
readonly lines: readonly string[]
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
export function readScope(text: string): Scope | undefined {
|
|
1060
|
-
const lines = linesOutsideFences(text)
|
|
1061
|
-
const opened = lines.findIndex((line) => SCOPE_HEADING.test(line.trim()))
|
|
1062
|
-
if (opened === -1) return undefined
|
|
1063
|
-
|
|
1064
|
-
const body: string[] = []
|
|
1065
|
-
|
|
1066
|
-
for (const line of lines.slice(opened + 1)) {
|
|
1067
|
-
if (ANY_HEADING.test(line.trim())) break
|
|
1068
|
-
body.push(line)
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
const statement = body.find((line) => line.trim().length > 0)
|
|
1072
|
-
|
|
1073
|
-
return { statement: statement?.trim() ?? '', lines: body }
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
/**
|
|
1077
|
-
* The paths a scope statement declares, read the way `scripts/standards/list.sh`
|
|
1078
|
-
* reads them for the catalog's `appliesTo` field: backticked spans in the first
|
|
1079
|
-
* sentence alone. One sentence read two ways would let a standard pass here
|
|
1080
|
-
* while publishing a different jurisdiction to every consumer of the catalog.
|
|
1081
|
-
*/
|
|
1082
|
-
export function governedPaths(statement: string): string[] {
|
|
1083
|
-
const [sentence] = statement.split('. ')
|
|
1084
|
-
return [...sentence.matchAll(CODE_SPAN)].map((match) => match[1])
|
|
1085
|
-
}
|
|
1086
1049
|
|
|
1087
1050
|
/**
|
|
1088
1051
|
* The words a governed path offers a filename. Each segment gives its own word
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { parseFrontmatter, readField } from '@/indexes/frontmatter'
|
|
3
|
+
import { resolveAllStandards } from '@/standards/read'
|
|
4
|
+
import { appliesTo } from '@/standards/scope'
|
|
5
|
+
|
|
6
|
+
export interface StandardEntry {
|
|
7
|
+
readonly name: string
|
|
8
|
+
readonly description: string
|
|
9
|
+
readonly appliesTo: string[]
|
|
10
|
+
/** The whole file, frontmatter included. */
|
|
11
|
+
readonly content: string
|
|
12
|
+
/** The root-labeled path `canon standards <name>` would read. */
|
|
13
|
+
readonly source: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Lists every standard a resolve reaches, the first root winning, so a project
|
|
18
|
+
* standard shows beside the toolkit's and a local override of a package name
|
|
19
|
+
* shows once, as the local copy.
|
|
20
|
+
*/
|
|
21
|
+
export function listStandardEntries(root: string): StandardEntry[] {
|
|
22
|
+
return resolveAllStandards(root).map(({ name, path, source }) => {
|
|
23
|
+
const content = readFileSync(path, 'utf8')
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
name,
|
|
27
|
+
description: readField(parseFrontmatter(content), 'description') ?? '',
|
|
28
|
+
appliesTo: appliesTo(content),
|
|
29
|
+
content,
|
|
30
|
+
source,
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
}
|
package/src/standards/read.ts
CHANGED
|
@@ -95,6 +95,19 @@ export function listStandards(root: string): string[] {
|
|
|
95
95
|
return [...names].sort()
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Pairs each listed name with the copy a resolve would read, so a catalog shows
|
|
100
|
+
* what `canon standards <name>` prints rather than every file a root holds.
|
|
101
|
+
*/
|
|
102
|
+
export function resolveAllStandards(
|
|
103
|
+
root: string,
|
|
104
|
+
): Array<ResolvedStandard & { readonly name: string }> {
|
|
105
|
+
return listStandards(root).flatMap((name) => {
|
|
106
|
+
const resolved = resolveStandard(root, name)
|
|
107
|
+
return resolved ? [{ name, ...resolved }] : []
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
98
111
|
export function readStandard(standard: ResolvedStandard): string {
|
|
99
112
|
return stripFrontmatter(readFileSync(standard.path, 'utf8'))
|
|
100
113
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { linesOutsideFences } from '@/markdown/scan'
|
|
2
|
+
|
|
3
|
+
const SCOPE_HEADING = /^##[ \t]+Scope[ \t]*$/
|
|
4
|
+
const ANY_HEADING = /^#{1,6}[ \t]+\S/
|
|
5
|
+
const CODE_SPAN = /`([^`]+)`/g
|
|
6
|
+
|
|
7
|
+
/** What an attribute standard says of itself, since it names no path. */
|
|
8
|
+
export const ATTRIBUTE_MARKER = 'attribute standard'
|
|
9
|
+
|
|
10
|
+
export interface Scope {
|
|
11
|
+
/** The first non-blank line under the heading, which is the statement. */
|
|
12
|
+
readonly statement: string
|
|
13
|
+
readonly lines: readonly string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function readScope(text: string): Scope | undefined {
|
|
17
|
+
const lines = linesOutsideFences(text)
|
|
18
|
+
const opened = lines.findIndex((line) => SCOPE_HEADING.test(line.trim()))
|
|
19
|
+
if (opened === -1) return undefined
|
|
20
|
+
|
|
21
|
+
const body: string[] = []
|
|
22
|
+
|
|
23
|
+
for (const line of lines.slice(opened + 1)) {
|
|
24
|
+
if (ANY_HEADING.test(line.trim())) break
|
|
25
|
+
body.push(line)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const statement = body.find((line) => line.trim().length > 0)
|
|
29
|
+
|
|
30
|
+
return { statement: statement?.trim() ?? '', lines: body }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The paths a scope statement declares: backticked spans in the first sentence
|
|
35
|
+
* alone. The catalog's `appliesTo` field and the records gate both read the
|
|
36
|
+
* statement through this, since one sentence read two ways would let a standard
|
|
37
|
+
* pass the gate while publishing a different jurisdiction to every consumer.
|
|
38
|
+
*/
|
|
39
|
+
export function governedPaths(statement: string): string[] {
|
|
40
|
+
const [sentence] = statement.split('. ')
|
|
41
|
+
return [...sentence.matchAll(CODE_SPAN)].map((match) => match[1])
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The jurisdiction a standard publishes. The marker is read only where the
|
|
46
|
+
* first sentence backticks nothing, so a statement naming a path publishes that
|
|
47
|
+
* path however the rest of it describes itself. An empty array means the
|
|
48
|
+
* statement did not parse, which consumers report rather than skip.
|
|
49
|
+
*/
|
|
50
|
+
export function appliesTo(text: string): string[] {
|
|
51
|
+
const statement = readScope(text)?.statement ?? ''
|
|
52
|
+
const paths = governedPaths(statement)
|
|
53
|
+
|
|
54
|
+
if (paths.length === 0 && statement.includes(ATTRIBUTE_MARKER)) return ['*']
|
|
55
|
+
return paths
|
|
56
|
+
}
|