@erclx/canon 5.57.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 +29 -0
- package/README.md +1 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/context-fold/references/context.md +1 -1
- package/claude/skills/document-health/REQUIREMENT.md +2 -2
- package/claude/skills/document-health/SKILL.md +14 -0
- package/claude/skills/document-health/references/axes.md +12 -0
- package/claude/skills/document-repair/REQUIREMENT.md +6 -1
- package/claude/skills/document-repair/SKILL.md +21 -0
- package/claude/skills/plan-feature/SKILL.md +1 -1
- package/claude/skills/target-update/SKILL.md +5 -1
- package/docs/agents/context-audit.md +1 -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/governance/rules/code/routing.md +5 -1
- package/governance/rules/ui/design-taste.md +4 -0
- 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/context/audit.ts +26 -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
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifies the published install path end to end: packs this repo, installs
|
|
3
|
+
* the tarball for production, and runs `canon init` from it into a fresh
|
|
4
|
+
* project. Runs on Bun alone.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, mkdirSync, rmSync } from 'node:fs'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
intro,
|
|
12
|
+
logError,
|
|
13
|
+
logInfo,
|
|
14
|
+
logStep,
|
|
15
|
+
outro,
|
|
16
|
+
pipeOutput,
|
|
17
|
+
} from '../../src/cli/ui'
|
|
18
|
+
|
|
19
|
+
const root = fileURLToPath(new URL('../..', import.meta.url)).replace(/\/$/, '')
|
|
20
|
+
const tmpRoot = join(root, '.canon/tmp/install-check')
|
|
21
|
+
const packDir = join(tmpRoot, 'pack')
|
|
22
|
+
const extractDir = join(tmpRoot, 'extract')
|
|
23
|
+
const targetDir = join(tmpRoot, 'target')
|
|
24
|
+
|
|
25
|
+
const HELP = `Usage: scripts/core/install-check.ts [--keep]
|
|
26
|
+
|
|
27
|
+
Verifies the published install path end to end:
|
|
28
|
+
1. Packs this repo into a tarball at .canon/tmp/install-check/pack
|
|
29
|
+
2. Extracts it and asserts sandbox is absent from the tree
|
|
30
|
+
3. Runs bun install --production in the extracted tree
|
|
31
|
+
4. Runs the CLI with --help from the extracted tree to confirm it resolves
|
|
32
|
+
5. Scaffolds a fresh project in .canon/tmp/install-check/target
|
|
33
|
+
6. Runs canon init from the extracted package and asserts a scaffold landed
|
|
34
|
+
|
|
35
|
+
Flags:
|
|
36
|
+
--keep Keep the tmp tree on exit for inspection
|
|
37
|
+
`
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Every domain canon init installs needs at least one path here. A domain with
|
|
41
|
+
* no assertion can truncate silently: init still exits 0 because run_domain
|
|
42
|
+
* catches a failed domain, and the gate stays green while the target is
|
|
43
|
+
* missing everything that domain provides.
|
|
44
|
+
*
|
|
45
|
+
* Authoring references name no path because they ship inside the plugin's
|
|
46
|
+
* skills rather than installing into a target. The two rule paths stand for
|
|
47
|
+
* the `claude` and `canon` groups `base` takes whole.
|
|
48
|
+
*/
|
|
49
|
+
const SCAFFOLD_PATHS = [
|
|
50
|
+
'CLAUDE.md',
|
|
51
|
+
'.claude',
|
|
52
|
+
'canon/context/index.md',
|
|
53
|
+
'.oxfmtrc.json',
|
|
54
|
+
'.lintstagedrc',
|
|
55
|
+
'.husky/pre-commit',
|
|
56
|
+
'.github/workflows/verify.yml',
|
|
57
|
+
'scripts/verify.ts',
|
|
58
|
+
'.claude/rules/canon/claude/output.md',
|
|
59
|
+
'.claude/rules/canon/canon/scratch.md',
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
const args = process.argv.slice(2)
|
|
63
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
64
|
+
process.stdout.write(HELP)
|
|
65
|
+
process.exit(0)
|
|
66
|
+
}
|
|
67
|
+
const keep = args.includes('--keep')
|
|
68
|
+
|
|
69
|
+
/** Runs a command to completion, showing its output inside the frame. */
|
|
70
|
+
function run(
|
|
71
|
+
failure: string,
|
|
72
|
+
cmd: string[],
|
|
73
|
+
opts: { cwd?: string; show?: boolean } = {},
|
|
74
|
+
): string {
|
|
75
|
+
const result = Bun.spawnSync(cmd, {
|
|
76
|
+
cwd: opts.cwd ?? root,
|
|
77
|
+
stdin: 'ignore',
|
|
78
|
+
stdout: 'pipe',
|
|
79
|
+
stderr: 'pipe',
|
|
80
|
+
})
|
|
81
|
+
const stdout = result.stdout.toString()
|
|
82
|
+
const output = `${stdout}${result.stderr.toString()}`.trim()
|
|
83
|
+
if (opts.show && output !== '') pipeOutput(output)
|
|
84
|
+
if (result.exitCode !== 0) {
|
|
85
|
+
if (!opts.show && output !== '') pipeOutput(output)
|
|
86
|
+
throw new Error(failure)
|
|
87
|
+
}
|
|
88
|
+
return stdout
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function check(): void {
|
|
92
|
+
logStep('Assert excluded path exists in source')
|
|
93
|
+
// An absence check over a path the source tree no longer holds passes on
|
|
94
|
+
// nothing, so the excluded tree has to exist here before its absence below
|
|
95
|
+
// can mean the publish left it out.
|
|
96
|
+
if (!existsSync(join(root, 'sandbox/exempt.toml'))) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
'sandbox/exempt.toml is missing from the source tree, so the absence check below would pass on nothing',
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
logInfo('Present: sandbox/exempt.toml')
|
|
102
|
+
|
|
103
|
+
logStep('Pack')
|
|
104
|
+
const packed = run('bun pm pack failed', [
|
|
105
|
+
'bun',
|
|
106
|
+
'pm',
|
|
107
|
+
'pack',
|
|
108
|
+
'--quiet',
|
|
109
|
+
'--ignore-scripts',
|
|
110
|
+
'--destination',
|
|
111
|
+
packDir,
|
|
112
|
+
])
|
|
113
|
+
const tarball = packed.trim().split('\n').pop() ?? ''
|
|
114
|
+
logInfo(`Packed to ${tarball}`)
|
|
115
|
+
|
|
116
|
+
logStep('Extract')
|
|
117
|
+
run('tar extract failed', [
|
|
118
|
+
'tar',
|
|
119
|
+
'-xzf',
|
|
120
|
+
tarball,
|
|
121
|
+
'-C',
|
|
122
|
+
extractDir,
|
|
123
|
+
'--strip-components=1',
|
|
124
|
+
])
|
|
125
|
+
logInfo(`Extracted to ${extractDir}`)
|
|
126
|
+
|
|
127
|
+
logStep('Assert excluded path is absent')
|
|
128
|
+
if (existsSync(join(extractDir, 'sandbox'))) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
'sandbox shipped in the tarball, and the files field in package.json leaves it out',
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
logInfo('Absent: sandbox')
|
|
134
|
+
|
|
135
|
+
logStep('Install dependencies (production)')
|
|
136
|
+
run(
|
|
137
|
+
'bun install --production failed',
|
|
138
|
+
['bun', 'install', '--production', '--ignore-scripts', '--silent'],
|
|
139
|
+
{ cwd: extractDir, show: true },
|
|
140
|
+
)
|
|
141
|
+
logInfo('Dependencies installed without devDependencies')
|
|
142
|
+
|
|
143
|
+
logStep('Confirm CLI runs')
|
|
144
|
+
run('canon --help failed', ['bun', 'run', 'src/cli.ts', '--help'], {
|
|
145
|
+
cwd: extractDir,
|
|
146
|
+
})
|
|
147
|
+
logInfo('canon --help ran clean from the extracted package')
|
|
148
|
+
|
|
149
|
+
logStep('Scaffold fresh project')
|
|
150
|
+
mkdirSync(targetDir, { recursive: true })
|
|
151
|
+
run('git init failed', ['git', 'init', '--quiet'], { cwd: targetDir })
|
|
152
|
+
logInfo(`Initialized git in ${targetDir}`)
|
|
153
|
+
|
|
154
|
+
logStep('Run canon init')
|
|
155
|
+
run(
|
|
156
|
+
'canon init failed',
|
|
157
|
+
['bun', 'run', join(extractDir, 'src/cli.ts'), 'init', '--stack', 'base'],
|
|
158
|
+
{ cwd: targetDir, show: true },
|
|
159
|
+
)
|
|
160
|
+
logInfo('canon init completed')
|
|
161
|
+
|
|
162
|
+
logStep('Assert scaffold')
|
|
163
|
+
for (const path of SCAFFOLD_PATHS) {
|
|
164
|
+
if (!existsSync(join(targetDir, path))) {
|
|
165
|
+
throw new Error(`Missing after canon init: ${path}`)
|
|
166
|
+
}
|
|
167
|
+
logInfo(`Found: ${path}`)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
logStep('Verification passed')
|
|
171
|
+
logInfo('Manual check still needed: bun link and global canon invocation')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
rmSync(tmpRoot, { recursive: true, force: true })
|
|
175
|
+
mkdirSync(packDir, { recursive: true })
|
|
176
|
+
mkdirSync(extractDir, { recursive: true })
|
|
177
|
+
|
|
178
|
+
intro('Install verification')
|
|
179
|
+
let failed = false
|
|
180
|
+
try {
|
|
181
|
+
check()
|
|
182
|
+
} catch (error) {
|
|
183
|
+
failed = true
|
|
184
|
+
logError(error instanceof Error ? error.message : String(error))
|
|
185
|
+
} finally {
|
|
186
|
+
if (keep) {
|
|
187
|
+
logInfo(`Kept artifacts at ${tmpRoot}`)
|
|
188
|
+
} else {
|
|
189
|
+
rmSync(tmpRoot, { recursive: true, force: true })
|
|
190
|
+
}
|
|
191
|
+
outro()
|
|
192
|
+
}
|
|
193
|
+
process.exit(failed ? 1 : 0)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export interface RetiredVerb {
|
|
2
|
+
/** The words a caller types after `canon`, one for a group and two for a subcommand. */
|
|
3
|
+
readonly path: readonly string[]
|
|
4
|
+
/** The release that dropped the verb, from the changelog. */
|
|
5
|
+
readonly release: string
|
|
6
|
+
readonly movedTo: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const DESIGN_MOVED =
|
|
10
|
+
'the design values now live in the stylesheet your canon/DESIGN.md lead names'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Every verb a release dropped, with the release and where the work went. The
|
|
14
|
+
* router reads this ahead of the parser, and `canon migrate status` reads it to
|
|
15
|
+
* find project calls. A verb that returns leaves this table in the same change.
|
|
16
|
+
*/
|
|
17
|
+
export const RETIRED_VERBS: readonly RetiredVerb[] = [
|
|
18
|
+
{
|
|
19
|
+
path: ['canvas'],
|
|
20
|
+
release: '5.54.0',
|
|
21
|
+
movedTo: 'Frameboard, which carries the design board in its own repository',
|
|
22
|
+
},
|
|
23
|
+
{ path: ['design', 'render'], release: '5.54.0', movedTo: DESIGN_MOVED },
|
|
24
|
+
{ path: ['design', 'install'], release: '5.54.0', movedTo: DESIGN_MOVED },
|
|
25
|
+
{ path: ['design', 'sync'], release: '5.54.0', movedTo: DESIGN_MOVED },
|
|
26
|
+
{ path: ['design', 'fonts'], release: '5.54.0', movedTo: DESIGN_MOVED },
|
|
27
|
+
{ path: ['design', 'list'], release: '5.54.0', movedTo: DESIGN_MOVED },
|
|
28
|
+
{ path: ['design', 'add'], release: '5.54.0', movedTo: DESIGN_MOVED },
|
|
29
|
+
{
|
|
30
|
+
path: ['teach'],
|
|
31
|
+
release: '5.53.0',
|
|
32
|
+
movedTo: 'the canon:teach-craft skill',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
path: ['slides'],
|
|
36
|
+
release: '5.53.0',
|
|
37
|
+
movedTo: 'the canon:teach-craft skill',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
path: ['demo'],
|
|
41
|
+
release: '5.53.0',
|
|
42
|
+
movedTo: 'a tool built for composed video',
|
|
43
|
+
},
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Matches the leading words of argv, stopping at the first option so a flag's
|
|
48
|
+
* value is never read as a verb name.
|
|
49
|
+
*/
|
|
50
|
+
export function findRetired(args: readonly string[]): RetiredVerb | undefined {
|
|
51
|
+
const words: string[] = []
|
|
52
|
+
for (const arg of args) {
|
|
53
|
+
if (arg.startsWith('-')) break
|
|
54
|
+
words.push(arg)
|
|
55
|
+
}
|
|
56
|
+
return RETIRED_VERBS.find((verb) =>
|
|
57
|
+
verb.path.every((word, index) => words[index] === word),
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function retiredMessage(verb: RetiredVerb): string {
|
|
62
|
+
return `error: 'canon ${verb.path.join(' ')}' was retired in v${verb.release}. The work moved to ${verb.movedTo}.`
|
|
63
|
+
}
|
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/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/context/audit.ts
CHANGED
|
@@ -616,8 +616,16 @@ function shapeIssues(
|
|
|
616
616
|
let inItem = false
|
|
617
617
|
let inParagraph = false
|
|
618
618
|
let afterBlank = false
|
|
619
|
+
let afterTable = false
|
|
620
|
+
let deferred = false
|
|
621
|
+
|
|
622
|
+
const settleDeferred = (): void => {
|
|
623
|
+
if (deferred && folder) folder.paragraphs++
|
|
624
|
+
deferred = false
|
|
625
|
+
}
|
|
619
626
|
|
|
620
627
|
const closeFolder = (): void => {
|
|
628
|
+
settleDeferred()
|
|
621
629
|
if (folder && !exempt.has(folder.text)) {
|
|
622
630
|
if (folder.paragraphs > LAYOUT_PARAGRAPH_CAP) {
|
|
623
631
|
issues.push({
|
|
@@ -661,6 +669,7 @@ function shapeIssues(
|
|
|
661
669
|
inItem = false
|
|
662
670
|
inParagraph = false
|
|
663
671
|
afterBlank = false
|
|
672
|
+
afterTable = false
|
|
664
673
|
|
|
665
674
|
if (level <= 2) {
|
|
666
675
|
closeFolder()
|
|
@@ -697,11 +706,22 @@ function shapeIssues(
|
|
|
697
706
|
inItem = true
|
|
698
707
|
inParagraph = false
|
|
699
708
|
afterBlank = false
|
|
709
|
+
afterTable = false
|
|
710
|
+
deferred = false
|
|
700
711
|
if (gotchas) gotchas.bullets++
|
|
701
712
|
if (folder) folder.bullets++
|
|
702
713
|
continue
|
|
703
714
|
}
|
|
704
715
|
|
|
716
|
+
if (folder && TABLE_ROW.test(line.text)) {
|
|
717
|
+
settleDeferred()
|
|
718
|
+
inItem = false
|
|
719
|
+
inParagraph = false
|
|
720
|
+
afterBlank = false
|
|
721
|
+
afterTable = true
|
|
722
|
+
continue
|
|
723
|
+
}
|
|
724
|
+
|
|
705
725
|
const continues = inItem && (!afterBlank || INDENTED.test(line.text))
|
|
706
726
|
afterBlank = false
|
|
707
727
|
if (continues) continue
|
|
@@ -717,7 +737,12 @@ function shapeIssues(
|
|
|
717
737
|
text: line.text.trim(),
|
|
718
738
|
})
|
|
719
739
|
}
|
|
720
|
-
if (folder)
|
|
740
|
+
if (folder) {
|
|
741
|
+
settleDeferred()
|
|
742
|
+
if (afterTable) deferred = true
|
|
743
|
+
else folder.paragraphs++
|
|
744
|
+
}
|
|
745
|
+
afterTable = false
|
|
721
746
|
}
|
|
722
747
|
|
|
723
748
|
closeFolder()
|
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
|
+
}
|