@erclx/canon 5.3.0 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -94,6 +94,7 @@ Working with an agent rather than reading this yourself? It loads `CLAUDE.md` at
94
94
 
95
95
  - [AI workflow](docs/workflow/ai-workflow.md): feature-development loop inside a toolkit-managed project
96
96
  - [Operating model](docs/workflow/operating-model.md): orchestrator, planner, and worker roles for building across parallel sessions
97
+ - [Skill map](docs/workflow/skill-map.md): plugin skills grouped by the moment a project meets them
97
98
  - [Visual design workflow](docs/workflow/visual-design-workflow.md): tiered guide for design and wireframe authoring
98
99
  - [Target projects](docs/target/projects.md): scaffold, add a domain later, sync upstream drift
99
100
  - [Agents](docs/agents/index.md): CLI flags, exit codes, and JSON output shapes
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "5.3.0",
4
+ "version": "5.4.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -91,7 +91,7 @@ The standard sets the open question format and requires it inside a topic file a
91
91
 
92
92
  ## Close mode
93
93
 
94
- 1. Write `06-decision.md`. It states the problem once, names the goal, lists what to do, and lists what was considered and dropped.
94
+ 1. Write `06-decision.md`. It states the problem once, names the goal, lists what to do, and lists what was considered and dropped. Carry the "Overturned by" line of each lean that became the decision into a `## Revisit when` section, and carry none from a lean the decision did not take.
95
95
  2. Write `07-next-session.md` self-contained, so it survives a compaction that loses the conversation.
96
96
  3. Update the file map in `README.md`.
97
97
  4. Write one task file in `.canon/tasks/` recording what the track concluded, even when the conclusion is to do nothing. Follow `${CLAUDE_SKILL_DIR}/../../standards/tasks.md` for the filename and frontmatter. Place the row through `task-board` Step 4, which checks the roster for a live orchestrator before writing `priority.md` or `backlog.md` directly. Aside from an experiment fixture, this and the routing in Step 5 are the only ways close mode reaches outside the folder.
@@ -87,7 +87,7 @@ A hit the closed set could not separate from correct prose was the case with no
87
87
 
88
88
  ### Where the rules are enforced
89
89
 
90
- Five surfaces apply the ban set and four of them go through this verb. `.claude/hooks/standards-audit.sh` runs it against a single file after each markdown edit, the seed copy a project installs does the same, the `Markdown bans` stage in `canon gate run` runs it across the whole corpus before this repository's own push, and the same-named stage in `tooling/base/configs/scripts/verify.sh` runs it across a scaffolded project's tracked markdown before its own push. Each hook parsed its own copy of the word bans in awk before that, which left a British spelling passing at edit time and failing the push with nothing in between explaining the difference.
90
+ Five surfaces apply the ban set and four of them go through this verb. `.claude/hooks/standards-audit.sh` runs it against a single file after each markdown edit, the seed copy a project installs does the same, the `Markdown bans` stage in `canon gate run` runs it across the whole corpus before this repository's own push, and the same-named stage in every stack's `scripts/verify.sh` runs it across a scaffolded project's tracked markdown before its own push. Each hook parsed its own copy of the word bans in awk before that, which left a British spelling passing at edit time and failing the push with nothing in between explaining the difference.
91
91
 
92
92
  The seed copy moved onto the verb when the sets became data, since its awk had nothing left to parse. It resolves one runner where the toolkit copy resolves two, looking for no checkout source, and a machine carrying no `canon` gets a report naming the binary to install rather than a silent pass. `scripts/core/check-seed-independence.sh` scopes its walk to markdown and leaves the seed hooks outside it, which its own comment records as deliberate.
93
93
 
@@ -64,6 +64,23 @@ carries no evidence path, such as `base` or `python`, hits this reason on
64
64
  every pull request and posts nothing, which is the correct behavior rather
65
65
  than a gap.
66
66
 
