@erclx/canon 5.49.0 → 5.51.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/CHANGELOG.md +4024 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/references/fan-in.md +20 -6
- package/claude/skills/canon-cli/SKILL.md +9 -7
- package/claude/skills/canvas-board/REQUIREMENT.md +4 -3
- package/claude/skills/canvas-board/SKILL.md +8 -9
- package/claude/skills/ci-workflow/REQUIREMENT.md +3 -3
- package/claude/skills/ci-workflow/SKILL.md +26 -1
- package/claude/skills/ci-workflow/references/workflows.md +28 -0
- package/claude/skills/context-fold/references/context.md +4 -3
- package/claude/skills/design-record/REQUIREMENT.md +33 -0
- package/claude/skills/design-record/SKILL.md +84 -0
- package/claude/skills/design-record/references/folder.md +69 -0
- package/claude/skills/design-taste/SKILL.md +3 -2
- package/claude/skills/git-followup/SKILL.md +1 -0
- package/claude/skills/git-pr/references/pr.md +1 -1
- package/claude/skills/markdown-craft/REQUIREMENT.md +1 -0
- package/claude/skills/markdown-craft/SKILL.md +6 -1
- package/claude/skills/markdown-craft/references/markdown.md +3 -3
- package/claude/skills/markdown-craft/references/tables.md +30 -0
- package/claude/skills/pick-by-eye/SKILL.md +10 -8
- package/claude/skills/plan-feature/SKILL.md +13 -1
- package/claude/skills/plan-feature/references/batches.md +1 -1
- package/claude/skills/session-worktree/SKILL.md +1 -1
- package/claude/skills/target-check/references/domains.md +8 -6
- package/docs/agents/index.md +2 -2
- package/docs/agents/markdown-audit.md +5 -2
- package/docs/agents/migrate.md +2 -0
- package/docs/agents/targets.md +8 -1
- package/docs/target/migrations.md +8 -1
- package/docs/target/projects.md +1 -1
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map.md +2 -1
- package/docs/workflow/visual-design-workflow.md +2 -1
- package/governance/detect.toml +1 -1
- package/governance/ledger/tooling/ci-workflow.md +6 -0
- package/governance/ledger/ui/design-reference.md +8 -12
- package/governance/rules/tooling/ci-workflow.md +4 -0
- package/governance/rules/ui/design-reference.md +4 -6
- package/package.json +2 -1
- package/scripts/ci/check-browsers.sh +13 -0
- package/scripts/ci/delete-previews.sh +34 -0
- package/scripts/ci/publish-check.sh +29 -0
- package/scripts/ci/refresh-frames-pr.sh +63 -0
- package/scripts/ci/report-baseline.sh +23 -0
- package/scripts/ci/stack-issues.sh +24 -0
- package/scripts/ci/verify-npm-token.sh +14 -0
- package/src/claude/cases/misc.ts +5 -0
- package/src/cli/help.ts +1 -0
- package/src/cli.ts +2 -0
- package/src/commands/changelog.ts +146 -0
- package/src/commands/claude.ts +26 -1
- package/src/commands/gate.ts +57 -16
- package/src/commands/hooks.ts +7 -5
- package/src/commands/markdown.ts +98 -19
- package/src/commands/migrate.ts +77 -0
- package/src/commands/worktrees.ts +273 -0
- package/src/context/audit.ts +6 -5
- package/src/design/budget.ts +1 -1
- package/src/design/components.ts +1 -1
- package/src/design/document.ts +1 -1
- package/src/design/regen.ts +1 -1
- package/src/design/render.ts +1 -1
- package/src/gate/hand-suite.ts +52 -0
- package/src/gate/measures.ts +9 -2
- package/src/gate/sequencer.ts +17 -8
- package/src/gov/detect.ts +28 -10
- package/src/hooks/post-merge.ts +45 -6
- package/src/markdown/gated.ts +5 -7
- package/src/markdown/scope.ts +60 -10
- package/src/markdown/structure.ts +1 -1
- package/src/markdown/tables.ts +150 -0
- package/src/migrate/status.ts +128 -0
- package/src/tasks/validate.ts +15 -12
- package/src/version/changelog.ts +156 -0
- package/src/worktrees/members.ts +540 -0
- package/tooling/base/configs/.husky/post-merge +7 -6
- package/tooling/claude/reference.md +1 -0
- package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +11 -1
- package/tooling/claude/seeds/CLAUDE.md +9 -0
- package/tooling/cloudflare/configs/.github/workflows/deploy.yml +10 -28
- package/tooling/cloudflare/configs/scripts/delete-previews.sh +36 -0
- package/tooling/cloudflare/reference.md +7 -4
- package/tooling/web/reference.md +5 -0
- package/tooling/web/seeds/canon/design/index.md +23 -0
- /package/claude/skills/{canvas-board → design-record}/references/design.md +0 -0
package/src/hooks/post-merge.ts
CHANGED
|
@@ -39,6 +39,7 @@ export interface PostMergeInput {
|
|
|
39
39
|
export const STEP_VERBS = {
|
|
40
40
|
archive: ['tasks', 'archive'],
|
|
41
41
|
push: ['records', 'push'],
|
|
42
|
+
members: ['worktrees', 'retire-members'],
|
|
42
43
|
reclaim: ['worktrees', 'reclaim'],
|
|
43
44
|
upgrade: ['upgrade'],
|
|
44
45
|
pluginUpdate: ['claude', 'plugin-update'],
|
|
@@ -225,6 +226,33 @@ function reclaimLines(fields: Fields): string | undefined {
|
|
|
225
226
|
return lines.length > 0 ? lines : undefined
|
|
226
227
|
}
|
|
227
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Keyed on the fields, like the reclaim, since a run that retires one member
|
|
231
|
+
* and fails on another exits 1 with a branch already deleted. A body naming no
|
|
232
|
+
* members is every merge but an integrator's, so it stays quiet.
|
|
233
|
+
*/
|
|
234
|
+
function memberLines(number: number, fields: Fields): string | undefined {
|
|
235
|
+
let lines = ''
|
|
236
|
+
const retired = count(fields, 'retired')
|
|
237
|
+
if (retired > 0) {
|
|
238
|
+
lines += `\n🌿 Retired ${retired} fan-in member(s) of #${number}.\n\n`
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const hint = `Run canon worktrees retire-members --pull-request ${number} --dry-run to read why.\n\n`
|
|
242
|
+
const reason = text(fields, 'reason')
|
|
243
|
+
const unfinished = count(fields, 'kept') + count(fields, 'failed')
|
|
244
|
+
if (
|
|
245
|
+
reason !== undefined &&
|
|
246
|
+
reason !== 'no-members' &&
|
|
247
|
+
reason !== 'gh-missing'
|
|
248
|
+
) {
|
|
249
|
+
lines += `\n🌿 Fan-in members of #${number} not retired: ${reason}\n${hint}`
|
|
250
|
+
} else if (unfinished > 0) {
|
|
251
|
+
lines += `\n🌿 ${unfinished} fan-in member(s) of #${number} kept or not fully deleted.\n${hint}`
|
|
252
|
+
}
|
|
253
|
+
return lines.length > 0 ? lines : undefined
|
|
254
|
+
}
|
|
255
|
+
|
|
228
256
|
/**
|
|
229
257
|
* Shared by the binary reinstall and the plugin update. `current` is the
|
|
230
258
|
* ordinary outcome on most merges and stays quiet, and `quiet` names the
|
|
@@ -271,8 +299,10 @@ async function step(
|
|
|
271
299
|
}
|
|
272
300
|
|
|
273
301
|
/**
|
|
274
|
-
* Runs the
|
|
275
|
-
* upgrade, plugin update. The
|
|
302
|
+
* Runs the six post-merge steps in order: archive, records push, member
|
|
303
|
+
* retirement, reclaim, upgrade, plugin update. The member retirement sits
|
|
304
|
+
* before the reclaim, so the reclaim reads a retired member's worktree as
|
|
305
|
+
* gone rather than racing it. Both sit before the upgrade, since the
|
|
276
306
|
* upgrade reinstalls the binary every later child runs under, and the two
|
|
277
307
|
* network-bound moves sit last so a slow registry delays nothing else.
|
|
278
308
|
*
|
|
@@ -309,11 +339,20 @@ export async function runPostMerge(input: PostMergeInput): Promise<void> {
|
|
|
309
339
|
pushLines,
|
|
310
340
|
)
|
|
311
341
|
|
|
312
|
-
// No `--root`
|
|
313
|
-
// directory to refuse the worktree it stands in, so a
|
|
314
|
-
// running worktree into an ordinary candidate and let a
|
|
315
|
-
// linked worktree delete the ground under itself.
|
|
342
|
+
// No `--root` on either worktree step, and that is load-bearing. Each verb
|
|
343
|
+
// reads its own working directory to refuse the worktree it stands in, so a
|
|
344
|
+
// root would turn the running worktree into an ordinary candidate and let a
|
|
345
|
+
// pull inside a linked worktree delete the ground under itself. One switch
|
|
346
|
+
// covers both, since both remove worktrees.
|
|
316
347
|
if (!isSkipped('CANON_SKIP_RECLAIM')) {
|
|
348
|
+
for (const number of pullRequestNumbers(input.subjects)) {
|
|
349
|
+
await step(
|
|
350
|
+
input,
|
|
351
|
+
at([...STEP_VERBS.members, '--pull-request', String(number), '--json']),
|
|
352
|
+
(_, fields) => memberLines(number, fields),
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
317
356
|
await step(input, at([...STEP_VERBS.reclaim, '--json']), (_, fields) =>
|
|
318
357
|
reclaimLines(fields),
|
|
319
358
|
)
|
package/src/markdown/gated.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
import { readFile } from 'node:fs/promises'
|
|
10
10
|
import { dirname, join } from 'node:path'
|
|
11
11
|
import { listRepositoryFiles } from '@/git/files'
|
|
12
|
-
import {
|
|
12
|
+
import type { DensityKind } from '@/markdown/density'
|
|
13
13
|
import { referenceShapeGates } from '@/markdown/gate'
|
|
14
14
|
import {
|
|
15
15
|
type ReferenceShapeKind,
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
densityGates,
|
|
21
21
|
GATED_PATHS,
|
|
22
22
|
GATED_SCOPE_ROOTS,
|
|
23
|
-
|
|
23
|
+
scopedDensityFindings,
|
|
24
24
|
} from '@/markdown/scope'
|
|
25
25
|
import { isOwnCheckout } from '@/roots/project'
|
|
26
26
|
|
|
@@ -198,11 +198,9 @@ export async function findingsUnder(
|
|
|
198
198
|
const found = await Promise.all(
|
|
199
199
|
under.map(async (rel) => {
|
|
200
200
|
const source = await readFile(join(root, rel), 'utf8')
|
|
201
|
-
const density =
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
)
|
|
205
|
-
: []
|
|
201
|
+
const density = scopedDensityFindings(rel, bodyLines(source)).filter(
|
|
202
|
+
(finding) => densityGates(rel, finding.kind, [prefix], false),
|
|
203
|
+
)
|
|
206
204
|
const shape = referenceShapeGates(rel, [prefix])
|
|
207
205
|
? referenceShapeFindings(source)
|
|
208
206
|
: []
|
package/src/markdown/scope.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
type DensityFinding,
|
|
3
|
+
type DensityKind,
|
|
4
|
+
densityFindings,
|
|
5
|
+
} from '@/markdown/density'
|
|
6
|
+
import type { BodyLine } from '@/markdown/scan'
|
|
2
7
|
|
|
3
8
|
/** The surfaces the retired bullet-stacks stage held, per `ALWAYS_GATED`. */
|
|
4
9
|
const BULLET_RUN_SCOPE = [
|
|
@@ -21,11 +26,12 @@ const REQUIREMENT_SCOPE =
|
|
|
21
26
|
* pages, the skill bodies, references, and requirements of both skill trees,
|
|
22
27
|
* the tooling seeds and stack references, the wiki, and the two root guides.
|
|
23
28
|
*
|
|
24
|
-
* Rules stay out, since a rule is one imperative per bullet
|
|
25
|
-
* `CLAUDE.md
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
+
* Rules stay out, since a rule is one imperative per bullet, and so does every
|
|
30
|
+
* `CLAUDE.md` section but Key paths, which `KEY_PATHS_SCOPE` holds. So do the
|
|
31
|
+
* project requirement and design records, a seed carrying one of those three
|
|
32
|
+
* names, the changelog, the examples, and every `.canon/` record, each with a
|
|
33
|
+
* shape and cap of its own. Named here and nowhere else, so the audit, the gate
|
|
34
|
+
* stage reading its exit, and their tests read one scope.
|
|
29
35
|
*/
|
|
30
36
|
const DENSITY_SCOPE = [
|
|
31
37
|
...BULLET_RUN_SCOPE,
|
|
@@ -36,10 +42,49 @@ const DENSITY_SCOPE = [
|
|
|
36
42
|
/^(?:README|CONTRIBUTING)\.md$/,
|
|
37
43
|
]
|
|
38
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The root `CLAUDE.md` and its seed, measured inside `## Key paths` alone. That
|
|
47
|
+
* section is a catalog rather than rules, so it takes the run limit and the H3
|
|
48
|
+
* opening a grouped catalog needs, and nothing else a rule would breach.
|
|
49
|
+
*/
|
|
50
|
+
const KEY_PATHS_SCOPE = /^(?:tooling\/claude\/seeds\/)?CLAUDE\.md$/
|
|
51
|
+
const KEY_PATHS_HEADING = /^## Key paths\s*$/
|
|
52
|
+
const KEY_PATHS_KINDS: readonly DensityKind[] = ['bullet-run', 'h3-opening']
|
|
53
|
+
|
|
54
|
+
/** The lines under `## Key paths` up to the next H1 or H2, keeping their numbers. */
|
|
55
|
+
function keyPathsSection(lines: readonly BodyLine[]): readonly BodyLine[] {
|
|
56
|
+
const start = lines.findIndex(
|
|
57
|
+
(line) => !line.fenced && KEY_PATHS_HEADING.test(line.text),
|
|
58
|
+
)
|
|
59
|
+
if (start === -1) return []
|
|
60
|
+
const rest = lines.slice(start + 1)
|
|
61
|
+
const end = rest.findIndex(
|
|
62
|
+
(line) => !line.fenced && /^#{1,2}\s/.test(line.text),
|
|
63
|
+
)
|
|
64
|
+
return end === -1 ? rest : rest.slice(0, end)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The density findings a path takes, being every finding on a density-scope
|
|
69
|
+
* file and the Key paths findings on a `CLAUDE.md`. The audit, the init probe,
|
|
70
|
+
* and `canon claude sync` all read this, so the three agree on what counts.
|
|
71
|
+
*/
|
|
72
|
+
export function scopedDensityFindings(
|
|
73
|
+
rel: string,
|
|
74
|
+
lines: readonly BodyLine[],
|
|
75
|
+
): DensityFinding[] {
|
|
76
|
+
if (inDensityScope(rel)) return densityFindings(lines)
|
|
77
|
+
if (!KEY_PATHS_SCOPE.test(rel)) return []
|
|
78
|
+
return densityFindings(keyPathsSection(lines)).filter((finding) =>
|
|
79
|
+
KEY_PATHS_KINDS.includes(finding.kind),
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
39
83
|
/**
|
|
40
84
|
* The roots `canon init` gates in a new project when each passes at init time:
|
|
41
|
-
* every density surface a target authors itself
|
|
42
|
-
* `claude/skills/` and `tooling/`
|
|
85
|
+
* every density surface a target authors itself and the Key paths of its
|
|
86
|
+
* `CLAUDE.md`, leaving out the toolkit's own `claude/skills/` and `tooling/`
|
|
87
|
+
* trees.
|
|
43
88
|
*/
|
|
44
89
|
export const GATED_SCOPE_ROOTS: readonly string[] = [
|
|
45
90
|
'canon/context/',
|
|
@@ -48,6 +93,7 @@ export const GATED_SCOPE_ROOTS: readonly string[] = [
|
|
|
48
93
|
'wiki/',
|
|
49
94
|
'README.md',
|
|
50
95
|
'CONTRIBUTING.md',
|
|
96
|
+
'CLAUDE.md',
|
|
51
97
|
]
|
|
52
98
|
|
|
53
99
|
/**
|
|
@@ -93,7 +139,6 @@ export const GATED_PATHS: readonly string[] = [
|
|
|
93
139
|
'claude/skills/canvas-board/REQUIREMENT.md',
|
|
94
140
|
'claude/skills/canvas-board/SKILL.md',
|
|
95
141
|
'claude/skills/canvas-board/references/canvas.md',
|
|
96
|
-
'claude/skills/canvas-board/references/design.md',
|
|
97
142
|
'claude/skills/ci-workflow/REQUIREMENT.md',
|
|
98
143
|
'claude/skills/ci-workflow/SKILL.md',
|
|
99
144
|
'claude/skills/ci-workflow/references/workflows.md',
|
|
@@ -133,6 +178,10 @@ export const GATED_PATHS: readonly string[] = [
|
|
|
133
178
|
'claude/skills/deprecation-migration/REQUIREMENT.md',
|
|
134
179
|
'claude/skills/deprecation-migration/SKILL.md',
|
|
135
180
|
'claude/skills/deprecation-migration/references/adopted.md',
|
|
181
|
+
'claude/skills/design-record/REQUIREMENT.md',
|
|
182
|
+
'claude/skills/design-record/SKILL.md',
|
|
183
|
+
'claude/skills/design-record/references/design.md',
|
|
184
|
+
'claude/skills/design-record/references/folder.md',
|
|
136
185
|
'claude/skills/design-taste/REQUIREMENT.md',
|
|
137
186
|
'claude/skills/design-taste/SKILL.md',
|
|
138
187
|
'claude/skills/design-taste/references/adopted.md',
|
|
@@ -394,6 +443,7 @@ export const GATED_PATHS: readonly string[] = [
|
|
|
394
443
|
'wiki/',
|
|
395
444
|
'README.md',
|
|
396
445
|
'CONTRIBUTING.md',
|
|
446
|
+
'CLAUDE.md',
|
|
397
447
|
]
|
|
398
448
|
|
|
399
449
|
/**
|
|
@@ -425,7 +475,7 @@ export function densityGates(
|
|
|
425
475
|
gated: readonly string[] = GATED_PATHS,
|
|
426
476
|
alwaysGated = true,
|
|
427
477
|
): boolean {
|
|
428
|
-
if (!inDensityScope(rel)) return false
|
|
478
|
+
if (!inDensityScope(rel) && !KEY_PATHS_SCOPE.test(rel)) return false
|
|
429
479
|
if (REQUIREMENT_SCOPE.test(rel)) return gated.includes(rel)
|
|
430
480
|
if (
|
|
431
481
|
alwaysGated &&
|
|
@@ -67,7 +67,7 @@ const MARKER_WIDTH = 20
|
|
|
67
67
|
|
|
68
68
|
export const LIST_ITEM = /^(\s*)([-*+]|\d+\.)\s+/
|
|
69
69
|
export const TABLE_ROW = /^\s*\|/
|
|
70
|
-
const TABLE_SEPARATOR = /^\s*\|[\s:|-]+\|\s*$/
|
|
70
|
+
export const TABLE_SEPARATOR = /^\s*\|[\s:|-]+\|\s*$/
|
|
71
71
|
export const BLOCKQUOTE = /^\s*>/
|
|
72
72
|
|
|
73
73
|
/**
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { type BodyLine, visibleText } from '@/markdown/scan'
|
|
2
|
+
import { LIST_ITEM, TABLE_ROW, TABLE_SEPARATOR } from '@/markdown/structure'
|
|
3
|
+
|
|
4
|
+
/** The most visible characters one table cell may hold. */
|
|
5
|
+
export const TABLE_CELL = 120
|
|
6
|
+
|
|
7
|
+
/** The most body rows a table may carry before it splits into groups. */
|
|
8
|
+
export const TABLE_ROWS = 8
|
|
9
|
+
|
|
10
|
+
/** The longest unbroken run a code span in a table cell may carry. */
|
|
11
|
+
export const TABLE_TOKEN = 30
|
|
12
|
+
|
|
13
|
+
export type TableKind =
|
|
14
|
+
| 'table-cell'
|
|
15
|
+
| 'table-rows'
|
|
16
|
+
| 'table-then-list'
|
|
17
|
+
| 'table-token'
|
|
18
|
+
|
|
19
|
+
export interface TableFinding {
|
|
20
|
+
readonly kind: TableKind
|
|
21
|
+
/** The row holding the cell or span, the header for a row count, or the list's first item. */
|
|
22
|
+
readonly line: number
|
|
23
|
+
/** What was measured, or null for a trailing list, which is a shape rather than a count. */
|
|
24
|
+
readonly value: number | null
|
|
25
|
+
readonly limit: number | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The body of every code span in a cell, read after link destinations are
|
|
30
|
+
* dropped, since only a link's label renders.
|
|
31
|
+
*/
|
|
32
|
+
export function codeSpans(cell: string): string[] {
|
|
33
|
+
return (cell.replace(/\]\([^)]*\)/g, ']').match(/`[^`]+`/g) ?? []).map(
|
|
34
|
+
(span) => span.slice(1, -1),
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The longest whitespace-free run in a code span. A preview also breaks a span
|
|
40
|
+
* after a hyphen or a slash, but a run this long still splits mid-token or
|
|
41
|
+
* widens its column, so whitespace is the break this counts.
|
|
42
|
+
*/
|
|
43
|
+
export function unbrokenRun(span: string): number {
|
|
44
|
+
return Math.max(...span.split(/\s+/).map((part) => part.length))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Splits a row on its unescaped pipes, dropping the outer two. */
|
|
48
|
+
function cellsOf(row: string): string[] {
|
|
49
|
+
return row
|
|
50
|
+
.trim()
|
|
51
|
+
.replace(/^\|/, '')
|
|
52
|
+
.replace(/(?<!\\)\|$/, '')
|
|
53
|
+
.split(/(?<!\\)\|/)
|
|
54
|
+
.map((cell) => cell.trim().replaceAll('\\|', '|'))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Every breach of the four table limits, in line order.
|
|
59
|
+
*
|
|
60
|
+
* A table is a run of pipe rows whose second row is a delimiter, the shape a
|
|
61
|
+
* renderer draws. Pipe lines with no delimiter render as paragraph text, and a
|
|
62
|
+
* fenced line is an example, so neither is measured. The limits are a
|
|
63
|
+
* 120-character cell, 8 body rows, a list as the next block, and a code span
|
|
64
|
+
* holding a run past 30 characters. Every finding reports and none gates.
|
|
65
|
+
*/
|
|
66
|
+
export function tableFindings(lines: readonly BodyLine[]): TableFinding[] {
|
|
67
|
+
const found: TableFinding[] = []
|
|
68
|
+
let index = 0
|
|
69
|
+
|
|
70
|
+
while (index < lines.length) {
|
|
71
|
+
const header = lines[index]
|
|
72
|
+
const delimiter = lines[index + 1]
|
|
73
|
+
if (
|
|
74
|
+
header.fenced ||
|
|
75
|
+
!TABLE_ROW.test(header.text) ||
|
|
76
|
+
delimiter === undefined ||
|
|
77
|
+
delimiter.fenced ||
|
|
78
|
+
!TABLE_SEPARATOR.test(delimiter.text)
|
|
79
|
+
) {
|
|
80
|
+
index++
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const tableRows = [header]
|
|
85
|
+
let end = index + 2
|
|
86
|
+
while (
|
|
87
|
+
end < lines.length &&
|
|
88
|
+
!lines[end].fenced &&
|
|
89
|
+
TABLE_ROW.test(lines[end].text)
|
|
90
|
+
) {
|
|
91
|
+
tableRows.push(lines[end])
|
|
92
|
+
end++
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const row of tableRows) {
|
|
96
|
+
for (const cell of cellsOf(row.text)) {
|
|
97
|
+
const length = visibleText(cell).length
|
|
98
|
+
if (length > TABLE_CELL) {
|
|
99
|
+
found.push({
|
|
100
|
+
kind: 'table-cell',
|
|
101
|
+
line: row.number,
|
|
102
|
+
value: length,
|
|
103
|
+
limit: TABLE_CELL,
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
for (const span of codeSpans(cell)) {
|
|
107
|
+
const run = unbrokenRun(span)
|
|
108
|
+
if (run > TABLE_TOKEN) {
|
|
109
|
+
found.push({
|
|
110
|
+
kind: 'table-token',
|
|
111
|
+
line: row.number,
|
|
112
|
+
value: run,
|
|
113
|
+
limit: TABLE_TOKEN,
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const bodyRows = tableRows.length - 1
|
|
121
|
+
if (bodyRows > TABLE_ROWS) {
|
|
122
|
+
found.push({
|
|
123
|
+
kind: 'table-rows',
|
|
124
|
+
line: header.number,
|
|
125
|
+
value: bodyRows,
|
|
126
|
+
limit: TABLE_ROWS,
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let next = end
|
|
131
|
+
while (next < lines.length && lines[next].text.trim() === '') next++
|
|
132
|
+
const following = lines[next]
|
|
133
|
+
if (
|
|
134
|
+
following !== undefined &&
|
|
135
|
+
!following.fenced &&
|
|
136
|
+
LIST_ITEM.test(following.text)
|
|
137
|
+
) {
|
|
138
|
+
found.push({
|
|
139
|
+
kind: 'table-then-list',
|
|
140
|
+
line: following.number,
|
|
141
|
+
value: null,
|
|
142
|
+
limit: null,
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
index = end
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return found.sort((a, b) => a.line - b.line)
|
|
150
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { RunCommand } from '@/gate/measures'
|
|
2
|
+
|
|
3
|
+
interface Migration {
|
|
4
|
+
readonly name: string
|
|
5
|
+
readonly flags: readonly string[]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Every `canon migrate` subcommand in the order a project runs them, records
|
|
10
|
+
* first. This is the source for that order, and `docs/target/migrations.md`
|
|
11
|
+
* cites the verb rather than keeping a second list. A new migration joins by
|
|
12
|
+
* one line here.
|
|
13
|
+
*/
|
|
14
|
+
export const MIGRATIONS: readonly Migration[] = [
|
|
15
|
+
{ name: 'records', flags: [] },
|
|
16
|
+
{ name: 'record-tree', flags: [] },
|
|
17
|
+
{ name: 'scratch-evidence', flags: [] },
|
|
18
|
+
{ name: 'record-layout', flags: [] },
|
|
19
|
+
{ name: 'rule-layout', flags: [] },
|
|
20
|
+
{ name: 'rename', flags: ['--scope', 'target'] },
|
|
21
|
+
{ name: 'skill-names', flags: [] },
|
|
22
|
+
{ name: 'surface-roots', flags: [] },
|
|
23
|
+
{ name: 'session-defaults', flags: [] },
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
export type MigrationState = 'pending' | 'clean' | 'refused'
|
|
27
|
+
|
|
28
|
+
export interface MigrationStatus {
|
|
29
|
+
readonly name: string
|
|
30
|
+
readonly verb: string
|
|
31
|
+
readonly state: MigrationState
|
|
32
|
+
/** The subcommand's own `--json` record, carried through unread. */
|
|
33
|
+
readonly record?: unknown
|
|
34
|
+
readonly reason?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface StatusReport {
|
|
38
|
+
readonly migrations: readonly MigrationStatus[]
|
|
39
|
+
readonly pending: number
|
|
40
|
+
readonly refused: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseRecord(stdout: string): unknown {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(stdout)
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const FRAME = /^[\s│├┌└]+/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A refusing subcommand writes its stderr inside the CLI's frame, so the raw
|
|
55
|
+
* text would carry the glyphs into a record a skill reads as data. The error
|
|
56
|
+
* lines are the reason, and a stderr without a marker keeps every line.
|
|
57
|
+
*/
|
|
58
|
+
function refusalReason(stderr: string): string {
|
|
59
|
+
const lines = stderr.split('\n').map((line) => line.replace(FRAME, '').trim())
|
|
60
|
+
const errors = lines
|
|
61
|
+
.filter((line) => line.startsWith('✗'))
|
|
62
|
+
.map((line) => line.replace(/^✗\s*/, ''))
|
|
63
|
+
const kept = errors.length > 0 ? errors : lines.filter(Boolean)
|
|
64
|
+
return kept.map((line) => line.replace(/\.+$/, '')).join('. ')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function readOne(
|
|
68
|
+
run: RunCommand,
|
|
69
|
+
migration: Migration,
|
|
70
|
+
): Promise<MigrationStatus> {
|
|
71
|
+
const verb = `canon migrate ${migration.name}`
|
|
72
|
+
const result = await run([
|
|
73
|
+
'migrate',
|
|
74
|
+
migration.name,
|
|
75
|
+
...migration.flags,
|
|
76
|
+
'--json',
|
|
77
|
+
])
|
|
78
|
+
|
|
79
|
+
if (result.exitCode === 0) {
|
|
80
|
+
return { name: migration.name, verb, state: 'clean' }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (result.exitCode === 2) {
|
|
84
|
+
return {
|
|
85
|
+
name: migration.name,
|
|
86
|
+
verb,
|
|
87
|
+
state: 'pending',
|
|
88
|
+
record: parseRecord(result.stdout),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const reason =
|
|
93
|
+
result.spawnError ??
|
|
94
|
+
(refusalReason(result.stderr) ||
|
|
95
|
+
`exited ${result.exitCode} with no message`)
|
|
96
|
+
return {
|
|
97
|
+
name: migration.name,
|
|
98
|
+
verb,
|
|
99
|
+
state: 'refused',
|
|
100
|
+
record: parseRecord(result.stdout),
|
|
101
|
+
reason,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Runs each migration's own dry run and sorts it by the exit contract every
|
|
107
|
+
* subcommand shares: 0 with nothing to move, 2 with a plan and no `--write`,
|
|
108
|
+
* anything else a refusal. It never passes `--write`, and it reads no count,
|
|
109
|
+
* since the records share no field to count from.
|
|
110
|
+
*/
|
|
111
|
+
export async function readMigrationStatus(
|
|
112
|
+
run: RunCommand,
|
|
113
|
+
): Promise<StatusReport> {
|
|
114
|
+
const migrations = await Promise.all(
|
|
115
|
+
MIGRATIONS.map((migration) => readOne(run, migration)),
|
|
116
|
+
)
|
|
117
|
+
return {
|
|
118
|
+
migrations,
|
|
119
|
+
pending: migrations.filter((m) => m.state === 'pending').length,
|
|
120
|
+
refused: migrations.filter((m) => m.state === 'refused').length,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A refusal outranks a pending plan, since a refused read measured nothing. */
|
|
125
|
+
export function statusExitCode(report: StatusReport): 0 | 1 | 2 {
|
|
126
|
+
if (report.refused > 0) return 1
|
|
127
|
+
return report.pending > 0 ? 2 : 0
|
|
128
|
+
}
|
package/src/tasks/validate.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, statSync } from 'node:fs'
|
|
2
2
|
import { readdir, readFile } from 'node:fs/promises'
|
|
3
3
|
import { join, resolve } from 'node:path'
|
|
4
|
+
import { codeSpans, unbrokenRun } from '@/markdown/tables'
|
|
4
5
|
import {
|
|
5
6
|
archiveDir,
|
|
6
7
|
declinedDir,
|
|
@@ -1084,28 +1085,30 @@ function checkFolderClaims(
|
|
|
1084
1085
|
/**
|
|
1085
1086
|
* The widest the longest unbroken run in a code span may be before a table
|
|
1086
1087
|
* scrolls in the preview pane. A 57-character span fit and a 78-character one
|
|
1087
|
-
* did not, so the limit sits at the nearest round number above the first.
|
|
1088
|
+
* did not, so the limit sits at the nearest round number above the first. It
|
|
1089
|
+
* is still too high: a 36-character path with no hyphen widened a board column
|
|
1090
|
+
* until the next one ran off the pane, which is why a table cell reads the same
|
|
1091
|
+
* measure at 30. The board keeps 60 until its own report is revisited.
|
|
1088
1092
|
*/
|
|
1089
1093
|
const WIDE_LIMIT = 60
|
|
1090
1094
|
|
|
1091
1095
|
/**
|
|
1092
|
-
* Reports a code span a table cell cannot wrap
|
|
1093
|
-
*
|
|
1094
|
-
*
|
|
1095
|
-
*
|
|
1096
|
-
*
|
|
1097
|
-
* and bare words at hyphens,
|
|
1096
|
+
* Reports a code span a table cell cannot wrap, measured by `unbrokenRun`,
|
|
1097
|
+
* which `canon markdown audit` reads for a table too. A rendered preview breaks
|
|
1098
|
+
* a span at whitespace and after a hyphen or a slash, so a long run either
|
|
1099
|
+
* widens its column or splits mid-token. Every group is scanned, since the
|
|
1100
|
+
* overflow this exists for happened under `## Up next`, where the folder-claim
|
|
1101
|
+
* scan would not have looked. Prose breaks at spaces and bare words at hyphens,
|
|
1102
|
+
* so only a span is read.
|
|
1098
1103
|
*/
|
|
1099
1104
|
function checkWideTokens(rows: readonly BoardRow[]): WideToken[] {
|
|
1100
1105
|
const wide: WideToken[] = []
|
|
1101
1106
|
|
|
1102
1107
|
for (const row of rows) {
|
|
1103
1108
|
for (const cell of row.cells) {
|
|
1104
|
-
const
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
const text = span.slice(1, -1)
|
|
1108
|
-
const run = Math.max(...text.split(/\s+/).map((part) => part.length))
|
|
1109
|
+
for (const text of codeSpans(cell)) {
|
|
1110
|
+
const span = `\`${text}\``
|
|
1111
|
+
const run = unbrokenRun(text)
|
|
1109
1112
|
|
|
1110
1113
|
if (/\{[^}]*,[^}]*\}/.test(text)) {
|
|
1111
1114
|
wide.push({
|