@erclx/canon 5.35.0 → 5.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-cli/REQUIREMENT.md +2 -2
- package/claude/skills/canon-cli/SKILL.md +2 -1
- package/claude/skills/canvas-board/REQUIREMENT.md +1 -1
- package/claude/skills/canvas-board/SKILL.md +2 -2
- package/claude/skills/canvas-board/references/design.md +53 -36
- package/claude/skills/codebase-layout/REQUIREMENT.md +1 -1
- package/claude/skills/codebase-layout/SKILL.md +2 -2
- package/claude/skills/create-rule/REQUIREMENT.md +6 -8
- package/claude/skills/create-rule/SKILL.md +8 -8
- package/claude/skills/create-rule/references/rule.md +11 -23
- package/claude/skills/create-skill/references/skill.md +1 -1
- package/claude/skills/design-taste/REQUIREMENT.md +6 -6
- package/claude/skills/design-taste/SKILL.md +2 -2
- package/claude/skills/design-taste/references/craft.md +1 -1
- package/claude/skills/design-taste/references/preflight.md +2 -2
- package/claude/skills/design-taste/references/tells.md +1 -1
- package/claude/skills/draft-identity/SKILL.md +3 -2
- package/claude/skills/markdown-craft/references/markdown.md +3 -2
- package/claude/skills/plan-feature/SKILL.md +1 -1
- package/claude/skills/review-craft/references/ui.md +1 -1
- package/claude/skills/test-craft/SKILL.md +2 -2
- package/claude/skills/ux-audit/REQUIREMENT.md +1 -1
- package/claude/skills/ux-audit/SKILL.md +2 -2
- package/claude/skills/ux-measure/REQUIREMENT.md +1 -1
- package/claude/skills/write-human/references/machine-tells.md +12 -0
- package/docs/agents/commands.md +12 -9
- package/docs/agents/comments.md +1 -1
- package/docs/agents/gov-detect.md +1 -1
- package/docs/agents/index.md +1 -1
- package/docs/agents/install-and-sync.md +4 -4
- package/docs/agents/markdown-audit.md +12 -0
- package/docs/agents/output-shape.md +1 -1
- package/docs/agents/overview.md +2 -2
- package/docs/agents/rule-citations.md +5 -10
- package/docs/agents/scripting.md +1 -1
- package/docs/target/projects.md +2 -2
- package/docs/target/scenarios.md +5 -5
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/visual-design-workflow.md +3 -3
- package/governance/detect.toml +32 -32
- package/governance/renames.toml +44 -0
- package/governance/rules/lib/{305-e2e-reliability.md → e2e-reliability.md} +1 -1
- package/governance/rules/lib/{306-test-scope.md → test-scope.md} +2 -2
- package/governance/rules/ui/{410-a11y.md → a11y.md} +1 -1
- package/governance/rules/ui/{440-surface-capture.md → surface-capture.md} +3 -3
- package/governance/stacks/base.toml +6 -6
- package/governance/stacks/go.toml +1 -1
- package/governance/stacks/node.toml +1 -1
- package/governance/stacks/php.toml +1 -1
- package/governance/stacks/python.toml +1 -1
- package/package.json +1 -1
- package/scripts/core/install-check.sh +2 -7
- package/src/capture/frame-values.ts +3 -6
- package/src/cli/help-all.ts +96 -0
- package/src/cli/help.ts +27 -26
- package/src/cli.ts +8 -1
- package/src/commands/claude.ts +29 -0
- package/src/commands/design.ts +22 -1
- package/src/commands/gov.ts +25 -3
- package/src/commands/init.ts +10 -1
- package/src/commands/markdown.ts +96 -3
- package/src/commands/records.ts +1 -1
- package/src/commands/sync.ts +11 -0
- package/src/commands/tooling.ts +8 -0
- package/src/design/budget.ts +14 -10
- package/src/design/document.ts +5 -5
- package/src/design/parse.ts +21 -4
- package/src/design/render.ts +3 -3
- package/src/gate/measures.ts +1 -1
- package/src/gov/citations.ts +10 -33
- package/src/gov/stacks.ts +3 -3
- package/src/inventory/subjects.ts +1 -1
- package/src/markdown/ceiling.ts +6 -1
- package/src/markdown/colons.ts +86 -0
- package/src/markdown/gate.ts +15 -0
- package/src/markdown/heading-case.ts +74 -0
- package/src/shipped/references.ts +14 -14
- package/src/sync/copy.ts +4 -5
- package/tooling/astro/manifest.toml +0 -1
- package/tooling/astro/reference.md +0 -8
- package/tooling/base/configs/scripts/verify.json +1 -1
- package/tooling/base/configs/scripts/verify.ts +22 -6
- package/tooling/base/manifest.toml +6 -5
- package/tooling/base/reference.md +15 -22
- package/tooling/base/seeds/.github/workflows/verify.yml +0 -6
- package/tooling/base/seeds/.lintstagedrc +1 -2
- package/tooling/base/seeds/canon/context/ci.md +4 -5
- package/tooling/base/seeds/canon/context/development.md +2 -2
- package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +1 -1
- package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +27 -2
- package/tooling/claude/seeds/CLAUDE.md +1 -1
- package/tooling/claude/seeds/canon/DESIGN.md +24 -37
- package/tooling/shell/configs/.github/workflows/shell.yml +30 -0
- package/tooling/shell/manifest.toml +12 -0
- package/tooling/shell/reference.md +46 -0
- package/tooling/vite-react/manifest.toml +0 -1
- package/tooling/vite-react/reference.md +0 -8
- package/tooling/web/configs/.github/workflows/readme-screenshot.yml +3 -5
- package/tooling/web/configs/.github/workflows/verify.yml +0 -6
- package/tooling/web/manifest.toml +1 -1
- package/tooling/web/reference.md +3 -3
- package/tooling/base/configs/scripts/verify.sh +0 -2
- /package/governance/rules/canon/{606-git.md → git.md} +0 -0
- /package/governance/rules/canon/{611-indexes.md → indexes.md} +0 -0
- /package/governance/rules/canon/{604-scratch.md → scratch.md} +0 -0
- /package/governance/rules/canon/{602-tasks.md → task-scope.md} +0 -0
- /package/governance/rules/canon/{605-worktrees.md → worktrees.md} +0 -0
- /package/governance/rules/claude/{566-output.md → output.md} +0 -0
- /package/governance/rules/code/{000-code.md → routing.md} +0 -0
- /package/governance/rules/code/{010-testing.md → testing.md} +0 -0
- /package/governance/rules/framework/{210-astro.md → astro.md} +0 -0
- /package/governance/rules/framework/{220-fastapi.md → fastapi.md} +0 -0
- /package/governance/rules/framework/{230-nextjs.md → nextjs.md} +0 -0
- /package/governance/rules/framework/{200-react.md → react.md} +0 -0
- /package/governance/rules/framework/{260-shadcn.md → shadcn.md} +0 -0
- /package/governance/rules/framework/{250-tailwind.md → tailwind.md} +0 -0
- /package/governance/rules/lang/{130-go.md → go.md} +0 -0
- /package/governance/rules/lang/{140-php.md → php.md} +0 -0
- /package/governance/rules/lang/{110-python.md → python.md} +0 -0
- /package/governance/rules/lang/{100-typescript.md → typescript.md} +0 -0
- /package/governance/rules/lib/{370-database.md → database.md} +0 -0
- /package/governance/rules/lib/{340-pydantic.md → pydantic.md} +0 -0
- /package/governance/rules/lib/{360-security-server.md → security-server.md} +0 -0
- /package/governance/rules/lib/{350-security-web.md → security-web.md} +0 -0
- /package/governance/rules/lib/{320-tanstack-query.md → tanstack-query.md} +0 -0
- /package/governance/rules/lib/{335-testing-go.md → testing-go.md} +0 -0
- /package/governance/rules/lib/{336-testing-php.md → testing-php.md} +0 -0
- /package/governance/rules/lib/{330-testing-py.md → testing-py.md} +0 -0
- /package/governance/rules/lib/{300-testing-ts.md → testing-ts.md} +0 -0
- /package/governance/rules/lib/{310-zod.md → zod.md} +0 -0
- /package/governance/rules/tooling/{700-ci-workflow.md → ci-workflow.md} +0 -0
- /package/governance/rules/tooling/{790-code-comments.md → code-comments.md} +0 -0
- /package/governance/rules/tooling/{780-config-comments.md → config-comments.md} +0 -0
- /package/governance/rules/tooling/{775-dependencies.md → dependencies.md} +0 -0
- /package/governance/rules/ui/{480-canvas.md → canvas.md} +0 -0
- /package/governance/rules/ui/{400-ui.md → copy.md} +0 -0
- /package/governance/rules/ui/{460-design-taste.md → design-taste.md} +0 -0
- /package/governance/rules/ui/{420-forms.md → forms.md} +0 -0
- /package/governance/rules/ui/{465-interface-casing.md → interface-casing.md} +0 -0
- /package/governance/rules/ui/{450-link-behavior.md → link-behavior.md} +0 -0
- /package/governance/rules/ui/{470-motion.md → motion.md} +0 -0
- /package/governance/rules/ui/{430-ux-completeness.md → ux-completeness.md} +0 -0
- /package/tooling/{base → shell}/configs/.editorconfig +0 -0
- /package/tooling/{base → shell}/configs/.shellcheckrc +0 -0
package/src/commands/markdown.ts
CHANGED
|
@@ -3,9 +3,14 @@ import { readFile } from 'node:fs/promises'
|
|
|
3
3
|
import { dirname, relative, resolve } from 'node:path'
|
|
4
4
|
import type { Command } from 'commander'
|
|
5
5
|
import { BAN_SETS, emptyBanSets } from '@/markdown/bans'
|
|
6
|
-
import { lengthExemption } from '@/markdown/ceiling'
|
|
6
|
+
import { isChangelog, lengthExemption } from '@/markdown/ceiling'
|
|
7
|
+
import { type ColonFinding, findColons } from '@/markdown/colons'
|
|
7
8
|
import { type MarkdownAuditRefusal, resolveMarkdown } from '@/markdown/files'
|
|
8
9
|
import { isGating } from '@/markdown/gate'
|
|
10
|
+
import {
|
|
11
|
+
findHeadingCase,
|
|
12
|
+
type HeadingCaseFinding,
|
|
13
|
+
} from '@/markdown/heading-case'
|
|
9
14
|
import { decodePath, findBrokenLinks, type LinkFinding } from '@/markdown/links'
|
|
10
15
|
import {
|
|
11
16
|
type BanFinding,
|
|
@@ -52,6 +57,8 @@ interface FileReport {
|
|
|
52
57
|
readonly rel: string
|
|
53
58
|
readonly bans: readonly BanFinding[]
|
|
54
59
|
readonly links: readonly LinkFinding[]
|
|
60
|
+
readonly colons: readonly ColonFinding[]
|
|
61
|
+
readonly headingCase: readonly HeadingCaseFinding[]
|
|
55
62
|
readonly structure: StructureReport
|
|
56
63
|
readonly renderedLines: number
|
|
57
64
|
/** The stated reason, or null where the document is not exempt. */
|
|
@@ -90,6 +97,10 @@ export function register(program: Command): void {
|
|
|
90
97
|
'Bullet, paragraph, and depth weight are judgments a reader settles,',
|
|
91
98
|
'so all three report and none of them fails a run.',
|
|
92
99
|
'',
|
|
100
|
+
'A mid-sentence colon and a heading with a capital after its first word',
|
|
101
|
+
'report the same way. A proper noun is the one case the second cannot',
|
|
102
|
+
'tell from title case, and the first is sometimes right.',
|
|
103
|
+
'',
|
|
93
104
|
'Length reports a whole document past the ceiling and never exits 2.',
|
|
94
105
|
'The Document ceiling gate stage owns that verdict, since this audit also',
|
|
95
106
|
'runs on a record kept out of version control, where a long plan is not',
|
|
@@ -100,7 +111,7 @@ export function register(program: Command): void {
|
|
|
100
111
|
'it correctly and a flat paragraph there is not a defect.',
|
|
101
112
|
'',
|
|
102
113
|
'Rewrite the sentence carrying a hit rather than swapping the token for',
|
|
103
|
-
'a near-synonym. A code span clears the report and is the answer only',
|
|
114
|
+
'a near-synonym or a colon or a comma. A code span clears the report and is the answer only',
|
|
104
115
|
'where the token is genuinely an identifier under discussion, which is',
|
|
105
116
|
'what markdown.md reserves the span for.',
|
|
106
117
|
'',
|
|
@@ -160,9 +171,13 @@ async function runAudit(
|
|
|
160
171
|
const abs = resolve(root, rel)
|
|
161
172
|
const source = await readFile(abs, 'utf8')
|
|
162
173
|
const lines = bodyLines(source)
|
|
174
|
+
// The release tool writes a changelog, so neither advisory reads it.
|
|
175
|
+
const advisory = !isChangelog(rel)
|
|
163
176
|
return {
|
|
164
177
|
rel,
|
|
165
178
|
bans: scanBans(lines, bans),
|
|
179
|
+
colons: advisory ? findColons(lines) : [],
|
|
180
|
+
headingCase: advisory ? findHeadingCase(source) : [],
|
|
166
181
|
links: findBrokenLinks(lines, (path) =>
|
|
167
182
|
existsSync(resolve(dirname(abs), path)),
|
|
168
183
|
),
|
|
@@ -177,6 +192,8 @@ async function runAudit(
|
|
|
177
192
|
reportScope(scope.files, scope.unmatched)
|
|
178
193
|
reportBans(reports, bans, empty)
|
|
179
194
|
reportLinks(reports, root)
|
|
195
|
+
reportColons(reports)
|
|
196
|
+
reportHeadingCase(reports)
|
|
180
197
|
reportBullets(reports, checkpoints)
|
|
181
198
|
reportParagraphs(reports, checkpoints)
|
|
182
199
|
reportCadence(reports, checkpoints)
|
|
@@ -210,6 +227,8 @@ async function runAudit(
|
|
|
210
227
|
path: report.rel,
|
|
211
228
|
bans: report.bans,
|
|
212
229
|
links: report.links,
|
|
230
|
+
colons: report.colons,
|
|
231
|
+
headingCase: report.headingCase,
|
|
213
232
|
longestRun: report.structure.longestRun,
|
|
214
233
|
longestRunLine: report.structure.longestRunLine,
|
|
215
234
|
heavyBullets: report.structure.heavyBullets,
|
|
@@ -230,6 +249,8 @@ async function runAudit(
|
|
|
230
249
|
bans: reports.flatMap((report) => report.bans),
|
|
231
250
|
links: reports.flatMap((report) => report.links),
|
|
232
251
|
structure: reports.map((report) => report.structure),
|
|
252
|
+
colons: reports.flatMap((report) => report.colons),
|
|
253
|
+
headingCase: reports.flatMap((report) => report.headingCase),
|
|
233
254
|
})
|
|
234
255
|
|
|
235
256
|
return gating ? EXIT_GATE : 0
|
|
@@ -310,7 +331,7 @@ function reportBans(
|
|
|
310
331
|
logWarn(`${plural(total, 'hit')} across ${plural(carrying.length, 'file')}`)
|
|
311
332
|
logWarn('This fails the run. Every other check below reports.')
|
|
312
333
|
logInfo(
|
|
313
|
-
|
|
334
|
+
"Rewrite the sentence rather than swapping the token for a near-synonym, or a colon or a comma in the dash's place. That is the same sentence.",
|
|
314
335
|
)
|
|
315
336
|
logInfo(
|
|
316
337
|
'A code span clears the report and is the answer only where the token is genuinely an identifier under discussion, which is what markdown.md reserves the span for.',
|
|
@@ -383,6 +404,78 @@ function reportLinks(reports: readonly FileReport[], root: string): void {
|
|
|
383
404
|
)
|
|
384
405
|
}
|
|
385
406
|
|
|
407
|
+
function reportColons(reports: readonly FileReport[]): void {
|
|
408
|
+
logStep('Colons')
|
|
409
|
+
logInfo(
|
|
410
|
+
'A colon followed by more text in a prose line, outside a label, a code span, and a link.',
|
|
411
|
+
)
|
|
412
|
+
logInfo(
|
|
413
|
+
'A heading, a table row, a fenced block, and a line-final colon are excluded. This never fails a run.',
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
const carrying = reports
|
|
417
|
+
.filter((report) => report.colons.length > 0)
|
|
418
|
+
.sort((a, b) => b.colons.length - a.colons.length)
|
|
419
|
+
|
|
420
|
+
if (carrying.length === 0) {
|
|
421
|
+
logInfo('No colon joining two clauses.')
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const total = carrying.reduce((sum, report) => sum + report.colons.length, 0)
|
|
426
|
+
logWarn(`${plural(total, 'colon')} across ${plural(carrying.length, 'file')}`)
|
|
427
|
+
logInfo(
|
|
428
|
+
'Split the sentence or rewrite it. A colon belongs after a label or at the end of a line introducing a list.',
|
|
429
|
+
)
|
|
430
|
+
pipeOutput(
|
|
431
|
+
carrying
|
|
432
|
+
.map(
|
|
433
|
+
(report) =>
|
|
434
|
+
`${report.rel} ${plural(report.colons.length, 'colon')}\n${report.colons
|
|
435
|
+
.map((found) => ` :${found.line}:${found.column + 1}`)
|
|
436
|
+
.join('\n')}`,
|
|
437
|
+
)
|
|
438
|
+
.join('\n'),
|
|
439
|
+
)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function reportHeadingCase(reports: readonly FileReport[]): void {
|
|
443
|
+
logStep('Heading case')
|
|
444
|
+
logInfo(
|
|
445
|
+
'A heading or frontmatter title with a capitalized word after its first, outside code spans and all-caps tokens.',
|
|
446
|
+
)
|
|
447
|
+
logInfo(
|
|
448
|
+
'A proper noun reports too and is left as it is. This never fails a run.',
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
const carrying = reports
|
|
452
|
+
.filter((report) => report.headingCase.length > 0)
|
|
453
|
+
.sort((a, b) => b.headingCase.length - a.headingCase.length)
|
|
454
|
+
|
|
455
|
+
if (carrying.length === 0) {
|
|
456
|
+
logInfo('No heading carrying a capital after its first word.')
|
|
457
|
+
return
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const total = carrying.reduce(
|
|
461
|
+
(sum, report) => sum + report.headingCase.length,
|
|
462
|
+
0,
|
|
463
|
+
)
|
|
464
|
+
logWarn(
|
|
465
|
+
`${plural(total, 'heading')} across ${plural(carrying.length, 'file')}`,
|
|
466
|
+
)
|
|
467
|
+
pipeOutput(
|
|
468
|
+
carrying
|
|
469
|
+
.map(
|
|
470
|
+
(report) =>
|
|
471
|
+
`${report.rel} ${plural(report.headingCase.length, 'heading')}\n${report.headingCase
|
|
472
|
+
.map((found) => ` :${found.line} ${found.text}`)
|
|
473
|
+
.join('\n')}`,
|
|
474
|
+
)
|
|
475
|
+
.join('\n'),
|
|
476
|
+
)
|
|
477
|
+
}
|
|
478
|
+
|
|
386
479
|
function reportBullets(
|
|
387
480
|
reports: readonly FileReport[],
|
|
388
481
|
checkpoints: Checkpoints,
|
package/src/commands/records.ts
CHANGED
|
@@ -81,7 +81,7 @@ export function register(program: Command): void {
|
|
|
81
81
|
const records = program
|
|
82
82
|
.command('records')
|
|
83
83
|
.description(
|
|
84
|
-
'Check a governed corpus against its standard, and back up the session records under .
|
|
84
|
+
'Check a governed corpus against its standard, and back up the session records under .canon/',
|
|
85
85
|
)
|
|
86
86
|
.helpOption('-h, --help', 'Show this help message')
|
|
87
87
|
|
package/src/commands/sync.ts
CHANGED
|
@@ -57,6 +57,17 @@ export function register(program: Command): void {
|
|
|
57
57
|
.addHelpText(
|
|
58
58
|
'after',
|
|
59
59
|
[
|
|
60
|
+
'',
|
|
61
|
+
'Runs `canon gov sync`, `canon design sync`, and `canon claude sync`',
|
|
62
|
+
'on each domain installed, so each carries its own replace behavior:',
|
|
63
|
+
'governance replaces an edited rule and deletes a retired one, design',
|
|
64
|
+
'replaces its shipped files, and claude writes .gitignore alone.',
|
|
65
|
+
'Tooling is not synced. Refuses a dirty working tree.',
|
|
66
|
+
'',
|
|
67
|
+
'Exit codes:',
|
|
68
|
+
' 0 synced, no domain installed, or --check without --exit-code',
|
|
69
|
+
' 1 the target or a dirty tree was refused, opening the pull request',
|
|
70
|
+
' failed, or --check --exit-code found drift',
|
|
60
71
|
'',
|
|
61
72
|
'Examples:',
|
|
62
73
|
' canon sync',
|
package/src/commands/tooling.ts
CHANGED
|
@@ -106,6 +106,14 @@ export function register(program: Command): void {
|
|
|
106
106
|
'what `canon sync --check --exit-code` spells with a flag. Pass --check',
|
|
107
107
|
'to report the same list and always exit 0.',
|
|
108
108
|
'',
|
|
109
|
+
'--write replaces every golden config that differs from the stack.',
|
|
110
|
+
'Seeds are copy-once, so a seed the target already holds is left alone.',
|
|
111
|
+
'',
|
|
112
|
+
'Exit codes:',
|
|
113
|
+
' 0 synced, up to date, cancelled, or run with --check',
|
|
114
|
+
' 1 refused, or run headlessly with neither flag while a file would',
|
|
115
|
+
' be replaced',
|
|
116
|
+
'',
|
|
109
117
|
].join('\n'),
|
|
110
118
|
)
|
|
111
119
|
.action(
|
package/src/design/budget.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { canonicalHeading } from '@/design/parse'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* The prose budget `claude/skills/canvas-board/references/design.md` sets for a design record, and the
|
|
3
5
|
* counter the `design` gate stage reads it through.
|
|
@@ -11,21 +13,23 @@
|
|
|
11
13
|
* `<` is dropped the way `prose()` drops it, and so is a fenced block or an
|
|
12
14
|
* HTML comment spanning several lines, which `prose()` would read in part. A
|
|
13
15
|
* `###` subsection counts toward the `##` section holding it, since the
|
|
14
|
-
* standard fixes the sections and a subheading is no way past the cap.
|
|
16
|
+
* standard fixes the sections and a subheading is no way past the cap. A
|
|
17
|
+
* legacy `## Personality` takes the Overview cap through the parser's alias,
|
|
18
|
+
* and a finding names the heading as the record wrote it.
|
|
15
19
|
*/
|
|
16
20
|
export const DESIGN_BUDGET = {
|
|
17
21
|
/** Words before the first `##` section, the title excluded. */
|
|
18
22
|
leadWords: 40,
|
|
19
|
-
/** Words in `##
|
|
20
|
-
|
|
21
|
-
|
|
23
|
+
/** Words in `## Overview`, held to one paragraph. */
|
|
24
|
+
overviewWords: 60,
|
|
25
|
+
overviewParagraphs: 1,
|
|
22
26
|
/** Bullets or paragraphs in any other section, beside its table. */
|
|
23
27
|
rules: 3,
|
|
24
28
|
/** Words in one of those rules, the proxy for one line. */
|
|
25
29
|
ruleWords: 25,
|
|
26
30
|
} as const
|
|
27
31
|
|
|
28
|
-
const
|
|
32
|
+
const OVERVIEW = 'Overview'
|
|
29
33
|
|
|
30
34
|
/** A list item at any depth, which starts a rule of its own. */
|
|
31
35
|
const BULLET = /^\s*(?:[-*+]|\d+[.)])\s+/
|
|
@@ -52,15 +56,15 @@ export function measureDesignProse(source: string): string[] {
|
|
|
52
56
|
`The lead holds ${total} words against a cap of ${DESIGN_BUDGET.leadWords}`,
|
|
53
57
|
)
|
|
54
58
|
}
|
|
55
|
-
} else if (section.heading ===
|
|
56
|
-
if (rules.length > DESIGN_BUDGET.
|
|
59
|
+
} else if (canonicalHeading(section.heading) === OVERVIEW) {
|
|
60
|
+
if (rules.length > DESIGN_BUDGET.overviewParagraphs) {
|
|
57
61
|
findings.push(
|
|
58
|
-
`${
|
|
62
|
+
`${section.heading} holds ${rules.length} paragraphs against a cap of ${DESIGN_BUDGET.overviewParagraphs}`,
|
|
59
63
|
)
|
|
60
64
|
}
|
|
61
|
-
if (total > DESIGN_BUDGET.
|
|
65
|
+
if (total > DESIGN_BUDGET.overviewWords) {
|
|
62
66
|
findings.push(
|
|
63
|
-
`${
|
|
67
|
+
`${section.heading} holds ${total} words against a cap of ${DESIGN_BUDGET.overviewWords}`,
|
|
64
68
|
)
|
|
65
69
|
}
|
|
66
70
|
} else {
|
package/src/design/document.ts
CHANGED
|
@@ -114,10 +114,10 @@ export function renderDesignDocument(tokens: DesignTokens = TOKENS): string {
|
|
|
114
114
|
'',
|
|
115
115
|
LEAD,
|
|
116
116
|
'',
|
|
117
|
-
section('
|
|
117
|
+
section('Overview', tokens.personality),
|
|
118
118
|
'',
|
|
119
119
|
section(
|
|
120
|
-
'
|
|
120
|
+
'Colors',
|
|
121
121
|
bullets(tokens.colorRules),
|
|
122
122
|
formatTable(['Role', 'Intent', 'Value'], colorRows(tokens.color)),
|
|
123
123
|
),
|
|
@@ -137,8 +137,10 @@ export function renderDesignDocument(tokens: DesignTokens = TOKENS): string {
|
|
|
137
137
|
formatTable(['Step', 'Multiplier', 'Value'], spaceRows(tokens.spacing)),
|
|
138
138
|
),
|
|
139
139
|
'',
|
|
140
|
+
section('Layout', bullets(tokens.layout)),
|
|
141
|
+
'',
|
|
140
142
|
section(
|
|
141
|
-
'
|
|
143
|
+
'Shapes',
|
|
142
144
|
bullets(tokens.bordersRules),
|
|
143
145
|
formatTable(
|
|
144
146
|
['Role', 'Radius', 'Width', 'When used'],
|
|
@@ -146,8 +148,6 @@ export function renderDesignDocument(tokens: DesignTokens = TOKENS): string {
|
|
|
146
148
|
),
|
|
147
149
|
),
|
|
148
150
|
'',
|
|
149
|
-
section('Layout', bullets(tokens.layout)),
|
|
150
|
-
'',
|
|
151
151
|
section('Motion', bullets(tokens.motion)),
|
|
152
152
|
'',
|
|
153
153
|
section('Iconography', bullets(tokens.iconography)),
|
package/src/design/parse.ts
CHANGED
|
@@ -25,6 +25,23 @@ export interface DesignDoc {
|
|
|
25
25
|
iconography: string
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The three headings the standard renamed toward Google's DESIGN.md, each read
|
|
30
|
+
* as the section it became. Records the seed and `canvas-board` wrote before
|
|
31
|
+
* the rename sit in targets and will not rename themselves, and an unmatched
|
|
32
|
+
* heading parses to nothing without saying so. The field names on `DesignDoc`
|
|
33
|
+
* keep the old words, since the preview reading them is retired separately.
|
|
34
|
+
*/
|
|
35
|
+
export const LEGACY_HEADINGS: Readonly<Record<string, string>> = {
|
|
36
|
+
Personality: 'Overview',
|
|
37
|
+
Color: 'Colors',
|
|
38
|
+
Borders: 'Shapes',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function canonicalHeading(heading: string): string {
|
|
42
|
+
return LEGACY_HEADINGS[heading] ?? heading
|
|
43
|
+
}
|
|
44
|
+
|
|
28
45
|
const VERIFY_TAG = /\s*\?\s*verify\s*$/
|
|
29
46
|
|
|
30
47
|
/** A cell whose whole content is one balanced code span and nothing else. */
|
|
@@ -34,11 +51,11 @@ export function parseDesignDoc(path: string): DesignDoc {
|
|
|
34
51
|
const raw = readFileSync(path, 'utf8')
|
|
35
52
|
const sections = splitSections(raw)
|
|
36
53
|
return {
|
|
37
|
-
personality: prose(sections['
|
|
38
|
-
color: table(sections['
|
|
54
|
+
personality: prose(sections['Overview']),
|
|
55
|
+
color: table(sections['Colors']),
|
|
39
56
|
typography: table(sections['Typography']),
|
|
40
57
|
spacing: table(sections['Spacing']),
|
|
41
|
-
borders: table(sections['
|
|
58
|
+
borders: table(sections['Shapes']),
|
|
42
59
|
motion: prose(sections['Motion']),
|
|
43
60
|
iconography: prose(sections['Iconography']),
|
|
44
61
|
}
|
|
@@ -53,7 +70,7 @@ function splitSections(raw: string): Record<string, string> {
|
|
|
53
70
|
const match = line.match(/^##\s+(.+?)\s*$/)
|
|
54
71
|
if (match) {
|
|
55
72
|
if (current) out[current] = buffer.join('\n')
|
|
56
|
-
current = match[1]
|
|
73
|
+
current = canonicalHeading(match[1])
|
|
57
74
|
buffer = []
|
|
58
75
|
} else if (current) {
|
|
59
76
|
buffer.push(line)
|
package/src/design/render.ts
CHANGED
|
@@ -268,7 +268,7 @@ ${sections.join('\n')}
|
|
|
268
268
|
|
|
269
269
|
function sectionPersonality(text: string): string {
|
|
270
270
|
if (!text) return ''
|
|
271
|
-
return `<h2>
|
|
271
|
+
return `<h2>Overview</h2>\n<p>${escape(text)}</p>`
|
|
272
272
|
}
|
|
273
273
|
|
|
274
274
|
function sectionColor(rows: Row[]): string {
|
|
@@ -283,7 +283,7 @@ function sectionColor(rows: Row[]): string {
|
|
|
283
283
|
return `<tr><td>${swatch}${cellText(r, 'Role')}</td><td>${cellText(r, 'Intent')}</td><td><code>${shown}</code>${mark(r, 'Value')}</td></tr>`
|
|
284
284
|
})
|
|
285
285
|
.join('\n')
|
|
286
|
-
return `<h2>
|
|
286
|
+
return `<h2>Colors</h2>\n<table><thead><tr><th>Role</th><th>Intent</th><th>Value</th></tr></thead><tbody>${body}</tbody></table>`
|
|
287
287
|
}
|
|
288
288
|
|
|
289
289
|
function sectionTypography(rows: Row[]): string {
|
|
@@ -325,7 +325,7 @@ function sectionBorders(rows: Row[]): string {
|
|
|
325
325
|
return `<tr><td>${cellText(r, 'Role')}</td><td><code>${escape(radius)}</code>${mark(r, 'Radius')}</td><td><code>${escape(width)}</code>${mark(r, 'Width')}</td><td>${cellText(r, 'When used')}</td><td>${sample}</td></tr>`
|
|
326
326
|
})
|
|
327
327
|
.join('\n')
|
|
328
|
-
return `<h2>
|
|
328
|
+
return `<h2>Shapes</h2>\n<table><thead><tr><th>Role</th><th>Radius</th><th>Width</th><th>When used</th><th>Sample</th></tr></thead><tbody>${body}</tbody></table>`
|
|
329
329
|
}
|
|
330
330
|
|
|
331
331
|
/** A section the record writes as one-line rules draws them as a list. */
|
package/src/gate/measures.ts
CHANGED
|
@@ -659,7 +659,7 @@ export const markdownBans: Measure = async (ctx) => {
|
|
|
659
659
|
return {
|
|
660
660
|
emissions: [output(frame.all)],
|
|
661
661
|
failure:
|
|
662
|
-
'Markdown prose carries a banned character, or a relative link resolves to nothing on disk. Rewrite the sentence or fix the link, and reach for a code span only where the token is genuinely an identifier under discussion.',
|
|
662
|
+
'Markdown prose carries a banned character, or a relative link resolves to nothing on disk. Rewrite the sentence, since a colon or a comma standing where the em dash was is the same sentence, or fix the link, and reach for a code span only where the token is genuinely an identifier under discussion.',
|
|
663
663
|
}
|
|
664
664
|
}
|
|
665
665
|
|
package/src/gov/citations.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { readFile } from 'node:fs/promises'
|
|
3
|
-
import {
|
|
3
|
+
import { join, resolve } from 'node:path'
|
|
4
4
|
import { $ } from 'bun'
|
|
5
5
|
import { isMarked } from '@/sweep/exempt-marker'
|
|
6
6
|
import { gitEnv } from '@/git/env'
|
|
@@ -41,14 +41,11 @@ export const RULE_DIRS: readonly string[] = [
|
|
|
41
41
|
export const GLOB_CORPUS = join('internal', 'rules')
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
|
-
* How the citation was written,
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* `path` is a backticked repository path. `sibling` is a rule naming another
|
|
48
|
-
* rule by filename alone, which resolves inside the folder the citing rule
|
|
49
|
-
* sits in.
|
|
44
|
+
* How the citation was written, being a backticked repository path. A bare
|
|
45
|
+
* filename is no form, since with no folder around it nothing tells a rule
|
|
46
|
+
* from a convention such as `index.md`.
|
|
50
47
|
*/
|
|
51
|
-
export type CitationForm = 'path'
|
|
48
|
+
export type CitationForm = 'path'
|
|
52
49
|
|
|
53
50
|
/**
|
|
54
51
|
* What the sweep decided about one citation.
|
|
@@ -115,9 +112,6 @@ const FENCE = /^\s*(?:```|~~~)/
|
|
|
115
112
|
|
|
116
113
|
const FRONTMATTER_DELIMITER = /^---\s*$/
|
|
117
114
|
|
|
118
|
-
/** A rule filename, which is how a rule names a sibling with no folder around it. */
|
|
119
|
-
const SIBLING_RULE = /^\d{3}-[a-z0-9-]+\.md$/
|
|
120
|
-
|
|
121
115
|
/**
|
|
122
116
|
* A character that puts the span outside this repository, or outside paths
|
|
123
117
|
* altogether.
|
|
@@ -160,24 +154,7 @@ function namesAFile(span: string): boolean {
|
|
|
160
154
|
export function classifySpan(span: string): CitationForm | undefined {
|
|
161
155
|
if (isNotRepositoryPath(span)) return undefined
|
|
162
156
|
if (!namesAFile(span)) return undefined
|
|
163
|
-
|
|
164
|
-
return SIBLING_RULE.test(span) ? 'sibling' : undefined
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Where a citation could answer, which is wherever the reader's own tools look
|
|
169
|
-
* and nowhere else.
|
|
170
|
-
*
|
|
171
|
-
* A sibling resolves inside the folder the citing rule sits in, since that is
|
|
172
|
-
* the only place a bare rule filename means anything.
|
|
173
|
-
*/
|
|
174
|
-
function candidatesFor(
|
|
175
|
-
form: CitationForm,
|
|
176
|
-
cited: string,
|
|
177
|
-
ruleFile: string,
|
|
178
|
-
): string[] {
|
|
179
|
-
if (form === 'sibling') return [join(dirname(ruleFile), cited)]
|
|
180
|
-
return [cited]
|
|
157
|
+
return span.includes('/') ? 'path' : undefined
|
|
181
158
|
}
|
|
182
159
|
|
|
183
160
|
interface RawCitation {
|
|
@@ -268,7 +245,7 @@ function isGoverned(cited: string, declared: readonly string[]): boolean {
|
|
|
268
245
|
*
|
|
269
246
|
* Anchored on the entry shape rather than searched for as a substring. A bare
|
|
270
247
|
* scan for the glob text finds it in the `description:` line first wherever a
|
|
271
|
-
* rule names what it governs in prose, which is how `
|
|
248
|
+
* rule names what it governs in prose, which is how `seed-mirror.md` reported
|
|
272
249
|
* its `CLAUDE.md` glob against line 2 instead of line 4.
|
|
273
250
|
*/
|
|
274
251
|
const LIST_ENTRY = /^\s*-\s*(?:'([^']*)'|"([^"]*)"|(\S.*?))\s*$/
|
|
@@ -410,7 +387,7 @@ export async function readCitations(root: string): Promise<CitationReport> {
|
|
|
410
387
|
}
|
|
411
388
|
|
|
412
389
|
for (const raw of collectCitations(text)) {
|
|
413
|
-
const candidates =
|
|
390
|
+
const candidates = [raw.cited]
|
|
414
391
|
const resolved = candidates.find((path) =>
|
|
415
392
|
existsSync(resolve(root, path)),
|
|
416
393
|
)
|
|
@@ -439,7 +416,7 @@ function classifyStatus(
|
|
|
439
416
|
): CitationStatus {
|
|
440
417
|
if (resolved !== undefined) return 'resolved'
|
|
441
418
|
if (raw.marked) return 'exempt'
|
|
442
|
-
if (
|
|
419
|
+
if (isGoverned(raw.cited, declared)) return 'governed'
|
|
443
420
|
return 'dead'
|
|
444
421
|
}
|
|
445
422
|
|
|
@@ -456,7 +433,7 @@ async function applyIgnored(
|
|
|
456
433
|
measured: Extract<CitationReport, { kind: 'measured' }>,
|
|
457
434
|
): Promise<CitationReport> {
|
|
458
435
|
const pending = measured.citations.filter(
|
|
459
|
-
(citation) => citation.status === 'dead'
|
|
436
|
+
(citation) => citation.status === 'dead',
|
|
460
437
|
)
|
|
461
438
|
if (pending.length === 0) return measured
|
|
462
439
|
|
package/src/gov/stacks.ts
CHANGED
|
@@ -78,9 +78,9 @@ export function loadGovStack(
|
|
|
78
78
|
* entry resolves to itself, so a folder and a slug reach the caller as one
|
|
79
79
|
* shape rather than two the caller has to tell apart.
|
|
80
80
|
*
|
|
81
|
-
* The directory wins over a rule file of the same name
|
|
82
|
-
*
|
|
83
|
-
*
|
|
81
|
+
* The directory wins over a rule file of the same name, which would leave
|
|
82
|
+
* that rule unreachable from a stack entry. `src/gov/rule-layout.test.ts`
|
|
83
|
+
* fails on any rule basename equal to a folder name, so the two never meet.
|
|
84
84
|
*/
|
|
85
85
|
export function expandStackEntry(root: string, entry: string): string[] {
|
|
86
86
|
const dir = join(rulesSourceDir(root), entry)
|
|
@@ -27,7 +27,7 @@ export interface Subject {
|
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* Reports what each element changes about itself when it takes focus, which is
|
|
30
|
-
* the reading `governance/rules/ui/
|
|
30
|
+
* the reading `governance/rules/ui/a11y.md` states three rules against and
|
|
31
31
|
* nothing measures.
|
|
32
32
|
*
|
|
33
33
|
* The difference between rest and focus is the treatment, rather than the
|
package/src/markdown/ceiling.ts
CHANGED
|
@@ -34,9 +34,14 @@ export interface CeilingFinding {
|
|
|
34
34
|
readonly exempt: string | null
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/** Whether the release tool writes this document, which no measure reads. */
|
|
38
|
+
export function isChangelog(rel: string): boolean {
|
|
39
|
+
return basename(rel) === CHANGELOG
|
|
40
|
+
}
|
|
41
|
+
|
|
37
42
|
/** The reason a document is exempt from the ceiling, or null where it is not. */
|
|
38
43
|
export function lengthExemption(rel: string, source: string): string | null {
|
|
39
|
-
if (
|
|
44
|
+
if (isChangelog(rel)) return CHANGELOG_REASON
|
|
40
45
|
|
|
41
46
|
for (const line of bodyLines(source)) {
|
|
42
47
|
if (line.fenced) continue
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { type BodyLine, LINK, maskDisplayed } from '@/markdown/scan'
|
|
2
|
+
|
|
3
|
+
export interface ColonFinding {
|
|
4
|
+
readonly line: number
|
|
5
|
+
/** Zero-based, the colon itself. */
|
|
6
|
+
readonly column: number
|
|
7
|
+
/** The whole source line, so a reader can match it against new text. */
|
|
8
|
+
readonly text: string
|
|
9
|
+
/**
|
|
10
|
+
* The run of non-space characters ending at the colon. A hook matches it
|
|
11
|
+
* against new text, since `column` is a UTF-16 offset that a reader counting
|
|
12
|
+
* code points would misplace after an emoji.
|
|
13
|
+
*/
|
|
14
|
+
readonly lead: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const HEADING = /^#{1,6}\s/
|
|
18
|
+
const TABLE_ROW = /^\s*\|/
|
|
19
|
+
const LIST_MARKER = /^\s*(?:[-*+]|\d+[.)])\s+/
|
|
20
|
+
const QUOTE_MARKER = /^(?:\s*>\s?)+/
|
|
21
|
+
|
|
22
|
+
/** A colon followed by whitespace and more text, ruling out `10:30` and a scheme. */
|
|
23
|
+
const COLON = /:(?=\s+\S)/g
|
|
24
|
+
|
|
25
|
+
const BOLD_LABEL = /^(\*\*|__)[^*_]+\1$/
|
|
26
|
+
|
|
27
|
+
/** Blanks a span while holding its width, so a column stays where it was. */
|
|
28
|
+
function blank(match: string): string {
|
|
29
|
+
return ' '.repeat(match.length)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether the text before a line's first colon is a label rather than a
|
|
34
|
+
* clause: the lead of a list item, or a prefix that is only emphasis and a
|
|
35
|
+
* masked code span.
|
|
36
|
+
*/
|
|
37
|
+
function isLabel(prefix: string, listItem: boolean): boolean {
|
|
38
|
+
if (BOLD_LABEL.test(prefix.trim())) return true
|
|
39
|
+
|
|
40
|
+
const words = prefix.replace(/[*_]/g, ' ').trim()
|
|
41
|
+
if (words === '') return true
|
|
42
|
+
return listItem && words.split(/\s+/).length <= 4
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Finds a colon joining two clauses in the middle of a prose sentence.
|
|
47
|
+
*
|
|
48
|
+
* A heading, a table row, a fence, a code span, a link, and a line-final colon
|
|
49
|
+
* are left alone, and so is the first colon of a list item or of a line opening
|
|
50
|
+
* on a bold or code label. The measure is advisory, because a colon is
|
|
51
|
+
* sometimes right and the corpus count shows a gate would be a sweep nobody
|
|
52
|
+
* asked for.
|
|
53
|
+
*/
|
|
54
|
+
export function findColons(lines: readonly BodyLine[]): ColonFinding[] {
|
|
55
|
+
const found: ColonFinding[] = []
|
|
56
|
+
|
|
57
|
+
for (const line of lines) {
|
|
58
|
+
if (line.fenced || HEADING.test(line.text) || TABLE_ROW.test(line.text)) {
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const masked = maskDisplayed(line.text.replace(LINK, blank))
|
|
63
|
+
const quote = QUOTE_MARKER.exec(masked)?.[0] ?? ''
|
|
64
|
+
const marker = LIST_MARKER.exec(masked.slice(quote.length))?.[0] ?? ''
|
|
65
|
+
const start = quote.length + marker.length
|
|
66
|
+
let labelled = false
|
|
67
|
+
|
|
68
|
+
for (const match of masked.matchAll(COLON)) {
|
|
69
|
+
if (match.index < start) continue
|
|
70
|
+
|
|
71
|
+
if (!labelled) {
|
|
72
|
+
labelled = true
|
|
73
|
+
if (isLabel(masked.slice(start, match.index), marker !== '')) continue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
found.push({
|
|
77
|
+
line: line.number,
|
|
78
|
+
column: match.index,
|
|
79
|
+
text: line.text,
|
|
80
|
+
lead: line.text.slice(0, match.index).replace(/^.*\s/, ''),
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return found
|
|
86
|
+
}
|
package/src/markdown/gate.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ColonFinding } from '@/markdown/colons'
|
|
2
|
+
import type { HeadingCaseFinding } from '@/markdown/heading-case'
|
|
1
3
|
import type { LinkFinding } from '@/markdown/links'
|
|
2
4
|
import type { BanFinding } from '@/markdown/scan'
|
|
3
5
|
import type { StructureReport } from '@/markdown/structure'
|
|
@@ -16,6 +18,19 @@ export interface GateInput {
|
|
|
16
18
|
* alone states the same rule and leaves no place to assert it.
|
|
17
19
|
*/
|
|
18
20
|
readonly structure: readonly StructureReport[]
|
|
21
|
+
/**
|
|
22
|
+
* Every mid-sentence colon the run measured, read by nothing here.
|
|
23
|
+
*
|
|
24
|
+
* A colon is sometimes right and the corpus carries over a thousand
|
|
25
|
+
* candidates, so the measure warns and a push never fails on it.
|
|
26
|
+
*/
|
|
27
|
+
readonly colons: readonly ColonFinding[]
|
|
28
|
+
/**
|
|
29
|
+
* Every heading or title with a capital after its first word, read by
|
|
30
|
+
* nothing here. A proper noun is the one case it cannot tell from title
|
|
31
|
+
* case, and gating it would need a names list and an escape marker.
|
|
32
|
+
*/
|
|
33
|
+
readonly headingCase: readonly HeadingCaseFinding[]
|
|
19
34
|
}
|
|
20
35
|
|
|
21
36
|
/**
|