@erclx/canon 5.44.0 → 5.45.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 +1 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-cli/SKILL.md +0 -1
- package/claude/skills/design-taste/REQUIREMENT.md +2 -2
- package/claude/skills/pick-by-eye/REQUIREMENT.md +17 -67
- package/claude/skills/role-orchestrator/REQUIREMENT.md +16 -99
- package/claude/skills/role-planner/REQUIREMENT.md +15 -73
- package/claude/skills/role-worker/REQUIREMENT.md +13 -41
- package/claude/skills/target-setup/SKILL.md +2 -2
- package/claude/skills/target-setup/references/detect.md +0 -1
- package/claude/skills/target-setup/references/verify.md +2 -2
- package/claude/skills/teach-workspace/REQUIREMENT.md +1 -1
- package/claude/skills/teach-workspace/references/promotion.md +5 -5
- package/docs/agents/install-and-sync.md +2 -2
- package/docs/agents/output-shape.md +1 -2
- package/docs/target/migrations.md +18 -0
- package/docs/target/projects.md +1 -2
- package/docs/target/scenarios.md +4 -1
- package/docs/workflow/skill-map-on-request.md +1 -1
- package/governance/detect.toml +4 -0
- package/governance/ledger/lang/go.md +61 -0
- package/governance/ledger/lang/php.md +50 -0
- package/governance/ledger/lang/python.md +68 -0
- package/governance/ledger/lang/typescript.md +64 -0
- package/governance/ledger/lib/database.md +65 -0
- package/governance/ledger/lib/e2e-reliability.md +57 -0
- package/governance/ledger/lib/pydantic.md +61 -0
- package/governance/ledger/lib/security-server.md +62 -0
- package/governance/ledger/lib/security-web.md +51 -0
- package/governance/ledger/lib/tanstack-query.md +57 -0
- package/governance/ledger/lib/test-scope.md +43 -0
- package/governance/ledger/lib/testing-go.md +57 -0
- package/governance/ledger/lib/testing-php.md +51 -0
- package/governance/ledger/lib/testing-py.md +67 -0
- package/governance/ledger/lib/testing-ts.md +63 -0
- package/governance/ledger/lib/zod.md +47 -0
- package/governance/ledger/tooling/ci-workflow.md +33 -0
- package/governance/ledger/tooling/code-comments.md +48 -0
- package/governance/ledger/tooling/config-comments.md +27 -0
- package/governance/ledger/tooling/dependencies.md +38 -0
- package/governance/ledger/ui/a11y.md +55 -0
- package/governance/ledger/ui/copy.md +49 -0
- package/governance/ledger/ui/design-reference.md +42 -0
- package/governance/ledger/ui/design-taste.md +19 -0
- package/governance/ledger/ui/forms.md +71 -0
- package/governance/ledger/ui/interface-casing.md +47 -0
- package/governance/ledger/ui/keyboard.md +68 -0
- package/governance/ledger/ui/link-behavior.md +29 -0
- package/governance/ledger/ui/motion.md +33 -0
- package/governance/ledger/ui/surface-capture.md +69 -0
- package/governance/ledger/ui/ux-completeness.md +79 -0
- package/governance/rules/lang/go.md +7 -16
- package/governance/rules/lang/php.md +3 -3
- package/governance/rules/lang/python.md +9 -18
- package/governance/rules/lang/typescript.md +9 -19
- package/governance/rules/lib/database.md +5 -5
- package/governance/rules/lib/e2e-reliability.md +4 -6
- package/governance/rules/lib/pydantic.md +14 -16
- package/governance/rules/lib/security-server.md +4 -4
- package/governance/rules/lib/security-web.md +6 -10
- package/governance/rules/lib/tanstack-query.md +10 -12
- package/governance/rules/lib/test-scope.md +4 -4
- package/governance/rules/lib/testing-go.md +1 -2
- package/governance/rules/lib/testing-php.md +4 -5
- package/governance/rules/lib/testing-py.md +11 -19
- package/governance/rules/lib/testing-ts.md +7 -16
- package/governance/rules/lib/zod.md +6 -8
- package/governance/rules/tooling/code-comments.md +2 -4
- package/governance/rules/tooling/config-comments.md +1 -4
- package/governance/rules/tooling/dependencies.md +1 -1
- package/governance/rules/ui/a11y.md +16 -45
- package/governance/rules/ui/copy.md +6 -9
- package/governance/rules/ui/forms.md +13 -19
- package/governance/rules/ui/interface-casing.md +5 -7
- package/governance/rules/ui/keyboard.md +38 -0
- package/governance/rules/ui/link-behavior.md +3 -3
- package/governance/rules/ui/motion.md +3 -3
- package/governance/rules/ui/surface-capture.md +15 -27
- package/governance/rules/ui/ux-completeness.md +16 -39
- package/package.json +1 -1
- package/scripts/core/install-check.sh +1 -1
- package/src/cli/help.ts +0 -1
- package/src/cli.ts +0 -2
- package/src/commands/gov.ts +4 -0
- package/src/commands/init.ts +2 -2
- package/src/commands/tooling.ts +18 -5
- package/src/design/components.ts +1 -1
- package/src/gov/audit.ts +22 -2
- package/src/init/plan.ts +1 -8
- package/src/init/steps.ts +0 -8
- package/src/inventory/subjects.ts +1 -1
- package/src/markdown/scope.ts +4 -0
- package/src/migrate/scratch-evidence.ts +12 -4
- package/src/records/backup.ts +3 -3
- package/src/records/validate.ts +3 -3
- package/src/roots/record.ts +17 -8
- package/src/tooling/inject.ts +26 -7
- package/src/tooling/package.ts +32 -0
- package/src/tooling/read.ts +46 -3
- package/src/tooling/scan.ts +8 -2
- package/tooling/astro/reference.md +64 -93
- package/tooling/base/configs/.github/workflows/phase-label-gate.yml +1 -1
- package/tooling/base/configs/scripts/verify.ts +1 -1
- package/tooling/base/reference.md +1 -9
- package/tooling/base/seeds/canon/context/{ci.md → development/ci.md} +1 -1
- package/tooling/base/seeds/canon/context/development/index.md +11 -0
- package/tooling/base/seeds/canon/context/{development.md → development/setup.md} +8 -12
- package/tooling/base/seeds/canon/context/index.md +1 -2
- package/tooling/claude/manifest.toml +1 -5
- package/tooling/claude/reference.md +36 -59
- package/tooling/claude/seeds/.claude/hooks/index-reminder.sh +2 -7
- package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +2 -7
- package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +3 -10
- package/tooling/claude/seeds/.claude/hooks/search-reminder.sh +2 -7
- package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +2 -4
- package/tooling/nextjs/reference.md +0 -6
- package/tooling/php/reference.md +0 -4
- package/tooling/web/configs/.github/workflows/verify.yml +28 -0
- package/tooling/web/configs/.oxlintrc.json +7 -0
- package/tooling/web/configs/e2e/capture-types.ts +35 -0
- package/tooling/web/configs/e2e/screenshot.ts +23 -72
- package/tooling/web/manifest.toml +1 -1
- package/tooling/web/reference.md +77 -124
- package/tooling/web/seeds/e2e/capture/cases.ts +47 -0
- package/src/commands/wiki.ts +0 -100
- package/src/wiki/init.ts +0 -85
package/src/cli.ts
CHANGED
|
@@ -7,7 +7,6 @@ import { register as sync } from '@/commands/sync'
|
|
|
7
7
|
import { register as gov } from '@/commands/gov'
|
|
8
8
|
import { register as tooling } from '@/commands/tooling'
|
|
9
9
|
import { register as claude } from '@/commands/claude'
|
|
10
|
-
import { register as wiki } from '@/commands/wiki'
|
|
11
10
|
import { register as indexes } from '@/commands/indexes'
|
|
12
11
|
import { register as docs } from '@/commands/docs'
|
|
13
12
|
import { register as design } from '@/commands/design'
|
|
@@ -68,7 +67,6 @@ sync(program)
|
|
|
68
67
|
gov(program)
|
|
69
68
|
tooling(program)
|
|
70
69
|
claude(program)
|
|
71
|
-
wiki(program)
|
|
72
70
|
indexes(program)
|
|
73
71
|
docs(program)
|
|
74
72
|
design(program)
|
package/src/commands/gov.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
hasFinding,
|
|
38
38
|
PRACTICE_FOLDERS,
|
|
39
39
|
readRuleAudit,
|
|
40
|
+
RULE_GATE_EXEMPT,
|
|
40
41
|
RULE_GATED_FOLDERS,
|
|
41
42
|
RULE_LIMITS,
|
|
42
43
|
type RuleAuditReport,
|
|
@@ -631,6 +632,7 @@ export function register(program: Command): void {
|
|
|
631
632
|
' inside it.',
|
|
632
633
|
'',
|
|
633
634
|
`Gated folders: ${RULE_GATED_FOLDERS.length === 0 ? 'none yet' : RULE_GATED_FOLDERS.join(', ')}`,
|
|
635
|
+
`Exempt from the gate: ${RULE_GATE_EXEMPT.length === 0 ? 'none' : RULE_GATE_EXEMPT.join(', ')}`,
|
|
634
636
|
'',
|
|
635
637
|
'Exit codes:',
|
|
636
638
|
' 0 no finding, or under --gate no finding in a gated folder',
|
|
@@ -691,6 +693,7 @@ async function runAudit(
|
|
|
691
693
|
root,
|
|
692
694
|
limits: RULE_LIMITS,
|
|
693
695
|
gated: RULE_GATED_FOLDERS,
|
|
696
|
+
exempt: RULE_GATE_EXEMPT,
|
|
694
697
|
...report,
|
|
695
698
|
})}\n`,
|
|
696
699
|
)
|
|
@@ -699,6 +702,7 @@ async function runAudit(
|
|
|
699
702
|
return auditExit(report, {
|
|
700
703
|
gate: opts.gate ?? false,
|
|
701
704
|
gated: RULE_GATED_FOLDERS,
|
|
705
|
+
exempt: RULE_GATE_EXEMPT,
|
|
702
706
|
})
|
|
703
707
|
}
|
|
704
708
|
|
package/src/commands/init.ts
CHANGED
|
@@ -39,8 +39,8 @@ export function register(program: Command): void {
|
|
|
39
39
|
'',
|
|
40
40
|
'Runs `canon tooling sync base --write` first, which replaces every',
|
|
41
41
|
'base golden config the target holds, then `canon claude init`,',
|
|
42
|
-
'`canon gov install
|
|
43
|
-
'
|
|
42
|
+
'and `canon gov install`. Refuses a target that is not a directory or is',
|
|
43
|
+
'the toolkit root.',
|
|
44
44
|
'',
|
|
45
45
|
'Exit codes:',
|
|
46
46
|
' 0 every domain installed, or the prompt was cancelled',
|
package/src/commands/tooling.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
2
3
|
import type { Command } from 'commander'
|
|
3
4
|
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
|
|
4
5
|
import {
|
|
@@ -12,11 +13,16 @@ import { buildStackSummaries, describeStack } from '@/tooling/list'
|
|
|
12
13
|
import {
|
|
13
14
|
isStackExcluded,
|
|
14
15
|
listStacks,
|
|
16
|
+
loadManifest,
|
|
15
17
|
type Manifest,
|
|
16
18
|
resolveChain,
|
|
17
19
|
stackExists,
|
|
18
20
|
} from '@/tooling/manifest'
|
|
19
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
readReference,
|
|
23
|
+
renderStackSections,
|
|
24
|
+
resolveReference,
|
|
25
|
+
} from '@/tooling/read'
|
|
20
26
|
import { scan, type ScanResult } from '@/tooling/scan'
|
|
21
27
|
import { recordToolingChain } from '@/tooling/stamp'
|
|
22
28
|
import { subfolderPath } from '@/tooling/subfolder'
|
|
@@ -302,6 +308,8 @@ function printReference(stack: string): number {
|
|
|
302
308
|
|
|
303
309
|
logStep(resolved.source)
|
|
304
310
|
process.stdout.write(readReference(resolved))
|
|
311
|
+
const manifest = loadManifest(resolved.root, stack)
|
|
312
|
+
if (manifest) process.stdout.write(renderStackSections(manifest))
|
|
305
313
|
outro()
|
|
306
314
|
return 0
|
|
307
315
|
}
|
|
@@ -625,12 +633,16 @@ function report(result: ScanResult, target: string): void {
|
|
|
625
633
|
if (entry.state === 'missing') logAdd(entry.rel)
|
|
626
634
|
}
|
|
627
635
|
|
|
628
|
-
if (result.
|
|
636
|
+
if (result.newManifest) {
|
|
637
|
+
logStep('Scanning package.json')
|
|
638
|
+
logAdd('package.json')
|
|
639
|
+
reportPackage(result)
|
|
640
|
+
} else if (result.hasPackageJson) {
|
|
629
641
|
reportPackage(result)
|
|
630
|
-
} else {
|
|
642
|
+
} else if (existsSync(join(target, 'package.json'))) {
|
|
631
643
|
logStep('Scanning package.json')
|
|
632
644
|
logWarn(
|
|
633
|
-
|
|
645
|
+
`Skipped scripts and deps: package.json is not valid JSON (fix it in ${target}, then sync again)`,
|
|
634
646
|
)
|
|
635
647
|
}
|
|
636
648
|
|
|
@@ -688,6 +700,7 @@ function summarize(result: ScanResult): string {
|
|
|
688
700
|
if (count > 0) parts.push(`${count} ${label}`)
|
|
689
701
|
}
|
|
690
702
|
|
|
703
|
+
add(result.newManifest ? 1 : 0, 'package.json')
|
|
691
704
|
add(
|
|
692
705
|
result.configs.filter((entry) => entry.state !== 'matching').length,
|
|
693
706
|
'configs',
|
package/src/design/components.ts
CHANGED
|
@@ -520,7 +520,7 @@ html.sb-shut .pane { border-left: 0; }
|
|
|
520
520
|
}
|
|
521
521
|
|
|
522
522
|
/* A bar link is its own tap target rather than a line of text, so it carries
|
|
523
|
-
the 24 pixel minimum the
|
|
523
|
+
the 24 pixel minimum the keyboard rule sets. */
|
|
524
524
|
.mast a,
|
|
525
525
|
.mast summary {
|
|
526
526
|
min-height: 1.6rem;
|
package/src/gov/audit.ts
CHANGED
|
@@ -36,6 +36,21 @@ export const RULE_GATED_FOLDERS: readonly string[] = [
|
|
|
36
36
|
join('governance', 'rules', 'framework') + sep,
|
|
37
37
|
join('governance', 'rules', 'writing') + sep,
|
|
38
38
|
join('internal', 'rules') + sep,
|
|
39
|
+
join('governance', 'rules', 'lang') + sep,
|
|
40
|
+
join('governance', 'rules', 'lib') + sep,
|
|
41
|
+
join('governance', 'rules', 'ui') + sep,
|
|
42
|
+
join('governance', 'rules', 'tooling') + sep,
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Rule files in a gated folder whose findings `--gate` still passes, one path
|
|
47
|
+
* each and never a folder, so a new rule beside one is gated the day it lands.
|
|
48
|
+
*
|
|
49
|
+
* `ui/canvas.md` carries a shape finding and no ledger, and v149.2 removes the
|
|
50
|
+
* rule, so this entry goes with it.
|
|
51
|
+
*/
|
|
52
|
+
export const RULE_GATE_EXEMPT: readonly string[] = [
|
|
53
|
+
join('governance', 'rules', 'ui', 'canvas.md'),
|
|
39
54
|
]
|
|
40
55
|
|
|
41
56
|
/** The folders whose rules claim outside practice, so each carries a ledger. */
|
|
@@ -429,13 +444,18 @@ export function hasFinding(reading: RuleReading): boolean {
|
|
|
429
444
|
*/
|
|
430
445
|
export function auditExit(
|
|
431
446
|
report: Extract<RuleAuditReport, { kind: 'measured' }>,
|
|
432
|
-
options: {
|
|
447
|
+
options: {
|
|
448
|
+
readonly gate: boolean
|
|
449
|
+
readonly gated: readonly string[]
|
|
450
|
+
readonly exempt?: readonly string[]
|
|
451
|
+
},
|
|
433
452
|
): number {
|
|
434
453
|
const failing = report.rules.filter(
|
|
435
454
|
(reading) =>
|
|
436
455
|
hasFinding(reading) &&
|
|
437
456
|
(!options.gate ||
|
|
438
|
-
options.gated.some((prefix) => reading.file.startsWith(prefix))
|
|
457
|
+
(options.gated.some((prefix) => reading.file.startsWith(prefix)) &&
|
|
458
|
+
!options.exempt?.includes(reading.file))),
|
|
439
459
|
)
|
|
440
460
|
return failing.length > 0 ? 2 : 0
|
|
441
461
|
}
|
package/src/init/plan.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const SKIPPABLE_DOMAINS = ['
|
|
1
|
+
export const SKIPPABLE_DOMAINS = ['governance', 'records'] as const
|
|
2
2
|
|
|
3
3
|
export type SkippableDomain = (typeof SKIPPABLE_DOMAINS)[number]
|
|
4
4
|
|
|
@@ -97,13 +97,6 @@ export function planInit(flags: InitFlags): InitPlan {
|
|
|
97
97
|
})
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
if (!flags.skip.skipped.has('wiki')) {
|
|
101
|
-
preview.push({
|
|
102
|
-
level: 'info',
|
|
103
|
-
text: 'wiki (.claude/wiki/ with a stub index)',
|
|
104
|
-
})
|
|
105
|
-
}
|
|
106
|
-
|
|
107
100
|
if (!flags.skip.skipped.has('records')) {
|
|
108
101
|
preview.push({
|
|
109
102
|
level: 'info',
|
package/src/init/steps.ts
CHANGED
|
@@ -55,14 +55,6 @@ export function buildSteps(
|
|
|
55
55
|
})
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
if (!flags.skip.skipped.has('wiki')) {
|
|
59
|
-
steps.push({
|
|
60
|
-
kind: 'run',
|
|
61
|
-
label: 'Wiki',
|
|
62
|
-
run: child(['wiki', 'init', resolved]),
|
|
63
|
-
})
|
|
64
|
-
}
|
|
65
|
-
|
|
66
58
|
if (!flags.skip.skipped.has('records')) {
|
|
67
59
|
// `skip` here means there is nothing to run non-interactively, not that
|
|
68
60
|
// the caller opted out, unlike every other push of this kind above.
|
|
@@ -27,7 +27,7 @@ export interface Subject {
|
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* Reports what each element changes about itself when it takes focus, which is
|
|
30
|
-
* the reading `governance/rules/ui/
|
|
30
|
+
* the reading `governance/rules/ui/keyboard.md` states three rules against and
|
|
31
31
|
* nothing measures.
|
|
32
32
|
*
|
|
33
33
|
* The difference between rest and focus is the treatment, rather than the
|
package/src/markdown/scope.ts
CHANGED
|
@@ -114,6 +114,7 @@ export const GATED_PATHS: readonly string[] = [
|
|
|
114
114
|
'claude/skills/docs-sync/references/readme.md',
|
|
115
115
|
'claude/skills/document-health/SKILL.md',
|
|
116
116
|
'claude/skills/document-health/references/axes.md',
|
|
117
|
+
'claude/skills/pick-by-eye/REQUIREMENT.md',
|
|
117
118
|
'claude/skills/pick-by-eye/SKILL.md',
|
|
118
119
|
'claude/skills/pick-by-eye/references/live-arms.md',
|
|
119
120
|
'claude/skills/write-doc/SKILL.md',
|
|
@@ -204,6 +205,7 @@ export const GATED_PATHS: readonly string[] = [
|
|
|
204
205
|
'claude/skills/review-ui/references/hosted-preview.md',
|
|
205
206
|
'claude/skills/review-ui/references/post.md',
|
|
206
207
|
'claude/skills/review-ui/references/tick.md',
|
|
208
|
+
'claude/skills/role-orchestrator/REQUIREMENT.md',
|
|
207
209
|
'claude/skills/role-orchestrator/SKILL.md',
|
|
208
210
|
'claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md',
|
|
209
211
|
'claude/skills/role-orchestrator/references/orchestrator-dispatch.md',
|
|
@@ -221,8 +223,10 @@ export const GATED_PATHS: readonly string[] = [
|
|
|
221
223
|
'claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md',
|
|
222
224
|
'claude/skills/role-orchestrator/references/orchestrator-sweep.md',
|
|
223
225
|
'claude/skills/role-orchestrator/references/orchestrator-watch.md',
|
|
226
|
+
'claude/skills/role-planner/REQUIREMENT.md',
|
|
224
227
|
'claude/skills/role-planner/SKILL.md',
|
|
225
228
|
'claude/skills/role-reviewer/SKILL.md',
|
|
229
|
+
'claude/skills/role-worker/REQUIREMENT.md',
|
|
226
230
|
'claude/skills/role-worker/SKILL.md',
|
|
227
231
|
'claude/skills/search-craft/SKILL.md',
|
|
228
232
|
'claude/skills/search-craft/references/adopted.md',
|
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
slugOf,
|
|
35
35
|
} from '@/migrate/evidence-ordinal'
|
|
36
36
|
import { presentFolders } from '@/records/backup'
|
|
37
|
-
import { recordDir, SCRATCH } from '@/roots/record'
|
|
37
|
+
import { LEGACY_SCRATCH_REL, recordDir, SCRATCH } from '@/roots/record'
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Every folder this promotion moves, at the name it carries under scratch.
|
|
@@ -64,9 +64,17 @@ export const PROMOTED_FOLDERS: readonly string[] = [
|
|
|
64
64
|
'target-survey',
|
|
65
65
|
]
|
|
66
66
|
|
|
67
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Where a promoted folder sits before the move: `.canon/tmp` when it holds the
|
|
69
|
+
* folder, so a target part-way through a move promotes the newer copy, and the
|
|
70
|
+
* retired scratch folder otherwise. The verb is a migration, so it reads an
|
|
71
|
+
* unmigrated target where it is.
|
|
72
|
+
*/
|
|
68
73
|
export function sourcePath(root: string, folder: string): string {
|
|
69
|
-
|
|
74
|
+
const current = recordDir(root, SCRATCH, folder)
|
|
75
|
+
const legacy = join(root, LEGACY_SCRATCH_REL, folder)
|
|
76
|
+
|
|
77
|
+
return !existsSync(current) && existsSync(legacy) ? legacy : current
|
|
70
78
|
}
|
|
71
79
|
|
|
72
80
|
/** The folder a promoted one lands inside, under whichever root carries it. */
|
|
@@ -85,7 +93,7 @@ function escape(value: string): string {
|
|
|
85
93
|
* swallowing a sibling folder whose name extends it.
|
|
86
94
|
*/
|
|
87
95
|
const OLD_PREFIXES = [
|
|
88
|
-
'.claude/.tmp/',
|
|
96
|
+
'.claude/.tmp/', // canon-keep-record-root
|
|
89
97
|
'.canon/tmp/',
|
|
90
98
|
'../.tmp/',
|
|
91
99
|
'../tmp/',
|
package/src/records/backup.ts
CHANGED
|
@@ -107,9 +107,9 @@ export const EXCLUDED_ENTRIES = [
|
|
|
107
107
|
/**
|
|
108
108
|
* Whether a top-level name stays out of every push, at either root.
|
|
109
109
|
*
|
|
110
|
-
* `SCRATCH` is the
|
|
111
|
-
*
|
|
112
|
-
* spelling only.
|
|
110
|
+
* `SCRATCH` is the retired `.tmp` spelling. It is a guard rather than a lookup,
|
|
111
|
+
* keeping a stray `.tmp` out of a push, and `EXCLUDED_ENTRIES` names the
|
|
112
|
+
* `.canon` spelling only.
|
|
113
113
|
*/
|
|
114
114
|
function isExcluded(name: string): boolean {
|
|
115
115
|
return (
|
package/src/records/validate.ts
CHANGED
|
@@ -228,9 +228,9 @@ const PLAN_REQUIRED: readonly PlanSection[] = [
|
|
|
228
228
|
]
|
|
229
229
|
|
|
230
230
|
/**
|
|
231
|
-
* The files whose edit changes what a person sees, the set the `ui/a11y`
|
|
232
|
-
*
|
|
233
|
-
* one owes a `Design references` section, so a set too wide forces it on every
|
|
231
|
+
* The files whose edit changes what a person sees, the set the `ui/a11y` and
|
|
232
|
+
* `ui/keyboard` rules glob plus the other markup and style extensions a plan
|
|
233
|
+
* names. A plan touching one owes a `Design references` section, so a set too wide forces it on every
|
|
234
234
|
* plan and one too narrow lets a UI plan skip it.
|
|
235
235
|
*/
|
|
236
236
|
const RENDERED_SURFACE = /\.(?:tsx|jsx|astro|vue|svelte|html|css|scss)$/
|
package/src/roots/record.ts
CHANGED
|
@@ -29,17 +29,22 @@ export type RecordRoot = (typeof RECORD_ROOTS)[number]
|
|
|
29
29
|
export const CREATION_ROOT: RecordRoot = '.canon'
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* The deletable scratch folder, named at the spelling `.claude/`
|
|
32
|
+
* The deletable scratch folder, named at the spelling `.claude/` gave it.
|
|
33
33
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* `
|
|
38
|
-
*
|
|
39
|
-
*
|
|
34
|
+
* Scratch resolves at `.canon/tmp` only. The `.claude/.tmp` spelling is a
|
|
35
|
+
* migration source, named through `LEGACY_SCRATCH_REL` by the verbs that move
|
|
36
|
+
* it, and no lookup falls back to it. Callers still name the folder at this
|
|
37
|
+
* spelling because `spell` is the only place the variant is decided. Inside a
|
|
38
|
+
* dotted root the leading dot hides nothing already hidden and costs a bare
|
|
39
|
+
* `ls` that omits the folder, so the move dropped it. Every other record
|
|
40
|
+
* folder keeps its name, `.records.git` included, where the dot marks the
|
|
41
|
+
* mechanism apart from a payload rather than hiding it.
|
|
40
42
|
*/
|
|
41
43
|
export const SCRATCH = '.tmp'
|
|
42
44
|
|
|
45
|
+
/** The retired scratch location, read only as the source of a migration. */
|
|
46
|
+
export const LEGACY_SCRATCH_REL = '.claude/.tmp' // canon-keep-record-root
|
|
47
|
+
|
|
43
48
|
/** The scratch folder's name under `.canon/`. */
|
|
44
49
|
const CANON_SCRATCH = 'tmp'
|
|
45
50
|
|
|
@@ -109,6 +114,8 @@ export function spell(root: RecordRoot, folder: string): string {
|
|
|
109
114
|
* records it belongs to rather than at the creation default.
|
|
110
115
|
*/
|
|
111
116
|
function rootOf(root: string, folder: string): RecordRoot {
|
|
117
|
+
if (folder === SCRATCH) return CREATION_ROOT
|
|
118
|
+
|
|
112
119
|
return (
|
|
113
120
|
RECORD_ROOTS.find((candidate) =>
|
|
114
121
|
existsSync(join(root, candidate, spell(candidate, folder))),
|
|
@@ -147,7 +154,9 @@ export function recordDirs(
|
|
|
147
154
|
folder: string,
|
|
148
155
|
...rest: string[]
|
|
149
156
|
): string[] {
|
|
150
|
-
|
|
157
|
+
const candidates = folder === SCRATCH ? [CREATION_ROOT] : RECORD_ROOTS
|
|
158
|
+
|
|
159
|
+
return candidates.map((candidate) =>
|
|
151
160
|
join(root, candidate, spell(candidate, folder), ...rest),
|
|
152
161
|
)
|
|
153
162
|
}
|
package/src/tooling/inject.ts
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
import {
|
|
17
17
|
applyScripts,
|
|
18
18
|
collectDeps,
|
|
19
|
+
declaresInstalls,
|
|
20
|
+
minimalPackage,
|
|
19
21
|
readPackage,
|
|
20
22
|
serializePackage,
|
|
21
23
|
} from '@/tooling/package'
|
|
@@ -156,15 +158,22 @@ export async function pruneGitignore(
|
|
|
156
158
|
|
|
157
159
|
/**
|
|
158
160
|
* Installs missing dev dependencies, fills package scripts, and merges
|
|
159
|
-
* gitignore entries. A target with no package.json gets
|
|
160
|
-
*
|
|
161
|
-
*
|
|
161
|
+
* gitignore entries. A target with no package.json gets a minimal one first
|
|
162
|
+
* when the chain installs anything. A package.json that exists but cannot be
|
|
163
|
+
* parsed is never rewritten, and the install is skipped with a warning.
|
|
162
164
|
*/
|
|
163
165
|
export async function injectManifest(
|
|
164
166
|
chain: readonly Manifest[],
|
|
165
167
|
target: string,
|
|
166
168
|
): Promise<void> {
|
|
167
169
|
const packagePath = join(target, 'package.json')
|
|
170
|
+
|
|
171
|
+
if (!existsSync(packagePath) && declaresInstalls(chain)) {
|
|
172
|
+
logStep('Writing package.json')
|
|
173
|
+
await writeFile(packagePath, serializePackage(minimalPackage(target)))
|
|
174
|
+
logAdd('package.json')
|
|
175
|
+
}
|
|
176
|
+
|
|
168
177
|
const pkg = readPackage(packagePath)
|
|
169
178
|
|
|
170
179
|
if (pkg) {
|
|
@@ -192,14 +201,22 @@ export async function injectManifest(
|
|
|
192
201
|
|
|
193
202
|
await injectGitignore(chain, target)
|
|
194
203
|
|
|
195
|
-
if (!pkg)
|
|
204
|
+
if (!pkg) warnUnreadablePackage(chain, target)
|
|
196
205
|
}
|
|
197
206
|
|
|
198
|
-
|
|
207
|
+
/**
|
|
208
|
+
* Only an existing package.json that fails to parse reaches here with installs
|
|
209
|
+
* pending, since an absent one was written above. It is left untouched.
|
|
210
|
+
*/
|
|
211
|
+
function warnUnreadablePackage(
|
|
212
|
+
chain: readonly Manifest[],
|
|
213
|
+
target: string,
|
|
214
|
+
): void {
|
|
215
|
+
if (!declaresInstalls(chain)) return
|
|
216
|
+
|
|
199
217
|
const deps = collectDeps(chain, {}).map((dep) => dep.spec)
|
|
200
218
|
const skipped = applyScripts({}, chain)
|
|
201
219
|
const scripts = [...new Set([...skipped.added, ...skipped.overridden])]
|
|
202
|
-
if (deps.length === 0 && scripts.length === 0) return
|
|
203
220
|
|
|
204
221
|
logStep('Skipped installs')
|
|
205
222
|
if (deps.length > 0) {
|
|
@@ -208,5 +225,7 @@ function warnSkippedInstalls(chain: readonly Manifest[], target: string): void {
|
|
|
208
225
|
if (scripts.length > 0) {
|
|
209
226
|
logWarn(`Scripts not added: ${scripts.join(', ')}`)
|
|
210
227
|
}
|
|
211
|
-
logWarn(
|
|
228
|
+
logWarn(
|
|
229
|
+
`package.json is not valid JSON: fix it in ${target}, then sync again`,
|
|
230
|
+
)
|
|
212
231
|
}
|
package/src/tooling/package.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { basename } from 'node:path'
|
|
2
3
|
import type { Manifest } from '@/tooling/manifest'
|
|
3
4
|
|
|
4
5
|
export interface PackageJson {
|
|
@@ -28,6 +29,37 @@ export function readPackage(path: string): PackageJson | undefined {
|
|
|
28
29
|
}
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Whether the chain asks for any dev dependency or script, which is what makes
|
|
34
|
+
* a missing package.json worth writing. A stack that installs nothing, such as
|
|
35
|
+
* the one `canon claude` drives, leaves a project without a manifest alone.
|
|
36
|
+
*/
|
|
37
|
+
export function declaresInstalls(chain: readonly Manifest[]): boolean {
|
|
38
|
+
if (collectDeps(chain, {}).length > 0) return true
|
|
39
|
+
|
|
40
|
+
const scripts = applyScripts({}, chain)
|
|
41
|
+
return scripts.added.length > 0 || scripts.overridden.length > 0
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The manifest a sync writes into a folder that has none. The name is the
|
|
46
|
+
* folder's, reduced to the characters npm accepts, so it is never empty or
|
|
47
|
+
* invalid.
|
|
48
|
+
*/
|
|
49
|
+
export function minimalPackage(target: string): PackageJson {
|
|
50
|
+
const name = basename(target)
|
|
51
|
+
.toLowerCase()
|
|
52
|
+
.replace(/[^a-z0-9-._]/g, '-')
|
|
53
|
+
.replace(/^[._]+/, '')
|
|
54
|
+
.slice(0, 214)
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
name: name === '' ? 'project' : name,
|
|
58
|
+
version: '0.0.0',
|
|
59
|
+
type: 'module',
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
31
63
|
export function serializePackage(pkg: PackageJson): string {
|
|
32
64
|
return `${JSON.stringify(pkg, null, 2)}\n`
|
|
33
65
|
}
|
package/src/tooling/read.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { PROJECT_ROOT } from '@/roots/project'
|
|
4
|
+
import { configFiles, type Manifest, seedFiles } from '@/tooling/manifest'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Spells the package root in a report where every other root spells a
|
|
@@ -11,6 +12,8 @@ import { PROJECT_ROOT } from '@/roots/project'
|
|
|
11
12
|
const PACKAGE_LABEL = '<canon>'
|
|
12
13
|
|
|
13
14
|
export interface ReferenceRoot {
|
|
15
|
+
/** The root whose `tooling/` this searches, as the manifest loader takes it. */
|
|
16
|
+
readonly root: string
|
|
14
17
|
/** Absolute `tooling/` directory to search. */
|
|
15
18
|
readonly dir: string
|
|
16
19
|
/** How a report spells a copy found under this root. */
|
|
@@ -19,6 +22,8 @@ export interface ReferenceRoot {
|
|
|
19
22
|
|
|
20
23
|
export interface ResolvedReference {
|
|
21
24
|
readonly path: string
|
|
25
|
+
/** The root that won, so the manifest is read from the same copy. */
|
|
26
|
+
readonly root: string
|
|
22
27
|
/** The label of the root that won, joined to the stack and filename. */
|
|
23
28
|
readonly source: string
|
|
24
29
|
}
|
|
@@ -36,8 +41,9 @@ function toolingSourceDir(root: string): string {
|
|
|
36
41
|
*/
|
|
37
42
|
export function referenceRoots(root: string): ReferenceRoot[] {
|
|
38
43
|
return [
|
|
39
|
-
{ dir: toolingSourceDir(root), label: 'tooling' },
|
|
44
|
+
{ root, dir: toolingSourceDir(root), label: 'tooling' },
|
|
40
45
|
{
|
|
46
|
+
root: PROJECT_ROOT,
|
|
41
47
|
dir: toolingSourceDir(PROJECT_ROOT),
|
|
42
48
|
label: join(PACKAGE_LABEL, 'tooling'),
|
|
43
49
|
},
|
|
@@ -49,10 +55,10 @@ export function resolveReference(
|
|
|
49
55
|
root: string,
|
|
50
56
|
stack: string,
|
|
51
57
|
): ResolvedReference | undefined {
|
|
52
|
-
for (const { dir, label } of referenceRoots(root)) {
|
|
58
|
+
for (const { root: owner, dir, label } of referenceRoots(root)) {
|
|
53
59
|
const path = join(dir, stack, 'reference.md')
|
|
54
60
|
if (existsSync(path)) {
|
|
55
|
-
return { path, source: join(label, stack, 'reference.md') }
|
|
61
|
+
return { path, root: owner, source: join(label, stack, 'reference.md') }
|
|
56
62
|
}
|
|
57
63
|
}
|
|
58
64
|
|
|
@@ -62,3 +68,40 @@ export function resolveReference(
|
|
|
62
68
|
export function readReference(reference: ResolvedReference): string {
|
|
63
69
|
return readFileSync(reference.path, 'utf8')
|
|
64
70
|
}
|
|
71
|
+
|
|
72
|
+
function codeList(items: readonly string[]): string {
|
|
73
|
+
return items.map((item) => `- \`${item}\``).join('\n')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The sections the verb prints after a stack's reference, read from its
|
|
78
|
+
* manifest so no reference keeps a hand list that drifts from it. A stack lists
|
|
79
|
+
* its own files and groups alone, since a parent's print under the parent's
|
|
80
|
+
* reference, and a section with nothing to list prints no heading.
|
|
81
|
+
*/
|
|
82
|
+
export function renderStackSections(manifest: Manifest): string {
|
|
83
|
+
const sections: string[] = []
|
|
84
|
+
const configs = configFiles(manifest)
|
|
85
|
+
const seeds = seedFiles(manifest)
|
|
86
|
+
|
|
87
|
+
if (configs.length > 0 || seeds.length > 0) {
|
|
88
|
+
const sync = ['## Sync']
|
|
89
|
+
if (configs.length > 0) {
|
|
90
|
+
sync.push('Sync keeps these current, from `configs/`:', codeList(configs))
|
|
91
|
+
}
|
|
92
|
+
if (seeds.length > 0) {
|
|
93
|
+
sync.push('Sync copies these once, from `seeds/`:', codeList(seeds))
|
|
94
|
+
}
|
|
95
|
+
sections.push(sync.join('\n\n'))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (manifest.gitignore.length > 0) {
|
|
99
|
+
const groups = manifest.gitignore.map(
|
|
100
|
+
({ header, entries }) =>
|
|
101
|
+
`- \`${header}\`: ${entries.map((entry) => `\`${entry}\``).join(', ')}`,
|
|
102
|
+
)
|
|
103
|
+
sections.push(`## Gitignore\n\n${groups.join('\n')}`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return sections.map((section) => `\n${section}\n`).join('')
|
|
107
|
+
}
|
package/src/tooling/scan.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import {
|
|
13
13
|
collectDeps,
|
|
14
14
|
collectScripts,
|
|
15
|
+
declaresInstalls,
|
|
15
16
|
type DepState,
|
|
16
17
|
readPackage,
|
|
17
18
|
type ScriptState,
|
|
@@ -48,6 +49,7 @@ export interface ScanResult {
|
|
|
48
49
|
readonly deps: readonly DepState[]
|
|
49
50
|
readonly gitignore: readonly EntryState[]
|
|
50
51
|
readonly hasPackageJson: boolean
|
|
52
|
+
readonly newManifest: boolean
|
|
51
53
|
readonly totalChanges: number
|
|
52
54
|
}
|
|
53
55
|
|
|
@@ -128,8 +130,10 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
|
|
|
128
130
|
|
|
129
131
|
const packagePath = join(target, 'package.json')
|
|
130
132
|
const pkg = readPackage(packagePath)
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
+
const newManifest = !existsSync(packagePath) && declaresInstalls(chain)
|
|
134
|
+
const basis = pkg ?? (newManifest ? {} : undefined)
|
|
135
|
+
const scripts = basis ? collectScripts(chain, basis) : []
|
|
136
|
+
const deps = basis ? collectDeps(chain, basis) : []
|
|
133
137
|
|
|
134
138
|
const gitignorePath = join(target, '.gitignore')
|
|
135
139
|
const gitignoreContent = existsSync(gitignorePath)
|
|
@@ -138,6 +142,7 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
|
|
|
138
142
|
const gitignore = scanGitignore(chain, gitignoreContent)
|
|
139
143
|
|
|
140
144
|
const totalChanges =
|
|
145
|
+
(newManifest ? 1 : 0) +
|
|
141
146
|
configs.filter((entry) => entry.state !== 'matching').length +
|
|
142
147
|
seeds.filter((entry) => entry.state === 'missing').length +
|
|
143
148
|
scripts.filter((entry) => entry.state !== 'matching').length +
|
|
@@ -152,6 +157,7 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
|
|
|
152
157
|
deps,
|
|
153
158
|
gitignore,
|
|
154
159
|
hasPackageJson: pkg !== undefined,
|
|
160
|
+
newManifest,
|
|
155
161
|
totalChanges,
|
|
156
162
|
}
|
|
157
163
|
}
|