@erclx/canon 5.8.0 → 5.10.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 (97) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/api-design/REQUIREMENT.md +51 -0
  3. package/claude/skills/api-design/SKILL.md +102 -0
  4. package/claude/skills/api-design/references/adopted.md +40 -0
  5. package/claude/skills/codebase-layout/REQUIREMENT.md +1 -1
  6. package/claude/skills/codebase-layout/SKILL.md +2 -1
  7. package/claude/skills/git-followup/SKILL.md +1 -1
  8. package/claude/skills/git-pr/references/evidence.md +1 -1
  9. package/claude/skills/review-craft/REQUIREMENT.md +8 -2
  10. package/claude/skills/review-craft/SKILL.md +63 -7
  11. package/claude/skills/review-craft/references/adopted.md +49 -0
  12. package/claude/skills/review-ui/REQUIREMENT.md +3 -0
  13. package/claude/skills/review-ui/SKILL.md +15 -1
  14. package/claude/skills/review-ui/references/driving.md +3 -3
  15. package/claude/skills/review-ui/references/post.md +16 -3
  16. package/claude/skills/systematic-debugging/REQUIREMENT.md +14 -0
  17. package/claude/skills/systematic-debugging/SKILL.md +45 -17
  18. package/claude/skills/systematic-debugging/references/adopted.md +44 -0
  19. package/docs/agents/capture.md +2 -2
  20. package/docs/agents/commands.md +3 -1
  21. package/docs/agents/hooks.md +56 -0
  22. package/docs/agents/index.md +1 -0
  23. package/docs/agents/install-and-sync.md +1 -1
  24. package/docs/agents/markdown-audit.md +1 -1
  25. package/docs/agents/pr-evidence.md +33 -17
  26. package/docs/agents/records-push.md +1 -1
  27. package/docs/agents/records-validate.md +1 -1
  28. package/docs/agents/scripting.md +6 -3
  29. package/docs/agents/tasks-archive.md +4 -2
  30. package/docs/agents/tasks-plans.md +8 -5
  31. package/docs/agents/tasks.md +1 -1
  32. package/docs/agents/worktrees.md +2 -2
  33. package/docs/target/scenarios.md +2 -2
  34. package/docs/workflow/skill-map.md +1 -0
  35. package/package.json +1 -1
  36. package/scripts/core/regen-agent-fixture.sh +3 -3
  37. package/scripts/core/regen-hero.ts +119 -0
  38. package/scripts/core/regen-web-favicon.ts +1 -1
  39. package/src/capture/frame-values.ts +359 -0
  40. package/src/claude/cases/misc.ts +5 -0
  41. package/src/claude/skills-audit.ts +3 -0
  42. package/src/cli.ts +2 -0
  43. package/src/commands/claude.ts +1 -1
  44. package/src/commands/docs.ts +51 -11
  45. package/src/commands/hooks.ts +67 -0
  46. package/src/commands/pr.ts +146 -76
  47. package/src/commands/standards.ts +33 -2
  48. package/src/commands/tasks.ts +57 -2
  49. package/src/commands/tooling.ts +5 -5
  50. package/src/commands/upgrade.ts +3 -2
  51. package/src/commands/worktrees.ts +4 -3
  52. package/src/counts/catalogs.ts +3 -3
  53. package/src/design/tokens.ts +1 -1
  54. package/src/docs/list.ts +103 -0
  55. package/src/docs/read.ts +63 -19
  56. package/src/gate/stages.ts +1 -1
  57. package/src/gov/stacks.ts +1 -1
  58. package/src/help.ts +2 -1
  59. package/src/hooks/post-merge.ts +331 -0
  60. package/src/pr/evidence.ts +21 -0
  61. package/src/records/validate.ts +1 -38
  62. package/src/standards/list.ts +33 -0
  63. package/src/standards/read.ts +13 -0
  64. package/src/standards/scope.ts +56 -0
  65. package/src/sync/check.ts +38 -4
  66. package/src/tasks/archive.ts +195 -3
  67. package/src/tasks/record.ts +25 -2
  68. package/src/tooling/inject.ts +4 -3
  69. package/src/tooling/manifest.ts +62 -8
  70. package/src/tooling/scan.ts +4 -3
  71. package/src/tooling/stamp.ts +11 -2
  72. package/src/tooling/subfolder.ts +7 -3
  73. package/standards/tasks.md +2 -2
  74. package/tooling/astro/reference.md +1 -1
  75. package/tooling/base/configs/scripts/verify.json +3 -0
  76. package/tooling/base/configs/scripts/verify.sh +1 -110
  77. package/tooling/base/configs/scripts/verify.ts +378 -0
  78. package/tooling/base/manifest.toml +1 -0
  79. package/tooling/base/reference.md +5 -3
  80. package/tooling/go/configs/scripts/verify.json +10 -0
  81. package/tooling/go/reference.md +5 -4
  82. package/tooling/php/configs/scripts/verify.json +7 -0
  83. package/tooling/php/reference.md +5 -4
  84. package/tooling/python/configs/scripts/verify.json +4 -0
  85. package/tooling/python/reference.md +3 -3
  86. package/tooling/web/configs/scripts/verify.json +3 -0
  87. package/tooling/web/reference.md +3 -3
  88. package/scripts/core/regen-hero.sh +0 -419
  89. package/scripts/docs/list.sh +0 -184
  90. package/scripts/lib/frontmatter.sh +0 -30
  91. package/scripts/standards/list.sh +0 -115
  92. package/scripts/tooling/create.sh +0 -109
  93. package/src/commands/pass-through.ts +0 -39
  94. package/tooling/go/configs/scripts/verify.sh +0 -142
  95. package/tooling/php/configs/scripts/verify.sh +0 -147
  96. package/tooling/python/configs/scripts/verify.sh +0 -141
  97. package/tooling/web/configs/scripts/verify.sh +0 -153
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { writeChainStamp } from '@/sync/stamp'
4
- import type { Manifest } from '@/tooling/manifest'
4
+ import { isWholeStack, type Manifest } from '@/tooling/manifest'
5
5
  import { readPackage } from '@/tooling/package'
