@erclx/canon 4.30.2 → 4.31.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.30.2",
4
+ "version": "4.31.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -32,9 +32,9 @@ Each is invoked as the CLI the caller is running rather than as a global `canon`
32
32
 
33
33
  ## What gates and what reports
34
34
 
35
- Four findings fail the run: an unresolved context citation, a banned character, word, or spelling, a skill folder carrying no `REQUIREMENT.md`, and a credential-shaped value in the tree the package ships. Each is a fact with no false-positive class behind it.
35
+ Five findings fail the run: an unresolved context citation, a banned character, word, or spelling, a relative link resolving to nothing on disk, a skill folder carrying no `REQUIREMENT.md`, and a credential-shaped value in the tree the package ships. Each is a fact with no false-positive class behind it.
36
36
 
37
- Three of the four are the ones `canon gate run` already fails a push on. The secret scan is the one entry gating without a stage behind it, added on that same fact-or-judgment test rather than as a side effect of registering a measure, and the architecture record already ranks content leaving the repository above content that stays.
37
+ Four of the five are the ones `canon gate run` already fails a push on. The secret scan is the one entry gating without a stage behind it, added on that same fact-or-judgment test rather than as a side effect of registering a measure, and the architecture record already ranks content leaving the repository above content that stays.
38
38
 
39
39
  Everything else reports. A heavy bullet, a long entry, a board row nothing resolves, a degradation term in a comment, and an implementation reaching history ahead of its test are judgments a reader settles. A push failing on one of those teaches contributors to route around the stage, which is the split recorded across every audit here and the one this command inherits rather than moves.
40
40
 
@@ -54,7 +54,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
54
54
  | `canon worktrees reclaim` | Remove every reclaimable worktree and the branch behind it, reporting without acting under `--dry-run` (`--json`) |
55
55
  | `canon comments scan` | Measure comment density by language and comment kind, with a trend recomputed from git |
56
56
  | `canon context audit` | Report required sections, length, cited paths, reference form, catalog tables, provenance, superseded-decision narration, and index drift |
57
- | `canon markdown audit` | Fail any markdown path on a banned character, word, or spelling, and report the structural checkpoints |
57
+ | `canon markdown audit` | Fail any markdown path on a banned character, word, or spelling, or a dead relative link, and report the structural checkpoints |
58
58
  | `canon claude skills audit` | Report both skill corpora against the mechanical rules in `standards/skill.md` |
59
59
  | `canon standards audit` | Report the corpus against the success-criterion gate `standards/standard.md` states, failing only on a standard new to the branch (`--json`, `--arrivals-only`) |
60
60
  | `canon claude skills drift` | Name the shipped skill bodies rewritten between a given ref and `HEAD`, and the installed version against the newest published (`--json`) |
@@ -59,6 +59,12 @@ A banned spelling keeps the plain word boundary, hyphens included. The two bans
59
59
 
60
60
  Two ban shapes stay unmeasured and the report says so on every run. A multi-word ban escapes the harvest by width, whether it carries a placeholder standing in for the rest of the sentence or spells the phrase out in full, and every rule under `## Voice` is a judgment. The bans `## Language` states over what a sentence may claim sit in the first group by construction, since a literal match over a pattern reports the compliant text and reaches none of the violations. A report listing hits without naming those would read as a verdict on the whole standard.
61
61
 
62
+ ### Links
63
+
64
+ A relative link's destination resolves against the filesystem, over the same corpus the ban scan reads. A destination is skipped rather than resolved when it is empty, opens with a URL scheme, opens with `#` as a same-file anchor, opens with `/` as a root-absolute reference, or carries `<` as a template placeholder such as `<slug>` or `<name>`. What remains is split on its first `#`, decoded, and resolved against the linking file's own folder, and a destination resolving to nothing on disk reports.
65
+
66
+ The placeholder exemption is the one live case the corpus needs. Every current instance is a genuine illustration rather than a literal path, and a marker-based override is available for the day a real one needs an exception instead.
67
+
62
68
  ### Bullets, paragraphs, and depth