67
+ Both `ok` and `no-evidence` also report what the marked comment already
68
+ shows a reviewer, each field present only when that comment holds it:
69
+
70
+ | Field | What it carries |
71
+ | ----------- | ----------------------------------------------------------------- |
72
+ | `preview` | The hosted `**Preview:**` address the comment opens with. |
73
+ | `local` | The `**Local preview:**` address under it. |
74
+ | `checklist` | The checklist between its delimiters, with any ticked boxes kept. |
75
+
76
+ These come from the comment already posted, never from the flags the call
77
+ passed, so a skill driving a review reads which address and which checklist a
78
+ reviewer has without parsing the comment itself. A checklist posted on its own
79
+ carries no marker, so the record reports none of the three for it. Reading
80
+ them means the thread is read before the `no-evidence` answer too, so an
81
+ unreadable thread refuses as `gh-failed` on both reasons rather than
82
+ reporting the fields absent.
83
+
67
84
  ## What `[number]` selects, and what it does not
68
85
 
69
86
  Naming a number picks which pull request the rendered body claims to
@@ -12,14 +12,15 @@ This page is the corpus the coverage claim is measured against: every name `cano
12
12
 
13
13
  ## Set up a project
14
14
 
15
- | Skill | When to use |
16
- | ---------------------- | ------------------------------------------------------------------------------------------------------ |
17
- | `canon:target-setup` | On a fresh scaffold, to detect the stack and run the install chain, or to reach one phase of it alone |
18
- | `canon:canon-operator` | On a project that already exists, to read what it carries before an install is picked |
19
- | `canon:sketch-design` | Before `design-extract`'s greenfield path, to trace a design direction from reference images or URLs |
20
- | `canon:design-extract` | Before the first UI feature, to draft `canon/DESIGN.md` |
21
- | `canon:draft-diagram` | Once the architecture is written, to draft per-kind entries under `.canon/diagrams/` |
22
- | `canon:repo-metadata` | When the GitHub About text, homepage, or topics may have drifted, to reconcile them against the README |
15
+ | Skill | When to use |
16
+ | ------------------------- | ----------------------------------------------------------------------------------------------------------- |
17
+ | `canon:target-setup` | On a fresh scaffold, to detect the stack and run the install chain, or to reach one phase of it alone |
18
+ | `canon:canon-operator` | On a project that already exists, to read what it carries before an install is picked |
19
+ | `canon:sketch-design` | Before `design-extract`'s greenfield path, to trace a design direction from reference images or URLs |
20
+ | `canon:design-extract` | Before the first UI feature, to draft `canon/DESIGN.md` |
21
+ | `canon:draft-diagram` | Once the architecture is written, to draft per-kind entries under `.canon/diagrams/` |
22
+ | `canon:deploy-cloudflare` | Once a project is ready to publish, to set up its Cloudflare Pages deploy and stop for the token and domain |
23
+ | `canon:repo-metadata` | When the GitHub About text, homepage, or topics may have drifted, to reconcile them against the README |
23
24
 
24
25
  ## Decide what to build
25
26
 
@@ -46,6 +47,7 @@ This page is the corpus the coverage claim is measured against: every name `cano
46
47
  | `canon:test-craft` | When writing or changing any test, to pick its layer and filter what it asserts |
47
48
  | `canon:review-craft` | When reviewing a change, for what to look for and how much evidence a finding needs |
48
49
  | `canon:systematic-debugging` | When a test fails or a bug surfaces, to force root cause first |
50
+ | `canon:design-taste` | When drafting or judging an interface, or when output reads generic, to settle which decision comes first |
49
51
  | `canon:ui-checklist` | After a UI change, to write what to look at and name what ships untested |
50
52
 
51
53
  ## Check the work before it leaves the branch
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.3.0",
4
+ "version": "5.4.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -462,6 +462,16 @@ export function register(program: Command): void {
462
462
  'comment. Together with --checklist it does, so the checklist and the',
463
463
  'link land in one comment.',
464
464
  '',
