@erclx/canon 4.113.0 → 4.115.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 (63) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/codebase-layout/SKILL.md +1 -1
  3. package/claude/skills/document-health/references/axes.md +1 -1
  4. package/claude/skills/target-setup/SKILL.md +3 -1
  5. package/claude/skills/target-setup/references/detect.md +43 -9
  6. package/claude/skills/target-setup/references/verify.md +26 -10
  7. package/claude/skills/teach-workspace/SKILL.md +5 -3
  8. package/claude/skills/test-craft/SKILL.md +1 -1
  9. package/docs/agents/audits.md +4 -2
  10. package/docs/agents/commands.md +2 -2
  11. package/docs/agents/context-audit-checks.md +6 -6
  12. package/docs/agents/context-audit.md +1 -1
  13. package/docs/agents/context-classify.md +1 -1
  14. package/docs/agents/counts.md +3 -3
  15. package/docs/agents/install-and-sync.md +2 -0
  16. package/docs/agents/records.md +22 -15
  17. package/docs/agents/teach.md +12 -4
  18. package/docs/target-projects.md +11 -2
  19. package/docs/workflow/ai-workflow.md +1 -1
  20. package/governance/rules/lang/130-go.md +40 -0
  21. package/governance/rules/lang/140-php.md +31 -0
  22. package/governance/rules/lib/335-testing-go.md +35 -0
  23. package/governance/rules/lib/336-testing-php.md +30 -0
  24. package/governance/rules/lib/360-security-server.md +2 -0
  25. package/governance/rules/lib/370-database.md +2 -0
  26. package/governance/rules/ui/410-a11y.md +1 -0
  27. package/governance/rules/ui/440-surface-capture.md +2 -0
  28. package/governance/stacks/nextjs.toml +2 -0
  29. package/governance/stacks/react.toml +1 -1
  30. package/package.json +1 -1
  31. package/src/audits/catalog.ts +52 -0
  32. package/src/commands/context.ts +30 -10
  33. package/src/commands/records.ts +18 -1
  34. package/src/commands/teach.ts +4 -1
  35. package/src/commands/tooling.ts +25 -2
  36. package/src/context/audit.ts +54 -18
  37. package/src/design/components.ts +17 -0
  38. package/src/design/tokens.ts +1 -1
  39. package/src/gate/measures.ts +13 -2
  40. package/src/records/backup.ts +197 -27
  41. package/src/records/push-guard.ts +71 -0
  42. package/src/teach/nav.ts +278 -34
  43. package/src/tooling/inject.ts +43 -4
  44. package/src/tooling/scan.ts +35 -0
  45. package/src/tooling/subfolder.ts +110 -0
  46. package/standards/teach.md +13 -12
  47. package/tooling/base/configs/.husky/post-merge +5 -1
  48. package/tooling/go/configs/.golangci.yml +25 -0
  49. package/tooling/go/configs/scripts/verify.sh +95 -0
  50. package/tooling/go/manifest.toml +13 -0
  51. package/tooling/go/reference.md +60 -0
  52. package/tooling/go/seeds/.cspell/tech-stack.txt +16 -0
  53. package/tooling/go/seeds/smoke_test.go +9 -0
  54. package/tooling/php/configs/.php-cs-fixer.dist.php +18 -0
  55. package/tooling/php/configs/phpstan.neon +5 -0
  56. package/tooling/php/configs/phpunit.xml +18 -0
  57. package/tooling/php/configs/scripts/verify.sh +100 -0
  58. package/tooling/php/manifest.toml +15 -0
  59. package/tooling/php/reference.md +74 -0
  60. package/tooling/php/seeds/.cspell/tech-stack.txt +5 -0
  61. package/tooling/php/seeds/tests/SmokeTest.php +13 -0
  62. package/tooling/python/configs/scripts/verify.sh +32 -15
  63. package/tooling/web/configs/scripts/verify.sh +32 -15
