@erclx/canon 5.59.0 → 5.60.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 +14 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/document-health/REQUIREMENT.md +1 -1
- package/claude/skills/document-health/SKILL.md +19 -0
- package/claude/skills/document-health/references/axes.md +18 -0
- 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/package.json +1 -1
- package/src/commands/design.ts +57 -1
- package/src/commands/tooling.ts +17 -2
- package/src/design/audit.ts +109 -0
- package/src/design/css.ts +1 -5
- package/src/sync/stamp.ts +24 -4
- package/src/tooling/inject.ts +6 -3
- package/src/tooling/install.ts +55 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [5.60.0](https://github.com/erclx/canon/compare/v5.59.0...v5.60.0) (2026-10-24)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **design:** add canon design audit verb ([#2661](https://github.com/erclx/canon/issues/2661)) ([303a52a](https://github.com/erclx/canon/commit/303a52ada294d9740205885336b6f1ce006ddbba))
|
|
9
|
+
* **skills:** reach design-taste in planner and later review passes ([#2657](https://github.com/erclx/canon/issues/2657)) ([dedc351](https://github.com/erclx/canon/commit/dedc35132a57cf8f82e9a25603e6128e0c77d6ed))
|
|
10
|
+
* **tooling:** bound the dev dependency install ([#2666](https://github.com/erclx/canon/issues/2666)) ([09017a6](https://github.com/erclx/canon/commit/09017a6bfcf11d3feffa1eccfa1ec99f8a0ff3ae))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **sync:** drop retired domains when reading the stamp ([#2664](https://github.com/erclx/canon/issues/2664)) ([21e0a1c](https://github.com/erclx/canon/commit/21e0a1cccfcab13dba5e50e3a55ea620d0d77907))
|
|
16
|
+
|
|
3
17
|
## [5.59.0](https://github.com/erclx/canon/compare/v5.58.0...v5.59.0) (2026-10-24)
|
|
4
18
|
|
|
5
19
|
|
|
@@ -17,7 +17,7 @@ Length, placement, and staleness answer to different evidence, and one list hand
|
|
|
17
17
|
- Propose a split or an owner as a structure line for each overgrown entry and doubled or unnamed coverage finding, with the headings and the reason
|
|
18
18
|
- Ship the staleness axis as a reading rather than cutting it for lacking a verb
|
|
19
19
|
- Report a refusal, an absent key, or a missing subcommand as an unread axis naming the cause, and say which layer answered the classifier sweep
|
|
20
|
-
- Run standalone,
|
|
20
|
+
- Run standalone, reading the requirements record for drift with its reminder, and the design record through `canon design audit --json`, routing its findings to `design-record`
|
|
21
21
|
|
|
22
22
|
## Must not
|
|
23
23
|
|
|
@@ -60,6 +60,16 @@ A refusal, an absent key, and a subcommand the project's binary does not carry e
|
|
|
60
60
|
- `canon context audit` refuses with `reason: "no-folders"` where the layout is not the expected one. Reading that honest refusal as an empty finding set reports a healthy corpus nothing looked at.
|
|
61
61
|
- A narrower read is not an unread one. The sweep running on its regex layer alone still answered, so report it with the layer named.
|
|
62
62
|
|
|
63
|
+
### The design record
|
|
64
|
+
|
|
65
|
+
`canon/DESIGN.md` reads outside both verbs above, since neither measures its prose budget or its tokens. When the scope reaches it, read it through its own verb:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
canon design audit --json
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The record carries `prose`, the lines past the budget with their counts, and `tokens`, the custom properties the stylesheet does not declare. `${CLAUDE_SKILL_DIR}/references/axes.md` holds how each reads and where it routes. Skip the verb when the scope excludes the record or the record is `null`.
|
|
72
|
+
|
|
63
73
|
## Step 3: read the staleness axis
|
|
64
74
|
|
|
65
75
|
No verb answers whether a document still describes the tree it was written about. Read each document against the surfaces it names and report where the two have parted, taking the file paths, commands, and counts it states as the claims to test.
|
|
@@ -121,3 +131,12 @@ Structure `canon/context/surfaces/`: `cli.md` (300 lines) splits into `cli.md` t
|
|
|
121
131
|
The line is a proposal for the operator, with the headings each entry takes and why. Health applies nothing, and `document-repair` offers the proposal before it rewrites.
|
|
122
132
|
|
|
123
133
|
Keep the evidence line under the table on every run. A table read without it flattens a judgment into a measurement, which is the one shape the three classes exist to prevent.
|
|
134
|
+
|
|
135
|
+
### The design record row
|
|
136
|
+
|
|
137
|
+
When Step 2 read the design record, add a row for `canon/DESIGN.md`, with both findings `measured`.
|
|
138
|
+
|
|
139
|
+
- Put the `prose` findings in the Length cell and the `tokens` findings in the Placement cell
|
|
140
|
+
- Write `unread: <reason>` in the Placement cell when `tokens.status` reads `unchecked`, since a check that could not run is no pass
|
|
141
|
+
- Write `n/a: <reason>` there when it reads `not-applicable`, a values record with no property to compare
|
|
142
|
+
- Route every finding to `design-record`, never to `document-repair`
|
|
@@ -73,6 +73,24 @@ Both verbs resolve their folders by surface root rather than by a path written i
|
|
|
73
73
|
- The audit refuses with `reason: "no-folders"`, naming the folders it looked for. Report that refusal as an unread axis.
|
|
74
74
|
- The sweep with the model layer off answers from its regex layer alone, a narrower read rather than a refusal, so say which layer answered
|
|
75
75
|
|
|
76
|
+
## The design record
|
|
77
|
+
|
|
78
|
+
| Field | Value |
|
|
79
|
+
| -------------- | ------------------------------------------------------------------------------------------------ |
|
|
80
|
+
| Measures | Whether the design record holds to its prose budget and names only properties its stylesheet has |
|
|
81
|
+
| Verb | `canon design audit --json` |
|
|
82
|
+
| Evidence class | `measured` |
|
|
83
|
+
|
|
84
|
+
The record is exempt from the length and placement verbs above, so this is the only measured read it gets.
|
|
85
|
+
|
|
86
|
+
- `prose`: each place the record passes the budget, with the count and the cap it was read against. Take the caps from the finding rather than from a number written here.
|
|
87
|
+
- `tokens.missing`: a custom property a table names that the stylesheet the lead names does not declare
|
|
88
|
+
- `tokens.status: "unchecked"`: the lead names no stylesheet, or the one it names does not resolve. Report the axis as unread with the `reason`, since nothing was compared.
|
|
89
|
+
- `tokens.status: "not-applicable"`: a values record, which carries `Value` where an intent record carries `Property`. Report it as not applicable with the `reason`.
|
|
90
|
+
- `record: null`: the project holds no design record, so there is nothing to report
|
|
91
|
+
|
|
92
|
+
**Routes to:** `design-record`, which owns the record's shape. `document-repair` rewrites a context folder and does not know the budget.
|
|
93
|
+
|
|
76
94
|
## Staleness
|
|
77
95
|
|
|
78
96
|
| Field | Value |
|
|
@@ -38,7 +38,7 @@ Read these in parallel from the project root, skipping any that do not exist:
|
|
|
38
38
|
|
|
39
39
|
Also read `canon/DESIGN.md` when the feature touches code or UI, for visual intent and component rules, with values read from the stylesheet its lead names. Skip it for prose, docs, catalog, or config-only changes. For a UI feature, read `canon/design/index.md` too, if it exists, for the surfaces with an accepted design.
|
|
40
40
|
|
|
41
|
-
Coding standards live in `.claude/rules/`. Claude Code loads them automatically, and path-scoped rules apply to the files they match.
|
|
41
|
+
Coding standards live in `.claude/rules/`. Claude Code loads them automatically, and path-scoped rules apply to the files they match. For a UI feature, load `canon:design-taste` whether or not `canon/DESIGN.md` exists.
|
|
42
42
|
|
|
43
43
|
### Read the domain's context entry
|
|
44
44
|
|
|
@@ -78,7 +78,7 @@ Read these in parallel from the project root, skipping any that do not exist:
|
|
|
78
78
|
- `.canon/tasks/priority.md`: where this feature sits on the board and what each neighbor waits on, resolved at the main root the way `session-worktree` does, since a linked worktree holds no copy
|
|
79
79
|
- `.canon/plans/feature-<slug>.md` for the branch, when present: the intent the PR should satisfy, and its `**Review focus:**` section as the inputs this pass must confirm the diff handles
|
|
80
80
|
|
|
81
|
-
Coding standards from `.claude/rules/` are auto-loaded by Claude Code.
|
|
81
|
+
Coding standards from `.claude/rules/` are auto-loaded by Claude Code for the files a first pass reads. A path-scoped rule loads only when a session reads a matching file, so a later pass opens each changed file through the Read tool per `references/scope.md`, which loads the rules its trunk path matches and names the gap for an added file.
|
|
82
82
|
|
|
83
83
|
## Step 2: scope the read
|
|
84
84
|
|
|
@@ -117,3 +117,13 @@ Carry `<prior-heading>` into Step 4 and run Step 3 unchanged. This guard turns o
|
|
|
117
117
|
## The read
|
|
118
118
|
|
|
119
119
|
Read each changed file in scope. Skip deleted files. Run reads in parallel.
|
|
120
|
+
|
|
121
|
+
### Load the path-scoped rules on a later pass
|
|
122
|
+
|
|
123
|
+
A path-scoped rule loads when a session uses the Read tool on a matching file, and a Bash call such as `git show` loads none. A later pass reads only `<prior-oid>..<headRefOid>`, so the head copy of each changed file has to reach the Read tool.
|
|
124
|
+
|
|
125
|
+
- Open the trunk path with the Read tool when it exists, as the trigger, and take the content from `git show <headRefOid>:<path>`, since trunk content is stale
|
|
126
|
+
- For an added file, write its head blob to `.canon/tmp/pr/review/head-read/<number>-<short-sha>/<path>`, in the review body folder, then open that copy with the Read tool
|
|
127
|
+
- The copy loads only `**/` globs, since a root-anchored glob such as `canon/design/**` misses its prefix, so an added file under one loads no rule
|
|
128
|
+
- Read only files the range changed, never the whole tree, so the cost stays bounded by the range
|
|
129
|
+
- A range whose only change is a deletion has no file to read, so skip it and carry on
|
package/package.json
CHANGED
package/src/commands/design.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { join, relative } from 'node:path'
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
|
+
import { auditDesign } from '@/design/audit'
|
|
4
5
|
import { buildDesignCss } from '@/design/css'
|
|
6
|
+
import { currentWorktreeRoot } from '@/git/worktree'
|
|
5
7
|
import { HAND_DRAWN_FONT_FACES } from '@/design/fonts'
|
|
6
8
|
import { DESIGN_DOCUMENT, regenDesign } from '@/design/regen'
|
|
7
9
|
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
|
|
@@ -11,7 +13,7 @@ export function register(program: Command): void {
|
|
|
11
13
|
const design = program
|
|
12
14
|
.command('design')
|
|
13
15
|
.helpOption('-h, --help', 'Show this help message')
|
|
14
|
-
.description('Design system commands (regen, css)')
|
|
16
|
+
.description('Design system commands (regen, css, audit)')
|
|
15
17
|
|
|
16
18
|
design
|
|
17
19
|
.command('regen')
|
|
@@ -77,4 +79,58 @@ export function register(program: Command): void {
|
|
|
77
79
|
}),
|
|
78
80
|
)
|
|
79
81
|
})
|
|
82
|
+
|
|
83
|
+
design
|
|
84
|
+
.command('audit')
|
|
85
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
86
|
+
.description(
|
|
87
|
+
'Measure the design record against the prose budget and check its custom properties against the stylesheet',
|
|
88
|
+
)
|
|
89
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
90
|
+
.option('--root <path>', 'Project root, defaulting to the current worktree')
|
|
91
|
+
.addHelpText(
|
|
92
|
+
'after',
|
|
93
|
+
[
|
|
94
|
+
'',
|
|
95
|
+
'Reads canon/DESIGN.md. Prose findings carry their counts against the',
|
|
96
|
+
'budget the design standard sets. Each custom property in the Colors and',
|
|
97
|
+
'Shapes tables is checked against the stylesheet the lead names. A lead',
|
|
98
|
+
'naming none reads as `tokens.status: "unchecked"`, never as a pass.',
|
|
99
|
+
'',
|
|
100
|
+
'Exit codes:',
|
|
101
|
+
' 0 the reading completed, whatever it found',
|
|
102
|
+
'',
|
|
103
|
+
'It gates nothing and writes nothing.',
|
|
104
|
+
'',
|
|
105
|
+
'Examples:',
|
|
106
|
+
' canon design audit',
|
|
107
|
+
' canon design audit --json',
|
|
108
|
+
'',
|
|
109
|
+
].join('\n'),
|
|
110
|
+
)
|
|
111
|
+
.action(async (opts: { json?: boolean; root?: string }) => {
|
|
112
|
+
const root = opts.root ?? (await currentWorktreeRoot())
|
|
113
|
+
const audit = auditDesign(root)
|
|
114
|
+
|
|
115
|
+
if (opts.json ?? false) {
|
|
116
|
+
process.stdout.write(`${JSON.stringify(audit)}\n`)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
intro('canon design audit')
|
|
121
|
+
if (audit.record === null) {
|
|
122
|
+
logInfo('No design record to audit')
|
|
123
|
+
} else {
|
|
124
|
+
for (const finding of audit.prose) logWarn(finding)
|
|
125
|
+
if (audit.tokens?.status === 'unchecked') {
|
|
126
|
+
logWarn(`Tokens unchecked: ${audit.tokens.reason}`)
|
|
127
|
+
} else if (audit.tokens?.status === 'checked') {
|
|
128
|
+
for (const property of audit.tokens.missing) {
|
|
129
|
+
logWarn(`${property} is not declared in ${audit.tokens.stylesheet}`)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (audit.clean) logInfo(`${audit.record} is within budget`)
|
|
133
|
+
}
|
|
134
|
+
outro()
|
|
135
|
+
})
|
|
80
136
|
}
|
package/src/commands/tooling.ts
CHANGED
|
@@ -107,6 +107,9 @@ export function register(program: Command): void {
|
|
|
107
107
|
'A target below its git root syncs as a subfolder. It withholds',
|
|
108
108
|
'.github/, which GitHub reads only at the root.',
|
|
109
109
|
'',
|
|
110
|
+
'The dependency install is bounded at 120 seconds. A stalled or failed',
|
|
111
|
+
'install refuses with exit 1, and the stamp stays unwritten.',
|
|
112
|
+
'',
|
|
110
113
|
'To gate CI on tooling drift, run headlessly with neither flag. That',
|
|
111
114
|
'exits 1 when a file would be replaced and 0 when none would, which is',
|
|
112
115
|
'what `canon sync --check --exit-code` spells with a flag. Pass --check',
|
|
@@ -444,7 +447,14 @@ async function runSync(
|
|
|
444
447
|
}
|
|
445
448
|
|
|
446
449
|
await injectSeeds(prepared.chain, prepared.target)
|
|
447
|
-
await injectManifest(prepared.chain, prepared.target)
|
|
450
|
+
const manifest = await injectManifest(prepared.chain, prepared.target)
|
|
451
|
+
if (!manifest.ok) {
|
|
452
|
+
return refuse(
|
|
453
|
+
opts,
|
|
454
|
+
manifest.reason,
|
|
455
|
+
`${manifest.message}\nConfigs and seeds above were written. Scripts, gitignore entries, and the stamp were not, so the next sync retries.`,
|
|
456
|
+
)
|
|
457
|
+
}
|
|
448
458
|
|
|
449
459
|
await stampChain(prepared.chain, prepared.target)
|
|
450
460
|
|
|
@@ -553,7 +563,12 @@ async function runInject(
|
|
|
553
563
|
await injectSeeds(prepared.chain, prepared.target)
|
|
554
564
|
}
|
|
555
565
|
if (opts.manifest || applyAll) {
|
|
556
|
-
await injectManifest(prepared.chain, prepared.target)
|
|
566
|
+
const manifest = await injectManifest(prepared.chain, prepared.target)
|
|
567
|
+
if (!manifest.ok) {
|
|
568
|
+
logWarn(manifest.message)
|
|
569
|
+
if (framed) outro()
|
|
570
|
+
return 1
|
|
571
|
+
}
|
|
557
572
|
} else if (opts.gitignore) {
|
|
558
573
|
await injectGitignore(prepared.chain, prepared.target)
|
|
559
574
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
import { measureDesignProse } from '@/design/budget'
|
|
4
|
+
import { parseDesignDoc } from '@/design/parse'
|
|
5
|
+
import { DESIGN_DOCUMENT } from '@/design/regen'
|
|
6
|
+
|
|
7
|
+
/** The custom property a table row names, such as `--color-accent`. */
|
|
8
|
+
const PROPERTY = /--[A-Za-z0-9_-]+/g
|
|
9
|
+
|
|
10
|
+
/** A custom property declaration, which a stylesheet and a Tailwind `@theme` block share. */
|
|
11
|
+
const DECLARATION = /(--[A-Za-z0-9_-]+)\s*:/g
|
|
12
|
+
|
|
13
|
+
/** A backticked path to a stylesheet. */
|
|
14
|
+
const STYLESHEET = /`([^`\s]+\.css)`/
|
|
15
|
+
|
|
16
|
+
export type TokenCheck =
|
|
17
|
+
| {
|
|
18
|
+
readonly status: 'checked'
|
|
19
|
+
readonly stylesheet: string
|
|
20
|
+
readonly properties: number
|
|
21
|
+
readonly missing: readonly string[]
|
|
22
|
+
}
|
|
23
|
+
| { readonly status: 'unchecked'; readonly reason: string }
|
|
24
|
+
| { readonly status: 'not-applicable'; readonly reason: string }
|
|
25
|
+
|
|
26
|
+
export interface DesignAudit {
|
|
27
|
+
readonly ok: true
|
|
28
|
+
/** The record read, or null where the project holds none. */
|
|
29
|
+
readonly record: string | null
|
|
30
|
+
readonly prose: readonly string[]
|
|
31
|
+
readonly tokens: TokenCheck | null
|
|
32
|
+
/** True only when the prose is within budget and the token check ran clean. */
|
|
33
|
+
readonly clean: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Reads a project's design record against the prose budget and checks each
|
|
38
|
+
* custom property its tables name against the stylesheet its lead names. It
|
|
39
|
+
* writes nothing.
|
|
40
|
+
*
|
|
41
|
+
* A token check that cannot run says why under `unchecked` and leaves `clean`
|
|
42
|
+
* false, since a check that did not look is no pass.
|
|
43
|
+
*/
|
|
44
|
+
export function auditDesign(root: string): DesignAudit {
|
|
45
|
+
const path = join(root, DESIGN_DOCUMENT)
|
|
46
|
+
if (!existsSync(path)) {
|
|
47
|
+
return { ok: true, record: null, prose: [], tokens: null, clean: true }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const source = readFileSync(path, 'utf8')
|
|
51
|
+
const prose = measureDesignProse(source)
|
|
52
|
+
const tokens = checkTokens(root, path, source)
|
|
53
|
+
const clean =
|
|
54
|
+
prose.length === 0 &&
|
|
55
|
+
tokens.status !== 'unchecked' &&
|
|
56
|
+
(tokens.status !== 'checked' || tokens.missing.length === 0)
|
|
57
|
+
|
|
58
|
+
return { ok: true, record: DESIGN_DOCUMENT, prose, tokens, clean }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function checkTokens(root: string, path: string, source: string): TokenCheck {
|
|
62
|
+
const doc = parseDesignDoc(path)
|
|
63
|
+
const rows = [...doc.color, ...doc.borders]
|
|
64
|
+
|
|
65
|
+
// A values record renders from a token module and carries `Value` where an
|
|
66
|
+
// intent record carries `Property`, so it has no property to compare.
|
|
67
|
+
if (!rows.some((row) => 'Property' in row)) {
|
|
68
|
+
return {
|
|
69
|
+
status: 'not-applicable',
|
|
70
|
+
reason:
|
|
71
|
+
'The record has no Property column, so it holds no property to compare.',
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const lead = source.split(/^##\s/m)[0]
|
|
76
|
+
const named = lead.match(STYLESHEET)?.[1]
|
|
77
|
+
if (named === undefined) {
|
|
78
|
+
return {
|
|
79
|
+
status: 'unchecked',
|
|
80
|
+
reason: 'The lead names no stylesheet, so no property could be checked.',
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const stylesheet = resolve(root, named)
|
|
85
|
+
if (!stylesheet.startsWith(resolve(root)) || !existsSync(stylesheet)) {
|
|
86
|
+
return {
|
|
87
|
+
status: 'unchecked',
|
|
88
|
+
reason: `The lead names ${named}, which does not resolve in the project.`,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const css = readFileSync(stylesheet, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
|
|
93
|
+
const declared = new Set([...css.matchAll(DECLARATION)].map((m) => m[1]))
|
|
94
|
+
|
|
95
|
+
const properties = [
|
|
96
|
+
...new Set(
|
|
97
|
+
rows.flatMap((row) =>
|
|
98
|
+
[...(row['Property']?.value.matchAll(PROPERTY) ?? [])].map((m) => m[0]),
|
|
99
|
+
),
|
|
100
|
+
),
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
status: 'checked',
|
|
105
|
+
stylesheet: named,
|
|
106
|
+
properties: properties.length,
|
|
107
|
+
missing: properties.filter((property) => !declared.has(property)),
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/design/css.ts
CHANGED
|
@@ -154,8 +154,6 @@ export function fontFaceBlock(
|
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
export interface CssOptions {
|
|
157
|
-
/** Prepended as a comment, naming what wrote the file and from where. */
|
|
158
|
-
readonly banner?: string
|
|
159
157
|
/**
|
|
160
158
|
* Component rules ride along by default; a token-only consumer opts out
|
|
161
159
|
* with `false`. Pass an explicit list to emit those instead of the generic default.
|
|
@@ -173,8 +171,6 @@ export function buildDesignCss(
|
|
|
173
171
|
tokens: DesignTokens = TOKENS,
|
|
174
172
|
options: CssOptions = {},
|
|
175
173
|
): string {
|
|
176
|
-
const banner =
|
|
177
|
-
options.banner === undefined ? '' : `/* ${options.banner} */\n\n`
|
|
178
174
|
const root = [':root {', ...tokenProperties(tokens), '}'].join('\n')
|
|
179
175
|
const faces =
|
|
180
176
|
options.embedFonts === true
|
|
@@ -193,5 +189,5 @@ export function buildDesignCss(
|
|
|
193
189
|
: COMPONENTS
|
|
194
190
|
if (components) parts.push(componentBlock(components))
|
|
195
191
|
|
|
196
|
-
return `${
|
|
192
|
+
return `${parts.join('\n\n')}\n`
|
|
197
193
|
}
|
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
|
+
}
|