@erclx/canon 5.58.0 → 5.59.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 +15 -0
- package/README.md +1 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- 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/gov.ts +8 -2
- package/src/commands/migrate.ts +7 -1
- package/src/commands/pr.ts +10 -1
- 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 +51 -1
- package/scripts/core/bootstrap.sh +0 -86
- package/scripts/core/install-check.sh +0 -113
- package/scripts/lib/ui.sh +0 -45
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
|
+
}
|
package/src/migrate/status.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { RunCommand } from '@/gate/measures'
|
|
2
|
+
import { type RetiredCaller, scanRetiredCallers } from '@/migrate/callers'
|
|
2
3
|
|
|
3
4
|
interface Migration {
|
|
4
5
|
readonly name: string
|
|
@@ -38,6 +39,8 @@ export interface StatusReport {
|
|
|
38
39
|
readonly migrations: readonly MigrationStatus[]
|
|
39
40
|
readonly pending: number
|
|
40
41
|
readonly refused: number
|
|
42
|
+
/** Project calls to a retired verb. Reported only, since no `--write` fixes one. */
|
|
43
|
+
readonly retiredCallers: readonly RetiredCaller[]
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
function parseRecord(stdout: string): unknown {
|
|
@@ -111,14 +114,19 @@ async function readOne(
|
|
|
111
114
|
* subcommand shares: 0 with nothing to move, 2 with a plan and no `--write`,
|
|
112
115
|
* anything else a refusal. It never passes `--write`, and it reads no count,
|
|
113
116
|
* since the records share no field to count from.
|
|
117
|
+
* A retired caller is listed beside them and never changes the exit, since
|
|
118
|
+
* exit 2 means the pending verbs can be run.
|
|
114
119
|
*/
|
|
115
120
|
export async function readMigrationStatus(
|
|
116
121
|
run: RunCommand,
|
|
122
|
+
root: string,
|
|
117
123
|
): Promise<StatusReport> {
|
|
118
|
-
const migrations = await Promise.all(
|
|
119
|
-
MIGRATIONS.map((migration) => readOne(run, migration)),
|
|
120
|
-
|
|
124
|
+
const [migrations, retiredCallers] = await Promise.all([
|
|
125
|
+
Promise.all(MIGRATIONS.map((migration) => readOne(run, migration))),
|
|
126
|
+
scanRetiredCallers(root),
|
|
127
|
+
])
|
|
121
128
|
return {
|
|
129
|
+
retiredCallers,
|
|
122
130
|
migrations,
|
|
123
131
|
pending: migrations.filter((m) => m.state === 'pending').length,
|
|
124
132
|
refused: migrations.filter((m) => m.state === 'refused').length,
|
package/src/pr/merge.ts
CHANGED
|
@@ -22,8 +22,14 @@ export interface MergePull {
|
|
|
22
22
|
readonly body: string
|
|
23
23
|
/** The head the merge is pinned to, so a push after the read refuses. */
|
|
24
24
|
readonly head: string
|
|
25
|
+
/** The head branch name, which a bot pull request is recognized by. */
|
|
26
|
+
readonly branch: string
|
|
27
|
+
/** Whether the author is a bot account, which a release pull request's is not. */
|
|
28
|
+
readonly isBotAuthor: boolean
|
|
25
29
|
}
|
|
26
30
|
|
|
31
|
+
export type BotKind = 'release-please' | 'dependabot'
|
|
32
|
+
|
|
27
33
|
export interface MergeRequest {
|
|
28
34
|
readonly title: string
|
|
29
35
|
readonly description: string
|
|
@@ -105,6 +111,72 @@ export function composeDescription(body: string): Composed {
|
|
|
105
111
|
}
|
|
106
112
|
}
|
|
107
113
|
|
|
114
|
+
const RELEASE_BRANCH = 'release-please--branches--'
|
|
115
|
+
const RELEASE_OPENING = ':robot: I have created a release'
|
|
116
|
+
const RELEASE_FOOTER = 'This PR was generated with'
|
|
117
|
+
const DEPENDABOT_BRANCH = 'dependabot/'
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The bot a pull request comes from, read off the pull request itself. The
|
|
121
|
+
* release pull request's author is the operator's own account, so the head
|
|
122
|
+
* branch carries it, and a second condition keeps a human pull request that
|
|
123
|
+
* borrows the branch prefix on the human path.
|
|
124
|
+
*/
|
|
125
|
+
export function botKind(
|
|
126
|
+
pull: Pick<MergePull, 'branch' | 'body' | 'isBotAuthor'>,
|
|
127
|
+
): BotKind | undefined {
|
|
128
|
+
if (
|
|
129
|
+
pull.branch.startsWith(RELEASE_BRANCH) &&
|
|
130
|
+
pull.body.trimStart().startsWith(RELEASE_OPENING)
|
|
131
|
+
) {
|
|
132
|
+
return 'release-please'
|
|
133
|
+
}
|
|
134
|
+
if (pull.branch.startsWith(DEPENDABOT_BRANCH) && pull.isBotAuthor) {
|
|
135
|
+
return 'dependabot'
|
|
136
|
+
}
|
|
137
|
+
return undefined
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Where a Dependabot body stops. A group body runs a table and then one
|
|
142
|
+
* `Updates ` line per package, and a single-package body has neither and goes
|
|
143
|
+
* from its `Bumps` lead straight to a `<details>` block, so either line ends
|
|
144
|
+
* the kept text. Neither match relies on the commands footer, which a truncated
|
|
145
|
+
* body lacks.
|
|
146
|
+
*/
|
|
147
|
+
function dependabotCut(lines: readonly string[]): number {
|
|
148
|
+
const cut = lines.findIndex(
|
|
149
|
+
(line) =>
|
|
150
|
+
line.startsWith('Updates ') || line.trim().startsWith('<details>'),
|
|
151
|
+
)
|
|
152
|
+
return cut >= 0 ? cut : lines.length
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The squash description of a bot pull request, from the bot's own body. A
|
|
157
|
+
* release keeps its changelog without the robot line, the dividers, and the
|
|
158
|
+
* footer. Dependabot keeps its lead and package table. A body with nothing left
|
|
159
|
+
* refuses `missing-summary`.
|
|
160
|
+
*/
|
|
161
|
+
export function composeBotDescription(kind: BotKind, body: string): Composed {
|
|
162
|
+
const lines = body.replaceAll('\r\n', '\n').split('\n')
|
|
163
|
+
const kept =
|
|
164
|
+
kind === 'release-please'
|
|
165
|
+
? lines.filter((line) => {
|
|
166
|
+
const text = line.trim()
|
|
167
|
+
return (
|
|
168
|
+
text !== '---' &&
|
|
169
|
+
!text.startsWith(RELEASE_OPENING) &&
|
|
170
|
+
!text.startsWith(RELEASE_FOOTER)
|
|
171
|
+
)
|
|
172
|
+
})
|
|
173
|
+
: lines.slice(0, dependabotCut(lines))
|
|
174
|
+
const description = kept.join('\n').trim()
|
|
175
|
+
return description === ''
|
|
176
|
+
? { kind: 'refused', reason: 'missing-summary' }
|
|
177
|
+
: { kind: 'composed', description }
|
|
178
|
+
}
|
|
179
|
+
|
|
108
180
|
/** The suffix the web merge adds and the trunk log carries. */
|
|
109
181
|
export function mergeTitle(title: string, number: number): string {
|
|
110
182
|
return `${title} (#${number})`
|
|
@@ -137,7 +209,11 @@ export async function merge(
|
|
|
137
209
|
if (pull.isDraft) return refused('pull-draft', number)
|
|
138
210
|
|
|
139
211
|
const title = mergeTitle(pull.title, number)
|
|
140
|
-
const
|
|
212
|
+
const bot = botKind(pull)
|
|
213
|
+
const composed =
|
|
214
|
+
bot === undefined
|
|
215
|
+
? composeDescription(pull.body)
|
|
216
|
+
: composeBotDescription(bot, pull.body)
|
|
141
217
|
if (composed.kind === 'refused') {
|
|
142
218
|
return refused(composed.reason, number, { title })
|
|
143
219
|
}
|
package/src/pr/rest.ts
CHANGED
package/src/sandbox/expect.ts
CHANGED
|
@@ -224,7 +224,7 @@ export function parseExpectation(source: string): Expectation {
|
|
|
224
224
|
/**
|
|
225
225
|
* `manual` is deliberately excluded. Counting it would let an arm declare five
|
|
226
226
|
* prose lines, assert nothing, and still read green, which is the vacuous case
|
|
227
|
-
* `scripts/core/install-check.
|
|
227
|
+
* `scripts/core/install-check.ts` warns about in its own comment.
|
|
228
228
|
*/
|
|
229
229
|
export function countMechanicalAssertions(expectation: Expectation): number {
|
|
230
230
|
return (
|
package/src/sync/engine.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
logWarn,
|
|
28
28
|
outro,
|
|
29
29
|
outroSuccess,
|
|
30
|
+
plural,
|
|
30
31
|
select,
|
|
31
32
|
} from '@/cli/ui'
|
|
32
33
|
import { compareVersions, parseVersion } from '@/version/compare'
|
|
@@ -112,6 +113,17 @@ export interface SyncPlan {
|
|
|
112
113
|
readonly historyUnavailable: boolean
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
/**
|
|
117
|
+
* What an applied rule governs in the target. `paths` empty means the rule
|
|
118
|
+
* loads everywhere, and `files` unset means the target could not be listed.
|
|
119
|
+
*/
|
|
120
|
+
export interface RuleCoverage {
|
|
121
|
+
readonly rule: string
|
|
122
|
+
readonly paths: readonly string[]
|
|
123
|
+
readonly files: number | undefined
|
|
124
|
+
readonly ui: boolean
|
|
125
|
+
}
|
|
126
|
+
|
|
115
127
|
/**
|
|
116
128
|
* What a headless run does once the plan has changes. Domains whose files the
|
|
117
129
|
* toolkit owns apply them. Domains whose files a project is expected to edit
|
|
@@ -189,6 +201,14 @@ export interface SyncAdapter {
|
|
|
189
201
|
readonly nonInteractive?: NonInteractivePolicy
|
|
190
202
|
/** Runs on a completed sync, including one with no changes. */
|
|
191
203
|
onComplete?(target: string): Promise<void>
|
|
204
|
+
/**
|
|
205
|
+
* What each rule an apply copied governs in the target. Called only after
|
|
206
|
+
* an apply, and advisory: a throw is reported and never changes the exit.
|
|
207
|
+
*/
|
|
208
|
+
coverage?(
|
|
209
|
+
target: string,
|
|
210
|
+
changes: readonly SyncChange[],
|
|
211
|
+
): Promise<readonly RuleCoverage[]>
|
|
192
212
|
/** Where this domain's hashes are stamped. Unset domains go unstamped. */
|
|
193
213
|
readonly stamp?: StampSource
|
|
194
214
|
}
|
|
@@ -403,6 +423,8 @@ export async function applyChanges(
|
|
|
403
423
|
export interface SyncRunOptions {
|
|
404
424
|
/** Path the sync refuses to run against, normally the toolkit root. */
|
|
405
425
|
readonly protectedRoot: string
|
|
426
|
+
/** Writes the coverage record to stdout on every run that exits 0. */
|
|
427
|
+
readonly json?: boolean
|
|
406
428
|
}
|
|
407
429
|
|
|
408
430
|
/**
|
|
@@ -442,6 +464,7 @@ export async function runDomainSync(
|
|
|
442
464
|
plan.retired.length === 0
|
|
443
465
|
) {
|
|
444
466
|
logWarn(adapter.missingMessage)
|
|
467
|
+
emitCoverage(options, [])
|
|
445
468
|
outro()
|
|
446
469
|
return 0
|
|
447
470
|
}
|
|
@@ -452,6 +475,7 @@ export async function runDomainSync(
|
|
|
452
475
|
if (count === 0) {
|
|
453
476
|
await adapter.onComplete?.(resolved)
|
|
454
477
|
await recordStamp(adapter, resolved, new Date())
|
|
478
|
+
emitCoverage(options, [])
|
|
455
479
|
outroSuccess('Everything up to date')
|
|
456
480
|
return 0
|
|
457
481
|
}
|
|
@@ -464,6 +488,7 @@ export async function runDomainSync(
|
|
|
464
488
|
) {
|
|
465
489
|
logWarn(policy.message)
|
|
466
490
|
logInfo(policy.hint)
|
|
491
|
+
emitCoverage(options, [])
|
|
467
492
|
outro()
|
|
468
493
|
return 0
|
|
469
494
|
}
|
|
@@ -479,6 +504,7 @@ export async function runDomainSync(
|
|
|
479
504
|
|
|
480
505
|
if (!shouldApply) {
|
|
481
506
|
logWarn('Sync cancelled')
|
|
507
|
+
emitCoverage(options, [])
|
|
482
508
|
outro()
|
|
483
509
|
return 0
|
|
484
510
|
}
|
|
@@ -487,10 +513,68 @@ export async function runDomainSync(
|
|
|
487
513
|
await adapter.onComplete?.(resolved)
|
|
488
514
|
await recordStamp(adapter, resolved, new Date())
|
|
489
515
|
|
|
516
|
+
const coverage = await readAdapterCoverage(adapter, resolved, plan.changes)
|
|
517
|
+
reportCoverage(coverage)
|
|
518
|
+
emitCoverage(options, coverage)
|
|
519
|
+
|
|
490
520
|
outroSuccess('Sync complete', `(${count} ${adapter.unit})`)
|
|
491
521
|
return 0
|
|
492
522
|
}
|
|
493
523
|
|
|
524
|
+
async function readAdapterCoverage(
|
|
525
|
+
adapter: SyncAdapter,
|
|
526
|
+
target: string,
|
|
527
|
+
changes: readonly SyncChange[],
|
|
528
|
+
): Promise<readonly RuleCoverage[]> {
|
|
529
|
+
if (adapter.coverage === undefined) return []
|
|
530
|
+
|
|
531
|
+
try {
|
|
532
|
+
return await adapter.coverage(target, changes)
|
|
533
|
+
} catch (error) {
|
|
534
|
+
logWarn(
|
|
535
|
+
`Coverage unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
|
536
|
+
)
|
|
537
|
+
return []
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function reportCoverage(coverage: readonly RuleCoverage[]): void {
|
|
542
|
+
if (coverage.length === 0) return
|
|
543
|
+
|
|
544
|
+
logStep('Coverage')
|
|
545
|
+
for (const entry of coverage) {
|
|
546
|
+
logInfo(describeCoverage(entry))
|
|
547
|
+
if (entry.ui) {
|
|
548
|
+
logInfo(' Audit those files with /canon:ux-audit.')
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function describeCoverage(entry: RuleCoverage): string {
|
|
554
|
+
if (entry.paths.length === 0) return `${entry.rule} (loads everywhere)`
|
|
555
|
+
|
|
556
|
+
const globs = entry.paths.join(', ')
|
|
557
|
+
if (entry.files === undefined) return `${entry.rule} (${globs})`
|
|
558
|
+
|
|
559
|
+
return `${entry.rule} (${globs}): ${plural(entry.files, 'file')}`
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** The one stdout write of a `--json` run, so a pipe reads JSON alone. */
|
|
563
|
+
function emitCoverage(
|
|
564
|
+
options: SyncRunOptions,
|
|
565
|
+
coverage: readonly RuleCoverage[],
|
|
566
|
+
): void {
|
|
567
|
+
if (options.json !== true) return
|
|
568
|
+
|
|
569
|
+
const records = coverage.map((entry) => ({
|
|
570
|
+
rule: entry.rule,
|
|
571
|
+
paths: entry.paths,
|
|
572
|
+
files: entry.files ?? null,
|
|
573
|
+
ui: entry.ui,
|
|
574
|
+
}))
|
|
575
|
+
process.stdout.write(`${JSON.stringify({ coverage: records })}\n`)
|
|
576
|
+
}
|
|
577
|
+
|
|
494
578
|
/**
|
|
495
579
|
* Reports in walk order rather than grouped by state, so a long rule tree
|
|
496
580
|
* reads as a directory listing with drift called out in place.
|
package/src/sync/stamp.ts
CHANGED
|
@@ -286,7 +286,7 @@ async function putDomain(
|
|
|
286
286
|
|
|
287
287
|
const path = stampPath(target)
|
|
288
288
|
await mkdir(dirname(path), { recursive: true })
|
|
289
|
-
await writeFile(path, `${
|
|
289
|
+
await writeFile(path, `${serializeStamp(stamp)}\n`)
|
|
290
290
|
|
|
291
291
|
// The stamp hashes every file but itself, so reshaping it cannot read as
|
|
292
292
|
// drift. Seeds and goldens stay as copied for the opposite reason: their
|
|
@@ -302,6 +302,56 @@ async function putDomain(
|
|
|
302
302
|
recordTarget(target, now)
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
+
const WIDTH = 80
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* `JSON.stringify` expands every array one element per line, a shape oxfmt and
|
|
309
|
+
* prettier both reject, so a sync with no formatter to run would leave a
|
|
310
|
+
* target's format gate red. This inlines a string array that fits the default
|
|
311
|
+
* print width and expands one that does not, as both formatters would, and
|
|
312
|
+
* leaves objects expanded, which both preserve. Content and key order are
|
|
313
|
+
* `JSON.stringify`'s own.
|
|
314
|
+
*/
|
|
315
|
+
function serializeStamp(value: unknown): string {
|
|
316
|
+
return serialize(value, 0, 0, true)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function serialize(
|
|
320
|
+
value: unknown,
|
|
321
|
+
depth: number,
|
|
322
|
+
taken: number,
|
|
323
|
+
last: boolean,
|
|
324
|
+
): string {
|
|
325
|
+
if (Array.isArray(value) && value.every((item) => typeof item === 'string')) {
|
|
326
|
+
const inline = JSON.stringify(value).replace(/","/g, '", "')
|
|
327
|
+
// A comma follows every entry but the last of its object, and counts
|
|
328
|
+
// against the width as it does for a formatter.
|
|
329
|
+
return taken + inline.length + (last ? 0 : 1) <= WIDTH
|
|
330
|
+
? inline
|
|
331
|
+
: expand(value, depth)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
335
|
+
const entries = Object.entries(value).filter(([, v]) => v !== undefined)
|
|
336
|
+
if (entries.length === 0) return '{}'
|
|
337
|
+
|
|
338
|
+
const pad = ' '.repeat(depth + 1)
|
|
339
|
+
const lines = entries.map(([key, child], index) => {
|
|
340
|
+
const head = `${pad}${JSON.stringify(key)}: `
|
|
341
|
+
return `${head}${serialize(child, depth + 1, head.length, index === entries.length - 1)}`
|
|
342
|
+
})
|
|
343
|
+
return `{\n${lines.join(',\n')}\n${' '.repeat(depth)}}`
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return JSON.stringify(value)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function expand(items: readonly string[], depth: number): string {
|
|
350
|
+
const pad = ' '.repeat(depth + 1)
|
|
351
|
+
const lines = items.map((item) => `${pad}${JSON.stringify(item)}`)
|
|
352
|
+
return `[\n${lines.join(',\n')}\n${' '.repeat(depth)}]`
|
|
353
|
+
}
|
|
354
|
+
|
|
305
355
|
const commitCache = new Map<string, Promise<string | undefined>>()
|
|
306
356
|
|
|
307
357
|
/**
|