@erclx/canon 5.58.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 +29 -0
- package/README.md +1 -1
- 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/claude/skills/target-update/SKILL.md +5 -1
- package/docs/agents/install-and-sync.md +1 -1
- package/docs/agents/migrate.md +6 -0
- package/docs/target/sync.md +3 -1
- package/docs/workflow/operating-model.md +1 -1
- package/package.json +3 -3
- package/scripts/core/bootstrap.ts +120 -0
- package/scripts/core/install-check.ts +193 -0
- package/src/cli/retired.ts +63 -0
- package/src/cli/ui.ts +3 -5
- package/src/cli.ts +8 -2
- package/src/commands/design.ts +57 -1
- package/src/commands/gov.ts +8 -2
- package/src/commands/migrate.ts +7 -1
- package/src/commands/pr.ts +10 -1
- package/src/commands/tooling.ts +17 -2
- package/src/design/audit.ts +109 -0
- package/src/design/css.ts +1 -5
- package/src/gov/adapter.ts +2 -0
- package/src/gov/coverage.ts +62 -0
- package/src/migrate/callers.ts +192 -0
- package/src/migrate/status.ts +11 -3
- package/src/pr/merge.ts +77 -1
- package/src/pr/rest.ts +1 -0
- package/src/sandbox/expect.ts +1 -1
- package/src/sync/engine.ts +84 -0
- package/src/sync/stamp.ts +75 -5
- package/src/tooling/inject.ts +6 -3
- package/src/tooling/install.ts +55 -0
- package/scripts/core/bootstrap.sh +0 -86
- package/scripts/core/install-check.sh +0 -113
- package/scripts/lib/ui.sh +0 -45
package/src/cli/ui.ts
CHANGED
|
@@ -90,9 +90,8 @@ export function intro(title: string): void {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
* Timeline log levels
|
|
94
|
-
* prints
|
|
95
|
-
* leaving stdout clean for JSON and lists.
|
|
93
|
+
* Timeline log levels for the framed output every command and `scripts/core/`
|
|
94
|
+
* script prints. All output is stderr, leaving stdout clean for JSON and lists.
|
|
96
95
|
*/
|
|
97
96
|
export function logInfo(message: string): void {
|
|
98
97
|
if (quiet) return
|
|
@@ -151,8 +150,7 @@ export function outroSuccess(message: string, detail?: string): void {
|
|
|
151
150
|
}
|
|
152
151
|
|
|
153
152
|
/**
|
|
154
|
-
* Indents borrowed output inside an open frame
|
|
155
|
-
* `scripts/lib/ui.sh`. Used for a document the command did not compose itself,
|
|
153
|
+
* Indents borrowed output inside an open frame. Used for a document the command did not compose itself,
|
|
156
154
|
* such as a pull request body or the output of a git mutation.
|
|
157
155
|
*/
|
|
158
156
|
export function pipeOutput(text: string): void {
|
package/src/cli.ts
CHANGED
|
@@ -40,6 +40,7 @@ import { register as changelog } from '@/commands/changelog'
|
|
|
40
40
|
import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
|
|
41
41
|
import { renderHelp } from '@/cli/help'
|
|
42
42
|
import { renderAll, routeHelp } from '@/cli/help-all'
|
|
43
|
+
import { findRetired, retiredMessage } from '@/cli/retired'
|
|
43
44
|
import { setQuiet } from '@/cli/ui'
|
|
44
45
|
|
|
45
46
|
function showHelp(): void {
|
|
@@ -101,8 +102,13 @@ gate(program)
|
|
|
101
102
|
upgrade(program)
|
|
102
103
|
changelog(program)
|
|
103
104
|
|
|
104
|
-
const
|
|
105
|
-
|
|
105
|
+
const args = process.argv.slice(2)
|
|
106
|
+
const retired = findRetired(args)
|
|
107
|
+
const route = routeHelp(program, args)
|
|
108
|
+
if (retired !== undefined) {
|
|
109
|
+
console.error(retiredMessage(retired))
|
|
110
|
+
process.exitCode = 1
|
|
111
|
+
} else if (route.kind === 'top') showHelp()
|
|
106
112
|
else if (route.kind === 'all') process.stdout.write(renderAll(route.command))
|
|
107
113
|
else if (route.kind === 'unknown') {
|
|
108
114
|
console.error(`error: unknown command '${route.name}'`)
|
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/gov.ts
CHANGED
|
@@ -210,6 +210,7 @@ export function register(program: Command): void {
|
|
|
210
210
|
.description('Update rules already installed under .claude/rules/canon/')
|
|
211
211
|
.argument('[target]', 'Target directory', '.')
|
|
212
212
|
.helpOption('-h, --help', 'Show this help message')
|
|
213
|
+
.option('--json', 'Emit the coverage record as JSON on stdout')
|
|
213
214
|
.addHelpText(
|
|
214
215
|
'after',
|
|
215
216
|
[
|
|
@@ -219,17 +220,22 @@ export function register(program: Command): void {
|
|
|
219
220
|
'It adds no new rule, which is `canon gov install`, and never touches',
|
|
220
221
|
'.claude/rules/project/.',
|
|
221
222
|
'',
|
|
223
|
+
'After an apply it lists each rule it copied with the globs it loads on',
|
|
224
|
+
'and how many files in the target match them. --json writes the same',
|
|
225
|
+
'list as { "coverage": [{ rule, paths, files, ui }] } on stdout, empty',
|
|
226
|
+
'when nothing was copied. The report never changes the exit code.',
|
|
227
|
+
'',
|
|
222
228
|
'Exit codes:',
|
|
223
229
|
' 0 synced, already up to date, or the prompt was cancelled',
|
|
224
230
|
' 1 the target is missing or is the toolkit root',
|
|
225
231
|
'',
|
|
226
232
|
].join('\n'),
|
|
227
233
|
)
|
|
228
|
-
.action(async (target: string) => {
|
|
234
|
+
.action(async (target: string, opts: { json?: boolean }) => {
|
|
229
235
|
process.exitCode = await runDomainSync(
|
|
230
236
|
createGovAdapter(PROJECT_ROOT),
|
|
231
237
|
target,
|
|
232
|
-
{ protectedRoot: PROJECT_ROOT },
|
|
238
|
+
{ protectedRoot: PROJECT_ROOT, json: opts.json },
|
|
233
239
|
)
|
|
234
240
|
})
|
|
235
241
|
|
package/src/commands/migrate.ts
CHANGED
|
@@ -1056,7 +1056,7 @@ async function runStatus(opts: StatusOptions): Promise<number> {
|
|
|
1056
1056
|
const mismatch = checkoutMismatchWarning(process.cwd())
|
|
1057
1057
|
if (mismatch !== undefined) logWarn(mismatch)
|
|
1058
1058
|
|
|
1059
|
-
const report = await readMigrationStatus(cliRunner(root))
|
|
1059
|
+
const report = await readMigrationStatus(cliRunner(root), root)
|
|
1060
1060
|
|
|
1061
1061
|
if (opts.json) {
|
|
1062
1062
|
process.stdout.write(
|
|
@@ -1071,6 +1071,12 @@ async function runStatus(opts: StatusOptions): Promise<number> {
|
|
|
1071
1071
|
}
|
|
1072
1072
|
}
|
|
1073
1073
|
|
|
1074
|
+
for (const caller of report.retiredCallers) {
|
|
1075
|
+
logWarn(
|
|
1076
|
+
`Retired: ${caller.verb} at ${caller.file}:${caller.line}, retired in v${caller.release}, moved to ${caller.movedTo}`,
|
|
1077
|
+
)
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1074
1080
|
if (report.pending === 0 && report.refused === 0) {
|
|
1075
1081
|
logInfo('No migration is pending.')
|
|
1076
1082
|
}
|
package/src/commands/pr.ts
CHANGED
|
@@ -935,7 +935,7 @@ export function register(program: Command): void {
|
|
|
935
935
|
|
|
936
936
|
pr.command('merge')
|
|
937
937
|
.description(
|
|
938
|
-
|
|
938
|
+
"Squash-merge a ready pull request with a message carrying only the change: its Summary, Key changes, and Technical context, or a bot pull request's own changelog or package table",
|
|
939
939
|
)
|
|
940
940
|
.argument('[number]', 'Pull request to merge, defaulting to this branch')
|
|
941
941
|
.helpOption('-h, --help', 'Show this help message')
|
|
@@ -955,6 +955,13 @@ export function register(program: Command): void {
|
|
|
955
955
|
'UI pass line dropped. Technical context is optional. Testing, the',
|
|
956
956
|
'reviewer requests, and every other section stay out of the trunk.',
|
|
957
957
|
'',
|
|
958
|
+
'A release-please or Dependabot pull request is read off its head',
|
|
959
|
+
'branch, plus the release body opening or a bot author, and needs no',
|
|
960
|
+
'flag. A release composes to its changelog without the robot line, the',
|
|
961
|
+
'dividers, and the footer. Dependabot composes to its lead and package',
|
|
962
|
+
'table, cut at the first `Updates ` line or `<details>` block. A bot body with nothing left',
|
|
963
|
+
'refuses missing-summary.',
|
|
964
|
+
'',
|
|
958
965
|
'Refusals are read before the one write, which sends the head it read',
|
|
959
966
|
'as `sha`, so a push after the read refuses rather than merging. The',
|
|
960
967
|
'verb reads the draft flag and not the reviews, since a ready pull',
|
|
@@ -4124,6 +4131,8 @@ function mergeApi(root: string, number: string): MergeApi {
|
|
|
4124
4131
|
title: row.title,
|
|
4125
4132
|
body: row.body ?? '',
|
|
4126
4133
|
head: row.head.sha,
|
|
4134
|
+
branch: row.head.ref ?? '',
|
|
4135
|
+
isBotAuthor: row.user?.type === 'Bot',
|
|
4127
4136
|
}
|
|
4128
4137
|
} catch {
|
|
4129
4138
|
return undefined
|
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/gov/adapter.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { basename, join, relative, resolve } from 'node:path'
|
|
3
|
+
import { readCoverage } from '@/gov/coverage'
|
|
3
4
|
import { canonRulesDir, ruleSubdir } from '@/gov/install'
|
|
4
5
|
import { loadRenames, resolveSuccessor } from '@/gov/renames'
|
|
5
6
|
import { resolveMissingRules } from '@/gov/stacks'
|
|
@@ -93,6 +94,7 @@ export function createGovAdapter(root: string): SyncAdapter {
|
|
|
93
94
|
),
|
|
94
95
|
}
|
|
95
96
|
},
|
|
97
|
+
coverage: readCoverage,
|
|
96
98
|
stamp: { domain: 'governance', toolkitRoot: root },
|
|
97
99
|
}
|
|
98
100
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { relative } from 'node:path'
|
|
3
|
+
import { listRepositoryFiles } from '@/git/files'
|
|
4
|
+
import { canonRulesDir } from '@/gov/install'
|
|
5
|
+
import { parseFrontmatter } from '@/indexes/frontmatter'
|
|
6
|
+
import type { RuleCoverage, SyncChange } from '@/sync/engine'
|
|
7
|
+
|
|
8
|
+
/** Rule folder whose rules govern rendered output. */
|
|
9
|
+
const UI_FOLDER = 'ui'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One record per rule the sync copied, in apply order. A delete wrote nothing,
|
|
13
|
+
* so it has no record, and a rename arrives as the copy to the new path.
|
|
14
|
+
*
|
|
15
|
+
* `files` is undefined when git cannot list the target, and `paths` is empty
|
|
16
|
+
* for a rule that loads everywhere. Listing runs once for all rules, so a
|
|
17
|
+
* large target costs one git call rather than one per rule.
|
|
18
|
+
*/
|
|
19
|
+
export async function readCoverage(
|
|
20
|
+
target: string,
|
|
21
|
+
changes: readonly SyncChange[],
|
|
22
|
+
): Promise<RuleCoverage[]> {
|
|
23
|
+
const written = changes.filter((change) => change.kind === 'copy')
|
|
24
|
+
if (written.length === 0) return []
|
|
25
|
+
|
|
26
|
+
const listed = await listRepositoryFiles(target)
|
|
27
|
+
const rulesDir = canonRulesDir(target)
|
|
28
|
+
|
|
29
|
+
return written.map((change) => {
|
|
30
|
+
const paths = declaredPaths(change.dest)
|
|
31
|
+
return {
|
|
32
|
+
rule: change.rel,
|
|
33
|
+
paths,
|
|
34
|
+
files: listed === undefined ? undefined : countMatches(listed, paths),
|
|
35
|
+
ui: relative(rulesDir, change.dest).split(/[\\/]/)[0] === UI_FOLDER,
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** An unreadable file or an unparseable frontmatter reads as always loaded. */
|
|
41
|
+
function declaredPaths(path: string): string[] {
|
|
42
|
+
let text: string
|
|
43
|
+
try {
|
|
44
|
+
text = readFileSync(path, 'utf8')
|
|
45
|
+
} catch {
|
|
46
|
+
return []
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const paths = parseFrontmatter(text)?.fields.paths
|
|
50
|
+
if (!Array.isArray(paths)) return []
|
|
51
|
+
return paths.filter((entry): entry is string => typeof entry === 'string')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function countMatches(
|
|
55
|
+
files: readonly string[],
|
|
56
|
+
paths: readonly string[],
|
|
57
|
+
): number {
|
|
58
|
+
if (paths.length === 0) return 0
|
|
59
|
+
|
|
60
|
+
const globs = paths.map((path) => new Bun.Glob(path))
|
|
61
|
+
return files.filter((file) => globs.some((glob) => glob.match(file))).length
|
|
62
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises'
|
|
2
|
+
import { isAbsolute, join, relative } from 'node:path'
|
|
3
|
+
import { RETIRED_VERBS, type RetiredVerb } from '@/cli/retired'
|
|
4
|
+
|
|
5
|
+
export interface RetiredCaller {
|
|
6
|
+
/** Project-relative path of the file holding the call. */
|
|
7
|
+
readonly file: string
|
|
8
|
+
/** One-based line of the call. */
|
|
9
|
+
readonly line: number
|
|
10
|
+
/** The call as typed, such as `canon design render`. */
|
|
11
|
+
readonly verb: string
|
|
12
|
+
readonly release: string
|
|
13
|
+
readonly movedTo: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const WORKFLOWS = '.github/workflows'
|
|
17
|
+
const SCRIPTS = 'scripts'
|
|
18
|
+
const CODE_FILE = /\.(?:sh|bash|zsh|ts|mts|cts|js|mjs|cjs)$/
|
|
19
|
+
const WORKFLOW_FILE = /\.ya?ml$/
|
|
20
|
+
// A script path a command line names, such as `bun tools/sync.ts`.
|
|
21
|
+
const NAMED_FILE = /[\w./-]+\.(?:sh|bash|zsh|ts|mts|cts|js|mjs|cjs)\b/g
|
|
22
|
+
const MAX_FILE_BYTES = 1_000_000
|
|
23
|
+
|
|
24
|
+
function escape(word: string): string {
|
|
25
|
+
return word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The two call shapes, a command line and an argument list. A command line may
|
|
30
|
+
* go through `bunx -y` or `npx`, and an argument list carries `canon` as its
|
|
31
|
+
* own quoted element ahead of the verb words.
|
|
32
|
+
*/
|
|
33
|
+
function callPatterns(verb: RetiredVerb): readonly RegExp[] {
|
|
34
|
+
const words = verb.path.map(escape)
|
|
35
|
+
const shell = words.join('\\s+')
|
|
36
|
+
const list = words.map((word) => `['"]${word}['"]`).join('\\s*,\\s*')
|
|
37
|
+
return [
|
|
38
|
+
new RegExp(`(?:^|[\\s;&|(\`"'])canon\\s+${shell}(?![\\w-])`),
|
|
39
|
+
new RegExp(`['"]canon['"]\\s*,\\s*(?:\\[\\s*)?${list}`),
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const PATTERNS = RETIRED_VERBS.map((verb) => ({
|
|
44
|
+
verb,
|
|
45
|
+
patterns: callPatterns(verb),
|
|
46
|
+
}))
|
|
47
|
+
|
|
48
|
+
function isComment(line: string): boolean {
|
|
49
|
+
const trimmed = line.trimStart()
|
|
50
|
+
return trimmed.startsWith('#') || trimmed.startsWith('//')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function scanText(file: string, text: string): RetiredCaller[] {
|
|
54
|
+
const callers: RetiredCaller[] = []
|
|
55
|
+
text.split('\n').forEach((content, index) => {
|
|
56
|
+
if (isComment(content)) return
|
|
57
|
+
for (const { verb, patterns } of PATTERNS) {
|
|
58
|
+
if (!patterns.some((pattern) => pattern.test(content))) continue
|
|
59
|
+
callers.push({
|
|
60
|
+
file,
|
|
61
|
+
line: index + 1,
|
|
62
|
+
verb: `canon ${verb.path.join(' ')}`,
|
|
63
|
+
release: verb.release,
|
|
64
|
+
movedTo: verb.movedTo,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
return callers
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function readText(root: string, file: string): Promise<string | null> {
|
|
72
|
+
try {
|
|
73
|
+
const path = join(root, file)
|
|
74
|
+
if ((await stat(path)).size > MAX_FILE_BYTES) return null
|
|
75
|
+
return await readFile(path, 'utf8')
|
|
76
|
+
} catch {
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function listFiles(
|
|
82
|
+
root: string,
|
|
83
|
+
dir: string,
|
|
84
|
+
accepts: RegExp,
|
|
85
|
+
): Promise<string[]> {
|
|
86
|
+
try {
|
|
87
|
+
const entries = await readdir(join(root, dir), { withFileTypes: true })
|
|
88
|
+
const nested = await Promise.all(
|
|
89
|
+
entries.map(async (entry) => {
|
|
90
|
+
const path = `${dir}/${entry.name}`
|
|
91
|
+
if (entry.isDirectory()) {
|
|
92
|
+
return entry.name === 'node_modules'
|
|
93
|
+
? []
|
|
94
|
+
: listFiles(root, path, accepts)
|
|
95
|
+
}
|
|
96
|
+
return entry.isFile() && accepts.test(entry.name) ? [path] : []
|
|
97
|
+
}),
|
|
98
|
+
)
|
|
99
|
+
return nested.flat()
|
|
100
|
+
} catch {
|
|
101
|
+
return []
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function readScripts(root: string): Promise<Record<string, string>> {
|
|
106
|
+
const text = await readText(root, 'package.json')
|
|
107
|
+
if (text === null) return {}
|
|
108
|
+
try {
|
|
109
|
+
const scripts = (JSON.parse(text) as { scripts?: unknown }).scripts
|
|
110
|
+
if (typeof scripts !== 'object' || scripts === null) return {}
|
|
111
|
+
return Object.fromEntries(
|
|
112
|
+
Object.entries(scripts).filter(
|
|
113
|
+
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
} catch {
|
|
117
|
+
return {}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Reads the script values rather than the whole manifest, so a description
|
|
123
|
+
* naming a verb never reports. The line is the script's key, since JSON keeps
|
|
124
|
+
* no line for a value.
|
|
125
|
+
*/
|
|
126
|
+
async function scanPackageScripts(
|
|
127
|
+
root: string,
|
|
128
|
+
scripts: Record<string, string>,
|
|
129
|
+
): Promise<RetiredCaller[]> {
|
|
130
|
+
const lines = ((await readText(root, 'package.json')) ?? '').split('\n')
|
|
131
|
+
return Object.entries(scripts).flatMap(([name, command]) => {
|
|
132
|
+
const key = `${JSON.stringify(name)}:`
|
|
133
|
+
const line = lines.findIndex((text) => text.includes(key)) + 1
|
|
134
|
+
return scanText('package.json', command).map((caller) => ({
|
|
135
|
+
...caller,
|
|
136
|
+
line: Math.max(line, 1),
|
|
137
|
+
}))
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function namedFiles(commandLines: readonly string[]): string[] {
|
|
142
|
+
return commandLines.flatMap((line) => line.match(NAMED_FILE) ?? [])
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function insideRoot(root: string, file: string): boolean {
|
|
146
|
+
if (isAbsolute(file)) return false
|
|
147
|
+
const path = relative(root, join(root, file))
|
|
148
|
+
return path !== '' && !path.startsWith('..')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Finds the places a project calls a retired verb. It reads only the workflow
|
|
153
|
+
* folder, the `package.json` scripts, `scripts/`, and the files those scripts
|
|
154
|
+
* and workflow steps name, and it runs nothing. A markdown file is never read,
|
|
155
|
+
* so prose naming a verb never reports.
|
|
156
|
+
*/
|
|
157
|
+
export async function scanRetiredCallers(
|
|
158
|
+
root: string,
|
|
159
|
+
): Promise<RetiredCaller[]> {
|
|
160
|
+
const scripts = await readScripts(root)
|
|
161
|
+
const workflows = await listFiles(root, WORKFLOWS, WORKFLOW_FILE)
|
|
162
|
+
const ownScripts = await listFiles(root, SCRIPTS, CODE_FILE)
|
|
163
|
+
const workflowTexts = await Promise.all(
|
|
164
|
+
workflows.map(async (file) => ({
|
|
165
|
+
file,
|
|
166
|
+
text: (await readText(root, file)) ?? '',
|
|
167
|
+
})),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
const named = namedFiles([
|
|
171
|
+
...Object.values(scripts),
|
|
172
|
+
...workflowTexts.flatMap(({ text }) => text.split('\n')),
|
|
173
|
+
])
|
|
174
|
+
.map((file) => file.replace(/^\.\//, ''))
|
|
175
|
+
.filter((file) => insideRoot(root, file))
|
|
176
|
+
|
|
177
|
+
const codeFiles = [...new Set([...ownScripts, ...named])]
|
|
178
|
+
const codeTexts = await Promise.all(
|
|
179
|
+
codeFiles.map(async (file) => ({
|
|
180
|
+
file,
|
|
181
|
+
text: await readText(root, file),
|
|
182
|
+
})),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
return [
|
|
186
|
+
...(await scanPackageScripts(root, scripts)),
|
|
187
|
+
...workflowTexts.flatMap(({ file, text }) => scanText(file, text)),
|
|
188
|
+
...codeTexts.flatMap(({ file, text }) =>
|
|
189
|
+
text === null ? [] : scanText(file, text),
|
|
190
|
+
),
|
|
191
|
+
]
|
|
192
|
+
}
|