@@ -0,0 +1,40 @@
1
+ ---
2
+ description: Enforce Go naming, error wrapping, context passing, and package boundaries
3
+ paths:
4
+ - '**/*.go'
5
+ ---
6
+
7
+ # Go standards
8
+
9
+ ## Casing conventions
10
+
11
+ - Use `MixedCaps` for exported names and `mixedCaps` for unexported ones. Never use underscores in a Go identifier.
12
+ - Name packages with one short lowercase word. Do not use `util`, `common`, or `helpers` as a package name.
13
+ - Do not repeat the package name in an exported identifier (`user.New`, not `user.NewUser`).
14
+ - Keep initialisms in one case (`userID`, `HTTPClient`), never `userId` or `HttpClient`.
15
+
16
+ ## Errors
17
+
18
+ - Return an error as the last result value.
19
+ - Wrap an error with `fmt.Errorf("...: %w", err)` when adding context.
20
+ - Match an error with `errors.Is` or `errors.As`. Never compare `err.Error()` strings.
21
+ - Never discard an error with `_`.
22
+ - Do not `panic` for a failure a caller can expect. Reserve it for a broken invariant.
23
+
24
+ ## Context
25
+
26
+ - Pass `context.Context` as the first parameter, named `ctx`.
27
+ - Never store a `context.Context` in a struct field.
28
+ - Do not pass a `nil` context. Use `context.TODO()` where none is available yet.
29
+
30
+ ## Interfaces
31
+
32
+ - Declare an interface in the package that consumes it, not the one that implements it.
33
+ - Keep an interface to the methods its consumer calls.
34
+ - Accept interfaces and return concrete types.
35
+
36
+ ## Packages
37
+
38
+ - Place code no other module may import under `internal/`.
39
+ - Do not perform side effects in `init`. Wire dependencies explicitly from `main`.
40
+ - Do not hold mutable state in a package-level variable.
@@ -0,0 +1,31 @@
1
+ ---
2
+ description: Enforce PHP strict types, PSR-4 layout, immutable data shapes, and exception handling
3
+ paths:
4
+ - '**/*.php'
5
+ ---
6
+
7
+ # PHP standards
8
+
9
+ ## Strictness
10
+
11
+ - Open every file with `declare(strict_types=1);`.
12
+ - Declare native parameter, return, and property types. Do not rely on a docblock type where a native one exists.
13
+ - Compare with `===` and `!==`. Never use loose `==` or `!=`.
14
+
15
+ ## Layout
16
+
17
+ - Autoload classes through PSR-4 from `composer.json`. Do not `require` a class file by hand.
18
+ - Declare one class, interface, trait, or enum per file, named after the file.
19
+
20
+ ## Data shapes
21
+
22
+ - Use a backed `enum` for a closed set over class constants.
23
+ - Use `readonly` properties or a `readonly` class for value objects.
24
+ - Declare classes `final` unless they are designed for extension.
25
+ - Do not pass an associative array across a public boundary where a typed object fits.
26
+
27
+ ## Errors
28
+
29
+ - Throw a domain exception for a failure. Do not return `false` or `null` to signal one.
30
+ - Never suppress an error with the `@` operator.
31
+ - Do not use `extract`, variable variables, or `eval`.
@@ -0,0 +1,35 @@
1
+ ---
2
+ description: Enforce Go testing package, table-driven cases, and test helper patterns for Go tests
3
+ paths:
4
+ - '**/*_test.go'
5
+ ---
6
+
7
+ # Go testing tooling
8
+
9
+ ## Layer
10
+
11
+ - Load the `canon:test-craft` skill to pick the layer a test belongs at before choosing the tooling below, and report it rather than proceeding silently when the skill does not resolve.
12
+
13
+ ## Framework
14
+
15
+ - Use the standard `testing` package. Do not add an assertion library for what `t.Errorf` and `t.Fatalf` already state.
16
+ - Place a test beside the code it tests, in a `_test.go` file of the same package.
17
+ - Use an external `<pkg>_test` package when a test should see only the exported API.
18
+
19
+ ## Table-driven cases
20
+
21
+ - Write cases as a slice of structs run through `t.Run`, over one test function per case.
22
+ - Give every case a `name` field and pass it to `t.Run`.
23
+
24
+ ## Helpers and setup
25
+
26
+ - Call `t.Helper()` first in every test helper.
27
+ - Use `t.Cleanup` over `defer` for teardown a helper registers.
28
+ - Use `t.TempDir` and `t.Setenv` over hand-rolled temp directories or environment stashes.
29
+ - Use `net/http/httptest` over a real listener.
30
+
31
+ ## Concurrency
32
+
33
+ - Do not synchronize with `time.Sleep`. Wait on a channel, a `sync.WaitGroup`, or a deadline.
34
+ - Run tests with `-race`.
35
+ - Mark a test `t.Parallel()` only when it shares no mutable state with another test.
@@ -0,0 +1,30 @@
1
+ ---
2
+ description: Enforce PHPUnit test classes, attributes, and strict assertions for PHP tests
3
+ paths:
4
+ - 'tests/**/*.php'
5
+ - '**/*Test.php'
6
+ ---
7
+
8
+ # PHP testing tooling
9
+
10
+ ## Layer
11
+
12
+ - Load the `canon:test-craft` skill to pick the layer a test belongs at before choosing the tooling below, and report it rather than proceeding silently when the skill does not resolve.
13
+
14
+ ## Framework
15
+
16
+ - Use PHPUnit for all tests.
17
+ - Declare each test class `final` and extend `PHPUnit\Framework\TestCase`.
18
+ - Place tests under `tests/`, mirroring the `src/` namespace layout, in files named `*Test.php`.
19
+
20
+ ## Attributes
21
+
22
+ - Use attributes (`#[Test]`, `#[DataProvider]`, `#[CoversClass]`) over docblock annotations.
23
+ - Use a static data provider method for table-driven cases over per-case test methods.
24
+
25
+ ## Assertions and doubles
26
+
27
+ - Use `assertSame` over `assertEquals`.
28
+ - Use `expectException` over `try` and `catch` for expected exceptions.
29
+ - Use `createStub` where no interaction is asserted. Reserve `createMock` for asserting a call.
30
+ - Do not make real network calls. Use a fake or a stubbed client.
@@ -4,6 +4,8 @@ paths:
4
4
  - '**/*.py'