465
+ 'Both reasons carry what the marked comment already posted, each field',
466
+ 'present only when that comment holds it:',
467
+ ' preview the hosted **Preview:** address the comment opens with',
468
+ ' local the **Local preview:** address under it',
469
+ ' checklist the checklist between its delimiters, ticks included',
470
+ 'These come from the comment already posted, never from the flags this',
471
+ 'call passed. A checklist posted on its own carries no marker, so the',
472
+ 'record reports none of the three for it. An unread thread refuses as',
473
+ 'gh-failed on both reasons rather than reporting the fields absent.',
474
+ '',
465
475
  'Exit codes:',
466
476
  ' 0 read, whether it produced a body or reported no-evidence',
467
477
  ' 1 refused, with the reason on stderr or in the JSON record',
@@ -1424,47 +1434,6 @@ async function runEvidence(
1424
1434
  baseTree.has(path),
1425
1435
  )
1426
1436
 
1427
- // A local address rides on a checklist or a hosted preview and never opens
1428
- // a comment alone, so a docs-only branch with a server up posts nothing.
1429
- const hasChecklistAndLocal =
1430
- suppliedChecklist !== undefined && opts.local !== undefined
1431
- if (
1432
- grouped.kind === 'refused' &&
1433
- opts.preview === undefined &&
1434
- !hasChecklistAndLocal
1435
- ) {
1436
- logStep('Skipped')
1437
- logInfo(
1438
- 'No changed path carries an evidence/ segment, so there is nothing to post.',
1439
- )
1440
- outro()
1441
- if (emitJson) {
1442
- process.stdout.write(
1443
- `${JSON.stringify({
1444
- root,
1445
- ...(identity.number !== undefined && { number: identity.number }),
1446
- reason: 'no-evidence',
1447
- })}\n`,
1448
- )
1449
- }
1450
- return 0
1451
- }
1452
-
1453
- const repoRow = await gh(root, ['repo', 'view', '--json', 'nameWithOwner'])
1454
- const repo =
1455
- repoRow === null
1456
- ? undefined
1457
- : (JSON.parse(repoRow) as { nameWithOwner?: string }).nameWithOwner
1458
-
1459
- if (repo === undefined) {
1460
- return refuseWith(
1461
- 'gh-failed',
1462
- EVIDENCE_REFUSALS['gh-failed'],
1463
- emitJson,
1464
- root,
1465
- )
1466
- }
1467
-
1468
1437
  let commentId: number | undefined
1469
1438
  let carriedPreview: string | undefined
1470
1439
  let carriedChecklist: string | undefined