63
69
 
64
70
  Bullet weight and depth are the checks that moved off `canon context audit`, carrying what they measured at the time. A top-level bullet reports past roughly 400 characters with continuation lines folded in and nested items left out. A run reports past roughly 40 rendered lines, measured at 80 columns, where a heading breaks one and so does a bold section marker taking the whole line at column zero, either ending in a colon, or holding one whole code span at any width, or running to 20 characters or fewer, skipping fenced blocks and exempting a flat peer list averaging under 130 characters a bullet and a run that is entirely table rows. Each file reports its longest run alone, so a second run past the checkpoint in the same file is never named.
@@ -124,7 +130,7 @@ The condition on that was something identifying a finite verb rather than guessi
124
130
 
125
131
  ## Exit codes
126
132
 
127
- Exit codes are `0` for a completed run with no gating finding, `1` for a refusal, `2` for a ban hit, and `3` for a shipped ban set that arrived empty. A banned character, word, or spelling fails the run. Bullet, paragraph, and depth weight are judgments a reader settles, and cadence is a distribution whose healthy range moves with the surface, so all four report under every code.
133
+ Exit codes are `0` for a completed run with no gating finding, `1` for a refusal, `2` for a ban hit or a dead relative link, and `3` for a shipped ban set that arrived empty. A banned character, word, or spelling and a relative link resolving to nothing on disk each fail the run, both facts a scan settles rather than a reader judging. Bullet, paragraph, and depth weight are judgments a reader settles, and cadence is a distribution whose healthy range moves with the surface, so all four report under every code.
128
134
 
129
135
  `3` is separate from `1` because the two want different responses from a caller. A refusal means no corpus was built, and the `Markdown bans` stage in `canon gate run` is right to report it as unmeasured rather than as a pass. An empty set means the corpus was walked and nothing was looked for, so that stage fails the push on `3` rather than skipping.
130
136
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.30.2",
4
+ "version": "4.31.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -1,9 +1,11 @@
1
+ import { existsSync } from 'node:fs'
1
2
  import { readFile } from 'node:fs/promises'
2
- import { resolve } from 'node:path'
3
+ import { dirname, relative, resolve } from 'node:path'
3
4
  import type { Command } from 'commander'
4
5
  import { BAN_SETS, emptyBanSets } from '@/markdown/bans'
5
6
  import { type MarkdownAuditRefusal, resolveMarkdown } from '@/markdown/files'
6
7
  import { isGating } from '@/markdown/gate'
