@erclx/canon 5.33.0 → 5.34.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 -2
- package/claude/skills/api-design/SKILL.md +2 -3
- package/claude/skills/api-design/references/adopted.md +1 -1
- package/claude/skills/canon-feedback/REQUIREMENT.md +1 -0
- package/claude/skills/canon-feedback/SKILL.md +10 -28
- package/claude/skills/canon-feedback/references/template.md +53 -0
- package/claude/skills/canvas-board/SKILL.md +4 -1
- package/claude/skills/canvas-board/references/canvas.md +26 -3
- package/claude/skills/code-craft/REQUIREMENT.md +1 -1
- package/claude/skills/code-craft/SKILL.md +0 -1
- package/claude/skills/create-rule/SKILL.md +1 -1
- package/claude/skills/create-rule/references/rule.md +3 -1
- package/claude/skills/create-skill/references/skill.md +1 -1
- package/claude/skills/git-issue/references/issue.md +4 -1
- package/claude/skills/review-craft/references/ui.md +1 -1
- package/claude/skills/review-ui/SKILL.md +7 -5
- package/claude/skills/review-ui/references/driving.md +8 -8
- package/claude/skills/review-ui/references/post.md +52 -16
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +9 -0
- package/claude/skills/target-setup/SKILL.md +10 -4
- package/claude/skills/target-setup/references/detect.md +19 -2
- package/claude/skills/test-craft/REQUIREMENT.md +2 -3
- package/claude/skills/test-craft/SKILL.md +15 -3
- package/docs/agents/canvas.md +5 -1
- package/docs/agents/commands.md +1 -1
- package/docs/agents/gov-detect.md +47 -0
- package/docs/agents/index.md +4 -1
- package/docs/agents/install-and-sync.md +4 -0
- package/docs/agents/pr-evidence-layout.md +70 -0
- package/docs/agents/pr-evidence.md +8 -9
- package/docs/agents/pr-frames.md +7 -2
- package/docs/agents/pr-lift.md +132 -0
- package/docs/agents/scripting.md +2 -1
- package/docs/target/projects.md +3 -3
- package/governance/detect.toml +201 -0
- package/governance/rules/code/000-code.md +1 -13
- package/governance/rules/code/010-testing.md +2 -30
- package/governance/rules/ui/400-ui.md +1 -1
- package/governance/rules/ui/480-canvas.md +13 -0
- package/package.json +1 -1
- package/scripts/core/regen-hero.ts +0 -6
- package/scripts/lib/ui.sh +0 -9
- package/src/canvas/arrange.ts +66 -0
- package/src/canvas/capture.ts +20 -6
- package/src/canvas/content.ts +39 -0
- package/src/canvas/fit.ts +238 -0
- package/src/canvas/tokens.ts +28 -10
- package/src/capture/frame-values.ts +2 -32
- package/src/commands/canvas.ts +179 -1
- package/src/commands/gov.ts +144 -1
- package/src/commands/pr.ts +376 -1
- package/src/feedback/body.ts +26 -5
- package/src/gate/measures.ts +58 -44
- package/src/gate/stages.ts +3 -4
- package/src/gov/detect.ts +301 -0
- package/src/gov/list.ts +23 -2
- package/src/gov/stacks.ts +9 -0
- package/src/pr/evidence.ts +218 -45
- package/src/pr/frames.ts +23 -3
- package/src/pr/lift.ts +418 -0
- package/src/pr/rest.ts +3 -0
- package/src/pr/review-scope.ts +76 -0
- package/src/sandbox/equivalence.ts +14 -7
package/src/pr/evidence.ts
CHANGED
|
@@ -188,23 +188,218 @@ function evidenceImage(url: string, alt: string, width?: number): string {
|
|
|
188
188
|
|
|
189
189
|
const EVIDENCE_IMAGE = String.raw`(?:!\[\]\([^)]*\)|<img [^>]*>)`
|
|
190
190
|
|
|
191
|
-
/** A case row in
|
|
192
|
-
const
|
|
191
|
+
/** A case row in a shape before the head image carried an alt: a Base cell before the head image, or the head image alone. */
|
|
192
|
+
const LEGACY_ROW_PATTERN = new RegExp(
|
|
193
193
|
String.raw`^\| .+ \| (?:(?:\*\(new\)\*|${EVIDENCE_IMAGE}) \| )?${EVIDENCE_IMAGE} \|$`,
|
|
194
|
-
'gm',
|
|
195
194
|
)
|
|
196
195
|
|
|
196
|
+
/** A head image, which every render since the alt text was added names with a trailing `head`. A row carries one per case it holds. */
|
|
197
|
+
const HEAD_IMAGE_PATTERN = /<img [^>]*alt="[^"]* head">/g
|
|
198
|
+
|
|
199
|
+
/** The form a case name takes when it names a view, a theme, and a width. Anchored on the right, since a view may carry hyphens. */
|
|
200
|
+
const CASE_NAME = /^(.+)-(dark|light)-(\d+)$/
|
|
201
|
+
|
|
202
|
+
const THEMES = ['dark', 'light'] as const
|
|
203
|
+
|
|
204
|
+
type Theme = (typeof THEMES)[number]
|
|
205
|
+
|
|
206
|
+
interface ViewCase {
|
|
207
|
+
readonly item: EvidenceItem
|
|
208
|
+
readonly theme: Theme
|
|
209
|
+
readonly width: string
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function isTheme(value: string): value is Theme {
|
|
213
|
+
return value === 'dark' || value === 'light'
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function readCaseName(stem: string):
|
|
217
|
+
| {
|
|
218
|
+
readonly view: string
|
|
219
|
+
readonly theme: Theme
|
|
220
|
+
readonly width: string
|
|
221
|
+
}
|
|
222
|
+
| undefined {
|
|
223
|
+
const match = CASE_NAME.exec(stem)
|
|
224
|
+
const view = match?.[1]
|
|
225
|
+
const theme = match?.[2]
|
|
226
|
+
const width = match?.[3]
|
|
227
|
+
if (view === undefined || theme === undefined || width === undefined) {
|
|
228
|
+
return undefined
|
|
229
|
+
}
|
|
230
|
+
return isTheme(theme) ? { view, theme, width } : undefined
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function compareWidths(a: string, b: string): number {
|
|
234
|
+
return Number(a) - Number(b) || a.localeCompare(b)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function commitLink(repo: string, sha: string): string {
|
|
238
|
+
return `[\`${sha.slice(0, SHORT_SHA_LENGTH)}\`](https://github.com/${repo}/commit/${sha})`
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function baseImage(item: EvidenceItem, repo: string, base: string): string {
|
|
242
|
+
return item.added
|
|
243
|
+
? '*(new)*'
|
|
244
|
+
: evidenceImage(
|
|
245
|
+
rawUrl(repo, base, item.path),
|
|
246
|
+
`${escapeCell(item.stem)} base`,
|
|
247
|
+
item.baseWidth,
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function headImage(item: EvidenceItem, repo: string, head: string): string {
|
|
252
|
+
return evidenceImage(
|
|
253
|
+
rawUrl(repo, head, item.path),
|
|
254
|
+
`${escapeCell(item.stem)} head`,
|
|
255
|
+
item.width,
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function block(
|
|
260
|
+
opener: string,
|
|
261
|
+
summary: string,
|
|
262
|
+
header: readonly string[],
|
|
263
|
+
rows: readonly string[],
|
|
264
|
+
): string {
|
|
265
|
+
return [
|
|
266
|
+
opener,
|
|
267
|
+
`<summary>${summary}</summary>`,
|
|
268
|
+
'',
|
|
269
|
+
`| ${header.join(' | ')} |`,
|
|
270
|
+
`| ${header.map(() => '---').join(' | ')} |`,
|
|
271
|
+
...rows,
|
|
272
|
+
'',
|
|
273
|
+
'</details>',
|
|
274
|
+
].join('\n')
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** One block for the cases of a state that did not name a view, one case per row. */
|
|
278
|
+
function renderFlat(
|
|
279
|
+
label: string,
|
|
280
|
+
items: readonly EvidenceItem[],
|
|
281
|
+
repo: string,
|
|
282
|
+
base: string,
|
|
283
|
+
head: string,
|
|
284
|
+
opener: string,
|
|
285
|
+
): string {
|
|
286
|
+
const hasBase = items.some((item) => !item.added)
|
|
287
|
+
const rows = items.map((item) => {
|
|
288
|
+
const stem = escapeCell(item.stem)
|
|
289
|
+
const after = headImage(item, repo, head)
|
|
290
|
+
return hasBase
|
|
291
|
+
? `| ${stem} | ${baseImage(item, repo, base)} | ${after} |`
|
|
292
|
+
: `| ${stem} | ${after} |`
|
|
293
|
+
})
|
|
294
|
+
return block(
|
|
295
|
+
opener,
|
|
296
|
+
`${label} (${items.length})`,
|
|
297
|
+
hasBase ? ['Case', 'Base', 'Head'] : ['Case', 'Head'],
|
|
298
|
+
rows,
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** One block for a view, a width per row and a theme per column, each theme taking a base column when the view already exists at the base. */
|
|
303
|
+
function renderView(
|
|
304
|
+
label: string,
|
|
305
|
+
cases: readonly ViewCase[],
|
|
306
|
+
repo: string,
|
|
307
|
+
base: string,
|
|
308
|
+
head: string,
|
|
309
|
+
opener: string,
|
|
310
|
+
): string {
|
|
311
|
+
const themes = THEMES.filter((theme) =>
|
|
312
|
+
cases.some((entry) => entry.theme === theme),
|
|
313
|
+
)
|
|
314
|
+
const hasBase = cases.some((entry) => !entry.item.added)
|
|
315
|
+
const widths = [...new Set(cases.map((entry) => entry.width))].sort(
|
|
316
|
+
compareWidths,
|
|
317
|
+
)
|
|
318
|
+
const header = [
|
|
319
|
+
'Width',
|
|
320
|
+
...themes.flatMap((theme) => {
|
|
321
|
+
const name = theme === 'dark' ? 'Dark' : 'Light'
|
|
322
|
+
return hasBase ? [`${name} base`, `${name} head`] : [name]
|
|
323
|
+
}),
|
|
324
|
+
]
|
|
325
|
+
const rows = widths.map((width) => {
|
|
326
|
+
const cells = themes.flatMap((theme) => {
|
|
327
|
+
const found = cases.find(
|
|
328
|
+
(entry) => entry.theme === theme && entry.width === width,
|
|
329
|
+
)
|
|
330
|
+
if (found === undefined) return hasBase ? ['', ''] : ['']
|
|
331
|
+
const after = headImage(found.item, repo, head)
|
|
332
|
+
return hasBase ? [baseImage(found.item, repo, base), after] : [after]
|
|
333
|
+
})
|
|
334
|
+
return `| ${[width, ...cells].join(' | ')} |`
|
|
335
|
+
})
|
|
336
|
+
return block(opener, `${label} (${cases.length})`, header, rows)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Renders the blocks of one state: a block per view the case names split
|
|
341
|
+
* into, then one flat block for the names that did not. Nothing is dropped,
|
|
342
|
+
* so a state of unparsed names reads as the flat table it always was.
|
|
343
|
+
*/
|
|
344
|
+
function renderState(
|
|
345
|
+
entry: EvidenceState,
|
|
346
|
+
repo: string,
|
|
347
|
+
base: string,
|
|
348
|
+
head: string,
|
|
349
|
+
opener: string,
|
|
350
|
+
): string[] {
|
|
351
|
+
const views = new Map<string, ViewCase[]>()
|
|
352
|
+
const flat: EvidenceItem[] = []
|
|
353
|
+
const taken = new Set<string>()
|
|
354
|
+
for (const item of entry.items) {
|
|
355
|
+
const name = readCaseName(item.stem)
|
|
356
|
+
// Two files can share a stem across extensions, and a view cell holds one.
|
|
357
|
+
const cell =
|
|
358
|
+
name === undefined ? '' : `${name.view}|${name.theme}|${name.width}`
|
|
359
|
+
if (name === undefined || taken.has(cell)) {
|
|
360
|
+
flat.push(item)
|
|
361
|
+
continue
|
|
362
|
+
}
|
|
363
|
+
taken.add(cell)
|
|
364
|
+
const cases = views.get(name.view)
|
|
365
|
+
const viewCase = { item, theme: name.theme, width: name.width }
|
|
366
|
+
if (cases === undefined) views.set(name.view, [viewCase])
|
|
367
|
+
else cases.push(viewCase)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const prefix = entry.state === '' ? '' : `${escapeCell(entry.state)} / `
|
|
371
|
+
const blocks = [...views.entries()]
|
|
372
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
373
|
+
.map(([view, cases]) =>
|
|
374
|
+
renderView(
|
|
375
|
+
`${prefix}${escapeCell(view)}`,
|
|
376
|
+
cases,
|
|
377
|
+
repo,
|
|
378
|
+
base,
|
|
379
|
+
head,
|
|
380
|
+
opener,
|
|
381
|
+
),
|
|
382
|
+
)
|
|
383
|
+
if (flat.length > 0) {
|
|
384
|
+
const label = entry.state === '' ? 'evidence' : escapeCell(entry.state)
|
|
385
|
+
blocks.push(renderFlat(label, flat, repo, base, head, opener))
|
|
386
|
+
}
|
|
387
|
+
return blocks
|
|
388
|
+
}
|
|
389
|
+
|
|
197
390
|
export function evidenceMarker(head: string): string {
|
|
198
391
|
return `${MARKER_PREFIX} head=${head} -->`
|
|
199
392
|
}
|
|
200
393
|
|
|
201
394
|
/**
|
|
202
|
-
* Renders the whole comment body: one `<details>` block per
|
|
203
|
-
*
|
|
395
|
+
* Renders the whole comment body: one `<details>` block per view within each
|
|
396
|
+
* state, a width per row with a column per theme, a flat Base/Head table for
|
|
397
|
+
* the case names that do not split into a view, and the trailing marker naming
|
|
398
|
+
* the head this body describes.
|
|
204
399
|
* Every state opens while the comment carries at most `EVIDENCE_OPEN_LIMIT`
|
|
205
400
|
* images, one per row for the head plus one per row not added, and every state
|
|
206
401
|
* closes past it, so a comment is never half open. A visible line under
|
|
207
|
-
* `## Evidence` names the base and head as short shas. Every image URL is
|
|
402
|
+
* `## Evidence` names the base and head as short shas, each linking its commit. Every image URL is
|
|
208
403
|
* pinned to a commit sha rather than a branch, so the comment keeps showing
|
|
209
404
|
* what it claimed even after the branch moves.
|
|
210
405
|
*
|
|
@@ -252,42 +447,13 @@ export function renderEvidenceBody(
|
|
|
252
447
|
const opener =
|
|
253
448
|
imageCount <= EVIDENCE_OPEN_LIMIT ? '<details open>' : '<details>'
|
|
254
449
|
|
|
255
|
-
const sections = states.
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const stem = escapeCell(item.stem)
|
|
259
|
-
const before = item.added
|
|
260
|
-
? '*(new)*'
|
|
261
|
-
: evidenceImage(
|
|
262
|
-
rawUrl(repo, base, item.path),
|
|
263
|
-
`${stem} base`,
|
|
264
|
-
item.baseWidth,
|
|
265
|
-
)
|
|
266
|
-
const after = evidenceImage(
|
|
267
|
-
rawUrl(repo, head, item.path),
|
|
268
|
-
`${stem} head`,
|
|
269
|
-
item.width,
|
|
270
|
-
)
|
|
271
|
-
return hasBase
|
|
272
|
-
? `| ${stem} | ${before} | ${after} |`
|
|
273
|
-
: `| ${stem} | ${after} |`
|
|
274
|
-
})
|
|
275
|
-
|
|
276
|
-
return [
|
|
277
|
-
opener,
|
|
278
|
-
`<summary>${entry.state === '' ? 'evidence' : entry.state} (${entry.items.length})</summary>`,
|
|
279
|
-
'',
|
|
280
|
-
hasBase ? '| Case | Base | Head |' : '| Case | Head |',
|
|
281
|
-
hasBase ? '| --- | --- | --- |' : '| --- | --- |',
|
|
282
|
-
...rows,
|
|
283
|
-
'',
|
|
284
|
-
'</details>',
|
|
285
|
-
].join('\n')
|
|
286
|
-
})
|
|
450
|
+
const sections = states.flatMap((entry) =>
|
|
451
|
+
renderState(entry, repo, base, head, opener),
|
|
452
|
+
)
|
|
287
453
|
|
|
288
454
|
return [
|
|
289
455
|
...opening,
|
|
290
|
-
`${BASE_PREFIX}
|
|
456
|
+
`${BASE_PREFIX} ${commitLink(repo, base)} · **Head:** ${commitLink(repo, head)}`,
|
|
291
457
|
'',
|
|
292
458
|
...sections,
|
|
293
459
|
'',
|
|
@@ -297,14 +463,21 @@ export function renderEvidenceBody(
|
|
|
297
463
|
}
|
|
298
464
|
|
|
299
465
|
/**
|
|
300
|
-
* How many
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
* before the change still counts its cases and the
|
|
466
|
+
* How many cases a rendered body carries. Every case has exactly one head
|
|
467
|
+
* image, so a row counts the head images it holds, which is one in the flat
|
|
468
|
+
* table and up to two in a view's row. A row from before the alt text named
|
|
469
|
+
* its head image counts as one case when it matches the old row shape, so a
|
|
470
|
+
* comment posted before the change still counts its cases and the
|
|
471
|
+
* `would-empty` guard holds.
|
|
305
472
|
*/
|
|
306
473
|
export function countEvidenceCases(body: string): number {
|
|
307
|
-
|
|
474
|
+
let total = 0
|
|
475
|
+
for (const line of body.split('\n')) {
|
|
476
|
+
if (!line.startsWith('| ')) continue
|
|
477
|
+
const heads = line.match(HEAD_IMAGE_PATTERN)?.length ?? 0
|
|
478
|
+
total += heads > 0 ? heads : LEGACY_ROW_PATTERN.test(line) ? 1 : 0
|
|
479
|
+
}
|
|
480
|
+
return total
|
|
308
481
|
}
|
|
309
482
|
|
|
310
483
|
export interface EvidenceComment {
|
package/src/pr/frames.ts
CHANGED
|
@@ -20,6 +20,10 @@ const FOLDER_PATTERN = /^pr-(\d+)$/
|
|
|
20
20
|
|
|
21
21
|
const DAY_MS = 86_400_000
|
|
22
22
|
|
|
23
|
+
const THEMES = ['dark', 'light'] as const
|
|
24
|
+
|
|
25
|
+
export type FrameTheme = (typeof THEMES)[number]
|
|
26
|
+
|
|
23
27
|
export interface TreeEntry {
|
|
24
28
|
readonly path: string
|
|
25
29
|
readonly mode: string
|
|
@@ -71,6 +75,7 @@ export interface FrameInput {
|
|
|
71
75
|
readonly head: string
|
|
72
76
|
readonly pass: string
|
|
73
77
|
readonly box: number
|
|
78
|
+
readonly theme?: FrameTheme
|
|
74
79
|
readonly bytes: Uint8Array
|
|
75
80
|
}
|
|
76
81
|
|
|
@@ -113,18 +118,27 @@ export function isFramePass(pass: string): boolean {
|
|
|
113
118
|
return PASS_PATTERN.test(pass)
|
|
114
119
|
}
|
|
115
120
|
|
|
121
|
+
/** Whether a value names a theme a box can be shot at, the only theme segment the path takes. */
|
|
122
|
+
export function isFrameTheme(theme: string): theme is FrameTheme {
|
|
123
|
+
return (THEMES as readonly string[]).includes(theme)
|
|
124
|
+
}
|
|
125
|
+
|
|
116
126
|
/**
|
|
117
127
|
* Where a frame lands. The pass segment keeps a second pass at the same head
|
|
118
128
|
* and box from replacing the image an earlier review comment shows, since a
|
|
119
|
-
* branch-path link reads whatever the path holds now.
|
|
129
|
+
* branch-path link reads whatever the path holds now. The theme segment keeps
|
|
130
|
+
* two shots of one box in one pass apart, and a frame without one keeps the
|
|
131
|
+
* plain `box-<n>.png` form.
|
|
120
132
|
*/
|
|
121
133
|
export function framePath(
|
|
122
134
|
number: number,
|
|
123
135
|
head: string,
|
|
124
136
|
pass: string,
|
|
125
137
|
box: number,
|
|
138
|
+
theme?: FrameTheme,
|
|
126
139
|
): string {
|
|
127
|
-
|
|
140
|
+
const name = theme === undefined ? `box-${box}` : `box-${box}-${theme}`
|
|
141
|
+
return `pr-${number}/${head.slice(0, SHORT_HEAD_LENGTH)}/${pass}/${name}.png`
|
|
128
142
|
}
|
|
129
143
|
|
|
130
144
|
/**
|
|
@@ -160,7 +174,13 @@ export async function pushFrame(
|
|
|
160
174
|
api: FramesApi,
|
|
161
175
|
input: FrameInput,
|
|
162
176
|
): Promise<PushResult> {
|
|
163
|
-
const path = framePath(
|
|
177
|
+
const path = framePath(
|
|
178
|
+
input.number,
|
|
179
|
+
input.head,
|
|
180
|
+
input.pass,
|
|
181
|
+
input.box,
|
|
182
|
+
input.theme,
|
|
183
|
+
)
|
|
164
184
|
const blob = await api.createBlob(Buffer.from(input.bytes).toString('base64'))
|
|
165
185
|
if (blob === undefined) return { kind: 'refused', reason: 'push-failed' }
|
|
166
186
|
const entry: TreeEntry = { path, mode: '100644', type: 'blob', sha: blob }
|