@@ -1480,6 +1449,8 @@ async function runEvidence(
1480
1449
  // An unread thread refuses rather than rendering, since a body built
1481
1450
  // without it knows neither the comment to edit nor the preview address
1482
1451
  // to carry, and posting it would duplicate the comment and drop the link.
1452
+ // It refuses ahead of no-evidence too, so that record never reports an
1453
+ // absent field it did not read.
1483
1454
  let comments: readonly { url?: string; body: string }[] | undefined
1484
1455
  if (commentsRow !== null) {
1485
1456
  try {
@@ -1506,6 +1477,56 @@ async function runEvidence(
1506
1477
  carriedLocal = findEvidenceLocal(comments)
1507
1478
  }
1508
1479
 
1480
+ // What the marked comment already shows a reviewer, kept apart from the
1481
+ // flags this call passed so a reader learns what is posted.
1482
+ const carried = {
1483
+ ...(carriedPreview !== undefined && { preview: carriedPreview }),
1484
+ ...(carriedLocal !== undefined && { local: carriedLocal }),
1485
+ ...(carriedChecklist !== undefined && { checklist: carriedChecklist }),
1486
+ }
1487
+
1488
+ // A local address rides on a checklist or a hosted preview and never opens
1489
+ // a comment alone, so a docs-only branch with a server up posts nothing.
1490
+ const hasChecklistAndLocal =
1491
+ suppliedChecklist !== undefined && opts.local !== undefined
1492
+ if (
1493
+ grouped.kind === 'refused' &&
1494
+ opts.preview === undefined &&
1495
+ !hasChecklistAndLocal
1496
+ ) {
1497
+ logStep('Skipped')
1498
+ logInfo(
1499
+ 'No changed path carries an evidence/ segment, so there is nothing to post.',
1500
+ )
1501
+ outro()
1502
+ if (emitJson) {
1503
+ process.stdout.write(
1504
+ `${JSON.stringify({
1505
+ root,
1506
+ ...(identity.number !== undefined && { number: identity.number }),
1507
+ reason: 'no-evidence',
1508
+ ...carried,
1509
+ })}\n`,
1510
+ )
1511
+ }
1512
+ return 0
1513
+ }
1514
+
1515
+ const repoRow = await gh(root, ['repo', 'view', '--json', 'nameWithOwner'])
1516
+ const repo =
1517
+ repoRow === null
1518
+ ? undefined
1519
+ : (JSON.parse(repoRow) as { nameWithOwner?: string }).nameWithOwner
1520
+
1521
+ if (repo === undefined) {
1522
+ return refuseWith(
1523
+ 'gh-failed',
1524
+ EVIDENCE_REFUSALS['gh-failed'],
1525
+ emitJson,
1526
+ root,
1527
+ )
1528
+ }
1529
+
1509
1530
  const preview = opts.preview ?? carriedPreview
1510
1531
  const local = opts.local ?? carriedLocal
1511
1532
  const checklist = suppliedChecklist ?? carriedChecklist
@@ -1548,6 +1569,7 @@ async function runEvidence(
1548
1569
  head: identity.head,
1549
1570
  body,
1550
1571
  ...(commentId !== undefined && { commentId }),
1572
+ ...carried,
1551
1573
  })}\n`,
1552
1574
  )
1553
1575
  }
@@ -56,7 +56,7 @@ Both fields feed `canon/context/index.md` when regenerated.
56
56
 
57
57
  Include whenever the domain has content for them. Never pad an entry with an empty heading.
58
58
 
59
- - `## Decisions`: non-obvious choices and the reasoning behind them. The highest-value section in the folder and the one most often missing.
59
+ - `## Decisions`: non-obvious choices and the reasoning behind them. The highest-value section in the folder and the one most often missing. A decision resting on how an outside tool, an installer, or the model behaves may close with one `Revisit when <finding>.` sentence naming the harm the decision prevents, and a decision resting on the project's own code takes none.
60
60
  - `## Gotchas`: workarounds, things tried and rejected, scar tissue worth preserving.
61
61
 
62
62
  ## Optional sections
@@ -45,11 +45,12 @@ A record failing these is non-conforming even when it satisfies every shape rule
45
45
 
46
46
  ## Sections
47
47
 
48
- Use `## Context`, `## Decision`, `## Alternatives`, and `## Measurements`.
48
+ Use `## Context`, `## Decision`, `## Alternatives`, `## Revisit when`, and `## Measurements`.
49
49
 
50
50
  - `## Context`: the problem as it stood, stated so a reader needs nothing else open. Restate a fact rather than pointing at where it was found.
51
51
  - `## Decision`: what was chosen, and why, in enough detail that a reader can tell it apart from an alternative that sounds similar.
52
52
  - `## Alternatives`: each one considered and dropped, with the reason it lost. An alternative with no stated reason reads as a claim nobody checked.
53
+ - `## Revisit when`: one bullet per finding that would reopen the decision. Required when the record came from a groundwork track whose deciding leans carried "Overturned by" lines, which carry over as written. It is optional otherwise, for a decision resting on how an outside tool, an installer, or the model behaves. Skip it for a decision resting on the project's own code.
53
54
  - `## Measurements`: skip when the decision cites no number. When it does, state the number and close with the commit it was read against, per Verification anchors below.
54
55
 
55
56
  ## Verification anchors
@@ -94,6 +95,10 @@ description: <one line naming what was decided>
94
95
  - **<Alternative>.** <Why it lost.>
95
96
  - **<Alternative>.** <Why it lost.>
96
97
 
98
+ ## Revisit when
99
+
100
+ - <The finding that would reopen the decision.>
101
+
97
102
  ## Measurements
98
103
 
99
104
  <The number the decision rests on.> Measured at <short-sha> on <YYYY-MM-DD>.
@@ -102,6 +102,7 @@ Closes the folder. Everything above it is input.
102
102
  - The goal
103
103
  - The items to do
104
104
  - What was considered and dropped
105
+ - `## Revisit when`: the "Overturned by" line of each lean that became the decision, carried over as written. A lean the decision did not take carries nothing, and a track whose deciding leans carried no overturn line omits the section.
105
106
 
106
107
  The dropped list is what stops a future session re-proposing something already rejected.
107
108
 
@@ -21,7 +21,7 @@ Does not govern:
21
21
 
22
22
  - The problem being solved and for whom
23
23
  - User-facing goals stated as outcomes, not implementation
24
- - Explicit non-goals that prevent feature creep. Mark deferred items "(deferred)" so they read as paused, not excluded.
24
+ - Explicit non-goals that prevent feature creep. Mark deferred items "(deferred)" so they read as paused, not excluded. A non-goal resting on how an outside tool, an installer, or the model behaves may close with one `Revisit when <finding>.` sentence, and a non-goal resting on scope alone takes none.
25
25
  - MVP features as a numbered list: feature name and one-line description
26
26
  - Tech stack as a plain list of tools
27
27
  - Hard constraints that shape every decision
package/standards/rule.md CHANGED
@@ -79,6 +79,7 @@ paths:
79
79
  - Use imperative voice for every rule (`Prefix booleans with is`, not `Booleans should be prefixed`)
80
80
  - State one rule per bullet as a single directive line
81
81
  - State what to do and what not to do. Do not explain the reasoning behind a rule.
82
+ - End the directive line of a ban asserting how the model or a tool behaves with one `Revisit when <finding>.` sentence, which states a condition rather than the reasoning. A style, safety, or consistency ban takes none.
82
83
  - Phrase a rule as a ban on the forbidden shape when it could otherwise enumerate allowed options, so it stays stable as categories grow
83
84
  - Cut any rule that resists crisp one-line phrasing. Vague guidance is worse than none.
84
85
  - Keep the file to one topic. A second topic is a second rule file.
@@ -105,7 +105,7 @@ allowed-tools: <tools required>
105
105
  - Group bullets under H2 headings by domain concern. Keep dos and don'ts together under the topic they belong to rather than splitting them into flat rules and constraints sections.
106
106
  - One actionable constraint per bullet. Prefer the `X over Y` form for preferences.
107
107
  - Do not include code examples unless a one to three line inline snippet captures a pattern the model cannot infer.
108
- - Do not duplicate general knowledge the model already has. Focus on project-specific conventions and preferences.
108
+ - Do not duplicate general knowledge the model already has. Focus on project-specific conventions and preferences. Revisit when removing a line of general knowledge from a skill is shown to change what the agent does.
109
109
 
110
110
  ### Progressive disclosure
111
111
 
@@ -79,6 +79,7 @@ A standard failing these questions is non-conforming even when it satisfies ever
79
79
  - State the forbidden shape rather than enumerating allowed options, so a rule survives new categories
80
80
  - Cut any rule that resists a crisp one-line phrasing
81
81
  - Do not pad with filler prose. Every line earns its place as a usable reference entry.
82
+ - Close a ban asserting how the model or a tool behaves with one `Revisit when <finding>.` sentence, since that claim can stop holding while the ban reads the same. A style, safety, or consistency ban takes none, and filling one with a condition is padding.
82
83
 
83
84
  ## Success criterion
84
85
 
@@ -52,6 +52,50 @@ run_base_phase() {
52
52
  log_info "$pass_msg"
53
53
  }
54
54
 
55
+ check_markdown_bans() {
56
+ if ! command -v canon >/dev/null 2>&1; then
57
+ log_info "Skipped: no canon binary on PATH. Install with \`bun install --global @erclx/canon\`."
58
+ return 0
59
+ fi
60
+
61
+ local files=()
62
+ while IFS= read -r file; do
63
+ files+=("$file")
64
+ done < <(git ls-files --cached --others --exclude-standard -- '*.md' ':(exclude)CHANGELOG.md' ':(exclude)**/CHANGELOG.md')
65
+
66
+ if [ ${#files[@]} -eq 0 ]; then
67
+ log_info "No markdown file to check outside the exclusion set."
68
+ return 0
69
+ fi
70
+
71
+ local output code
72
+ if output=$(canon markdown audit "${files[@]}" 2>&1); then
73
+ code=0
74
+ else
75
+ code=$?
76
+ fi
77
+
78
+ case "$code" in
79
+ 0)
80
+ log_info "No banned character"
81
+ ;;
82
+ 1)
83
+ log_info "Skipped: canon markdown audit refused and measured nothing."
84
+ ;;
85
+ 2)
86
+ echo "$output" | pipe_output
87
+ log_error "Markdown prose carries a banned character, or a relative link resolves to nothing on disk"
88
+ ;;
89
+ 3)
90
+ echo "$output" | pipe_output
91
+ log_error "The markdown audit shipped an empty ban set, so the corpus was walked and nothing was looked for"
92
+ ;;
93
+ *)
94
+ log_error "canon markdown audit exited $code, which is neither a pass nor a finding"
95
+ ;;
96
+ esac
97
+ }
98
+
55
99
  run_check() {
56
100
  local cmd=$1
57
101
  local err_msg=$2
@@ -86,6 +130,9 @@ main() {
86
130
  run_check "bun run test:run" "Unit tests failed"
87
131
  log_info "Unit tests passed"
88
132
 
133
+ log_step "Markdown bans"
134
+ check_markdown_bans
135
+
89
136
  if [ "$NESTED" = false ]; then
90
137
  echo -e "${GREY}└${NC}\n"
91
138
  echo -e "${GREEN}✓ Verification passed${NC}"
@@ -28,7 +28,7 @@ The go stack also ships `smoke_test.go` at the module root as a copy-once seed.
28
28
 
29
29
  ## verify.sh
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.
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
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.
33
33
  - 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
34
 
@@ -56,6 +56,50 @@ run_base_phase() {
56
56
  log_info "$pass_msg"
57
57
  }
58
58
 
59
+ check_markdown_bans() {
60
+ if ! command -v canon >/dev/null 2>&1; then
61
+ log_info "Skipped: no canon binary on PATH. Install with \`bun install --global @erclx/canon\`."
62
+ return 0
63
+ fi
64
+
65
+ local files=()
66
+ while IFS= read -r file; do
67
+ files+=("$file")
68
+ done < <(git ls-files --cached --others --exclude-standard -- '*.md' ':(exclude)CHANGELOG.md' ':(exclude)**/CHANGELOG.md')
69
+
70
+ if [ ${#files[@]} -eq 0 ]; then
71
+ log_info "No markdown file to check outside the exclusion set."
72
+ return 0
73
+ fi
74
+
75
+ local output code
76
+ if output=$(canon markdown audit "${files[@]}" 2>&1); then
77
+ code=0
78
+ else
79
+ code=$?
80
+ fi
81
+
82
+ case "$code" in
83
+ 0)
84
+ log_info "No banned character"
85
+ ;;
86
+ 1)
87
+ log_info "Skipped: canon markdown audit refused and measured nothing."
88
+ ;;
89
+ 2)
90
+ echo "$output" | pipe_output
91
+ log_error "Markdown prose carries a banned character, or a relative link resolves to nothing on disk"
92
+ ;;
93
+ 3)
94
+ echo "$output" | pipe_output
95
+ log_error "The markdown audit shipped an empty ban set, so the corpus was walked and nothing was looked for"
96
+ ;;
97
+ *)
98
+ log_error "canon markdown audit exited $code, which is neither a pass nor a finding"
99
+ ;;
100
+ esac
101
+ }
102
+
59
103
  run_check() {
60
104
  local cmd=$1
61
105
  local err_msg=$2
@@ -91,6 +135,9 @@ main() {
91
135
  run_check "bun run test:run" "Unit tests failed"
92
136
  log_info "Unit tests passed"
93
137
 
138
+ log_step "Markdown bans"
139
+ check_markdown_bans
140
+
94
141
  if [ "$NESTED" = false ]; then
95
142
  echo -e "${GREY}└${NC}\n"
96
143
  echo -e "${GREEN}✓ Verification passed${NC}"
@@ -42,7 +42,7 @@ The php stack also ships `tests/SmokeTest.php` as a copy-once seed, so `bun run
42
42
  ## verify.sh
43
43
 
44
44
  - Stop and name `composer install` when `vendor/bin/phpunit` is missing.
45
- - Run Typecheck (`phpstan analyse`), Lint (`php-cs-fixer check`), and Unit tests (`phpunit --testdox`), plus the four base phases wherever the folder declares their scripts.
45
+ - Run Typecheck (`phpstan analyse`), Lint (`php-cs-fixer check`), and Unit tests (`phpunit --testdox`), plus the four base phases wherever the folder declares their scripts, then the markdown bans stage base ships.
46
46
 
47
47
  ## Gitignore
48
48
 
@@ -51,6 +51,50 @@ run_base_phase() {
51
51
  log_info "$pass_msg"
52
52
  }
53
53
 
54
+ check_markdown_bans() {
55
+ if ! command -v canon >/dev/null 2>&1; then
56
+ log_info "Skipped: no canon binary on PATH. Install with \`bun install --global @erclx/canon\`."
57
+ return 0
58
+ fi
59
+
60
+ local files=()
61
+ while IFS= read -r file; do
62
+ files+=("$file")
63
+ done < <(git ls-files --cached --others --exclude-standard -- '*.md' ':(exclude)CHANGELOG.md' ':(exclude)**/CHANGELOG.md')
64
+
65
+ if [ ${#files[@]} -eq 0 ]; then
66
+ log_info "No markdown file to check outside the exclusion set."
67
+ return 0
68
+ fi
69
+
70
+ local output code
71
+ if output=$(canon markdown audit "${files[@]}" 2>&1); then
72
+ code=0
73
+ else
74
+ code=$?
75
+ fi
76
+
77
+ case "$code" in
78
+ 0)
79
+ log_info "No banned character"
80
+ ;;
81
+ 1)
82
+ log_info "Skipped: canon markdown audit refused and measured nothing."
83
+ ;;
84
+ 2)
85
+ echo "$output" | pipe_output
86
+ log_error "Markdown prose carries a banned character, or a relative link resolves to nothing on disk"
87
+ ;;
88
+ 3)
89
+ echo "$output" | pipe_output
90
+ log_error "The markdown audit shipped an empty ban set, so the corpus was walked and nothing was looked for"
91
+ ;;
92
+ *)
93
+ log_error "canon markdown audit exited $code, which is neither a pass nor a finding"
94
+ ;;
95
+ esac
96
+ }
97
+
54
98
  run_check() {
55
99
  local cmd=$1
56
100
  local err_msg=$2
@@ -85,6 +129,9 @@ main() {
85
129
  run_check "bun run test:run" "Unit tests failed"
86
130
  log_info "Unit tests passed"
87
131
 
132
+ log_step "Markdown bans"
133
+ check_markdown_bans
134
+
88
135
  if [ "$NESTED" = false ]; then
89
136
  echo -e "${GREY}└${NC}\n"
90
137
  echo -e "${GREEN}✓ Verification passed${NC}"
@@ -27,7 +27,7 @@ The python stack also ships `tests/test_smoke.py` as a copy-once seed. `pytest`
27
27
  - `pytest.ini`: tests live under `tests/`, source under `src/`.
28
28
  - `.coveragerc`: branch coverage on `src/`, html report under `.coverage_cache/html`.
29
29
  - `.python-version`: pinned to `3.14` to match `uv init` defaults.
30
- - `scripts/verify.sh`: overrides base verify to add Typecheck (`mypy`), Lint (`ruff check && ruff format --check`), and Tests (`pytest -v`) phases.
30
+ - `scripts/verify.sh`: overrides base verify to add Typecheck (`mypy`), Lint (`ruff check && ruff format --check`), and Tests (`pytest -v`) phases, then the same markdown bans phase base ships.
31
31
 
32
32
  ## Hybrid project shape
33
33
 
@@ -50,6 +50,50 @@ run_base_phase() {
50
50
  log_info "$pass_msg"
51
51
  }
52
52
 
53
+ check_markdown_bans() {
54
+ if ! command -v canon >/dev/null 2>&1; then
55
+ log_info "Skipped: no canon binary on PATH. Install with \`bun install --global @erclx/canon\`."
56
+ return 0
57
+ fi
58
+
59
+ local files=()
60
+ while IFS= read -r file; do
61
+ files+=("$file")
62
+ done < <(git ls-files --cached --others --exclude-standard -- '*.md' ':(exclude)CHANGELOG.md' ':(exclude)**/CHANGELOG.md')
63
+
64
+ if [ ${#files[@]} -eq 0 ]; then
65
+ log_info "No markdown file to check outside the exclusion set."
66
+ return 0
67
+ fi
68
+
69
+ local output code
70
+ if output=$(canon markdown audit "${files[@]}" 2>&1); then
71
+ code=0
72
+ else
73
+ code=$?
74
+ fi
75
+
76
+ case "$code" in
77
+ 0)
78
+ log_info "No banned character"
79
+ ;;
80
+ 1)
81
+ log_info "Skipped: canon markdown audit refused and measured nothing."
82
+ ;;
83
+ 2)
84
+ echo "$output" | pipe_output
85
+ log_error "Markdown prose carries a banned character, or a relative link resolves to nothing on disk"
86
+ ;;
87
+ 3)
88
+ echo "$output" | pipe_output
89
+ log_error "The markdown audit shipped an empty ban set, so the corpus was walked and nothing was looked for"
90
+ ;;
91
+ *)
92
+ log_error "canon markdown audit exited $code, which is neither a pass nor a finding"
93
+ ;;
94
+ esac
95
+ }
96
+
53
97
  run_check() {
54
98
  local cmd=$1
55
99
  local err_msg=$2
@@ -88,6 +132,9 @@ main() {
88
132
  run_check "bun run build" "Build failed"
89
133
  log_info "Build passed"
90
134
 
135
+ log_step "Markdown bans"
136
+ check_markdown_bans
137
+
91
138
  # Runs whenever the stack shipped a card exclusion check, which keeps the
92
139
  # stage stack-agnostic here and the check itself in the stack that owns the
93
140
  # card route's shape. It reads the build above, so it sits after it.
@@ -128,6 +128,6 @@ The sweep under `screenshots/` is ignored again, and only a flagged case's `evid
128
128
 
129
129
  ## Verify script
130
130
 
131
- The web layer's `scripts/verify.sh` replaces the base version. Order: typecheck, lint, format, spelling, shell, unit tests, build, then the card exclusion check where one is installed. Stack adapters may override if their typecheck or build differs.
131
+ The web layer's `scripts/verify.sh` replaces the base version. Order: typecheck, lint, format, spelling, shell, unit tests, build, markdown bans, then the card exclusion check where one is installed. Stack adapters may override if their typecheck or build differs.
132
132
 
133
133
  The card stage is conditional on `scripts/check-card-exclusion.sh` existing rather than on the stack, so a project with no card route runs one less stage and needs no override to skip it. A stack that ships a card route ships that script, and the check reads the build the stage before it produced.