@erclx/canon 5.12.0 → 5.14.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/claude/skills/api-design/REQUIREMENT.md +1 -1
- package/claude/skills/api-design/SKILL.md +4 -3
- package/claude/skills/api-design/references/adopted.md +1 -1
- package/claude/skills/auto-ship/REQUIREMENT.md +1 -0
- package/claude/skills/auto-ship/SKILL.md +2 -2
- package/claude/skills/build-in-slices/REQUIREMENT.md +45 -0
- package/claude/skills/build-in-slices/SKILL.md +94 -0
- package/claude/skills/build-in-slices/references/adopted.md +41 -0
- package/claude/skills/code-craft/REQUIREMENT.md +48 -0
- package/claude/skills/code-craft/SKILL.md +107 -0
- package/claude/skills/code-craft/references/adopted.md +52 -0
- package/claude/skills/codebase-layout/REQUIREMENT.md +1 -1
- package/claude/skills/deprecation-migration/REQUIREMENT.md +62 -0
- package/claude/skills/deprecation-migration/SKILL.md +115 -0
- package/claude/skills/deprecation-migration/references/adopted.md +50 -0
- package/claude/skills/git-followup/REQUIREMENT.md +1 -0
- package/claude/skills/git-followup/SKILL.md +3 -0
- package/claude/skills/git-pr/REQUIREMENT.md +1 -0
- package/claude/skills/git-pr/SKILL.md +1 -1
- package/claude/skills/git-pr/references/evidence.md +2 -2
- package/claude/skills/git-ship/REQUIREMENT.md +4 -0
- package/claude/skills/git-ship/SKILL.md +13 -2
- package/claude/skills/review-craft/REQUIREMENT.md +3 -0
- package/claude/skills/review-craft/SKILL.md +1 -1
- package/claude/skills/review-craft/references/adopted.md +8 -0
- package/claude/skills/review-craft/references/security.md +5 -0
- package/claude/skills/review-ui/REQUIREMENT.md +6 -2
- package/claude/skills/review-ui/SKILL.md +23 -4
- package/claude/skills/review-ui/references/driving.md +5 -3
- package/claude/skills/review-ui/references/post.md +3 -2
- package/claude/skills/role-worker/REQUIREMENT.md +1 -0
- package/claude/skills/role-worker/SKILL.md +4 -0
- package/claude/skills/test-first/REQUIREMENT.md +3 -0
- package/claude/skills/test-first/SKILL.md +2 -1
- package/docs/agents/commands.md +1 -1
- package/docs/agents/index.md +1 -0
- package/docs/agents/markdown-audit.md +1 -1
- package/docs/agents/pr-evidence.md +5 -0
- package/docs/agents/pr-frames.md +112 -0
- package/docs/agents/pr-preview.md +40 -12
- package/docs/workflow/skill-map.md +15 -12
- package/governance/rules/code/000-code.md +5 -1
- package/package.json +1 -1
- package/src/claude/cases/misc.ts +15 -0
- package/src/claude/skills-audit.ts +3 -0
- package/src/commands/pr.ts +559 -7
- package/src/commands/tooling.ts +39 -16
- package/src/context/architecture.ts +5 -2
- package/src/gate/boundaries.ts +509 -0
- package/src/gate/measures.ts +8 -19
- package/src/gate/stages.ts +12 -28
- package/src/pr/frames.ts +323 -0
- package/src/pr/preview.ts +66 -0
- package/src/tooling/manifest.ts +18 -3
- package/src/tooling/seeds.ts +37 -0
- package/src/tooling/verify-stack.ts +249 -0
- package/tooling/base/seeds/.cspell/tech-stack.txt +2 -0
- package/tooling/claude/manifest.toml +3 -3
- package/tooling/go/manifest.toml +3 -0
- package/tooling/go/reference.md +1 -0
- package/tooling/php/manifest.toml +3 -0
- package/tooling/php/reference.md +1 -0
- package/tooling/python/manifest.toml +3 -0
- package/tooling/python/reference.md +1 -1
- package/tooling/python/seeds/.cspell/tech-stack.txt +0 -1
- package/scripts/core/check-capability-seeding.sh +0 -134
- package/scripts/core/check-ignore-parity.sh +0 -141
- package/scripts/core/check-plugin-boundary.sh +0 -45
- package/scripts/core/check-seed-independence.sh +0 -62
- package/scripts/core/check-skill-paths.sh +0 -24
- package/scripts/core/list-seed-roots.sh +0 -18
- package/scripts/lib/tooling.sh +0 -37
- package/scripts/tooling/verify.sh +0 -184
package/src/gate/stages.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import {
|
|
2
|
+
capabilitySeeding,
|
|
3
|
+
ignoreParity,
|
|
4
|
+
pluginBoundary,
|
|
5
|
+
seedIndependence,
|
|
6
|
+
skillPaths,
|
|
7
|
+
} from '@/gate/boundaries'
|
|
1
8
|
import {
|
|
2
9
|
architectureRecord,
|
|
3
10
|
auditSet,
|
|
@@ -99,7 +106,6 @@ export const TEST_CORPORA_PATTERNS = [
|
|
|
99
106
|
'^standards/markdown\\.md$',
|
|
100
107
|
'^tooling/base/reference\\.md$',
|
|
101
108
|
'^tooling/web/configs/scripts/worktree-port\\.sh$',
|
|
102
|
-
'^scripts/core/check-ignore-parity\\.sh$',
|
|
103
109
|
]
|
|
104
110
|
|
|
105
111
|
const TESTS_SCOPE = new RegExp(
|
|
@@ -277,34 +283,19 @@ export const STAGES: readonly Stage[] = [
|
|
|
277
283
|
// produces either list, so there is nothing to regenerate and diff.
|
|
278
284
|
id: 'ignore-parity',
|
|
279
285
|
label: 'Ignore parity',
|
|
280
|
-
checks: [
|
|
281
|
-
script(
|
|
282
|
-
'check-ignore-parity.sh',
|
|
283
|
-
"The ignore set a target receives disagrees with this repository's own.",
|
|
284
|
-
),
|
|
285
|
-
],
|
|
286
|
+
checks: [{ kind: 'measure', measure: ignoreParity }],
|
|
286
287
|
success: 'Ignore parity clean',
|
|
287
288
|
},
|
|
288
289
|
{
|
|
289
290
|
id: 'skill-paths',
|
|
290
291
|
label: 'Skill paths',
|
|
291
|
-
checks: [
|
|
292
|
-
script(
|
|
293
|
-
'check-skill-paths.sh',
|
|
294
|
-
'Shipped skills reference a repo-local path.',
|
|
295
|
-
),
|
|
296
|
-
],
|
|
292
|
+
checks: [{ kind: 'measure', measure: skillPaths }],
|
|
297
293
|
success: 'Skill paths clean',
|
|
298
294
|
},
|
|
299
295
|
{
|
|
300
296
|
id: 'plugin-boundary',
|
|
301
297
|
label: 'Plugin boundary',
|
|
302
|
-
checks: [
|
|
303
|
-
script(
|
|
304
|
-
'check-plugin-boundary.sh',
|
|
305
|
-
'Plugin ships toolkit-internal content.',
|
|
306
|
-
),
|
|
307
|
-
],
|
|
298
|
+
checks: [{ kind: 'measure', measure: pluginBoundary }],
|
|
308
299
|
success: 'Plugin boundary clean',
|
|
309
300
|
},
|
|
310
301
|
{
|
|
@@ -315,9 +306,7 @@ export const STAGES: readonly Stage[] = [
|
|
|
315
306
|
// propagates into every project scaffolded after it.
|
|
316
307
|
id: 'seed-independence',
|
|
317
308
|
label: 'Seed independence',
|
|
318
|
-
checks: [
|
|
319
|
-
script('check-seed-independence.sh', 'Seed prose cites the toolkit CLI.'),
|
|
320
|
-
],
|
|
309
|
+
checks: [{ kind: 'measure', measure: seedIndependence }],
|
|
321
310
|
success: 'Seed prose cites no toolkit CLI',
|
|
322
311
|
},
|
|
323
312
|
{
|
|
@@ -326,12 +315,7 @@ export const STAGES: readonly Stage[] = [
|
|
|
326
315
|
// recorded reason, per the criterion in canon/context/tooling/seeds.md.
|
|
327
316
|
id: 'capability-seeding',
|
|
328
317
|
label: 'Capability seeding',
|
|
329
|
-
checks: [
|
|
330
|
-
script(
|
|
331
|
-
'check-capability-seeding.sh',
|
|
332
|
-
'A capability reaches one side of the seed or config boundary and not the other.',
|
|
333
|
-
),
|
|
334
|
-
],
|
|
318
|
+
checks: [{ kind: 'measure', measure: capabilitySeeding }],
|
|
335
319
|
success: 'Capability seeding clean',
|
|
336
320
|
},
|
|
337
321
|
{
|
package/src/pr/frames.ts
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
|
|
3
|
+
/** The branch holding review frames. It never merges into a branch anyone checks out. */
|
|
4
|
+
export const FRAMES_BRANCH = 'canon-frames'
|
|
5
|
+
|
|
6
|
+
/** The eight bytes every PNG opens with. A frame that lacks them embeds as a broken image. */
|
|
7
|
+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
|
8
|
+
|
|
9
|
+
const SHORT_HEAD_LENGTH = 7
|
|
10
|
+
|
|
11
|
+
const HEAD_PATTERN = /^[0-9a-f]{7,40}$/
|
|
12
|
+
|
|
13
|
+
/** A compact UTC instant such as `20261002T154450Z`, the form `date -u +%Y%m%dT%H%M%SZ` prints. */
|
|
14
|
+
const PASS_PATTERN = /^\d{8}T\d{6}Z$/
|
|
15
|
+
|
|
16
|
+
/** How many times an add rebuilds on a tip another writer moved. */
|
|
17
|
+
const ADD_ATTEMPTS = 3
|
|
18
|
+
|
|
19
|
+
const FOLDER_PATTERN = /^pr-(\d+)$/
|
|
20
|
+
|
|
21
|
+
const DAY_MS = 86_400_000
|
|
22
|
+
|
|
23
|
+
export interface TreeEntry {
|
|
24
|
+
readonly path: string
|
|
25
|
+
readonly mode: string
|
|
26
|
+
readonly type: 'blob' | 'tree'
|
|
27
|
+
readonly sha: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type Tip =
|
|
31
|
+
| { readonly kind: 'found'; readonly commit: string; readonly tree: string }
|
|
32
|
+
| { readonly kind: 'missing' }
|
|
33
|
+
| { readonly kind: 'unreadable' }
|
|
34
|
+
|
|
35
|
+
/** How a ref write landed. `conflict` means another writer holds the ref the call expected to own. */
|
|
36
|
+
export type RefWrite = 'ok' | 'conflict' | 'failed'
|
|
37
|
+
|
|
38
|
+
export interface PullClosure {
|
|
39
|
+
readonly state: 'open' | 'closed'
|
|
40
|
+
readonly closedAt?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The git data API calls a frame write needs, injected so the logic runs
|
|
45
|
+
* without a network. Every read that fails returns undefined rather than
|
|
46
|
+
* throwing, and each caller decides what an absent answer means.
|
|
47
|
+
*/
|
|
48
|
+
export interface FramesApi {
|
|
49
|
+
readTip(): Promise<Tip>
|
|
50
|
+
listTree(tree: string): Promise<readonly TreeEntry[] | undefined>
|
|
51
|
+
createBlob(base64: string): Promise<string | undefined>
|
|
52
|
+
createTree(
|
|
53
|
+
entries: readonly TreeEntry[],
|
|
54
|
+
base?: string,
|
|
55
|
+
): Promise<string | undefined>
|
|
56
|
+
createCommit(
|
|
57
|
+
message: string,
|
|
58
|
+
tree: string,
|
|
59
|
+
parents: readonly string[],
|
|
60
|
+
): Promise<string | undefined>
|
|
61
|
+
createRef(commit: string): Promise<RefWrite>
|
|
62
|
+
moveRef(commit: string, force: boolean): Promise<RefWrite>
|
|
63
|
+
deleteRef(): Promise<boolean>
|
|
64
|
+
readPull(number: number): Promise<PullClosure | undefined>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type FramesRefusal = 'unreadable-tip' | 'push-failed' | 'ref-conflict'
|
|
68
|
+
|
|
69
|
+
export interface FrameInput {
|
|
70
|
+
readonly number: number
|
|
71
|
+
readonly head: string
|
|
72
|
+
readonly pass: string
|
|
73
|
+
readonly box: number
|
|
74
|
+
readonly bytes: Uint8Array
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type PushResult =
|
|
78
|
+
| {
|
|
79
|
+
readonly kind: 'pushed'
|
|
80
|
+
readonly commit: string
|
|
81
|
+
readonly path: string
|
|
82
|
+
readonly created: boolean
|
|
83
|
+
}
|
|
84
|
+
| { readonly kind: 'refused'; readonly reason: FramesRefusal }
|
|
85
|
+
|
|
86
|
+
export type DropResult =
|
|
87
|
+
| {
|
|
88
|
+
readonly kind: 'dropped'
|
|
89
|
+
readonly removed: readonly number[]
|
|
90
|
+
readonly commit?: string
|
|
91
|
+
readonly deleted?: boolean
|
|
92
|
+
}
|
|
93
|
+
| { readonly kind: 'refused'; readonly reason: FramesRefusal }
|
|
94
|
+
|
|
95
|
+
export type PruneResult =
|
|
96
|
+
| {
|
|
97
|
+
readonly kind: 'dropped'
|
|
98
|
+
readonly removed: readonly number[]
|
|
99
|
+
readonly kept: readonly number[]
|
|
100
|
+
readonly unread: readonly number[]
|
|
101
|
+
readonly commit?: string
|
|
102
|
+
readonly deleted?: boolean
|
|
103
|
+
}
|
|
104
|
+
| { readonly kind: 'refused'; readonly reason: FramesRefusal }
|
|
105
|
+
|
|
106
|
+
/** Whether a value names a commit, so a branch name or a path never becomes a folder. */
|
|
107
|
+
export function isFrameHead(head: string): boolean {
|
|
108
|
+
return HEAD_PATTERN.test(head)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Whether a value is a compact UTC stamp, the only pass segment the path takes. */
|
|
112
|
+
export function isFramePass(pass: string): boolean {
|
|
113
|
+
return PASS_PATTERN.test(pass)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Where a frame lands. The pass segment keeps a second pass at the same head
|
|
118
|
+
* and box from replacing the image an earlier review comment shows, since a
|
|
119
|
+
* branch-path link reads whatever the path holds now.
|
|
120
|
+
*/
|
|
121
|
+
export function framePath(
|
|
122
|
+
number: number,
|
|
123
|
+
head: string,
|
|
124
|
+
pass: string,
|
|
125
|
+
box: number,
|
|
126
|
+
): string {
|
|
127
|
+
return `pr-${number}/${head.slice(0, SHORT_HEAD_LENGTH)}/${pass}/box-${box}.png`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The embed link, by branch path rather than by commit. Every drop rewrites
|
|
132
|
+
* the branch, so a commit-pinned link to another pull request's frame would
|
|
133
|
+
* stop resolving once GitHub collects the commit, while a path link lives
|
|
134
|
+
* exactly as long as its file. The blob form is the one `canon pr evidence`
|
|
135
|
+
* embeds, which a private repository's browser can open where the raw host
|
|
136
|
+
* serves nothing.
|
|
137
|
+
*/
|
|
138
|
+
export function frameLink(repo: string, path: string): string {
|
|
139
|
+
return `https://github.com/${repo}/blob/${FRAMES_BRANCH}/${path}?raw=true`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The frame's bytes, or undefined when the file is absent, empty, or not a PNG. */
|
|
143
|
+
export async function readFrame(path: string): Promise<Uint8Array | undefined> {
|
|
144
|
+
const bytes = await readFile(path).catch(() => undefined)
|
|
145
|
+
if (bytes === undefined || bytes.length <= PNG_SIGNATURE.length) {
|
|
146
|
+
return undefined
|
|
147
|
+
}
|
|
148
|
+
return PNG_SIGNATURE.every((byte, index) => bytes[index] === byte)
|
|
149
|
+
? new Uint8Array(bytes)
|
|
150
|
+
: undefined
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Commits one frame onto the branch, creating the branch with a root
|
|
155
|
+
* commit when it is missing. The ref only fast-forwards here, so a drop or a
|
|
156
|
+
* second add landing between the read and the move fails the move, and the
|
|
157
|
+
* add rebuilds on the tip that writer left.
|
|
158
|
+
*/
|
|
159
|
+
export async function pushFrame(
|
|
160
|
+
api: FramesApi,
|
|
161
|
+
input: FrameInput,
|
|
162
|
+
): Promise<PushResult> {
|
|
163
|
+
const path = framePath(input.number, input.head, input.pass, input.box)
|
|
164
|
+
const blob = await api.createBlob(Buffer.from(input.bytes).toString('base64'))
|
|
165
|
+
if (blob === undefined) return { kind: 'refused', reason: 'push-failed' }
|
|
166
|
+
const entry: TreeEntry = { path, mode: '100644', type: 'blob', sha: blob }
|
|
167
|
+
const message = `chore(frames): add box ${input.box} of #${input.number}`
|
|
168
|
+
|
|
169
|
+
for (let attempt = 0; attempt < ADD_ATTEMPTS; attempt += 1) {
|
|
170
|
+
const tip = await api.readTip()
|
|
171
|
+
if (tip.kind === 'unreadable') {
|
|
172
|
+
return { kind: 'refused', reason: 'unreadable-tip' }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const base = tip.kind === 'found' ? tip.tree : undefined
|
|
176
|
+
const tree = await api.createTree([entry], base)
|
|
177
|
+
if (tree === undefined) return { kind: 'refused', reason: 'push-failed' }
|
|
178
|
+
const parents = tip.kind === 'found' ? [tip.commit] : []
|
|
179
|
+
const commit = await api.createCommit(message, tree, parents)
|
|
180
|
+
if (commit === undefined) return { kind: 'refused', reason: 'push-failed' }
|
|
181
|
+
|
|
182
|
+
const write =
|
|
183
|
+
tip.kind === 'found'
|
|
184
|
+
? await api.moveRef(commit, false)
|
|
185
|
+
: await api.createRef(commit)
|
|
186
|
+
if (write === 'failed') return { kind: 'refused', reason: 'push-failed' }
|
|
187
|
+
if (write === 'ok') {
|
|
188
|
+
return { kind: 'pushed', commit, path, created: tip.kind === 'missing' }
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { kind: 'refused', reason: 'ref-conflict' }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function folderNumber(entry: TreeEntry): number | undefined {
|
|
195
|
+
if (entry.type !== 'tree') return undefined
|
|
196
|
+
const match = FOLDER_PATTERN.exec(entry.path)
|
|
197
|
+
return match?.[1] === undefined ? undefined : Number(match[1])
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function readFolders(
|
|
201
|
+
api: FramesApi,
|
|
202
|
+
): Promise<
|
|
203
|
+
| { readonly kind: 'read'; readonly entries: readonly TreeEntry[] }
|
|
204
|
+
| { readonly kind: 'missing' }
|
|
205
|
+
| { readonly kind: 'refused'; readonly reason: FramesRefusal }
|
|
206
|
+
> {
|
|
207
|
+
const tip = await api.readTip()
|
|
208
|
+
if (tip.kind === 'missing') return { kind: 'missing' }
|
|
209
|
+
if (tip.kind === 'unreadable') {
|
|
210
|
+
return { kind: 'refused', reason: 'unreadable-tip' }
|
|
211
|
+
}
|
|
212
|
+
const entries = await api.listTree(tip.tree)
|
|
213
|
+
if (entries === undefined) {
|
|
214
|
+
return { kind: 'refused', reason: 'unreadable-tip' }
|
|
215
|
+
}
|
|
216
|
+
return { kind: 'read', entries }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Rewrites the branch without the named pull requests' folders, as one
|
|
221
|
+
* root commit the ref is force-moved to. A plain delete commit would
|
|
222
|
+
* keep every dropped image reachable through history. The rewrite builds from
|
|
223
|
+
* the tip it reads, and a branch left empty is deleted rather than holding an
|
|
224
|
+
* empty tree.
|
|
225
|
+
*/
|
|
226
|
+
async function rewriteWithout(
|
|
227
|
+
api: FramesApi,
|
|
228
|
+
entries: readonly TreeEntry[],
|
|
229
|
+
drop: ReadonlySet<number>,
|
|
230
|
+
): Promise<DropResult> {
|
|
231
|
+
const removed = entries
|
|
232
|
+
.map(folderNumber)
|
|
233
|
+
.filter((n): n is number => n !== undefined && drop.has(n))
|
|
234
|
+
.sort((a, b) => a - b)
|
|
235
|
+
if (removed.length === 0) return { kind: 'dropped', removed: [] }
|
|
236
|
+
|
|
237
|
+
const kept = entries.filter((entry) => {
|
|
238
|
+
const n = folderNumber(entry)
|
|
239
|
+
return n === undefined || !drop.has(n)
|
|
240
|
+
})
|
|
241
|
+
if (kept.length === 0) {
|
|
242
|
+
return (await api.deleteRef())
|
|
243
|
+
? { kind: 'dropped', removed, deleted: true }
|
|
244
|
+
: { kind: 'refused', reason: 'push-failed' }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const tree = await api.createTree(kept)
|
|
248
|
+
if (tree === undefined) return { kind: 'refused', reason: 'push-failed' }
|
|
249
|
+
const message = `chore(frames): drop ${removed.map((n) => `#${n}`).join(', ')}`
|
|
250
|
+
const commit = await api.createCommit(message, tree, [])
|
|
251
|
+
if (commit === undefined) return { kind: 'refused', reason: 'push-failed' }
|
|
252
|
+
const write = await api.moveRef(commit, true)
|
|
253
|
+
return write === 'ok'
|
|
254
|
+
? { kind: 'dropped', removed, commit }
|
|
255
|
+
: { kind: 'refused', reason: 'push-failed' }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function dropFrames(
|
|
259
|
+
api: FramesApi,
|
|
260
|
+
numbers: readonly number[],
|
|
261
|
+
): Promise<DropResult> {
|
|
262
|
+
const folders = await readFolders(api)
|
|
263
|
+
if (folders.kind === 'missing') return { kind: 'dropped', removed: [] }
|
|
264
|
+
if (folders.kind === 'refused') return folders
|
|
265
|
+
return rewriteWithout(api, folders.entries, new Set(numbers))
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Whether a pull request's frames are past keeping. Only a pull request
|
|
270
|
+
* reading closed, with a close date strictly older than the window, expires,
|
|
271
|
+
* so a reopened one, an unread one, and one at the boundary all stay.
|
|
272
|
+
*/
|
|
273
|
+
export function isExpired(
|
|
274
|
+
closure: PullClosure | undefined,
|
|
275
|
+
days: number,
|
|
276
|
+
now: Date,
|
|
277
|
+
): boolean {
|
|
278
|
+
if (closure?.state !== 'closed' || closure.closedAt === undefined) {
|
|
279
|
+
return false
|
|
280
|
+
}
|
|
281
|
+
const closed = Date.parse(closure.closedAt)
|
|
282
|
+
if (Number.isNaN(closed)) return false
|
|
283
|
+
return now.getTime() - closed > days * DAY_MS
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Drops every pull request on the branch that closed more than `days` ago, in
|
|
288
|
+
* one rewrite. A pull request whose state could not be read is kept and
|
|
289
|
+
* listed under `unread`, so a failed read never deletes frames.
|
|
290
|
+
*/
|
|
291
|
+
export async function pruneFrames(
|
|
292
|
+
api: FramesApi,
|
|
293
|
+
days: number,
|
|
294
|
+
now: Date,
|
|
295
|
+
): Promise<PruneResult> {
|
|
296
|
+
const folders = await readFolders(api)
|
|
297
|
+
if (folders.kind === 'missing') {
|
|
298
|
+
return { kind: 'dropped', removed: [], kept: [], unread: [] }
|
|
299
|
+
}
|
|
300
|
+
if (folders.kind === 'refused') return folders
|
|
301
|
+
|
|
302
|
+
const numbers = folders.entries
|
|
303
|
+
.map(folderNumber)
|
|
304
|
+
.filter((n): n is number => n !== undefined)
|
|
305
|
+
const closures = await Promise.all(numbers.map((n) => api.readPull(n)))
|
|
306
|
+
const expired = new Set<number>()
|
|
307
|
+
const kept: number[] = []
|
|
308
|
+
const unread: number[] = []
|
|
309
|
+
numbers.forEach((n, index) => {
|
|
310
|
+
const closure = closures[index]
|
|
311
|
+
if (closure === undefined) unread.push(n)
|
|
312
|
+
if (isExpired(closure, days, now)) expired.add(n)
|
|
313
|
+
else kept.push(n)
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
const result = await rewriteWithout(api, folders.entries, expired)
|
|
317
|
+
if (result.kind === 'refused') return result
|
|
318
|
+
return {
|
|
319
|
+
...result,
|
|
320
|
+
kept: kept.sort((a, b) => a - b),
|
|
321
|
+
unread: unread.sort((a, b) => a - b),
|
|
322
|
+
}
|
|
323
|
+
}
|
package/src/pr/preview.ts
CHANGED
|
@@ -95,6 +95,8 @@ export interface RunRow {
|
|
|
95
95
|
readonly databaseId: number
|
|
96
96
|
readonly status: string
|
|
97
97
|
readonly conclusion: string
|
|
98
|
+
readonly headSha: string
|
|
99
|
+
readonly createdAt: string
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
/** The `gh` calls a preview makes, injected so the wait can be driven without a network. */
|
|
@@ -176,3 +178,67 @@ export async function mintPreview(
|
|
|
176
178
|
|
|
177
179
|
return { kind: 'minted', runId, url }
|
|
178
180
|
}
|
|
181
|
+
|
|
182
|
+
export type PreviewHeadReason = 'fresh' | 'stale' | 'building' | 'no-build'
|
|
183
|
+
|
|
184
|
+
export type PreviewHeadReading =
|
|
185
|
+
| {
|
|
186
|
+
readonly reason: PreviewHeadReason
|
|
187
|
+
readonly tip: string
|
|
188
|
+
readonly built?: string
|
|
189
|
+
readonly runId?: number
|
|
190
|
+
}
|
|
191
|
+
| { readonly reason: 'gh-failed' }
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Compares the head the newest successful dispatch built with the branch tip.
|
|
195
|
+
*
|
|
196
|
+
* The run listing is the only record of the built head, since the alias serves
|
|
197
|
+
* the newest successful deployment per branch and names no sha. Runs are
|
|
198
|
+
* ordered by creation rather than list position, so the verdict follows the
|
|
199
|
+
* most recent deploy. A run still going at the tip reads `building` rather
|
|
200
|
+
* than `stale`, so a mint already under way is not reported as a failure.
|
|
201
|
+
*/
|
|
202
|
+
export async function readPreviewHead(
|
|
203
|
+
runner: PreviewRunner,
|
|
204
|
+
target: {
|
|
205
|
+
readonly workflow: string
|
|
206
|
+
readonly branch: string
|
|
207
|
+
readonly tip: string
|
|
208
|
+
},
|
|
209
|
+
): Promise<PreviewHeadReading> {
|
|
210
|
+
const rows = await runner.listRuns(target.workflow, target.branch)
|
|
211
|
+
if (rows === undefined) return { reason: 'gh-failed' }
|
|
212
|
+
|
|
213
|
+
const { tip } = target
|
|
214
|
+
const newest = [...rows].sort((a, b) =>
|
|
215
|
+
b.createdAt.localeCompare(a.createdAt),
|
|
216
|
+
)
|
|
217
|
+
const built = newest.find(
|
|
218
|
+
(row) => row.status === 'completed' && row.conclusion === 'success',
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if (built?.headSha === tip) {
|
|
222
|
+
return {
|
|
223
|
+
reason: 'fresh',
|
|
224
|
+
built: built.headSha,
|
|
225
|
+
tip,
|
|
226
|
+
runId: built.databaseId,
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const pending = newest.find(
|
|
231
|
+
(row) => row.status !== 'completed' && row.headSha === tip,
|
|
232
|
+
)
|
|
233
|
+
if (pending !== undefined) {
|
|
234
|
+
return {
|
|
235
|
+
reason: 'building',
|
|
236
|
+
tip,
|
|
237
|
+
runId: pending.databaseId,
|
|
238
|
+
...(built !== undefined && { built: built.headSha }),
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (built === undefined) return { reason: 'no-build', tip }
|
|
243
|
+
return { reason: 'stale', built: built.headSha, tip, runId: built.databaseId }
|
|
244
|
+
}
|
package/src/tooling/manifest.ts
CHANGED
|
@@ -30,6 +30,14 @@ export interface Manifest {
|
|
|
30
30
|
* gitignore entries.
|
|
31
31
|
*/
|
|
32
32
|
readonly onlyConfigs?: readonly string[]
|
|
33
|
+
/**
|
|
34
|
+
* The command that creates a fresh project for this stack, from `[stack]
|
|
35
|
+
* scaffold`, with `{{name}}` standing for the project folder. Unset on a
|
|
36
|
+
* layer stack, which scaffolds nothing of its own.
|
|
37
|
+
*/
|
|
38
|
+
readonly scaffold?: string
|
|
39
|
+
/** Post-scaffold setup `canon tooling verify` runs ahead of Sync. */
|
|
40
|
+
readonly prepare?: string
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
export interface ChainOptions {
|
|
@@ -103,9 +111,8 @@ export function isStackExcluded(stack: string): boolean {
|
|
|
103
111
|
}
|
|
104
112
|
|
|
105
113
|
/**
|
|
106
|
-
* Lists installable stack names, filtering the
|
|
107
|
-
*
|
|
108
|
-
* managed by `canon claude`, not `canon tooling`.
|
|
114
|
+
* Lists installable stack names, filtering the stacks in `EXCLUDED_STACKS`.
|
|
115
|
+
* Claude is managed by `canon claude`, not `canon tooling`.
|
|
109
116
|
*/
|
|
110
117
|
export function listStacks(root: string): string[] {
|
|
111
118
|
const dir = toolingDir(root)
|
|
@@ -134,6 +141,8 @@ export function loadManifest(
|
|
|
134
141
|
typeof stackTable.extends === 'string' ? stackTable.extends : ''
|
|
135
142
|
const scriptsTable = asTable(parsed.scripts)
|
|
136
143
|
const perRoot = readStrings(asTable(parsed.sync).per_root)
|
|
144
|
+
const scaffold = readString(stackTable.scaffold)
|
|
145
|
+
const prepare = readString(asTable(parsed.verify).prepare)
|
|
137
146
|
const dir = join(toolingDir(root), stack)
|
|
138
147
|
|
|
139
148
|
return {
|
|
@@ -147,6 +156,8 @@ export function loadManifest(
|
|
|
147
156
|
gitignore: readGitignoreSections(asTable(parsed.gitignore)),
|
|
148
157
|
devPackages: readDevPackages(parsed.dependencies),
|
|
149
158
|
...(perRoot.length > 0 ? { perRoot } : {}),
|
|
159
|
+
...(scaffold !== undefined ? { scaffold } : {}),
|
|
160
|
+
...(prepare !== undefined ? { prepare } : {}),
|
|
150
161
|
}
|
|
151
162
|
}
|
|
152
163
|
|
|
@@ -231,6 +242,10 @@ function readDevPackages(dependencies: unknown): string[] {
|
|
|
231
242
|
return readStrings(asTable(asTable(dependencies).dev).packages)
|
|
232
243
|
}
|
|
233
244
|
|
|
245
|
+
function readString(value: unknown): string | undefined {
|
|
246
|
+
return typeof value === 'string' && value !== '' ? value : undefined
|
|
247
|
+
}
|
|
248
|
+
|
|
234
249
|
function readStrings(value: unknown): string[] {
|
|
235
250
|
if (!Array.isArray(value)) return []
|
|
236
251
|
return value.filter(
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const TOOLING = 'tooling'
|
|
5
|
+
const SURFACE_ROOTS = ['.claude', 'canon']
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Every `tooling/<stack>/seeds` holding a `.claude/` or a `canon/`, sorted and
|
|
9
|
+
* relative to the root.
|
|
10
|
+
*
|
|
11
|
+
* Both seed stages discover through this rather than naming a stack, so a stack
|
|
12
|
+
* seeding either root later reaches both and the two cannot disagree about which
|
|
13
|
+
* roots exist. A stack seeding only tracked surfaces carries `canon/` alone,
|
|
14
|
+
* which a `.claude/` test would drop from both stages. An absent `tooling/` is
|
|
15
|
+
* an empty answer, and each caller decides what an empty answer means.
|
|
16
|
+
*/
|
|
17
|
+
export function seedRoots(root: string): string[] {
|
|
18
|
+
const tooling = join(root, TOOLING)
|
|
19
|
+
if (!existsSync(tooling)) return []
|
|
20
|
+
|
|
21
|
+
return readdirSync(tooling)
|
|
22
|
+
.filter((name) => !name.startsWith('.'))
|
|
23
|
+
.sort()
|
|
24
|
+
.map((name) => `${TOOLING}/${name}/seeds`)
|
|
25
|
+
.filter((seeds) => isDirectory(join(root, seeds)))
|
|
26
|
+
.filter((seeds) =>
|
|
27
|
+
SURFACE_ROOTS.some((surface) => isDirectory(join(root, seeds, surface))),
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isDirectory(path: string): boolean {
|
|
32
|
+
try {
|
|
33
|
+
return statSync(path).isDirectory()
|
|
34
|
+
} catch {
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
}
|