@erclx/canon 4.112.0 → 4.114.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 (52) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/canon-feedback/SKILL.md +3 -3
  3. package/claude/skills/codebase-layout/SKILL.md +1 -1
  4. package/claude/skills/target-setup/SKILL.md +2 -0
  5. package/claude/skills/target-setup/references/detect.md +2 -2
  6. package/claude/skills/target-setup/references/verify.md +26 -10
  7. package/claude/skills/test-craft/SKILL.md +1 -1
  8. package/docs/agents/commands.md +1 -1
  9. package/docs/agents/counts.md +3 -3
  10. package/docs/agents/install-and-sync.md +2 -0
  11. package/docs/agents/records.md +22 -15
  12. package/docs/agents/teach.md +8 -4
  13. package/docs/target-projects.md +9 -2
  14. package/docs/workflow/ai-workflow.md +1 -1
  15. package/governance/rules/lang/130-go.md +40 -0
  16. package/governance/rules/lang/140-php.md +31 -0
  17. package/governance/rules/lib/335-testing-go.md +35 -0
  18. package/governance/rules/lib/336-testing-php.md +30 -0
  19. package/governance/rules/lib/360-security-server.md +2 -0
  20. package/governance/rules/lib/370-database.md +2 -0
  21. package/governance/rules/ui/410-a11y.md +1 -0
  22. package/governance/rules/ui/440-surface-capture.md +2 -0
  23. package/governance/stacks/nextjs.toml +2 -0
  24. package/governance/stacks/react.toml +1 -1
  25. package/package.json +3 -1
  26. package/src/claude/skills-audit.ts +2 -19
  27. package/src/commands/feedback-format.ts +24 -0
  28. package/src/commands/feedback.ts +2 -1
  29. package/src/commands/records.ts +18 -1
  30. package/src/commands/teach.ts +41 -1
  31. package/src/commands/tooling.ts +25 -2
  32. package/src/design/components.ts +17 -0
  33. package/src/design/tokens.ts +1 -1
  34. package/src/gate/measures.ts +13 -2
  35. package/src/indexes/frontmatter.ts +10 -1
  36. package/src/pr/evidence.ts +3 -1
  37. package/src/records/backup.ts +197 -27
  38. package/src/records/push-guard.ts +71 -0
  39. package/src/teach/nav.ts +75 -25
  40. package/src/teach/workspace.ts +14 -6
  41. package/src/tooling/inject.ts +43 -4
  42. package/src/tooling/scan.ts +35 -0
  43. package/src/tooling/subfolder.ts +110 -0
  44. package/tooling/base/configs/.husky/post-merge +5 -1
  45. package/tooling/base/seeds/cspell.json +1 -1
  46. package/tooling/claude/seeds/.claude/hooks/silent-turn.sh +7 -8
  47. package/tooling/nextjs/configs/vitest.config.ts +1 -1
  48. package/tooling/python/configs/scripts/verify.sh +32 -15
  49. package/tooling/python/manifest.toml +1 -1
  50. package/tooling/python/reference.md +3 -4
  51. package/tooling/vite-react/configs/vite.config.ts +1 -1
  52. package/tooling/web/configs/scripts/verify.sh +32 -15
@@ -1,10 +1,6 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
- import {
4
- type Frontmatter,
5
- parseFrontmatter,
6
- readField,
7
- } from '@/indexes/frontmatter'
3
+ import { parseFrontmatter, readField } from '@/indexes/frontmatter'
8
4
 
9
5
  /** Returned when a skill folder carries no `REQUIREMENT.md`, the gating check. */
10
6
  export const EXIT_MISSING_REQUIREMENT = 2
@@ -149,7 +145,7 @@ async function readSkill(
149
145
  : Promise.resolve(undefined),
150
146
  ])
151
147
 
152
- const fields = readFields(body)
148
+ const fields = parseFrontmatter(body)
153
149
 
154
150
  return {
155
151
  rel: join(rel, folder),
@@ -162,19 +158,6 @@ async function readSkill(
162
158
  }
163
159
  }
164
160
 