6
6
 
7
7
  /**
@@ -33,12 +33,21 @@ export async function recordToolingChain(
33
33
  await writeChainStamp(
34
34
  target,
35
35
  { domain: 'tooling', toolkitRoot },
36
- chain.map((manifest) => manifest.name),
36
+ recordedStacks(chain),
37
37
  now,
38
38
  )
39
39
  return true
40
40
  }
41
41
 
42
+ /**
43
+ * The stack names a chain stamp records. A skipped stack kept for its
44
+ * per-root configs stays out, since the report re-resolves each recorded name
45
+ * as a whole manifest and would then measure every config the skip withheld.
46
+ */
47
+ export function recordedStacks(chain: readonly Manifest[]): string[] {
48
+ return chain.filter(isWholeStack).map((manifest) => manifest.name)
49
+ }
50
+
42
51
  function isRecord(value: unknown): value is Record<string, unknown> {
43
52
  return typeof value === 'object' && value !== null && !Array.isArray(value)
44
53
  }
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readdirSync, realpathSync } from 'node:fs'
2
2
  import { basename, join, relative } from 'node:path'
3
3
  import { gitEnv } from '@/git-env'
4
- import { listFiles, type Manifest } from '@/tooling/manifest'
4
+ import { isWholeStack, type Manifest, seedFiles } from '@/tooling/manifest'
5
5
 
