@erclx/canon 5.59.0 → 5.61.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 +28 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/design-record/references/design.md +19 -0
- package/claude/skills/document-health/REQUIREMENT.md +1 -1
- package/claude/skills/document-health/SKILL.md +23 -3
- package/claude/skills/document-health/references/axes.md +21 -1
- package/claude/skills/plan-feature/SKILL.md +1 -1
- package/claude/skills/review-pr/SKILL.md +1 -1
- package/claude/skills/review-pr/references/scope.md +10 -0
- package/claude/skills/review-ui/REQUIREMENT.md +1 -1
- package/claude/skills/review-ui/SKILL.md +3 -2
- package/claude/skills/review-ui/references/post.md +2 -2
- package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
- package/claude/skills/ui-checklist/SKILL.md +12 -0
- package/docs/agents/records.md +1 -1
- package/docs/target/migrations.md +1 -1
- package/docs/workflow/parallel-features.md +1 -1
- package/package.json +2 -1
- package/src/commands/design.ts +57 -1
- package/src/commands/driver.ts +29 -3
- package/src/commands/migrate.ts +7 -0
- package/src/commands/records.ts +3 -2
- package/src/commands/tooling.ts +17 -2
- package/src/design/audit.ts +133 -0
- package/src/design/budget.ts +2 -1
- package/src/design/css.ts +1 -5
- package/src/design/document.ts +5 -1
- package/src/design/themes.ts +176 -0
- package/src/design/tokens.ts +3 -0
- package/src/driver/drive.ts +17 -1
- package/src/driver/probes/axe.ts +195 -0
- package/src/driver/steps.ts +20 -1
- package/src/gate/measures.ts +9 -3
- package/src/markdown/structure.ts +4 -1
- package/src/migrate/records.ts +46 -7
- package/src/records/stale-canonical.ts +2 -2
- package/src/sync/stamp.ts +24 -4
- package/src/tooling/inject.ts +6 -3
- package/src/tooling/install.ts +55 -0
package/src/gate/measures.ts
CHANGED
|
@@ -590,10 +590,16 @@ export const recordIdempotence: Measure = async (ctx) => {
|
|
|
590
590
|
}
|
|
591
591
|
|
|
592
592
|
const record = parseJson(run.stdout) as
|
|
593
|
-
| {
|
|
593
|
+
| {
|
|
594
|
+
moves?: unknown
|
|
595
|
+
rewritten?: unknown
|
|
596
|
+
dropped?: unknown
|
|
597
|
+
paths?: unknown
|
|
598
|
+
}
|
|
594
599
|
| undefined
|
|
595
600
|
const moves = record?.moves
|
|
596
601
|
const rewritten = record?.rewritten
|
|
602
|
+
const dropped = typeof record?.dropped === 'number' ? record.dropped : 0
|
|
597
603
|
|
|
598
604
|
if (!Array.isArray(moves) || typeof rewritten !== 'number') {
|
|
599
605
|
return {
|
|
@@ -613,14 +619,14 @@ export const recordIdempotence: Measure = async (ctx) => {
|
|
|
613
619
|
}
|
|
614
620
|
}
|
|
615
621
|
|
|
616
|
-
if (rewritten > 0) {
|
|
622
|
+
if (rewritten + dropped > 0) {
|
|
617
623
|
return {
|
|
618
624
|
// The payload already names every file, so listing them here is what
|
|
619
625
|
// separates a count a reader has to go and reproduce from a remedy they
|
|
620
626
|
// can act on. A malformed `paths` costs the list and not the finding,
|
|
621
627
|
// since the count above it was read from a field of its own.
|
|
622
628
|
emissions: citedPaths(record).map((path) => warn(path)),
|
|
623
|
-
failure: `The records are at .canon/ and ${rewritten} citation(s) still name the old root, so a second run of canon migrate records would rewrite them. Repoint each one, or mark it canon-keep-record-root where the sentence has to keep the old spelling.`,
|
|
629
|
+
failure: `The records are at .canon/ and ${rewritten + dropped} citation(s) still name the old root, so a second run of canon migrate records would rewrite or drop them. Repoint each one, or mark it canon-keep-record-root where the sentence has to keep the old spelling.`,
|
|
624
630
|
}
|
|
625
631
|
}
|
|
626
632
|
|
|
@@ -422,6 +422,9 @@ export function longestRun(
|
|
|
422
422
|
* continuing a bullet do fold in, so a heavy bullet cannot fall under the
|
|
423
423
|
* checkpoint by being wrapped across two source lines. A fenced line closes the
|
|
424
424
|
* open bullet, which keeps a bullet from absorbing the example below it.
|
|
425
|
+
*
|
|
426
|
+
* A bullet is measured without its list marker, the way the `bullet-length`
|
|
427
|
+
* gate measures it, so the count and the gate agree at the checkpoint.
|
|
425
428
|
*/
|
|
426
429
|
export function heavyBullets(
|
|
427
430
|
lines: readonly BodyLine[],
|
|
@@ -470,7 +473,7 @@ function foldBullets(
|
|
|
470
473
|
close()
|
|
471
474
|
open = {
|
|
472
475
|
line: line.number,
|
|
473
|
-
characters: visibleText(text).length,
|
|
476
|
+
characters: visibleText(text.replace(LIST_ITEM, '')).length,
|
|
474
477
|
text,
|
|
475
478
|
indent: item[1].length,
|
|
476
479
|
}
|
package/src/migrate/records.ts
CHANGED
|
@@ -56,14 +56,21 @@ export function destinationPath(entry: string): string {
|
|
|
56
56
|
* shorter entry can claim its prefix, and each entry is escaped because two of
|
|
57
57
|
* them carry a dot.
|
|
58
58
|
*/
|
|
59
|
+
const ENTRY_ALTERNATION = [...MOVED_ENTRIES]
|
|
60
|
+
.sort((left, right) => right.length - left.length)
|
|
61
|
+
.map(escape)
|
|
62
|
+
.join('|')
|
|
63
|
+
|
|
59
64
|
const CITATION = new RegExp(
|
|
60
|
-
`${escape(FROM_ROOT)}/(${[
|
|
61
|
-
.sort((left, right) => right.length - left.length)
|
|
62
|
-
.map(escape)
|
|
63
|
-
.join('|')})(?![A-Za-z0-9._-])`,
|
|
65
|
+
`${escape(FROM_ROOT)}/(${ENTRY_ALTERNATION})(?![A-Za-z0-9._-])`,
|
|
64
66
|
'g',
|
|
65
67
|
)
|
|
66
68
|
|
|
69
|
+
/** A gitignore line that is wholly an entry for one moved folder or file. */
|
|
70
|
+
const IGNORE_ENTRY = new RegExp(
|
|
71
|
+
`^/?${escape(FROM_ROOT)}/(?:${ENTRY_ALTERNATION})/?$`,
|
|
72
|
+
)
|
|
73
|
+
|
|
67
74
|
function escape(value: string): string {
|
|
68
75
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
69
76
|
}
|
|
@@ -386,6 +393,30 @@ export function scanText(text: string): {
|
|
|
386
393
|
return { rewritten, kept, globs, crossRepo, dated }
|
|
387
394
|
}
|
|
388
395
|
|
|
396
|
+
/**
|
|
397
|
+
* Deletes each live line that is wholly an entry for a moved folder, counting
|
|
398
|
+
* them. Only a root `.gitignore` that already ignores `.canon/` may be handed
|
|
399
|
+
* here, since the dropped line is redundant only under that entry. Splitting on
|
|
400
|
+
* `\n` and matching the trimmed line leaves CRLF endings and a missing final
|
|
401
|
+
* newline on the lines that stay.
|
|
402
|
+
*/
|
|
403
|
+
export function dropCoveredIgnoreLines(text: string): {
|
|
404
|
+
readonly text: string
|
|
405
|
+
readonly dropped: number
|
|
406
|
+
} {
|
|
407
|
+
const lines = text.split('\n')
|
|
408
|
+
const frontmatterEndIndex = frontmatterEnd(lines)
|
|
409
|
+
const kept = lines.filter(
|
|
410
|
+
(line, index) =>
|
|
411
|
+
!(
|
|
412
|
+
IGNORE_ENTRY.test(line.trim()) &&
|
|
413
|
+
classifyLine(lines, index, frontmatterEndIndex) === 'live'
|
|
414
|
+
),
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
return { text: kept.join('\n'), dropped: lines.length - kept.length }
|
|
418
|
+
}
|
|
419
|
+
|
|
389
420
|
/** Where a citation sits, so a reader can judge it before `--write` runs. */
|
|
390
421
|
export interface CitationLine {
|
|
391
422
|
readonly line: number
|
|
@@ -503,6 +534,7 @@ export interface CitationEntry {
|
|
|
503
534
|
readonly text: string
|
|
504
535
|
readonly rewritten: number
|
|
505
536
|
readonly kept: number
|
|
537
|
+
readonly dropped: number
|
|
506
538
|
}
|
|
507
539
|
|
|
508
540
|
/** One file whose frontmatter `paths:` glob names a moved root, held rather than rewritten. */
|
|
@@ -533,6 +565,7 @@ export interface RecordsPlan {
|
|
|
533
565
|
readonly crossRepoCitations: readonly CrossRepoCitationEntry[]
|
|
534
566
|
readonly datedCitations: readonly DatedCitationEntry[]
|
|
535
567
|
readonly rewritten: number
|
|
568
|
+
readonly dropped: number
|
|
536
569
|
readonly kept: number
|
|
537
570
|
readonly globs: number
|
|
538
571
|
readonly crossRepo: number
|
|
@@ -578,7 +611,11 @@ export function planRecordsMove(
|
|
|
578
611
|
continue
|
|
579
612
|
}
|
|
580
613
|
|
|
581
|
-
const
|
|
614
|
+
const swept =
|
|
615
|
+
source.path === '.gitignore' && ignoresDestination(source.text)
|
|
616
|
+
? dropCoveredIgnoreLines(source.text)
|
|
617
|
+
: { text: source.text, dropped: 0 }
|
|
618
|
+
const counts = scanText(swept.text)
|
|
582
619
|
kept += counts.kept
|
|
583
620
|
globs += counts.globs
|
|
584
621
|
crossRepo += counts.crossRepo
|
|
@@ -599,15 +636,16 @@ export function planRecordsMove(
|
|
|
599
636
|
if (dates.length > 0)
|
|
600
637
|
datedCitations.push({ path: source.path, lines: dates })
|
|
601
638
|
|
|
602
|
-
if (counts.rewritten === 0) continue
|
|
639
|
+
if (counts.rewritten === 0 && swept.dropped === 0) continue
|
|
603
640
|
|
|
604
641
|
if (referencesExcluded(source.text)) coupled.push(source.path)
|
|
605
642
|
|
|
606
643
|
entries.push({
|
|
607
644
|
path: source.path,
|
|
608
|
-
text: rewriteText(
|
|
645
|
+
text: rewriteText(swept.text),
|
|
609
646
|
rewritten: counts.rewritten,
|
|
610
647
|
kept: counts.kept,
|
|
648
|
+
dropped: swept.dropped,
|
|
611
649
|
})
|
|
612
650
|
}
|
|
613
651
|
|
|
@@ -621,6 +659,7 @@ export function planRecordsMove(
|
|
|
621
659
|
crossRepoCitations,
|
|
622
660
|
datedCitations,
|
|
623
661
|
rewritten: entries.reduce((sum, entry) => sum + entry.rewritten, 0),
|
|
662
|
+
dropped: entries.reduce((sum, entry) => sum + entry.dropped, 0),
|
|
624
663
|
kept,
|
|
625
664
|
globs,
|
|
626
665
|
crossRepo,
|
|
@@ -5,8 +5,8 @@ import { relative } from 'node:path'
|
|
|
5
5
|
import { gitEnv } from '@/git/env'
|
|
6
6
|
import { surfaceDir } from '@/roots/surface'
|
|
7
7
|
|
|
8
|
-
/** The
|
|
9
|
-
const CANONICAL_DOCS = ['REQUIREMENTS.md'] as const
|
|
8
|
+
/** The records carrying a `reviewed:` review point, whose drift is how far the project moved since they were read whole. */
|
|
9
|
+
const CANONICAL_DOCS = ['REQUIREMENTS.md', 'DESIGN.md'] as const
|
|
10
10
|
|
|
11
11
|
export interface CanonicalDoc {
|
|
12
12
|
/** Relative to the root, at whichever surface root carries the doc. */
|
package/src/sync/stamp.ts
CHANGED
|
@@ -16,9 +16,10 @@ import { readInstalled } from '@/version/installed'
|
|
|
16
16
|
*
|
|
17
17
|
* A stamp written before the standards or snippets install channel closed
|
|
18
18
|
* still carries a `standards` or `snippets` record, and one written before the
|
|
19
|
-
* design install retired carries a `design` record. `isStamp` ignores the key
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* design install retired carries a `design` record. `isStamp` ignores the key,
|
|
20
|
+
* `readStamp` drops it from what it returns, and `sortDomains` drops it from
|
|
21
|
+
* the file on the next write, so the target loses a domain nothing can refresh
|
|
22
|
+
* rather than losing the whole file.
|
|
22
23
|
*/
|
|
23
24
|
export const STAMP_DOMAINS = ['governance', 'tooling'] as const
|
|
24
25
|
|
|
@@ -183,12 +184,31 @@ function readStampFile(path: string): Stamp | undefined {
|
|
|
183
184
|
|
|
184
185
|
try {
|
|
185
186
|
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
|
186
|
-
return isStamp(parsed) ? parsed : undefined
|
|
187
|
+
return isStamp(parsed) ? withoutRetired(parsed) : undefined
|
|
187
188
|
} catch {
|
|
188
189
|
return undefined
|
|
189
190
|
}
|
|
190
191
|
}
|
|
191
192
|
|
|
193
|
+
/**
|
|
194
|
+
* A stamp written before a domain retired still names it until the next write.
|
|
195
|
+
* Filtering here keeps a report on a target that has not synced since from
|
|
196
|
+
* listing a domain nothing can refresh. The file itself is left as it is.
|
|
197
|
+
*/
|
|
198
|
+
function withoutRetired(stamp: Stamp): Stamp {
|
|
199
|
+
const domains: Partial<Record<StampDomain, DomainStamp>> = {}
|
|
200
|
+
for (const domain of STAMP_DOMAINS) {
|
|
201
|
+
const record = stamp.domains[domain]
|
|
202
|
+
if (record !== undefined) domains[domain] = record
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const covers = Array.isArray(stamp.covers)
|
|
206
|
+
? stamp.covers.filter((domain) => STAMP_DOMAINS.includes(domain))
|
|
207
|
+
: []
|
|
208
|
+
|
|
209
|
+
return { covers, domains }
|
|
210
|
+
}
|
|
211
|
+
|
|
192
212
|
/** The newest canon release that has written this domain, when one was recorded. */
|
|
193
213
|
export function stampedVersion(
|
|
194
214
|
stamp: Stamp | undefined,
|
package/src/tooling/inject.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { dirname, join, relative } from 'node:path'
|
|
4
|
-
import { $ } from 'bun'
|
|
5
4
|
import { copyPreservingMode } from '@/sync/copy'
|
|
5
|
+
import { type InstallResult, installDevDependencies } from '@/tooling/install'
|
|
6
6
|
import { rewritesOnInstall, stripSeedMarker } from '@/tooling/seed-marker'
|
|
7
7
|
import { resolveSurfacePath } from '@/roots/surface'
|
|
8
8
|
import { mergeSections, pruneSections } from '@/tooling/gitignore'
|
|
@@ -165,7 +165,7 @@ export async function pruneGitignore(
|
|
|
165
165
|
export async function injectManifest(
|
|
166
166
|
chain: readonly Manifest[],
|
|
167
167
|
target: string,
|
|
168
|
-
): Promise<
|
|
168
|
+
): Promise<InstallResult> {
|
|
169
169
|
const packagePath = join(target, 'package.json')
|
|
170
170
|
|
|
171
171
|
if (!existsSync(packagePath) && declaresInstalls(chain)) {
|
|
@@ -183,7 +183,8 @@ export async function injectManifest(
|
|
|
183
183
|
|
|
184
184
|
if (missing.length > 0) {
|
|
185
185
|
logStep('Installing missing dependencies')
|
|
186
|
-
|
|
186
|
+
const installed = await installDevDependencies(missing, target)
|
|
187
|
+
if (!installed.ok) return installed
|
|
187
188
|
for (const spec of missing) logAdd(spec)
|
|
188
189
|
}
|
|
189
190
|
|
|
@@ -202,6 +203,8 @@ export async function injectManifest(
|
|
|
202
203
|
await injectGitignore(chain, target)
|
|
203
204
|
|
|
204
205
|
if (!pkg) warnUnreadablePackage(chain, target)
|
|
206
|
+
|
|
207
|
+
return { ok: true }
|
|
205
208
|
}
|
|
206
209
|
|
|
207
210
|
/**
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { execa } from 'execa'
|
|
2
|
+
|
|
3
|
+
export const INSTALL_TIMEOUT_MS = 120_000
|
|
4
|
+
const TAIL_LINES = 20
|
|
5
|
+
|
|
6
|
+
export type InstallResult =
|
|
7
|
+
| { ok: true }
|
|
8
|
+
| {
|
|
9
|
+
ok: false
|
|
10
|
+
reason: 'install-timeout' | 'install-failed'
|
|
11
|
+
message: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Runs `bun add -D` for the specs and bounds it, since a registry that accepts
|
|
16
|
+
* a connection and never answers would otherwise hang the caller for good. A
|
|
17
|
+
* stall or a failure comes back as a refusal naming the packages and the tail
|
|
18
|
+
* of the output, never as a thrown error.
|
|
19
|
+
*/
|
|
20
|
+
export async function installDevDependencies(
|
|
21
|
+
specs: readonly string[],
|
|
22
|
+
cwd: string,
|
|
23
|
+
timeoutMs: number = INSTALL_TIMEOUT_MS,
|
|
24
|
+
): Promise<InstallResult> {
|
|
25
|
+
const tried = specs.join(', ')
|
|
26
|
+
const result = await execa('bun', ['add', '-D', ...specs], {
|
|
27
|
+
cwd,
|
|
28
|
+
all: true,
|
|
29
|
+
reject: false,
|
|
30
|
+
stdin: 'ignore',
|
|
31
|
+
timeout: timeoutMs,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
if (!result.failed) return { ok: true }
|
|
35
|
+
|
|
36
|
+
if (result.timedOut) {
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
reason: 'install-timeout',
|
|
40
|
+
message: `Install timed out after ${Math.round(timeoutMs / 1000)}s. Tried: ${tried}`,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const detail = tail(result.all) || String(result.shortMessage ?? '')
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
reason: 'install-failed',
|
|
48
|
+
message: `Install failed. Tried: ${tried}${detail === '' ? '' : `\n${detail}`}`,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function tail(output: unknown): string {
|
|
53
|
+
if (typeof output !== 'string') return ''
|
|
54
|
+
return output.replace(/\n$/, '').split('\n').slice(-TAIL_LINES).join('\n')
|
|
55
|
+
}
|