165
- /**
166
- * Returns undefined rather than throwing on unparseable YAML, so one malformed
167
- * body reports as missing its required fields instead of ending the audit
168
- * before it reaches the rest of the corpus.
169
- */
170
- function readFields(body: string): Frontmatter | undefined {
171
- try {
172
- return parseFrontmatter(body)
173
- } catch {
174
- return undefined
175
- }
176
- }
177
-
178
161
  /**
179
162
  * A key carrying an empty value declares nothing, so blank reads as absent
180
163
  * rather than as a name that disagrees with every folder.
@@ -89,6 +89,30 @@ export function deriveSlug(body: string): string {
89
89
  return slug || SLUG_FALLBACK
90
90
  }
91
91
 
92
+ /**
93
+ * Each label must exist on the toolkit repository, because `gh issue create`
94
+ * refuses the whole issue on an unknown label rather than dropping it.
95
+ */
96
+ const DOMAIN_LABELS: readonly { pattern: RegExp; label: string }[] = [
97
+ { pattern: /\bskills?\b/, label: 'skills' },
98
+ { pattern: /\b(?:cli)s?\b/, label: 'cli' },
99
+ { pattern: /\b(?:tooling|seeds?)\b/, label: 'tooling' },
100
+ { pattern: /\b(?:governance)s?\b/, label: 'governance' },
101
+ { pattern: /\bsnippets?\b/, label: 'snippets' },
102
+ ]
103
+
104
+ /**
105
+ * Reads only the surface type, the text before the first comma, so a path or
106
+ * name after it never adds a label.
107
+ */
108
+ export function deriveDomainLabels(body: string): string[] {
109
+ const type = (surfaceField(body) ?? '').split(',')[0]?.toLowerCase() ?? ''
110
+ const labels = DOMAIN_LABELS.filter(({ pattern }) => pattern.test(type)).map(
111
+ ({ label }) => label,
112
+ )
113
+ return [...new Set(labels)]
114
+ }
115
+
92
116
  export function deriveTitle(body: string): string {
93
117
  const surface = surfaceField(body)
94
118
  const title = surface ? `feedback: ${surface}` : 'toolkit feedback'
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import type { Command } from 'commander'
4
4
  import {
5
+ deriveDomainLabels,
5
6
  deriveSlug,
6
7
  deriveTitle,
7
8
  missingField,
@@ -107,7 +108,7 @@ export function register(program: Command): void {
107
108
  const result = await createGithubIssue({
108
109
  title: deriveTitle(body),
109
110
  body,
110
- labels: ['feedback'],
111
+ labels: ['feedback', ...deriveDomainLabels(body)],
111
112
  })
112
113
  if (result.ok) {
113
114
  frameSuccess('canon feedback', result.url)
@@ -330,6 +330,12 @@ function backupHelp(verb: 'push' | 'pull'): string {
330
330
  'It commits nothing outside the folders above and leaves the project working',
331
331
  'tree untouched. The push runs even when this call committed nothing, since a',
332
332
  'previous run can have committed and then failed to reach the network.',
333
+ '',
334
+ 'Before staging, it refuses the whole push as unsafe-payload when any new or',
335
+ 'changed file is over 25 MB or carries a credential canon secrets scan would',
336
+ 'report, naming each path. Nothing is staged or committed when it refuses.',
337
+ 'A push the remote rejects undoes the commit this call made, so the records',
338
+ 'stay on disk and no later run sends that commit again.',
333
339
  ]
334
340
  : [
335
341
  'It refuses rather than discarding local records that never reached the remote,',
@@ -569,6 +575,12 @@ async function runPush(opts: BackupCommandOptions): Promise<number> {
569
575
  if (outcome.firstSeen.length > 0) {
570
576
  logWarn(`first seen: ${outcome.firstSeen.join(', ')}`)
571
577
  }
578
+ for (const path of outcome.added) logInfo(`added: ${path}`)
579
+ if (outcome.dropped.length > 0) {
580
+ logWarn(
581
+ `excluded but still tracked, removed from the history going forward: ${outcome.dropped.join(', ')}`,
582
+ )
583
+ }
572
584
  logStep(outcome.pushed ? 'Pushed' : 'Nothing to push')
573
585
  logInfo(
574
586
  outcome.commit
@@ -609,7 +621,11 @@ async function runPull(opts: BackupCommandOptions): Promise<number> {
609
621
  */
610
622
  function reportRefusal(
611
623
  banner: string,
612
- outcome: { readonly reason: string; readonly message: string },
624
+ outcome: {
625
+ readonly reason: string
626
+ readonly message: string
627
+ readonly blocked?: readonly unknown[]
628
+ },
613
629
  emitJson: boolean,
614
630
  ): number {
615
631
  if (emitJson) {
@@ -619,6 +635,7 @@ function reportRefusal(
619
635
  ok: false,
620
636
  reason: outcome.reason,
621
637
  message: outcome.message,
638
+ ...(outcome.blocked ? { blocked: outcome.blocked } : {}),
622
639
  })}\n`,
623
640
  )
624
641
  return 1
@@ -1,4 +1,4 @@
1
- import { relative } from 'node:path'
1
+ import { isAbsolute, relative, resolve, sep } from 'node:path'
2
2
  import type { Command } from 'commander'
3
3
  import { type LessonOutcome, planLesson } from '@/teach/lesson'
4
4
  import { type NavOutcome, generateNav } from '@/teach/nav'
@@ -18,6 +18,7 @@ import {
18
18
  type TeachRefused,
19
19
  type Term,
20
20
  type TermOutcome,
21
+ teachDir,
21
22
  type WorkspaceSummary,
22
23
  writeStylesheet,
23
24
  } from '@/teach/workspace'
@@ -91,6 +92,18 @@ export function register(program: Command): void {
91
92
  .command('teach')
92
93
  .description('Manage learning workspaces in .canon/teach/')
93
94
  .helpOption('-h, --help', 'Show this help message')
95
+ .addHelpText(
96
+ 'after',
97
+ [
98
+ '',
99
+ 'Opening a workspace:',
100
+ ' canon serve .canon/teach --entry <nn>-<topic>/index.html',
101
+ '',
102
+ 'canon teach list <topic> prints the exact line, with the teach folder',
103
+ 'written from where you stand rather than as this literal.',
104
+ '',
105
+ ].join('\n'),
106
+ )
94
107
 
95
108
  teach
96
109
  .command('list')
@@ -118,6 +131,9 @@ export function register(program: Command): void {
118
131
  'A workspace not named NN-<topic> is still listed. It sorts last and',
119
132
  'moves no ordinal, since dropping it hides the folder needing a fix.',
120
133
  '',
134
+ 'The human output ends with the canon serve line that opens a',
135
+ 'workspace, a template with no topic and the exact line with one.',
136
+ '',
121
137
  'Examples:',
122
138
  ' canon teach list',
123
139
  ' canon teach list regular-expressions --json',
@@ -852,6 +868,22 @@ function describe(workspace: WorkspaceSummary): string {
852
868
  return `${workspace.slug}: ${workspace.lessons} lesson(s), ${workspace.records} record(s), ${workspace.reference} reference page(s), ${workspace.terms} term(s)`
853
869
  }
854
870
 
871
+ /**
872
+ * `canon serve` resolves its folder against the caller's cwd, while the list
873
+ * verb reads the teach folder at the main worktree root, so the line carries
874
+ * the folder actually read. A literal `.canon/teach` serves an absent folder
875
+ * from a linked worktree.
876
+ */
877
+ function serveLine(root: string, entry: string): string {
878
+ const dir = resolve(teachDir(root))
879
+ const fromCwd = relative(process.cwd(), dir)
880
+ const isOutsideCwd =
881
+ fromCwd === '..' || fromCwd.startsWith(`..${sep}`) || isAbsolute(fromCwd)
882
+ const printed = fromCwd === '' ? '.' : isOutsideCwd ? dir : fromCwd
883
+
884
+ return `canon serve ${printed} --entry ${entry}`
885
+ }
886
+
855
887
  function reportList(
856
888
  outcome: ListOutcome,
857
889
  emitJson: boolean,
@@ -890,6 +922,12 @@ function reportList(
890
922
 
891
923
  logStep('Next ordinal')
892
924
  logInfo(outcome.next)
925
+
926
+ if (outcome.workspaces.length > 0) {
927
+ logStep('Open')
928
+ logInfo(serveLine(root, '<nn>-<topic>/index.html'))
929
+ }
930
+
893
931
  outro()
894
932
 
895
933
  return 0
@@ -940,6 +978,8 @@ function reportWorkspace(
940
978
  logWarn(`no ${workspace.missing.join(' and no ')}`)
941
979
  }
942
980
 
981
+ logStep('Open')
982
+ logInfo(serveLine(root, `${workspace.slug}/index.html`))
943
983
  outro()
944
984
 
945
985
  return 0
@@ -20,6 +20,7 @@ import {
20
20
  import { readReference, resolveReference } from '@/tooling/read'
21
21
  import { scan, type ScanResult } from '@/tooling/scan'
22
22
  import { recordToolingChain } from '@/tooling/stamp'
23
+ import { subfolderPath } from '@/tooling/subfolder'
23
24
  import {
24
25
  intro,
25
26
  isNonInteractive,
@@ -93,6 +94,11 @@ export function register(program: Command): void {
93
94
  ' canon tooling sync base',
94
95
  ' canon tooling sync base --check',
95
96
  ' CANON_NON_INTERACTIVE=1 canon tooling sync base --write',
97
+ ' canon tooling sync vite-react web --skip base',
98
+ '',
99
+ 'A target below its git root syncs as a subfolder. It withholds',
100
+ '.github/, which GitHub reads only at the root, and writes a nested',
101
+ 'cspell.json registering the stack word lists.',
96
102
  '',
97
103
  'To gate CI on tooling drift, run headlessly with neither flag. That',
98
104
  'exits 1 when a file would be replaced and 0 when none would, which is',
@@ -358,7 +364,7 @@ async function runSync(
358
364
 
359
365
  const result = scan(prepared.chain, prepared.target)
360
366
 
361
- report(result)
367
+ report(result, prepared.target)
362
368
 
363
369
  if (opts.diff !== undefined) {
364
370
  if (opts.diff.json) {
@@ -568,7 +574,7 @@ async function promptForStack(): Promise<string | undefined> {
568
574
  })
569
575
  }
570
576
 
571
- function report(result: ScanResult): void {
577
+ function report(result: ScanResult, target: string): void {
572
578
  logStep('Scanning configs')
573
579
  for (const entry of result.configs) {
574
580
  if (entry.state === 'matching') logInfo(entry.rel)
@@ -580,6 +586,8 @@ function report(result: ScanResult): void {
580
586
  if (entry.state === 'new') logAdd(entry.rel)
581
587
  }
582
588
 
589
+ if (result.withheld.length > 0) reportWithheld(result, target)
590
+
583
591
  logStep('Scanning seeds')
584
592
  for (const entry of result.seeds) {
585
593
  if (entry.state === 'present') logInfo(entry.rel)
@@ -591,6 +599,7 @@ function report(result: ScanResult): void {
591
599
  if (result.hasPackageJson) {
592
600
  reportPackage(result)
593
601
  } else {
602
+ logStep('Scanning package.json')
594
603
  logWarn(
595
604
  "Skipped scripts and deps: no package.json found (run 'bun init' to enable)",
596
605
  )
@@ -605,6 +614,20 @@ function report(result: ScanResult): void {
605
614
  }
606
615
  }
607
616
 
617
+ function reportWithheld(result: ScanResult, target: string): void {
618
+ logStep('Withheld from a subfolder')
619
+ for (const entry of result.withheld) logWarn(entry.rel)
620
+ for (const entry of result.withheld) {
621
+ if (entry.present) {
622
+ logWarn(`${entry.rel} is already here, and GitHub never reads this copy`)
623
+ }
624
+ }
625
+ logInfo(
626
+ 'GitHub reads workflows only at the repository root. Add a root job that runs this folder with:',
627
+ )
628
+ logInfo(`working-directory: ${subfolderPath(target) ?? '.'}`)
629
+ }
630
+
608
631
  function reportPackage(result: ScanResult): void {
609
632
  if (result.scripts.length > 0) {
610
633
  logStep('Scanning scripts')
@@ -594,6 +594,23 @@ html.sb-shut .sb { flex-basis: 0; width: 0; overflow: hidden; }
594
594
  .sb-ws > summary:hover, .sb-ws[open] > summary { background: var(--color-chrome); color: var(--color-text); }
595
595
  .sb-ws .car { color: var(--color-muted); display: inline-flex; align-items: center; }
596
596
 
597
+ /* The top band is as tall as the masthead and a mission title can run to a
598
+ sentence, so the name stops at two lines rather than overflowing the rule.
599
+ Nav carries the whole title in the element's \`title\` attribute. The cap
600
+ trim the crumb labels share is dropped here, since it ends the box at the
601
+ last baseline and the clip would cut every descender on the second line. */
602
+ .sb-ws { min-width: 0; }
603
+ .sb-ws .ws-name {
604
+ display: -webkit-box;
605
+ -webkit-box-orient: vertical;
606
+ -webkit-line-clamp: 2;
607
+ line-clamp: 2;
608
+ overflow: hidden;
609
+ min-width: 0;
610
+ line-height: 1.2;
611
+ text-box: normal;
612
+ }
613
+
597
614
  .sb-wl {
598
615
  position: absolute;
599
616
  top: calc(100% + 0.35rem);
@@ -373,7 +373,7 @@ export const TOKENS: DesignTokens = {
373
373
  ],
374
374
 
375
375
  layout:
376
- 'Capture widths are 320, 768, 1280 and 1536 pixels, declared once in `tooling/web/configs/e2e/screenshot.ts`, with 320 and 1280 committed as evidence. 320 is the reflow floor and the rest are the breakpoint buckets. The landing page under `web/` sets its own `max-width` media queries between 430 and 1100 pixels per component rather than from a shared scale.',
376
+ 'Capture widths are 320, 768, 1280 and 1536 pixels, declared once in `tooling/web/configs/e2e/screenshot.ts`, with 320 and 1280 committed as evidence. 320 is the reflow floor. Every range the width queries in the generated base and teach stylesheets lay out holds at least one of these widths, so a capture photographs every layout state those stylesheets have, and a design test fails when a query moves or is added so that it opens a range none of them reaches. The landing page under `web/` sets its own `max-width` media queries between 430 and 1100 pixels per component rather than from a shared scale.',
377
377
 
378
378
  motion:
379
379
  'Motion is not used. No transition, animation, or keyframe declaration appears on any rendered surface, and the capture pipeline screenshots a static frame.',
@@ -103,11 +103,22 @@ const output = (text: string): Emission => ({ kind: 'output', text })
103
103
  * `snippets` as a folder-whole entry of its own and the rule reaches every base
104
104
  * consumer through that instead.
105
105
  *
106
- * Both are recorded here rather than in a config file: the list is what a
106
+ * `130-go`, `140-php`, `335-testing-go`, and `336-testing-php` are opt-in by
107
+ * `--add` until a Go or PHP stack ships, which `target-setup` reaches by
108
+ * matching a detected language against each rule's description.
109
+ *
110
+ * All are recorded here rather than in a config file: the list is what a
107
111
  * reader compares a new arrival against, and a config file would absorb the
108
112
  * arrival silently.
109
113
  */
110
- export const GOV_EXPECTED_UNREFERENCED = ['260-shadcn', '320-tanstack-query']
114
+ export const GOV_EXPECTED_UNREFERENCED = [
115
+ '130-go',
116
+ '140-php',
117
+ '260-shadcn',
118
+ '320-tanstack-query',
119
+ '335-testing-go',
120
+ '336-testing-php',
121
+ ]
111
122
 
112
123
  /**
113
124
  * Scenarios declaring no expectation, taken from `canon sandbox coverage`
@@ -11,12 +11,21 @@ export interface Frontmatter {
11
11
  * `raw` keeps the delimiters so an index can be rewritten with its own
12
12
  * frontmatter untouched, including comments and key order that a parse and
13
13
  * re-emit would lose.
14
+ *
15
+ * A block that will not parse reads as absent rather than throwing, so one
16
+ * malformed file reports through the path a caller already takes for a
17
+ * missing block instead of ending the caller's whole run.
14
18
  */
15
19
  export function parseFrontmatter(source: string): Frontmatter | undefined {
16
20
  const match = source.match(FRONTMATTER)
17
21
  if (!match) return undefined
18
22
 
19
- const parsed = Bun.YAML.parse(match[1])
23
+ let parsed: unknown
24
+ try {
25
+ parsed = Bun.YAML.parse(match[1])
26
+ } catch {
27
+ return undefined
28
+ }
20
29
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
21
30
  return undefined
22
31
  }
@@ -106,8 +106,10 @@ export async function groupEvidence(
106
106
  return { kind: 'read', states }
107
107
  }
108
108
 
109
+ // The raw host serves nothing to a private repository's browser, while the
110
+ // blob form redirects to the bytes carrying the viewer's session.
109
111
  function rawUrl(repo: string, sha: string, path: string): string {
110
- return `https://raw.githubusercontent.com/${repo}/${sha}/${path}`
112
+ return `https://github.com/${repo}/blob/${sha}/${path}?raw=true`
111
113
  }
112
114
 
113
115
  export function evidenceMarker(head: string): string {