8
+ import { decodePath, findBrokenLinks, type LinkFinding } from '@/markdown/links'
7
9
  import {
8
10
  type BanFinding,
9
11
  type BanSets,
@@ -47,6 +49,7 @@ interface AuditCommandOptions {
47
49
  interface FileReport {
48
50
  readonly rel: string
49
51
  readonly bans: readonly BanFinding[]
52
+ readonly links: readonly LinkFinding[]
50
53
  readonly structure: StructureReport
51
54
  }
52
55
 
@@ -74,12 +77,13 @@ export function register(program: Command): void {
74
77
  'Exit codes:',
75
78
  ' 0 the audit completed with no gating finding',
76
79
  ' 1 refused, with the reason on stderr',
77
- ' 2 a banned character, word, or spelling is present',
80
+ ' 2 a banned character, word, or spelling is present, or a relative',
81
+ ' link resolves to nothing on disk',
78
82
  ' 3 a shipped ban set is empty, so the run measured nothing',
79
83
  '',
80
- 'A ban hit is a fact and gates unconditionally. Bullet, paragraph, and',
81
- 'depth weight are judgments a reader settles, so all three report and',
82
- 'none of them fails a run.',
84
+ 'A ban hit and a dead link are each a fact and gate unconditionally.',
85
+ 'Bullet, paragraph, and depth weight are judgments a reader settles,',
86
+ 'so all three report and none of them fails a run.',
83
87
  '',
84
88
  'Cadence reports the same way and carries one more caveat. Its range is',
85
89
  'drawn from prose a person reads, so terse reference prose sits below',
@@ -143,10 +147,14 @@ async function runAudit(
143
147
 
144
148
  const reports: FileReport[] = await Promise.all(
145
149
  scope.files.map(async (rel) => {
146
- const lines = bodyLines(await readFile(resolve(root, rel), 'utf8'))
150
+ const abs = resolve(root, rel)
151
+ const lines = bodyLines(await readFile(abs, 'utf8'))
147
152
  return {
148
153
  rel,
149
154
  bans: scanBans(lines, bans),
155
+ links: findBrokenLinks(lines, (path) =>
156
+ existsSync(resolve(dirname(abs), path)),
157
+ ),
150
158
  structure: measureStructure(rel, lines, checkpoints),
151
159
  }
152
160
  }),
@@ -155,6 +163,7 @@ async function runAudit(
155
163
  intro('canon markdown audit')
156
164
  reportScope(scope.files, scope.unmatched)
157
165
  reportBans(reports, bans, empty)
166
+ reportLinks(reports, root)
158
167
  reportBullets(reports, checkpoints)
159
168
  reportParagraphs(reports, checkpoints)
160
169
  reportCadence(reports, checkpoints)
@@ -187,6 +196,7 @@ async function runAudit(
187
196
  entries: reports.map((report) => ({
188
197
  path: report.rel,
189
198
  bans: report.bans,
199
+ links: report.links,
190
200
  longestRun: report.structure.longestRun,
191
201
  longestRunLine: report.structure.longestRunLine,
192
202
  heavyBullets: report.structure.heavyBullets,
@@ -203,6 +213,7 @@ async function runAudit(
203
213
 
204
214
  const gating = isGating({
205
215
  bans: reports.flatMap((report) => report.bans),
216
+ links: reports.flatMap((report) => report.links),
206
217
  structure: reports.map((report) => report.structure),
207
218
  })
208
219
 
@@ -304,6 +315,59 @@ function reportBans(
304
315
  )
305
316
  }
306
317
 
318
+ /**
319
+ * Names both the destination as written and the path it resolved to, since
320
+ * the second is what an editor can go check and the first is what a fix
321
+ * touches.
322
+ */
323
+ function resolvedLinkPath(
324
+ root: string,
325
+ rel: string,
326
+ destination: string,
327
+ ): string {
328
+ const path = decodePath(destination)
329
+ if (path === undefined) return '(destination carries a malformed encoding)'
330
+ return relative(root, resolve(dirname(resolve(root, rel)), path))
331
+ }
332
+
333
+ function reportLinks(reports: readonly FileReport[], root: string): void {
334
+ logStep('Links')
335
+ logInfo(
336
+ "A relative link's destination resolves against the filesystem, over the same corpus the ban scan reads.",
337
+ )
338
+ logInfo(
339
+ 'A destination carrying a template placeholder in angle brackets reads as an illustration rather than a literal path.',
340
+ )
341
+
342
+ const carrying = reports
343
+ .filter((report) => report.links.length > 0)
344
+ .sort((a, b) => b.links.length - a.links.length)
345
+
346
+ if (carrying.length === 0) {
347
+ logInfo('No relative link resolving to nothing on disk.')
348
+ return
349
+ }
350
+
351
+ const total = carrying.reduce((sum, report) => sum + report.links.length, 0)
352
+ logWarn(
353
+ `${plural(total, 'dead link')} across ${plural(carrying.length, 'file')}`,
354
+ )
355
+ logWarn('This fails the run. Every other check below reports.')
356
+ pipeOutput(
357
+ carrying
358
+ .map(
359
+ (report) =>
360
+ `${report.rel} ${plural(report.links.length, 'link')}\n${report.links
361
+ .map(
362
+ (found) =>
363
+ ` :${found.line}:${found.column + 1} ${found.destination} -> ${resolvedLinkPath(root, report.rel, found.destination)}`,
364
+ )
365
+ .join('\n')}`,
366
+ )
367
+ .join('\n'),
368
+ )
369
+ }
370
+
307
371
  function reportBullets(
308
372
  reports: readonly FileReport[],
309
373
  checkpoints: Checkpoints,
@@ -297,7 +297,7 @@ export const markdownBans: Measure = async (ctx) => {
297
297
  return {
298
298
  emissions: [output(frame.all)],
299
299
  failure:
300
- 'Markdown prose carries a banned character, word, or spelling. Rewrite the sentence, and reach for a code span only where the token is genuinely an identifier under discussion.',
300
+ 'Markdown prose carries a banned character, word, or spelling, or a relative link resolves to nothing on disk. Rewrite the sentence or fix the link, and reach for a code span only where the token is genuinely an identifier under discussion.',
301
301
  }
302
302
  }
303
303
 
@@ -1,9 +1,12 @@
1
+ import type { LinkFinding } from '@/markdown/links'
1
2
  import type { BanFinding } from '@/markdown/scan'
2
3
  import type { StructureReport } from '@/markdown/structure'
3
4
 
4
5
  export interface GateInput {
5
6
  /** Every ban hit across every file measured, flattened. */
6
7
  readonly bans: readonly BanFinding[]
8
+ /** Every dead relative link across every file measured, flattened. */
9
+ readonly links: readonly LinkFinding[]
7
10
  /**
8
11
  * Every structural measure the run made, read by nothing here.
9
12
  *
@@ -18,11 +21,12 @@ export interface GateInput {
18
21
  /**
19
22
  * Whether the audit found something that should fail the caller.
20
23
  *
21
- * A banned character, word, or spelling is a fact rather than a threshold, so
22
- * it gates unconditionally and there is no widened mode to reach for. The
23
- * standards decide what counts as banned, which keeps this answering how many
24
- * rather than which.
24
+ * A banned character, word, or spelling and a relative link resolving to
25
+ * nothing on disk are both facts rather than a threshold, so either gates
26
+ * unconditionally and there is no widened mode to reach for. The standards
27
+ * decide what counts as banned, which keeps this answering how many rather
28
+ * than which.
25
29
  */
26
- export function isGating({ bans }: GateInput): boolean {
27
- return bans.length > 0
30
+ export function isGating({ bans, links }: GateInput): boolean {
31
+ return bans.length > 0 || links.length > 0
28
32
  }
@@ -0,0 +1,73 @@
1
+ import { type BodyLine, LINK, maskCodeSpans } from '@/markdown/scan'
2
+
3
+ export interface LinkFinding {
4
+ readonly line: number
5
+ readonly column: number
6
+ readonly destination: string
7
+ }
8
+
9
+ const SCHEME = /^[a-z][a-z0-9+.-]*:/
10
+
11
+ function isSkipped(destination: string): boolean {
12
+ return (
13
+ destination === '' ||
14
+ SCHEME.test(destination) ||
15
+ destination.startsWith('#') ||
16
+ destination.startsWith('/') ||
17
+ destination.includes('<')
18
+ )
19
+ }
20
+
21
+ /**
22
+ * The path half of a destination, decoded, or undefined on a malformed
23
+ * percent-encoding.
24
+ *
25
+ * `decodeURIComponent` throws on a stray `%` not followed by two hex digits,
26
+ * and a destination is text an author typed rather than a value this command
27
+ * controls, so a throw here is reachable from any markdown file in the
28
+ * corpus. Exported so `src/commands/markdown.ts` decodes a destination for
29
+ * display the same way this decodes one for resolution, rather than a second
30
+ * definition of the same fallible call.
31
+ */
32
+ export function decodePath(destination: string): string | undefined {
33
+ try {
34
+ return decodeURIComponent(destination.split('#')[0] ?? '')
35
+ } catch {
36
+ return undefined
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Reports a relative link whose destination resolves to nothing on disk.
42
+ *
43
+ * `exists` stays injected rather than calling `existsSync` here, so a test
44
+ * asserts against a fake corpus rather than real paths on disk.
45
+ */
46
+ export function findBrokenLinks(
47
+ lines: readonly BodyLine[],
48
+ exists: (path: string) => boolean,
49
+ ): LinkFinding[] {
50
+ const found: LinkFinding[] = []
51
+
52
+ for (const line of lines) {
53
+ if (line.fenced) continue
54
+ const text = maskCodeSpans(line.text)
55
+
56
+ for (const match of text.matchAll(LINK)) {
57
+ const open = match[0].indexOf('](')
58
+ const destination = match[0].slice(open + 2, -1)
59
+ if (isSkipped(destination)) continue
60
+
61
+ const path = decodePath(destination)
62
+ if (path !== undefined && exists(path)) continue
63
+
64
+ found.push({
65
+ line: line.number,
66
+ column: (match.index ?? 0) + open + 2,
67
+ destination,
68
+ })
69
+ }
70
+ }
71
+
72
+ return found
73
+ }
@@ -20,7 +20,17 @@ const FRONTMATTER = /^---\n[\s\S]*?\n---\n?/
20
20
  * rewrite of the sentence can remove.
21
21
  */
22
22
  const CODE_SPAN = /(`+)(?:(?!\1).)*\1/g
23
- const LINK_DESTINATION = /\]\([^)]*\)/g
23
+
24
+ /**
25
+ * A destination body, reaching one level of balanced parentheses so
26
+ * `file(1).md` is not truncated at its first close paren. A plain `[^)]*`
27
+ * ends the whole match there, which reads a legitimate destination as
28
+ * unterminated and, in `src/markdown/links.ts`, reports it broken. CommonMark
29
+ * permits an unescaped destination to carry matched parentheses, and one
30
+ * level is what every destination measured in this corpus needs.
31
+ */
32
+ const LINK_TARGET = String.raw`(?:[^()]|\([^()]*\))*`
33
+ const LINK_DESTINATION = new RegExp(String.raw`\]\(${LINK_TARGET}\)`, 'g')
24
34
  const AUTOLINK = /<[^>\s]+>/g
25
35
 
26
36
  /**
@@ -31,8 +41,14 @@ const AUTOLINK = /<[^>\s]+>/g
31
41
  * the destination. The narrower pattern still runs after this one, since a link
32
42
  * wrapped across two source lines puts its opening bracket on a line this one
33
43
  * never matches.
44
+ *
45
+ * Exported so `src/markdown/links.ts` matches a destination against the same
46
+ * pattern rather than a second definition of the same shape. Take it through
47
+ * `replace` or `matchAll` alone. Both clone the pattern before reading
48
+ * `lastIndex`, where `test` or `exec` would mutate the shared instance and
49
+ * leave the other module's next match starting from a nonzero offset.
34
50
  */
35
- const LINK = /\[([^\]]*)\]\([^)]*\)/g
51
+ export const LINK = new RegExp(String.raw`\[([^\]]*)\]\(${LINK_TARGET}\)`, 'g')
36
52
 
37
53
  export interface BodyLine {
38
54
  readonly number: number
@@ -101,6 +101,7 @@ When frontmatter carries a short `title` or `description` used for catalog displ
101
101
 
102
102
  - Use descriptive anchor text for links. Avoid `click here` or `read more`.
103
103
  - Wrap file references in backticks by default. Use a labeled markdown link (`[label](path)`) only on rendered-for-human surfaces (`README.md`, `docs/`) and in an index file, whose rows exist to be followed. Never repeat the path verbatim as the label.
104
+ - A relative link's destination resolves against the filesystem, over the same corpus the ban scan reads. A destination carrying a template placeholder in angle brackets, such as `<slug>` or `<name>`, reads as an illustration rather than a literal path and is exempt.
104
105
 
105
106
  ## Examples
106
107