5
5
  - '**/*.ts'
6
6
  - '**/*.js'
7
+ - '**/*.go'
8
+ - '**/*.php'
7
9
  ---
8
10
 
9
11
  # Server security standards
@@ -4,6 +4,8 @@ paths:
4
4
  - '**/*.py'
5
5
  - '**/*.ts'
6
6
  - '**/*.js'
7
+ - '**/*.go'
8
+ - '**/*.php'
7
9
  - '**/*.sql'
8
10
  ---
9
11
 
@@ -60,3 +60,4 @@ paths:
60
60
 
61
61
  - Let content reflow at a viewport 320 CSS pixels wide with no loss of content or function and no two-dimensional scrolling. Only content whose meaning depends on both axes, such as a data table, a map, or a diagram, may scroll sideways (WCAG Reflow, 1.4.10, Level AA).
62
62
  - Treat 320 as the criterion's own test condition rather than a device width. It is equivalent to a 1280 pixel window at 400 percent zoom, so a layout that breaks there breaks for a person who zooms.
63
+ - Hold the same 320 floor for the content region beside persistent chrome a person can resize. The criterion measures the viewport, so this extension is this rule's own rather than the criterion's: a panel widened to 480 in a 768 window leaves 288 for the content, under the floor at a window the criterion passes. `440-surface-capture` states where the frame for it gets taken.
@@ -28,6 +28,8 @@ paths:
28
28
  - Capture the full page. Do not capture a component in isolation.
29
29
  - Declare the widths a layout case captures. Always include 320, the reflow floor from the reflow criterion in `410-a11y`, then add each breakpoint bucket the project writes rules for, read from the `## Layout` section of `DESIGN.md`.
30
30
  - Fall back to the 320 floor alone when the project has no `DESIGN.md` or its record carries no `## Layout` section. Do not impose another project's breakpoints.
31
+ - Add a layout case at each bound when a surface renders persistent chrome a person can resize, such as a draggable sidebar or a split pane, one with the chrome at its narrowest and one at its widest. Take both at the narrowest declared width where the chrome stays persistent rather than collapsing, since a frame where it has turned into an overlay shows none of the width it takes.
32
+ - Read those bounds from the code that clamps the chrome, since `## Layout` has no slot for them. Judge each frame by the content width left beside the chrome rather than by the window width, against the 320 floor `410-a11y` holds for that region.
31
33
  - Apply the declaration to a case that covers layout only. A case driving a menu open or an answer chosen tests a state, so it takes one width rather than the full set.
32
34
  - Read a capture at 320 as proof that a frame was taken there, not that the page reflows correctly. The criterion is stated in `410-a11y` and nothing here enforces it.
