@erclx/canon 5.34.0 → 5.35.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.
Files changed (143) hide show
  1. package/README.md +3 -5
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/REQUIREMENT.md +1 -1
  4. package/claude/skills/auto-ship/references/draft-mark.md +1 -1
  5. package/claude/skills/canon-cli/SKILL.md +10 -11
  6. package/claude/skills/create-skill/SKILL.md +2 -1
  7. package/claude/skills/create-skill/references/skill-paths.md +12 -15
  8. package/{standards/standard.md → claude/skills/create-skill/references/skill-reference.md} +50 -45
  9. package/claude/skills/create-skill/references/skill.md +9 -3
  10. package/claude/skills/draft-and-pick/SKILL.md +1 -1
  11. package/claude/skills/git-commit/references/versioning.md +13 -13
  12. package/claude/skills/git-pr/SKILL.md +1 -1
  13. package/claude/skills/git-pr/references/publish.md +1 -1
  14. package/claude/skills/review-address/REQUIREMENT.md +1 -1
  15. package/claude/skills/review-address/SKILL.md +2 -2
  16. package/claude/skills/review-pr/SKILL.md +3 -3
  17. package/claude/skills/review-pr/references/post.md +1 -1
  18. package/claude/skills/review-ui/REQUIREMENT.md +1 -1
  19. package/claude/skills/review-ui/SKILL.md +1 -1
  20. package/claude/skills/role-orchestrator/REQUIREMENT.md +2 -2
  21. package/claude/skills/role-orchestrator/SKILL.md +11 -12
  22. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +3 -1
  23. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +11 -8
  24. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +10 -4
  25. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +2 -2
  26. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +11 -12
  27. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +12 -15
  28. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +6 -4
  29. package/claude/skills/role-orchestrator/scripts/poll.ts +50 -1
  30. package/claude/skills/role-reviewer/REQUIREMENT.md +17 -7
  31. package/claude/skills/role-reviewer/SKILL.md +17 -10
  32. package/claude/skills/role-worker/REQUIREMENT.md +1 -1
  33. package/claude/skills/role-worker/SKILL.md +6 -6
  34. package/claude/skills/session-compact/references/handoff-note.md +1 -1
  35. package/claude/skills/target-setup/SKILL.md +2 -2
  36. package/claude/skills/target-setup/references/detect.md +3 -3
  37. package/docs/agents/audits.md +1 -1
  38. package/docs/agents/commands.md +2 -4
  39. package/docs/agents/counts.md +5 -6
  40. package/docs/agents/drift-surfaces.md +15 -19
  41. package/docs/agents/index.md +2 -3
  42. package/docs/agents/install-and-sync.md +7 -31
  43. package/docs/agents/markdown-audit.md +10 -12
  44. package/docs/agents/migrate.md +1 -1
  45. package/docs/agents/records-stale.md +1 -1
  46. package/docs/agents/records-validate.md +11 -23
  47. package/docs/agents/records.md +1 -1
  48. package/docs/agents/routing.md +1 -1
  49. package/docs/agents/rule-citations.md +11 -14
  50. package/docs/agents/scripting.md +2 -20
  51. package/docs/agents/state-scoped-risk.md +1 -1
  52. package/docs/agents/tasks-validate.md +1 -1
  53. package/docs/agents/teach.md +1 -1
  54. package/docs/target/index.md +1 -0
  55. package/docs/target/migrations.md +4 -0
  56. package/docs/target/projects.md +9 -9
  57. package/docs/target/scenarios.md +5 -6
  58. package/docs/target/standards-moved.md +32 -0
  59. package/docs/target/sync.md +5 -5
  60. package/docs/workflow/operating-model.md +16 -18
  61. package/docs/workflow/skill-map.md +1 -1
  62. package/governance/detect.toml +0 -4
  63. package/governance/renames.toml +0 -1
  64. package/governance/stacks/base.toml +15 -8
  65. package/governance/stacks/go.toml +2 -0
  66. package/governance/stacks/node.toml +1 -1
  67. package/governance/stacks/php.toml +2 -0
  68. package/governance/stacks/python.toml +1 -1
  69. package/package.json +1 -4
  70. package/scripts/core/install-check.sh +2 -3
  71. package/src/audits/catalog.ts +0 -9
  72. package/src/claude/routing.ts +1 -1
  73. package/src/claude/skills-reach.ts +4 -6
  74. package/src/cli/help.ts +3 -4
  75. package/src/cli.ts +0 -2
  76. package/src/commands/gov.ts +5 -9
  77. package/src/commands/init.ts +1 -1
  78. package/src/commands/labels.ts +4 -44
  79. package/src/commands/migrate.ts +1 -1
  80. package/src/commands/records.ts +4 -20
  81. package/src/commands/tooling.ts +2 -4
  82. package/src/counts/catalogs.ts +0 -6
  83. package/src/gate/boundaries.ts +2 -2
  84. package/src/gate/measures.ts +6 -28
  85. package/src/gate/sequencer.ts +51 -2
  86. package/src/gate/stages.ts +0 -18
  87. package/src/gov/citations.ts +11 -43
  88. package/src/markdown/bans.ts +1 -1
  89. package/src/migrate/scratch-evidence.ts +2 -2
  90. package/src/records/validate.ts +6 -194
  91. package/src/sandbox/provision.ts +0 -5
  92. package/src/secrets/shipped.ts +1 -2
  93. package/src/serve/static.ts +1 -2
  94. package/src/shipped/references.ts +29 -80
  95. package/src/sync/check.ts +18 -14
  96. package/src/tooling/inject.ts +6 -37
  97. package/src/tooling/read.ts +2 -3
  98. package/src/tooling/scan.ts +4 -17
  99. package/src/tooling/subfolder.ts +2 -66
  100. package/tooling/base/configs/scripts/verify.json +1 -1
  101. package/tooling/base/configs/scripts/verify.ts +1 -12
  102. package/tooling/base/manifest.toml +1 -2
  103. package/tooling/base/reference.md +10 -16
  104. package/tooling/base/seeds/.github/workflows/verify.yml +0 -3
  105. package/tooling/base/seeds/.lintstagedrc +1 -2
  106. package/tooling/base/seeds/canon/context/ci.md +0 -1
  107. package/tooling/base/seeds/canon/context/development.md +1 -1
  108. package/tooling/claude/reference.md +1 -1
  109. package/tooling/claude/seeds/.claude/hooks/review-post-scan.sh +13 -1
  110. package/tooling/go/reference.md +1 -1
  111. package/tooling/php/reference.md +1 -1
  112. package/tooling/python/reference.md +2 -2
  113. package/tooling/web/configs/.github/workflows/verify.yml +0 -3
  114. package/tooling/web/manifest.toml +0 -1
  115. package/tooling/web/reference.md +2 -4
  116. package/docs/agents/standards-audit.md +0 -40
  117. package/governance/rules/tooling/765-spelling.md +0 -19
  118. package/governance/stacks/astro.toml +0 -2
  119. package/governance/stacks/nextjs.toml +0 -2
  120. package/governance/stacks/node-server.toml +0 -2
  121. package/governance/stacks/python-fastapi.toml +0 -2
  122. package/governance/stacks/react.toml +0 -2
  123. package/src/commands/standards.ts +0 -278
  124. package/src/labels/spelling.ts +0 -120
  125. package/src/standards/audit.ts +0 -146
  126. package/src/standards/list.ts +0 -33
  127. package/src/standards/read.ts +0 -177
  128. package/src/standards/scope.ts +0 -56
  129. package/standards/index.md +0 -10
  130. package/tooling/base/configs/.vscode/extensions.json +0 -9
  131. package/tooling/base/configs/.vscode/settings.json +0 -3
  132. package/tooling/base/seeds/.cspell/project-terms.txt +0 -0
  133. package/tooling/base/seeds/.cspell/tech-stack.txt +0 -25
  134. package/tooling/base/seeds/cspell.json +0 -20
  135. package/tooling/go/seeds/.cspell/tech-stack.txt +0 -16
  136. package/tooling/nextjs/seeds/.cspell/tech-stack.txt +0 -3
  137. package/tooling/php/seeds/.cspell/tech-stack.txt +0 -5
  138. package/tooling/python/seeds/.cspell/tech-stack.txt +0 -18
  139. package/tooling/vite-react/seeds/.cspell/project-terms.txt +0 -1
  140. package/tooling/vite-react/seeds/.cspell/tech-stack.txt +0 -2
  141. package/tooling/web/configs/.vscode/extensions.json +0 -13
  142. package/tooling/web/configs/.vscode/settings.json +0 -10
  143. package/tooling/web/seeds/.cspell/tech-stack.txt +0 -21