6
6
  /**
7
7
  * The path the generated subfolder spell config is written to. Written
@@ -83,11 +83,15 @@ export function hasSpellConfig(target: string): boolean {
83
83
  export function subfolderSpellConfig(
84
84
  chain: readonly Manifest[],
85
85
  ): string | undefined {
86
- if (chain.some((manifest) => manifest.name === 'base')) return undefined
86
+ if (
87
+ chain.some((manifest) => manifest.name === 'base' && isWholeStack(manifest))
88
+ ) {
89
+ return undefined
90
+ }
87
91
 
88
92
  const lists = new Set<string>()
89
93
  for (const manifest of chain) {
90
- for (const rel of listFiles(manifest.seedsDir)) {
94
+ for (const rel of seedFiles(manifest)) {
91
95
  if (rel.startsWith('.cspell/') && rel.endsWith('.txt')) lists.add(rel)
92
96
  }
93
97
  }
@@ -124,9 +124,9 @@ An intake folder answers that direction at folder scope rather than item scope.
124
124
 
125
125
  A project that archived plans before the folder nested under `.canon/plans/` holds closed tasks pointing at `../plans-archive/` or `../.tmp/plans-archive/`. Leave those pointers where they are, since each resolves against the files it names and a task retargeted without its plan moving leads nowhere.
126
126
 
127
- One plan per task. A plan cited by two tasks is a misfile rather than a shape to design for.
127
+ One plan per task, or one per slice on a sliced task. A plan cited by two tasks is a misfile rather than a shape to design for.
128
128
 
129
- `canon tasks plan-link <task> <plan>` writes or corrects the `Plan:` line, so the line is a mechanical write rather than hand-edited markdown.
129
+ `canon tasks plan-link <task> <plan>` writes or corrects the `Plan:` line, and `--add` appends a slice's plan, so the line is never hand-edited.
130
130
 
131
131
  `Groundwork:` points at `../groundwork/<slug>/`, the folder `plan-groundwork` fills. It names the surface it points at the way `Plan:` does. Use this key alone. `Research record` and `Decision record` are earlier spellings of the same thing and both convert to it.
132
132
 
@@ -69,7 +69,7 @@ The social card is a route the project keeps rather than a page a render writes
69
69
  - `card.config.mjs`: a second Astro config whose `srcDir` points at `./card-src`, which the main config's page router never reads. That is what excludes the card from the published build structurally rather than by a filename convention, the same mechanism `scenarios.astro` reaches for with `import.meta.env.DEV`. Its base port is `4421` plus `WORKTREE_PORT_OFFSET`, outside the band `astro.config.mjs` serves from, so a card server and a dev server of one worktree never contend.
70
70
  - `card-src/pages/og-card.astro`: the route, shipped as a working placeholder so it serves before any pick has been taken. It imports the project's own global stylesheet, which is what lets the composition read real tokens and real fonts instead of typed copies. It reads `--color-background` and `--color-text`, the names `canon design css` emits, and sets no `font-family` at all so the card inherits the project's body font rather than reading a font token the design system does not emit.
71
71
  - `scripts/check-card-exclusion.sh`: fails when the route's `<meta name="og-card-marker">` tag reaches `dist/`, and fails when the route dropped that tag, since the marker is what the leak test reads for.
72
- - `bun run card:dev` serves the route. `bun run card:check` runs the exclusion check, which the web layer's `scripts/verify.sh` also runs after its build stage whenever the script is installed.
72
+ - `bun run card:dev` serves the route. `bun run card:check` runs the exclusion check, which base's verify runner, `scripts/verify.ts`, also runs after its build stage whenever the script is installed.
73
73
 
74
74
  ### Why each piece is shaped that way
75
75
 
@@ -0,0 +1,3 @@
1
+ {
2
+ "require": ["format", "check:format", "check:spell", "check:shell"]
3
+ }
@@ -1,111 +1,2 @@
1
1
  #!/bin/bash
2
- set -e
3
- set -o pipefail
4
-
5
- GREEN='\033[0;32m'
6
- RED='\033[0;31m'
7
- WHITE='\033[1;37m'
8
- GREY='\033[0;90m'
9
- NC='\033[0m'
10
-
11
- NESTED="${VERIFY_NESTED:-false}"
12
-
13
- log_info() { echo -e "${GREY}│${NC} ${GREEN}✓${NC} $1"; }
14
- log_error() {
15
- echo -e "${GREY}│${NC} ${RED}✗${NC} $1"
16
- exit 1
17
- }
18
- log_step() { echo -e "${GREY}│${NC}\n${GREY}├${NC} ${WHITE}$1${NC}"; }
19
-
20
- pipe_output() { while IFS= read -r line; do echo -e "${GREY}│${NC} $line"; done; }
21
-
22
- check_dependencies() {
23
- command -v bun >/dev/null 2>&1 || log_error "bun is not installed"
24
- }
25
-
26
- check_markdown_bans() {
27
- if ! command -v canon >/dev/null 2>&1; then
28
- log_info "Skipped: no canon binary on PATH. Install with \`bun install --global @erclx/canon\`."
29
- return 0
30
- fi
31
-
32
- local files=()
33
- while IFS= read -r file; do
34
- files+=("$file")
35
- done < <(git ls-files --cached --others --exclude-standard -- '*.md' ':(exclude)CHANGELOG.md' ':(exclude)**/CHANGELOG.md')
36
-
37
- if [ ${#files[@]} -eq 0 ]; then
38
- log_info "No markdown file to check outside the exclusion set."
39
- return 0
40
- fi
41
-
42
- local output code
43
- if output=$(canon markdown audit "${files[@]}" 2>&1); then
44
- code=0
45
- else
46
- code=$?
47
- fi
48
-
49
- case "$code" in
50
- 0)
51
- log_info "No banned character"
52
- ;;
53
- 1)
54
- log_info "Skipped: canon markdown audit refused and measured nothing."
55
- ;;
56
- 2)
57
- echo "$output" | pipe_output
58
- log_error "Markdown prose carries a banned character, or a relative link resolves to nothing on disk"
59
- ;;
60
- 3)
61
- echo "$output" | pipe_output
62
- log_error "The markdown audit shipped an empty ban set, so the corpus was walked and nothing was looked for"
63
- ;;
64
- *)
65
- log_error "canon markdown audit exited $code, which is neither a pass nor a finding"
66
- ;;
67
- esac
68
- }
69
-
70
- run_check() {
71
- local cmd=$1
72
- local err_msg=$2
73
- local output
74
- if ! output=$(eval "$cmd" 2>&1); then
75
- echo "$output" | pipe_output
76
- log_error "$err_msg"
77
- fi
78
- echo "$output" | pipe_output
79
- }
80
-
81
- main() {
82
- check_dependencies
83
-
84
- if [ "$NESTED" = false ]; then echo -e "${GREY}┌${NC}"; fi
85
-
86
- echo -e "${GREY}├${NC} ${WHITE}Formatting${NC}"
87
- run_check "bun run format" "Format failed"
88
- log_info "Format applied"
89
-
90
- log_step "Format check"
91
- run_check "bun run check:format" "Format check failed"
92
- log_info "Format check passed"
93
-
94
- log_step "Spelling"
95
- run_check "bun run check:spell" "Spell check failed"
96
- log_info "Spell check passed"
97
-
98
- log_step "Shell"
99
- run_check "bun run check:shell" "Shell check failed"
100
- log_info "Shell check passed"
101
-
102
- log_step "Markdown bans"
103
- check_markdown_bans
104
-
105
- if [ "$NESTED" = false ]; then
106
- echo -e "${GREY}└${NC}\n"
107
- echo -e "${GREEN}✓ Verification passed${NC}"
108
- fi
109
- }
110
-
111
- main "$@"
2
+ exec bun "$(dirname "$0")/verify.ts" "$@"
@@ -0,0 +1,378 @@
1
+ // Runs this folder's verify phases in a fixed order. The phases a stack
2
+ // requires come from scripts/verify.json, which each stack ships and the
3
+ // nearest one wins. Only node: modules, since a target's own eslint and
4
+ // tsc --noEmit read this file with no Bun types.
5
+ import { spawnSync } from 'node:child_process'
6
+ import { accessSync, constants, existsSync, readFileSync } from 'node:fs'
7
+ import { delimiter, join } from 'node:path'
8
+
9
+ interface Tool {
10
+ readonly name: string
11
+ readonly hint?: string
12
+ }
13
+
14
+ interface Precondition {
15
+ readonly path: string
16
+ readonly hint: string
17
+ }
18
+
19
+ interface PhaseList {
20
+ readonly require: readonly string[]
21
+ readonly tools: readonly Tool[]
22
+ readonly preconditions: readonly Precondition[]
23
+ }
24
+
25
+ interface Phase {
26
+ readonly script: string
27
+ readonly title: string
28
+ readonly failed: string
29
+ readonly passed: string
30
+ }
31
+
32
+ const PHASES: readonly Phase[] = [
33
+ {
34
+ script: 'typecheck',
35
+ title: 'Typecheck',
36
+ failed: 'Typecheck failed',
37
+ passed: 'Typecheck passed',
38
+ },
39
+ {
40
+ script: 'lint',
41
+ title: 'Lint',
42
+ failed: 'Lint failed',
43
+ passed: 'Lint passed',
44
+ },
45
+ {
46
+ script: 'format',
47
+ title: 'Formatting',
48
+ failed: 'Format failed',
49
+ passed: 'Format applied',
50
+ },
51
+ {
52
+ script: 'check:format',
53
+ title: 'Format check',
54
+ failed: 'Format check failed',
55
+ passed: 'Format check passed',
56
+ },
57
+ {
58
+ script: 'check:spell',
59
+ title: 'Spelling',
60
+ failed: 'Spell check failed',
61
+ passed: 'Spell check passed',
62
+ },
63
+ {
64
+ script: 'check:shell',
65
+ title: 'Shell',
66
+ failed: 'Shell check failed',
67
+ passed: 'Shell check passed',
68
+ },
69
+ {
70
+ script: 'test:run',
71
+ title: 'Unit tests',
72
+ failed: 'Unit tests failed',
73
+ passed: 'Unit tests passed',
74
+ },
75
+ {
76
+ script: 'build',
77
+ title: 'Build',
78
+ failed: 'Build failed',
79
+ passed: 'Build passed',
80
+ },
81
+ ]
82
+
83
+ // A subfolder synced with --skip base declares none of these, since the
84
+ // repository root owns them, so a list that does not require one runs it only
85
+ // where the folder declares it.
86
+ const BASE_PHASES = new Set([
87
+ 'format',
88
+ 'check:format',
89
+ 'check:spell',
90
+ 'check:shell',
91
+ ])
92
+
93
+ const PHASE_LIST = 'scripts/verify.json'
94
+ const CARD_EXCLUSION = 'scripts/check-card-exclusion.sh'
95
+
96
+ const GREEN = '\x1b[0;32m'
97
+ const RED = '\x1b[0;31m'
98
+ const WHITE = '\x1b[1;37m'
99
+ const GREY = '\x1b[0;90m'
100
+ const NC = '\x1b[0m'
101
+
102
+ const isNested = (process.env.VERIFY_NESTED || 'false') !== 'false'
103
+ let hasStepped = false
104
+
105
+ class VerifyFailure extends Error {}
106
+
107
+ function print(line: string): void {
108
+ process.stdout.write(`${line}\n`)
109
+ }
110
+
111
+ function logInfo(message: string): void {
112
+ print(`${GREY}│${NC} ${GREEN}✓${NC} ${message}`)
113
+ }
114
+
115
+ function logSkip(message: string): void {
116
+ print(`${GREY}│ - ${message}${NC}`)
117
+ }
118
+
119
+ function fail(message: string): never {
120
+ print(`${GREY}│${NC} ${RED}✗${NC} ${message}`)
121
+ throw new VerifyFailure(message)
122
+ }
123
+
124
+ function logStep(title: string): void {
125
+ if (hasStepped) print(`${GREY}│${NC}`)
126
+ print(`${GREY}├${NC} ${WHITE}${title}${NC}`)
127
+ hasStepped = true
128
+ }
129
+
130
+ function pipeOutput(output: string): void {
131
+ for (const line of output.replace(/\n+$/, '').split('\n')) {
132
+ print(`${GREY}│${NC} ${line}`)
133
+ }
134
+ }
135
+
136
+ function isExecutable(path: string): boolean {
137
+ try {
138
+ accessSync(path, constants.X_OK)
139
+ return true
140
+ } catch {
141
+ return false
142
+ }
143
+ }
144
+
145
+ function onPath(name: string): boolean {
146
+ return (process.env.PATH ?? '')
147
+ .split(delimiter)
148
+ .some((dir) => dir !== '' && isExecutable(join(dir, name)))
149
+ }
150
+
151
+ // Runs through sh so stderr interleaves with stdout the way 2>&1 does.
152
+ function capture(
153
+ command: string,
154
+ args: readonly string[] = [],
155
+ ): {
156
+ readonly code: number
157
+ readonly output: string
158
+ } {
159
+ const result = spawnSync(
160
+ '/bin/sh',
161
+ ['-c', `${command} 2>&1`, 'sh', ...args],
162
+ {
163
+ encoding: 'utf8',
164
+ maxBuffer: 256 * 1024 * 1024,
165
+ },
166
+ )
167
+ return { code: result.status ?? 1, output: result.stdout ?? '' }
168
+ }
169
+
170
+ function runCheck(command: string, failed: string): void {
171
+ const { code, output } = capture(command)
172
+ pipeOutput(output)
173
+ if (code !== 0) fail(failed)
174
+ }
175
+
176
+ function readJson(path: string): unknown {
177
+ try {
178
+ return JSON.parse(readFileSync(path, 'utf8'))
179
+ } catch {
180
+ return undefined
181
+ }
182
+ }
183
+
184
+ function asRecord(value: unknown): Record<string, unknown> {
185
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
186
+ ? (value as Record<string, unknown>)
187
+ : {}
188
+ }
189
+
190
+ function asArray(value: unknown): unknown[] {
191
+ return Array.isArray(value) ? value : []
192
+ }
193
+
194
+ function readPhaseList(): PhaseList {
195
+ if (!existsSync(PHASE_LIST)) {
196
+ fail(
197
+ `No ${PHASE_LIST} here. Run 'canon tooling sync <stack>' so the stack ships its phase list`,
198
+ )
199
+ }
200
+ const parsed = readJson(PHASE_LIST)
201
+ if (parsed === undefined) fail(`${PHASE_LIST} is not valid JSON`)
202
+
203
+ const table = asRecord(parsed)
204
+ return {
205
+ require: asArray(table.require).filter(
206
+ (entry): entry is string => typeof entry === 'string',
207
+ ),
208
+ tools: asArray(table.tools).flatMap((entry) => {
209
+ const tool = asRecord(entry)
210
+ if (typeof tool.name !== 'string') return []
211
+ return [
212
+ {
213
+ name: tool.name,
214
+ hint: typeof tool.hint === 'string' ? tool.hint : undefined,
215
+ },
216
+ ]
217
+ }),
218
+ preconditions: asArray(table.preconditions).flatMap((entry) => {
219
+ const precondition = asRecord(entry)
220
+ if (typeof precondition.path !== 'string') return []
221
+ return [
222
+ {
223
+ path: precondition.path,
224
+ hint: typeof precondition.hint === 'string' ? precondition.hint : '',
225
+ },
226
+ ]
227
+ }),
228
+ }
229
+ }
230
+
231
+ function readDeclaredScripts(): Set<string> {
232
+ if (!existsSync('package.json')) {
233
+ fail("No package.json here. Run 'bun init', then sync the stack again")
234
+ }
235
+ return new Set(
236
+ Object.keys(asRecord(asRecord(readJson('package.json')).scripts)),
237
+ )
238
+ }
239
+
240
+ function checkTools(list: PhaseList): void {
241
+ for (const tool of list.tools) {
242
+ if (onPath(tool.name)) continue
243
+ fail(
244
+ tool.hint === undefined
245
+ ? `${tool.name} is not installed`
246
+ : `${tool.name} is not installed. ${tool.hint}`,
247
+ )
248
+ }
249
+ }
250
+
251
+ function checkPreconditions(list: PhaseList): void {
252
+ for (const precondition of list.preconditions) {
253
+ if (isExecutable(precondition.path)) continue
254
+ fail(`No ${precondition.path} here. ${precondition.hint}`.trimEnd())
255
+ }
256
+ }
257
+
258
+ function runPhase(
259
+ phase: Phase,
260
+ required: boolean,
261
+ declared: Set<string>,
262
+ ): void {
263
+ const isBase = BASE_PHASES.has(phase.script)
264
+ if (!required && !isBase) return
265
+
266
+ logStep(phase.title)
267
+ if (!declared.has(phase.script)) {
268
+ if (required) {
269
+ fail(
270
+ `${phase.script} is required by ${PHASE_LIST}, but package.json declares no such script`,
271
+ )
272
+ }
273
+ logSkip(
274
+ `Skipped: ${phase.script} is not declared here, so the repository root runs it`,
275
+ )
276
+ return
277
+ }
278
+ runCheck(`bun run ${phase.script}`, phase.failed)
279
+ logInfo(phase.passed)
280
+ }
281
+
282
+ function listMarkdown(): string[] {
283
+ const result = spawnSync(
284
+ 'git',
285
+ [
286
+ 'ls-files',
287
+ '--cached',
288
+ '--others',
289
+ '--exclude-standard',
290
+ '--',
291
+ '*.md',
292
+ ':(exclude)CHANGELOG.md',
293
+ ':(exclude)**/CHANGELOG.md',
294
+ ],
295
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] },
296
+ )
297
+ return (result.stdout ?? '').split('\n').filter((line) => line !== '')
298
+ }
299
+
300
+ // canon markdown audit exits 0 on a pass, 1 on a refusal that measured
301
+ // nothing, 2 on a finding, and 3 when it shipped an empty ban set.
302
+ function checkMarkdownBans(): void {
303
+ if (!onPath('canon')) {
304
+ logInfo(
305
+ 'Skipped: no canon binary on PATH. Install with `bun install --global @erclx/canon`.',
306
+ )
307
+ return
308
+ }
309
+
310
+ const files = listMarkdown()
311
+ if (files.length === 0) {
312
+ logInfo('No markdown file to check outside the exclusion set.')
313
+ return
314
+ }
315
+
316
+ const { code, output } = capture('canon markdown audit "$@"', files)
317
+ switch (code) {
318
+ case 0:
319
+ logInfo('No banned character')
320
+ return
321
+ case 1:
322
+ logInfo('Skipped: canon markdown audit refused and measured nothing.')
323
+ return
324
+ case 2:
325
+ pipeOutput(output)
326
+ fail(
327
+ 'Markdown prose carries a banned character, or a relative link resolves to nothing on disk',
328
+ )
329
+ break
330
+ case 3:
331
+ pipeOutput(output)
332
+ fail(
333
+ 'The markdown audit shipped an empty ban set, so the corpus was walked and nothing was looked for',
334
+ )
335
+ break
336
+ default:
337
+ fail(
338
+ `canon markdown audit exited ${code}, which is neither a pass nor a finding`,
339
+ )
340
+ }
341
+ }
342
+
343
+ function main(): void {
344
+ const declared = readDeclaredScripts()
345
+ const list = readPhaseList()
346
+ checkTools(list)
347
+ checkPreconditions(list)
348
+
349
+ if (!isNested) print(`${GREY}┌${NC}`)
350
+
351
+ const required = new Set(list.require)
352
+ for (const phase of PHASES) {
353
+ runPhase(phase, required.has(phase.script), declared)
354
+ }
355
+
356
+ logStep('Markdown bans')
357
+ checkMarkdownBans()
358
+
359
+ // The card exclusion check reads the build, so it runs last, and only where
360
+ // the stack that owns the card route shipped it.
361
+ if (existsSync(CARD_EXCLUSION)) {
362
+ logStep('Card exclusion')
363
+ runCheck(`bash ${CARD_EXCLUSION}`, 'Card exclusion check failed')
364
+ logInfo('Card exclusion passed')
365
+ }
366
+
367
+ if (!isNested) {
368
+ print(`${GREY}└${NC}\n`)
369
+ print(`${GREEN}✓ Verification passed${NC}`)
370
+ }
371
+ }
372
+
373
+ try {
374
+ main()
375
+ } catch (error) {
376
+ if (!(error instanceof VerifyFailure)) throw error
377
+ process.exitCode = 1
378
+ }
@@ -6,6 +6,7 @@ scaffold = ""
6
6
 