33
35
  - Write the capture into a folder named `evidence`. The image is collected for the pull request comment when any segment of its path is literally `evidence` and its filename carries an image extension, so the folder name is fixed and its position in the tree is the project's own.
@@ -0,0 +1,2 @@
1
+ extends = "react"
2
+ rules = ["230-nextjs"]
@@ -1,2 +1,2 @@
1
1
  extends = "node"
2
- rules = ["200-react", "230-nextjs", "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"]
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"]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.113.0",
4
+ "version": "4.115.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -2,6 +2,7 @@ import type { SkillsAuditRefusal } from '@/claude/skills-audit'
2
2
  import type { RankRefusal } from '@/claude/skills-rank'
3
3
  import type { ReachRefusal } from '@/claude/skills-reach'
4
4
  import type { ContextAuditRefusal } from '@/context/audit'
5
+ import type { SweepVerdict } from '@/context/classify/patterns'
5
6
  import type { AuditRefusal } from '@/deps/audit'
6
7
  import type { RestatedRefusal } from '@/gov/restated'
7
8
  import type { LabelAuditRefusal } from '@/labels/audit'
@@ -199,6 +200,45 @@ function architectureCounts(
199
200
  }
200
201
  }
201
202
 
203
+ /**
204
+ * The retained key for each verdict the sweep can return. A record rather than
205
+ * a list, so the compiler fails here when the classifier gains a verdict,
206
+ * instead of that verdict's sections dropping out of the tally.
207
+ */
208
+ const PLACEMENT_KEYS: Record<SweepVerdict, string> = {
209
+ KEEP: 'keep',
210
+ REWRITE: 'rewrite',
211
+ MOVE: 'move',
212
+ }
213
+
214
+ function isPlacementVerdict(value: unknown): value is SweepVerdict {
215
+ return typeof value === 'string' && Object.hasOwn(PLACEMENT_KEYS, value)
216
+ }
217
+
218
+ /**
219
+ * Tallies the sweep's findings by verdict, with a zero for a verdict nothing
220
+ * returned, so a baseline reads the same three keys on every run.
221
+ *
222
+ * `KEEP` is retained beside the two that ask for a change. It moves when a
223
+ * section is added or split, which is what tells a falling `rewrite` count
224
+ * from a corpus that shrank.
225
+ */
226
+ function placementCounts(record: unknown): Record<string, number> | undefined {
227
+ const findings = asObject(record)?.findings
228
+ if (!Array.isArray(findings)) return undefined
229
+
230
+ const counts: Record<string, number> = Object.fromEntries(
231
+ Object.values(PLACEMENT_KEYS).map((key) => [key, 0]),
232
+ )
233
+ for (const finding of findings) {
234
+ const verdict = asObject(finding)?.verdict
235
+ if (!isPlacementVerdict(verdict)) return undefined
236
+ counts[PLACEMENT_KEYS[verdict]] += 1
237
+ }
238
+
239
+ return counts
240
+ }
241
+
202
242
  function contextCounts(record: unknown): Record<string, number> | undefined {
203
243
  const root = asObject(record)
204
244
  if (root === undefined) return undefined
@@ -502,6 +542,18 @@ export const AUDITS: readonly AuditSpec[] = [
502
542
  absentReasons: ['no-folders'] satisfies ContextAuditRefusal[],
503
543
  counts: contextCounts,
504
544
  },
545
+ {
546
+ id: 'placement',
547
+ label: 'Context placement',
548
+ // Pinned to the regex layer rather than the resolved classifier setting.
549
+ // A count that moves with whichever model a machine runs is not
550
+ // comparable across clones, and the model layer turns a sub-second read
551
+ // into a multi-minute one. `document-health` is where the model reads.
552
+ argv: ['context', 'classify', 'sweep', '--backend', 'off', '--json'],
553
+ gatingExits: [],
554
+ corpus: 'tracked',
555
+ counts: placementCounts,
556
+ },
505
557
  {
506
558
  id: 'markdown',
507
559
  label: 'Markdown corpus',
@@ -14,6 +14,8 @@ import {
14
14
  missingSections,
15
15
  PROVENANCE_FOLDER,
16
16
  REQUIRED_SECTIONS,
17
+ REQUIRED_SECTIONS_BY_FOLDER,
18
+ requiredSections,
17
19
  type SectionFinding,
18
20
  } from '@/context/audit'
19
21
  import {
@@ -129,8 +131,9 @@ export function register(program: Command): void {
129
131
  'it holds more decisions than the cap. A record stating neither is',
130
132
  'reported and never gated. --gate widens the gate to the other two',
131
133
  'findings that are facts rather than judgments: a missing required',
132
- 'section and index',
133
- 'drift. Entry length, reference form, table, provenance, narration,',
134
+ 'section and index drift. A context entry requires Overview and',
135
+ 'Layout, and a wireframe requires Regions, States, Copy, and Not on',
136
+ 'this surface. Entry length, reference form, table, provenance, narration,',
134
137
  'and the record claim classification are judgments under both.',
135
138
  '',
136
139
  'Depth and bullet weight are stated over every markdown file rather',
@@ -740,6 +743,10 @@ async function runAudit(
740
743
  renderWidth: RENDER_WIDTH,
741
744
  provenanceFolder: PROVENANCE_FOLDER,
742
745
  requiredSections: REQUIRED_SECTIONS,
746
+ // Keyed on the audited folder name. The flat key above stays for a
747
+ // caller that read it before wireframes owed a section, since turning
748
+ // it into this map would break that caller without an error.
749
+ requiredSectionsByFolder: REQUIRED_SECTIONS_BY_FOLDER,
743
750
  // Three states rather than two, so a record showing no finding says
744
751
  // which terms were looked for. The key is absent when the run never
745
752
  // scanned, which is `--citations-only`, and null when it scanned and
@@ -946,7 +953,8 @@ function reportReferenceForm(
946
953
 
947
954
  /**
948
955
  * Names the path each finding belongs to, which is an entry in the folder named
949
- * under `.claude/` and the folder itself in a domain split across one. States
956
+ * under `.claude/`, the folder itself in a domain split across one, and the
957
+ * file in any wireframe folder. States
950
958
  * the reach on every run for the reason the provenance report does.
951
959
  *
952
960
  * This prints ahead of the four readability measures because a missing section
@@ -959,19 +967,31 @@ function reportSections(
959
967
  ): void {
960
968
  logStep('Sections')
961
969
 
962
- const governed = folders.filter(governsContent)
963
- if (governed.length === 0) {
970
+ const names = [
971
+ ...new Set(
972
+ folders
973
+ .filter((folder) => requiredSections(folder).length > 0)
974
+ .map((folder) => folder.name),
975
+ ),
976
+ ]
977
+ if (names.length === 0) {
964
978
  logInfo(
965
- `Out of scope. The list is stated in the standard governing canon/${PROVENANCE_FOLDER}/, and no audited folder is that one.`,
979
+ `Out of scope. The lists are stated in the standards governing ${Object.keys(
980
+ REQUIRED_SECTIONS_BY_FOLDER,
981
+ )
982
+ .map((name) => `canon/${name}/`)
983
+ .join(' and ')}, and no audited folder is one of them.`,
966
984
  )
967
985
  return
968
986
  }
969
987
 
988
+ for (const name of names) {
989
+ logInfo(
990
+ `canon/${name}/ requires ${REQUIRED_SECTIONS_BY_FOLDER[name].join(', ')}.`,
991
+ )
992
+ }
970
993
  logInfo(
971
- `Covers canon/${PROVENANCE_FOLDER}/ alone, whose standard requires ${REQUIRED_SECTIONS.join(' and ')}.`,
972
- )
973
- logInfo(
974
- 'A heading at any level counts. Each entry answers for itself, except in a domain split across a folder, where a sibling answers for the rest.',
994
+ 'A heading at any level counts. Each entry answers for itself, except in a context domain split across a folder, where a sibling answers for the rest. A wireframe answers for itself in every folder.',
975
995
  )
976
996
 
977
997
  if (missing.length === 0) {
@@ -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
@@ -797,6 +797,7 @@ function reportNav(
797
797
  root: outcome.root,
798
798
  contents: outcome.contents,
799
799
  lessons: outcome.lessons,
800
+ reference: outcome.reference,
800
801
  skipped: outcome.skipped,
801
802
  })}\n`,
802
803
  )
@@ -810,9 +811,11 @@ function reportNav(
810
811
  for (const path of outcome.contents) logInfo(path)
811
812
  logStep('Lessons rewritten')
812
813
  logInfo(String(outcome.lessons))
814
+ logStep('Reference pages rendered')
815
+ logInfo(String(outcome.reference))
813
816
 
814
817
  if (outcome.skipped.length > 0) {
815
- logStep('Refused, missing a chrome marker')
818
+ logStep('Refused, missing a chrome marker or the markdown renderer')
816
819
  for (const skip of outcome.skipped) {
817
820
  logWarn(`${skip.file}: no ${skip.missing}`)
818
821
  }
@@ -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')
@@ -45,19 +45,35 @@ export const CATALOG_ROW_CHECKPOINT = 6
45
45
  const CATALOG_NAMED_RATIO = 0.6
46
46
 
47
47
  /**
48
- * Sections `standards/context.md` marks required, in the order it states them.
48
+ * Required sections per audited folder name, each in the order its standard
49
+ * states them.
49
50
  *
50
- * The list is held here rather than read out of the standard, the way the four
51
- * checkpoints above quote their numbers. A parser over the standard's prose
51
+ * The lists are held here rather than read out of the standards, the way the
52
+ * four checkpoints above quote their numbers. A parser over a standard's prose
52
53
  * would decide which sections are required from the wording around them, so it
53
54
  * fails on a rewrite of that wording rather than on a defect in an entry.
54
55
  *
55
56
  * These names do not generalize the way a length threshold does, which is why
56
- * the measure is scoped to the folder `governsContent` names. A diagram entry
57
- * declares a heading per kind and a wireframe entry per screen, and neither
58
- * sibling standard states a required section at all.
57
+ * each list is keyed on the folder whose standard states it. `context.md`
58
+ * requires an overview and a layout. `wireframes.md` asks four questions of a
59
+ * surface and answers each in a section, so those four are required and
60
+ * `## Behavior` is not, since a static surface has no interaction to state. A
61
+ * diagram entry declares a heading per kind and its standard requires none.
59
62
  */
60
- export const REQUIRED_SECTIONS: readonly string[] = ['Overview', 'Layout']
63
+ export const REQUIRED_SECTIONS_BY_FOLDER: Readonly<
64
+ Record<string, readonly string[]>
65
+ > = {
66
+ context: ['Overview', 'Layout'],
67
+ wireframes: ['Regions', 'States', 'Copy', 'Not on this surface'],
68
+ }
69
+
70
+ /**
71
+ * The context standard's list, kept as its own export because the audit record
72
+ * has published it flat under `checkpoints.requiredSections` since before any
73
+ * other folder required a section.
74
+ */
75
+ export const REQUIRED_SECTIONS: readonly string[] =
76
+ REQUIRED_SECTIONS_BY_FOLDER.context
61
77
 
62
78
  const HEADING_TEXT = /^#{1,6}\s+(.+?)\s*$/
63
79
  const TABLE_ROW = /^\s*\|/
@@ -226,7 +242,7 @@ export interface EntryReport {
226
242
  readonly bareReferences: readonly BareReferenceFinding[]
227
243
  /**
228
244
  * Required sections this entry declares, in the standard's order, and empty
229
- * outside the folder whose standard names them. What the folder is short of
245
+ * outside a folder whose standard names them. What the folder is short of
230
246
  * is `missingSections`, since one entry answers for its siblings.
231
247
  */
232
248
  readonly sections: readonly string[]
@@ -550,17 +566,20 @@ function narration(
550
566
  * Fenced blocks are skipped for the reason the scans above skip them. A
551
567
  * standard quoted inside an example declares nothing about the entry quoting it.
552
568
  */
553
- function declaredSections(lines: readonly BodyLine[]): string[] {
569
+ function declaredSections(
570
+ lines: readonly BodyLine[],
571
+ required: readonly string[],
572
+ ): string[] {
554
573
  const found = new Set<string>()
555
574
 
556
575
  for (const line of lines) {
557
576
  if (line.fenced) continue
558
577
 
559
578
  const match = line.text.match(HEADING_TEXT)
560
- if (match && REQUIRED_SECTIONS.includes(match[1])) found.add(match[1])
579
+ if (match && required.includes(match[1])) found.add(match[1])
561
580
  }
562
581
 
563
- return REQUIRED_SECTIONS.filter((section) => found.has(section))
582
+ return required.filter((section) => found.has(section))
564
583
  }
565
584
 
566
585
  /**
@@ -611,7 +630,9 @@ function bareReferences(
611
630
  * The caller passes jurisdiction rather than deriving it from `rel`, because a
612
631
  * path prefix hardcodes what `--folder` exists to override and misses a domain
613
632
  * split into `context/<sub-area>/`. Sibling names arrive the same way and for
614
- * the same reason, since the folder an entry sits in is what holds them.
633
+ * the same reason, since the folder an entry sits in is what holds them. The
634
+ * required sections arrive apart from jurisdiction, since a wireframe owes
635
+ * sections under a standard that states no provenance rule.
615
636
  */
616
637
  export function measureEntry(
617
638
  rel: string,
@@ -619,6 +640,7 @@ export function measureEntry(
619
640
  governsContent = true,
620
641
  terms?: NarrationTerms,
621
642
  siblings: readonly string[] = [],
643
+ required: readonly string[] = governsContent ? REQUIRED_SECTIONS : [],
622
644
  ): EntryReport {
623
645
  const lines = bodyLines(source)
624
646
 
@@ -632,7 +654,7 @@ export function measureEntry(
632
654
  provenance: governsContent ? provenance(lines) : [],
633
655
  narration: governsContent && terms ? narration(lines, terms) : [],
634
656
  bareReferences: bareReferences(lines, siblings),
635
- sections: governsContent ? declaredSections(lines) : [],
657
+ sections: declaredSections(lines, required),
636
658
  stub: isStubSeed(source),
637
659
  governed: governsContent,
638
660
  }
@@ -713,6 +735,7 @@ export async function measureFolders(
713
735
  governsContent(folder),
714
736
  terms,
715
737
  names.filter((name) => name !== self),
738
+ requiredSections(folder),
716
739
  ),
717
740
  )
718
741
  }
@@ -726,6 +749,13 @@ export function governsContent(folder: AuditedFolder): boolean {
726
749
  return folder.name === PROVENANCE_FOLDER
727
750
  }
728
751
 
752
+ /** Names the sections the folder's standard requires, empty where it states none. */
753
+ export function requiredSections(folder: AuditedFolder): readonly string[] {
754
+ return Object.hasOwn(REQUIRED_SECTIONS_BY_FOLDER, folder.name)
755
+ ? REQUIRED_SECTIONS_BY_FOLDER[folder.name]
756
+ : []
757
+ }
758
+
729
759
  /**
730
760
  * Reports whether a bare sibling name here is a reference by construction.
731
761
  *
@@ -765,6 +795,11 @@ export function matchesSiblings(folder: AuditedFolder): boolean {
765
795
  * The standard sanctions omitting `## Layout` from a domain owning no paths,
766
796
  * which no measure can tell from an entry that forgot it. An entry of that
767
797
  * shape therefore reports, which is a reason this is printed and never gated on.
798
+ *
799
+ * A wireframe folder answers per file whether nested or not, since its
800
+ * standard keeps one surface per file. A subfolder of wireframes groups
801
+ * surfaces the way the named context folder groups domains, so a conforming
802
+ * sibling there says nothing about the surface beside it.
768
803
  */
769
804
  export function missingSections(
770
805
  root: string,
@@ -774,11 +809,12 @@ export function missingSections(
774
809
  const byRel = new Map(entries.map((entry) => [entry.rel, entry]))
775
810
  const findings: SectionFinding[] = []
776
811
 
777
- const shortOf = (declared: readonly string[]): string[] =>
778
- REQUIRED_SECTIONS.filter((name) => !declared.includes(name))
779
-
780
812
  for (const folder of folders) {
781
- if (!governsContent(folder) || folder.entries.length === 0) continue
813
+ const required = requiredSections(folder)
814
+ if (required.length === 0 || folder.entries.length === 0) continue
815
+
816
+ const shortOf = (declared: readonly string[]): string[] =>
817
+ required.filter((name) => !declared.includes(name))
782
818
 
783
819
  // A stub owes no sections, so it is dropped before either branch rather
784
820
  // than inside them. Leaving one in the split-folder aggregate would let a
@@ -790,7 +826,7 @@ export function missingSections(
790
826
 
791
827
  if (reports.length === 0) continue
792
828
 
793
- if (folder.nested) {
829
+ if (folder.nested && governsContent(folder)) {
794
830
  const missing = shortOf(reports.flatMap((entry) => entry.sections))
795
831
  if (missing.length > 0) findings.push({ rel: folder.rel, missing })
796
832
  continue