@@ -1,19 +0,0 @@
1
- ---
2
- description: Route .cspell/ dictionary additions to the right seeded file and keep each sorted
3
- paths:
4
- - '.cspell/**'
5
- ---
6
-
7
- # Spelling standards
8
-
9
- ## Dictionary routing
10
-
11
- - Rewrite a typo rather than adding it. Add only a real term.
12
- - Add a term from a tool, library, or platform the project depends on to `.cspell/tech-stack.txt`, whether it is the dependency's own name or vocabulary from its config, API, or spec.
13
- - Add jargon, an acronym, a handle, or another project-specific term to `.cspell/project-terms.txt`.
14
- - Leave a dictionary declared `addWords: false` alone. It mirrors a closed set maintained elsewhere in the project rather than approved vocabulary, so route a word there through whatever maintains that set instead of editing the dictionary file.
15
- - Add a third dictionary only when cspell still flags a term neither file covers. Declare its path in `cspell.json` with `addWords: true` before adding words to it.
16
-
17
- ## File shape
18
-
19
- - Keep every dictionary file sorted alphabetically.
@@ -1,2 +0,0 @@
1
- extends = "node"
2
- rules = ["210-astro", "300-testing-ts", "305-e2e-reliability", "306-test-scope", "350-security-web", "400-ui", "410-a11y", "420-forms", "430-ux-completeness", "440-surface-capture", "450-link-behavior", "460-design-taste", "465-interface-casing","470-motion"]
@@ -1,2 +0,0 @@
1
- extends = "react"
2
- rules = ["230-nextjs"]
@@ -1,2 +0,0 @@
1
- extends = "node"
2
- rules = ["360-security-server", "370-database"]
@@ -1,2 +0,0 @@
1
- extends = "python"
2
- rules = ["220-fastapi"]
@@ -1,2 +0,0 @@
1
- extends = "node"
2
- rules = ["200-react", "250-tailwind", "300-testing-ts", "305-e2e-reliability", "306-test-scope", "310-zod", "350-security-web", "400-ui", "410-a11y", "420-forms", "430-ux-completeness", "440-surface-capture", "450-link-behavior", "460-design-taste", "465-interface-casing","470-motion"]
@@ -1,278 +0,0 @@
1
- import { resolve } from 'node:path'
2
- import type { Command } from 'commander'
3
- import {
4
- auditExitCode,
5
- auditStandards,
6
- type StandardsAudit,
7
- } from '@/standards/audit'
8
- import { listStandardEntries } from '@/standards/list'
9
- import { listStandards, readStandard, resolveStandard } from '@/standards/read'
10
- import {
11
- frameError,
12
- intro,
13
- logError,
14
- logInfo,
15
- logStep,
16
- logWarn,
17
- outro,
18
- pipeOutput,
19
- plural,
20
- } from '@/cli/ui'
21
-
22
- interface StandardsListOptions {
23
- readonly json?: boolean
24
- }
25
-
26
- interface StandardsAuditOptions {
27
- readonly json?: boolean
28
- readonly arrivalsOnly?: boolean
29
- }
30
-
31
- export function register(program: Command): void {
32
- const standards = program
33
- .command('standards')
34
- .description('Standards commands (list, audit, <name>)')
35
- .argument('[name]', 'Standard to print, by name with or without .md')
36
- .helpOption('-h, --help', 'Show this help message')
37
- .addHelpText(
38
- 'after',
39
- [
40
- '',
41
- 'A name resolves under standards/ at the working root, then the corpus',
42
- 'inside the canon package. No standard installs into a project, so the',
43
- 'package corpus is what answers there. The frame names the copy it read.',
44
- '',
45
- 'Examples:',
46
- ' canon standards markdown',
47
- ' canon standards markdown.md',
48
- '',
49
- ].join('\n'),
50
- )
51
- .action((name: string | undefined, _options: unknown, cmd: Command) => {
52
- if (name === undefined) {
53
- // Registering an action replaces commander's own no-action fallback,
54
- // which writes this help to stderr. Help is UI rather than data, and
55
- // the default here is stdout.
56
- cmd.outputHelp({ error: true })
57
- process.exitCode = 1
58
- return
59
- }
60
-
61
- process.exitCode = print(name)
62
- })
63
-
64
- standards
65
- .command('list')
66
- .description('List the standards with their descriptions and jurisdiction')
67
- .helpOption('-h, --help', 'Show this help message')
68
- .option('--json', 'Emit machine-readable JSON')
69
- .action((opts: StandardsListOptions) => {
70
- process.exitCode = runStandardsList(opts)
71
- })
72
-
73
- standards
74
- .command('audit')
75
- .description(
76
- 'Report the corpus against the success-criterion gate standards/standard.md states',
77
- )
78
- .argument('[path]', 'Project root, defaulting to the current directory')
79
- .helpOption('-h, --help', 'Show this help message')
80
- .option('--json', 'Add a machine-readable record on stdout')
81
- .option(
82
- '--arrivals-only',
83
- 'Run the gating check for standards new on this branch alone',
84
- )
85
- .addHelpText(
86
- 'after',
87
- [
88
- '',
89
- 'Exit codes:',
90
- ' 0 the audit completed with every arriving standard carrying the section',
91
- ' 1 refused, with the reason on stderr',
92
- ' 2 a standard new to this branch carries no success-criterion section',
93
- '',
94
- 'A standard already in the corpus without the section is a known gap',
95
- 'standards/standard.md names, not a violation, so only an arrival fails.',
96
- '',
97
- 'Examples:',
98
- ' canon standards audit',
99
- ' canon standards audit --json',
100
- ' canon standards audit --arrivals-only',
101
- '',
102
- ].join('\n'),
103
- )
104
- .action(async (path: string | undefined, opts: StandardsAuditOptions) => {
105
- process.exitCode = await runStandardsAudit(path, opts)
106
- })
107
- }
108
-
109
- /**
110
- * Reads the working root first, the way `canon standards <name>` does, so a
111
- * project's own standards list beside the package corpus. The `--json` record
112
- * is the one thing on stdout and the frame opens only on the text path.
113
- */
114
- function runStandardsList(opts: StandardsListOptions): number {
115
- const standards = listStandardEntries(process.cwd())
116
-
117
- if (opts.json) {
118
- process.stdout.write(`${JSON.stringify({ standards })}\n`)
119
- return 0
120
- }
121
-
122
- intro('canon standards')
123
- logStep('Standards')
124
- for (const each of standards) logInfo(`${each.name} : ${each.description}`)
125
- outro()
126
- return 0
127
- }
128
-
129
- /**
130
- * Writes the standard to stdout and every frame line to stderr, so a caller
131
- * capturing the output with `$(...)` receives the document alone.
132
- *
133
- * The root is the caller's directory rather than the toolkit's, since a
134
- * repository that authors standards governs its own copy and the package copy
135
- * answers everywhere else.
136
- */
137
- function print(name: string): number {
138
- intro('canon standards')
139
-
140
- const root = process.cwd()
141
- const resolved = resolveStandard(root, name)
142
-
143
- if (!resolved) {
144
- logWarn(`Unknown standard: ${name}`)
145
- logStep('Available standards')
146
- for (const each of listStandards(root)) logInfo(each)
147
- logError("Run 'canon standards list' for descriptions.")
148
- outro()
149
- return 1
150
- }
151
-
152
- logStep(resolved.source)
153
- process.stdout.write(readStandard(resolved))
154
- outro()
155
- return 0
156
- }
157
-
158
- /**
159
- * Measures the corpus at the cwd rather than the toolkit root the catalog
160
- * reads, so a linked worktree audits its own branch instead of `main`.
161
- */
162
- async function runStandardsAudit(
163
- path: string | undefined,
164
- opts: StandardsAuditOptions,
165
- ): Promise<number> {
166
- const root = resolve(path ?? process.cwd())
167
- const gateOnly = opts.arrivalsOnly ?? false
168
- const audit = await auditStandards(root)
169
-
170
- if (audit.kind === 'refused') {
171
- const message =
172
- audit.reason === 'no-corpus'
173
- ? `No standards/ under ${root}.`
174
- : audit.reason === 'no-base'
175
- ? 'No merge base against main resolved.'
176
- : 'Could not read which standards arrived on this branch.'
177
-
178
- if (gateOnly) {
179
- frameError(message)
180
- } else {
181
- intro('canon standards audit')
182
- logStep('Refused')
183
- logWarn(message)
184
- outro()
185
- }
186
-
187
- if (opts.json) {
188
- process.stdout.write(
189
- `${JSON.stringify({ root, reason: audit.reason, message })}\n`,
190
- )
191
- }
192
-
193
- return auditExitCode(audit)
194
- }
195
-
196
- if (gateOnly) {
197
- reportArrivalGate(audit)
198
- } else {
199
- intro('canon standards audit')
200
- reportCorpus(audit)
201
- outro()
202
- }
203
-
204
- if (opts.json) {
205
- process.stdout.write(
206
- `${JSON.stringify({
207
- root,
208
- base: audit.base,
209
- standards: audit.standards,
210
- withCriterion: audit.withCriterion,
211
- withoutCriterion: audit.withoutCriterion,
212
- arrivals: audit.arrivals,
213
- arrivalsWithoutCriterion: audit.arrivalsWithoutCriterion,
214
- })}\n`,
215
- )
216
- }
217
-
218
- return auditExitCode(audit)
219
- }
220
-
221
- /**
222
- * Prints nothing when every arriving standard carries the section.
223
- *
224
- * `--arrivals-only` is what `verify.sh` runs on every push, and that script
225
- * pipes a stage's whole output into its own frame. A passing gate that
226
- * printed its frame would nest one inside the other on every contributor's
227
- * push.
228
- */
229
- function reportArrivalGate(
230
- audit: Extract<StandardsAudit, { kind: 'measured' }>,
231
- ): void {
232
- const missing = audit.arrivalsWithoutCriterion
233
- if (missing.length === 0) return
234
-
235
- intro('canon standards audit')
236
- logError(
237
- missing.length === 1
238
- ? '1 standard new to this branch carries no success-criterion section'
239
- : `${missing.length} standards new to this branch carry no success-criterion section`,
240
- )
241
- pipeOutput(missing.join('\n'))
242
- outro()
243
- }
244
-
245
- function reportCorpus(
246
- audit: Extract<StandardsAudit, { kind: 'measured' }>,
247
- ): void {
248
- logStep('Corpus')
249
- logInfo(`${plural(audit.standards.length, 'standard')} at standards/`)
250
- logInfo(
251
- `${plural(audit.withCriterion.length, 'standard')} carrying a success criterion`,
252
- )
253
-
254
- logStep('Known gaps')
255
- if (audit.withoutCriterion.length === 0) {
256
- logInfo('None. Every standard carries the section.')
257
- } else {
258
- pipeOutput(audit.withoutCriterion.join('\n'))
259
- }
260
-
261
- logStep('Arrivals since main')
262
- if (audit.arrivals.length === 0) {
263
- logInfo('No standard new to this branch.')
264
- return
265
- }
266
-
267
- if (audit.arrivalsWithoutCriterion.length === 0) {
268
- logInfo(
269
- `${plural(audit.arrivals.length, 'standard')} arrived, every one carrying the section.`,
270
- )
271
- return
272
- }
273
-
274
- logError(
275
- `${plural(audit.arrivalsWithoutCriterion.length, 'standard')} arrived carrying no success-criterion section`,
276
- )
277
- pipeOutput(audit.arrivalsWithoutCriterion.join('\n'))
278
- }
@@ -1,120 +0,0 @@
1
- import { existsSync } from 'node:fs'
2
- import { dirname, join, resolve } from 'node:path'
3
- import { execa } from 'execa'
4
-
5
- /**
6
- * Why no unknown-word list was produced, which is never the same as a clean
7
- * one.
8
- *
9
- * `no-binary` is the binary being absent anywhere from the root upward.
10
- * `cspell` is a devDependency of this repository alone, per
11
- * `775-dependencies.md`'s ban on importing a transitive-only package, so a
12
- * target project that never adopted it gets no coverage from this check
13
- * rather than a network fetch or a forced new dependency.
14
- *
15
- * `check-failed` is the binary running and returning neither of the two
16
- * codes it defines, clean or issues found. `reject: false` is right, because
17
- * a findings exit is the ordinary outcome for half of this check's runs, but
18
- * discarding the code outright folded a crash, a bad config, or an
19
- * unreadable dictionary into the same empty-stdout shape a clean title
20
- * produces. Reading it the way `spawnAudit` in `src/audits/run.ts` reads its
21
- * own child processes is what tells the two apart.
22
- */
23
- export type SpellingRefusal = 'no-binary' | 'check-failed'
24
-
25
- export type SpellingScan =
26
- | { readonly kind: 'checked'; readonly unknownWords: readonly string[] }
27
- | {
28
- readonly kind: 'unavailable'
29
- readonly reason: 'no-binary'
30
- readonly probedFrom: string
31
- }
32
- | {
33
- readonly kind: 'unavailable'
34
- readonly reason: 'check-failed'
35
- readonly message: string
36
- }
37
-
38
- const CSPELL_EXIT_CLEAN = 0
39
- const CSPELL_EXIT_ISSUES_FOUND = 1
40
-
41
- /**
42
- * Reads `cspell stdin --words-only --unique`'s stdout into a clean list.
43
- *
44
- * `--unique` already dedupes on cspell's side, so this exists for the same
45
- * reason `parseAdvisories` exists beside `auditDependencies`: a pure function
46
- * over a fixture string is a unit test that needs no binary on the machine
47
- * running it, where the trailing blank line an empty scan's stdout carries
48
- * is what actually needs the trim-and-filter below.
49
- */
50
- export function parseUnknownWords(stdout: string): string[] {
51
- return stdout
52
- .split(/\r?\n/)
53
- .map((word) => word.trim())
54
- .filter((word) => word.length > 0)
55
- }
56
-
57
- /**
58
- * Walks from `root` up to the filesystem root looking for a resolved
59
- * `cspell` binary, stopping at the first `node_modules/.bin/cspell` found.
60
- *
61
- * A caller running `canon labels scan` from a subdirectory of the checkout,
62
- * rather than from its own root, is what a single `join(root, …)` check
63
- * missed: `node_modules` sits at the checkout root, so the check found
64
- * nothing, the title went unchecked, and the command exited 0 with no path
65
- * naming what was probed or where it stopped looking.
66
- */
67
- function resolveCspellBinary(root: string): string | undefined {
68
- let dir = resolve(root)
69
-
70
- while (true) {
71
- const candidate = join(dir, 'node_modules', '.bin', 'cspell')
72
- if (existsSync(candidate)) return candidate
73
-
74
- const parent = dirname(dir)
75
- if (parent === dir) return undefined
76
- dir = parent
77
- }
78
- }
79
-
80
- /**
81
- * Shells this repository's own resolved `cspell` binary against a title over
82
- * stdin, rather than a bare `execa('cspell', …)`.
83
- *
84
- * `bun src/cli.ts labels scan` is the exact invocation `phase-label-gate.yml`
85
- * uses, and it runs outside `bun run`, so `node_modules/.bin` is not on
86
- * `PATH` and a bare spawn throws `ENOENT`. Resolving the binary path directly
87
- * under `root`, or an ancestor of it, is what a caller running from this
88
- * checkout needs, and it is also what keeps the check from reaching a
89
- * `bunx` fallback that could fetch `cspell` from the network on a machine
90
- * that never asked for it.
91
- */
92
- export async function scanTitleSpelling(
93
- title: string,
94
- root: string,
95
- ): Promise<SpellingScan> {
96
- const binary = resolveCspellBinary(root)
97
-
98
- if (binary === undefined) {
99
- return { kind: 'unavailable', reason: 'no-binary', probedFrom: root }
100
- }
101
-
102
- const result = await execa(
103
- binary,
104
- ['stdin', '--words-only', '--unique', '--no-progress', '--no-summary'],
105
- { cwd: root, input: title, reject: false },
106
- )
107
-
108
- if (
109
- result.exitCode !== CSPELL_EXIT_CLEAN &&
110
- result.exitCode !== CSPELL_EXIT_ISSUES_FOUND
111
- ) {
112
- return {
113
- kind: 'unavailable',
114
- reason: 'check-failed',
115
- message: result.stderr.trim().split('\n').pop() || 'no output on stderr',
116
- }
117
- }
118
-
119
- return { kind: 'checked', unknownWords: parseUnknownWords(result.stdout) }
120
- }
@@ -1,146 +0,0 @@
1
- import { existsSync, readFileSync, readdirSync } from 'node:fs'
2
- import { basename, join } from 'node:path'
3
- import { $ } from 'bun'
4
- import { gitEnv } from '@/git/env'
5
- import { resolveBaseRef } from '@/git/files'
6
- import { INDEX_FILE, standardsSourceDir } from '@/standards/read'
7
-
8
- /** Returned when a standard new to this branch states no success criterion under either heading `CRITERION_HEADING` accepts, the gating check. */
9
- export const EXIT_MISSING_CRITERION = 2
10
-
11
- /**
12
- * Matched at any casing, level-2 only, in either heading the corpus writes a
13
- * criterion under.
14
- *
15
- * `standards/standard.md` states the rule under `## Success criterion` and
16
- * templates the section itself as `## What a working <document type> looks
17
- * like`, and the template wins where the two disagree, which that file says in
18
- * as many words. Reading the rule group's own heading as the one to match is
19
- * what the first spelling here did, and no standard in the corpus carries a
20
- * criterion under it: 12 of 28 use the templated form and `standard.md` alone
21
- * carries the literal words, over its rules about criteria rather than over its
22
- * own. Nothing caught that, because the gate reads a standard new since the
23
- * merge base and none had arrived since it shipped.
24
- */
25
- const CRITERION_HEADING =
26
- /^##\s+(?:success criterion|what a working .+ looks like)\s*$/im
27
-
28
- /**
29
- * The reasons an audit produces no reading. `no-corpus` is the ordinary state
30
- * of a target that authors no standards of its own, the same absence the
31
- * skills audit reads as its own `no-corpus`. The other two are a broken git
32
- * invocation rather than a project stating nothing.
33
- */
34
- export type StandardsAuditRefusal =
35
- | 'no-corpus'
36
- | 'no-base'
37
- | 'unreadable-arrivals'
38
-
39
- export type StandardsAudit =
40
- | {
41
- readonly kind: 'measured'
42
- readonly base: string
43
- readonly standards: readonly string[]
44
- readonly withCriterion: readonly string[]
45
- readonly withoutCriterion: readonly string[]
46
- readonly arrivals: readonly string[]
47
- readonly arrivalsWithoutCriterion: readonly string[]
48
- }
49
- | { readonly kind: 'refused'; readonly reason: StandardsAuditRefusal }
50
-
51
- /**
52
- * Measures the corpus authored at `standards/` under `root` against the
53
- * `## Success criterion` gate `standards/standard.md` states, and names which
54
- * of those files are new since the branch's merge base.
55
- *
56
- * Reads the working-root corpus alone, never the packaged fallback
57
- * `src/standards/read.ts` falls through to for a name lookup, since a target
58
- * with no authored standards of its own has nothing here to gate.
59
- */
60
- export async function auditStandards(root: string): Promise<StandardsAudit> {
61
- const dir = standardsSourceDir(root)
62
- if (!existsSync(dir)) return { kind: 'refused', reason: 'no-corpus' }
63
-
64
- const standards = readdirSync(dir, { withFileTypes: true })
65
- .filter(
66
- (entry) =>
67
- entry.isFile() &&
68
- entry.name.endsWith('.md') &&
69
- entry.name !== INDEX_FILE,
70
- )
71
- .map((entry) => entry.name)
72
- .sort()
73
-
74
- const withCriterion: string[] = []
75
- const withoutCriterion: string[] = []
76
- for (const name of standards) {
77
- const body = readFileSync(join(dir, name), 'utf8')
78
- ;(CRITERION_HEADING.test(body) ? withCriterion : withoutCriterion).push(
79
- name,
80
- )
81
- }
82
-
83
- const base = await resolveBaseRef(root)
84
- if (base === undefined) return { kind: 'refused', reason: 'no-base' }
85
-
86
- const arrived = await arrivedStandards(root, base)
87
- if (arrived === undefined) {
88
- return { kind: 'refused', reason: 'unreadable-arrivals' }
89
- }
90
-
91
- const arrivals = standards.filter((name) => arrived.has(name))
92
- const arrivalsWithoutCriterion = arrivals.filter((name) =>
93
- withoutCriterion.includes(name),
94
- )
95
-
96
- return {
97
- kind: 'measured',
98
- base,
99
- standards,
100
- withCriterion,
101
- withoutCriterion,
102
- arrivals,
103
- arrivalsWithoutCriterion,
104
- }
105
- }
106
-
107
- /**
108
- * Only an arrival missing the section sets a failing code. Every other
109
- * standard without one is a known gap `standards/standard.md` names rather
110
- * than a violation, so failing the push on the 26 already there teaches
111
- * contributors to route around the stage.
112
- */
113
- export function auditExitCode(audit: StandardsAudit): number {
114
- if (audit.kind === 'refused') return 1
115
- return audit.arrivalsWithoutCriterion.length > 0 ? EXIT_MISSING_CRITERION : 0
116
- }
117
-
118
- /**
119
- * Filenames under `standards/` present in the working tree and absent at
120
- * `base`: a plain add, with rename detection forced off so a standard moved
121
- * into the corpus from elsewhere counts the same as one authored fresh.
122
- */
123
- async function arrivedStandards(
124
- root: string,
125
- base: string,
126
- ): Promise<Set<string> | undefined> {
127
- const [added, untracked] = await Promise.all([
128
- $`git -C ${root} diff --no-renames --name-only --diff-filter=A ${base} -- standards`
129
- .env(gitEnv())
130
- .quiet()
131
- .nothrow(),
132
- $`git -C ${root} ls-files --others --exclude-standard -- standards`
133
- .env(gitEnv())
134
- .quiet()
135
- .nothrow(),
136
- ])
137
-
138
- if (added.exitCode !== 0 || untracked.exitCode !== 0) return undefined
139
-
140
- const paths = [
141
- ...added.text().split('\n'),
142
- ...untracked.text().split('\n'),
143
- ].filter(Boolean)
144
-
145
- return new Set(paths.map((path) => basename(path)))
146
- }
@@ -1,33 +0,0 @@
1
- import { readFileSync } from 'node:fs'
2
- import { parseFrontmatter, readField } from '@/indexes/frontmatter'
3
- import { resolveAllStandards } from '@/standards/read'
4
- import { appliesTo } from '@/standards/scope'
5
-
6
- export interface StandardEntry {
7
- readonly name: string
8
- readonly description: string
9
- readonly appliesTo: string[]
10
- /** The whole file, frontmatter included. */
11
- readonly content: string
12
- /** The root-labeled path `canon standards <name>` would read. */
13
- readonly source: string
14
- }
15
-
16
- /**
17
- * Lists every standard a resolve reaches, the first root winning, so a project
18
- * standard shows beside the toolkit's and a local override of a package name
19
- * shows once, as the local copy.
20
- */
21
- export function listStandardEntries(root: string): StandardEntry[] {
22
- return resolveAllStandards(root).map(({ name, path, source }) => {
23
- const content = readFileSync(path, 'utf8')
24
-
25
- return {
26
- name,
27
- description: readField(parseFrontmatter(content), 'description') ?? '',
28
- appliesTo: appliesTo(content),
29
- content,
30
- source,
31
- }
32
- })
33
- }