7
7
  [sync]
8
8
  source = "configs"
9
+ per_root = ["scripts/verify.sh", "scripts/verify.ts", "scripts/verify.json"]
9
10
 
10
11
  [dependencies.dev]
11
12
  packages = [
@@ -16,7 +16,9 @@ Golden config files live in `tooling/base/configs/` and are copied into the targ
16
16
  - `commitlint.config.js`: ESM default export extending `@commitlint/config-conventional`. Rules are `header-max-length: 72`, `scope-case: lower-case`, `subject-full-stop: never`, `subject-case` disabled, and a local `no-claude-co-author` rule refusing a `Co-authored-by` trailer that names Claude or Anthropic.
17
17
  - `.husky/`: `pre-commit`, `commit-msg`, `pre-push`, `post-merge`, `post-rewrite`.
18
18
  - `.vscode/extensions.json` and `.vscode/settings.json`: editor wiring for Prettier, cspell, shfmt, and shellcheck.
19
- - `scripts/verify.sh`: the maintenance entry point behind `check`.
19
+ - `scripts/verify.sh`: the maintenance entry point behind `check`, a wrapper that hands off to `scripts/verify.ts`.
20
+ - `scripts/verify.ts`: the one verify runner every stack shares. It runs typecheck, lint, format, format check, spelling, shell, unit tests, build, markdown bans, then card exclusion, always in that order, and skips any phase the folder's `scripts/verify.json` leaves out. Node modules only, since a target's own lint and typecheck read it.
21
+ - `scripts/verify.json`: the phases a stack requires, plus any tools and preconditions it probes first. The nearest stack's copy wins. A required phase whose script `package.json` lacks fails the run. A base phase the list leaves out runs only where the folder declares its script. Base requires its four phases, so a base-only target still fails on a missing one. These three files are base's `[sync] per_root` set, which a `--skip base` subfolder still receives.
20
22
 
21
23
  ## What ships as user-owned seeds
22
24
 
@@ -88,7 +90,7 @@ Sticky negative knowledge. Do not relearn.
88
90
 
89
91
  | Script | What it does |
90
92
  | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
91
- | `bun run check` | Repairs a checkout via `scripts/verify.sh`. Honors `VERIFY_NESTED=true` to suppress timeline boundaries when another script calls it. |
93
+ | `bun run check` | Repairs a checkout via `scripts/verify.ts`. Honors `VERIFY_NESTED=true` to suppress timeline boundaries when another script calls it. |
92
94
  | `bun run check:format` | Asserts prettier and shfmt formatting without writing |
93
95
  | `bun run check:spell` | Runs cspell across every file, with context on failures |
94
96
  | `bun run check:shell` | Runs shellcheck at warning severity |
@@ -97,4 +99,4 @@ Sticky negative knowledge. Do not relearn.
97
99
 
98
100
  `bun run check` repairs a checkout rather than gating one. It runs `format` first to auto-fix drifted code, then asserts only what the formatters could not fix. The pull request workflow calls `check:format`, `check:spell`, and `check:shell` directly, and those three are the gate.
99
101
 
100
- `scripts/verify.sh`'s `Markdown bans` stage is the exception: it gates rather than repairs. It runs `canon markdown audit` over every tracked markdown file except `CHANGELOG.md`, since a generated changelog carries commit subjects nobody wrote against the ban set, and exits `2` on a finding or `3` on a shipped-empty ban set, failing `bun run check` and the `pre-push` hook that calls it. A refusal to measure exits `1` and is logged as a skip rather than a failure, as does a corpus with no file left to check once `CHANGELOG.md` is set aside. The stage needs `canon` on PATH. `canon docs projects` names the install command. Without it, the stage is skipped and logged, and coverage falls back to the three checks above.
102
+ `scripts/verify.ts`'s `Markdown bans` stage is the exception: it gates rather than repairs. It runs `canon markdown audit` over every tracked markdown file except `CHANGELOG.md`, since a generated changelog carries commit subjects nobody wrote against the ban set, and exits `2` on a finding or `3` on a shipped-empty ban set, failing `bun run check` and the `pre-push` hook that calls it. A refusal to measure exits `1` and is logged as a skip rather than a failure, as does a corpus with no file left to check once `CHANGELOG.md` is set aside. The stage needs `canon` on PATH. `canon docs projects` names the install command. Without it, the stage is skipped and logged, and coverage falls back to the three checks above.
@@ -0,0 +1,10 @@
1
+ {
2
+ "require": ["typecheck", "lint", "test:run"],
3
+ "tools": [
4
+ { "name": "go" },
5
+ {
6
+ "name": "golangci-lint",
7
+ "hint": "Run 'go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest'"
8
+ }
9
+ ]
10
+ }
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## Overview
6
6
 
7
- The go stack covers Go modules linted and formatted by `golangci-lint` v2, type-checked by `go vet`, and tested by `go test`. It ships a golden `.golangci.yml`, a Go-aware `verify.sh`, and a copy-once smoke test.
7
+ The go stack covers Go modules linted and formatted by `golangci-lint` v2, type-checked by `go vet`, and tested by `go test`. It ships a golden `.golangci.yml`, a go phase list for base's verify runner, and a copy-once smoke test.
8
8
 
9
9
  ## Scaffold checklist
10
10
 
@@ -26,10 +26,11 @@ The go stack also ships `smoke_test.go` at the module root as a copy-once seed.
26
26
  - Enable formatters `gofmt` and `goimports`. `lint:fix` runs `golangci-lint fmt` ahead of `golangci-lint run --fix`.
27
27
  - Exclude `node_modules` from both linters and formatters. The `go.mod` `ignore` directive in the checklist covers every Go tool, but `go.mod` belongs to the project and sync cannot write it, so the config keeps `golangci-lint run` from reporting and `golangci-lint fmt` from rewriting a vendored `.go` file in a module that never added the directive.
28
28
 
29
- ## verify.sh
29
+ ## verify.json
30
30
 
31
- - Run Typecheck (`go vet`), Lint (`golangci-lint run`), and Unit tests (`go test -v`), plus the four base phases wherever the folder declares their scripts, then the markdown bans stage base ships.
32
- - Keep `build` out of `verify.sh`. `go build ./...` exits 1 with `no packages to build` on a module holding only a test file, and a seeded `main.go` would assume every module is a binary rather than a library.
31
+ - Require Typecheck (`go vet`), Lint (`golangci-lint run`), and Unit tests (`go test -v`). Base's runner adds the four base phases wherever the folder declares their scripts, then the markdown bans stage.
32
+ - Probe `go` and `golangci-lint` first, and name the `go install` command when `golangci-lint` is missing.
33
+ - Keep `build` out of `verify.json`, even though the stack declares the script. `go build ./...` exits 1 with `no packages to build` on a module holding only a test file, and a seeded `main.go` would assume every module is a binary rather than a library.
33
34
  - Keep `-race` out of `test:run`. The race detector needs cgo and a C toolchain, so it belongs in CI rather than in a local `bun run check`.
34
35
 
35
36
  ## CI
@@ -0,0 +1,7 @@
1
+ {
2
+ "require": ["typecheck", "lint", "test:run"],
3
+ "tools": [{ "name": "php" }, { "name": "composer" }],
4
+ "preconditions": [
5
+ { "path": "vendor/bin/phpunit", "hint": "Run 'composer install'" }
6
+ ]
7
+ }