@erclx/canon 4.30.2 → 4.31.1
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/claude/.claude-plugin/plugin.json +1 -1
- package/docs/agents/audits.md +2 -2
- package/docs/agents/commands.md +1 -1
- package/docs/agents/markdown-audit.md +7 -1
- package/package.json +1 -1
- package/scripts/core/check-capability-seeding.sh +132 -0
- package/scripts/lib/sandbox-git.sh +8 -0
- package/scripts/manage-sandbox.sh +10 -3
- package/src/commands/markdown.ts +70 -6
- package/src/gate/measures.ts +1 -1
- package/src/gate/stages.ts +14 -0
- package/src/markdown/gate.ts +10 -6
- package/src/markdown/links.ts +73 -0
- package/src/markdown/scan.ts +18 -2
- package/standards/markdown.md +1 -0
- package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +55 -0
- package/tooling/claude/seeds/.claude/settings.json +9 -0
package/docs/agents/audits.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
package/docs/agents/commands.md
CHANGED
|
@@ -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
|
|
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
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -e
|
|
3
|
+
set -o pipefail
|
|
4
|
+
shopt -s nullglob
|
|
5
|
+
|
|
6
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
7
|
+
PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
|
|
8
|
+
|
|
9
|
+
TOOLING_ROOT="$PROJECT_ROOT/tooling"
|
|
10
|
+
MARKER="canon-no-seed:"
|
|
11
|
+
|
|
12
|
+
# `find` writes to stderr and returns non-zero for a missing root, inside a
|
|
13
|
+
# process substitution whose status nothing reads. Without this the walk covers
|
|
14
|
+
# nothing and the check reports every capability seeded having never read one.
|
|
15
|
+
if [ ! -d "$TOOLING_ROOT" ]; then
|
|
16
|
+
echo "No tooling root at ${TOOLING_ROOT#"$PROJECT_ROOT/"}, capability seeding unverifiable." >&2
|
|
17
|
+
exit 1
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
failures=""
|
|
21
|
+
|
|
22
|
+
# A comment naming the marker anywhere in the source file is the recorded
|
|
23
|
+
# reason a capability withholds itself from every destination it was compared
|
|
24
|
+
# against. The reason's own prose is free-form, so only the marker is tested.
|
|
25
|
+
has_reason() {
|
|
26
|
+
grep -q "$MARKER" "$1"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# Compares one source directory's files against the basenames already
|
|
30
|
+
# expanded from a set of destination paths. Presence on either side clears a
|
|
31
|
+
# name; a source file reaching neither the destination nor a marked reason is
|
|
32
|
+
# a capability answered on one side of the seed or config boundary and not
|
|
33
|
+
# the other.
|
|
34
|
+
check_capability() {
|
|
35
|
+
local label="$1" src_dir="$2"
|
|
36
|
+
shift 2
|
|
37
|
+
|
|
38
|
+
[ -d "$src_dir" ] || return 0
|
|
39
|
+
|
|
40
|
+
local dest_names=" "
|
|
41
|
+
local dest
|
|
42
|
+
for dest in "$@"; do
|
|
43
|
+
[ -f "$dest" ] || continue
|
|
44
|
+
dest_names="$dest_names$(basename "$dest") "
|
|
45
|
+
done
|
|
46
|
+
|
|
47
|
+
local src_file name
|
|
48
|
+
for src_file in "$src_dir"/*; do
|
|
49
|
+
[ -f "$src_file" ] || continue
|
|
50
|
+
name=$(basename "$src_file")
|
|
51
|
+
case "$dest_names" in
|
|
52
|
+
*" $name "*) continue ;;
|
|
53
|
+
esac
|
|
54
|
+
has_reason "$src_file" && continue
|
|
55
|
+
failures="$failures $label: ${src_file#"$PROJECT_ROOT/"} reaches no seed or config and carries no $MARKER reason"$'\n'
|
|
56
|
+
done
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
check_capability "Hooks" "$PROJECT_ROOT/.claude/hooks" \
|
|
60
|
+
"$PROJECT_ROOT"/tooling/claude/seeds/.claude/hooks/*
|
|
61
|
+
|
|
62
|
+
check_capability "Workflows" "$PROJECT_ROOT/.github/workflows" \
|
|
63
|
+
"$PROJECT_ROOT"/tooling/*/configs/.github/workflows/*
|
|
64
|
+
|
|
65
|
+
check_capability "Husky" "$PROJECT_ROOT/.husky" \
|
|
66
|
+
"$PROJECT_ROOT"/tooling/base/configs/.husky/*
|
|
67
|
+
|
|
68
|
+
# The reverse direction: a destination file whose source here is gone reaches
|
|
69
|
+
# neither check_capability above nor the wiring pass below, both of which read
|
|
70
|
+
# forward from the source, and it ships a target a hook, workflow, or husky
|
|
71
|
+
# script this repository has already deleted. A destination carrying the same
|
|
72
|
+
# marker clears just as a source does, which is the escape hatch a target-only
|
|
73
|
+
# capability needs, such as a stack-specific workflow with no root counterpart
|
|
74
|
+
# by design.
|
|
75
|
+
check_orphans() {
|
|
76
|
+
local label="$1" src_dir="$2" dest_dir="$3"
|
|
77
|
+
|
|
78
|
+
[ -d "$dest_dir" ] || return 0
|
|
79
|
+
|
|
80
|
+
local dest_file name
|
|
81
|
+
for dest_file in "$dest_dir"/*; do
|
|
82
|
+
[ -f "$dest_file" ] || continue
|
|
83
|
+
name=$(basename "$dest_file")
|
|
84
|
+
[ -f "$src_dir/$name" ] && continue
|
|
85
|
+
has_reason "$dest_file" && continue
|
|
86
|
+
failures="$failures $label: ${dest_file#"$PROJECT_ROOT/"} is seeded or configured with no source at ${src_dir#"$PROJECT_ROOT/"}/$name"$'\n'
|
|
87
|
+
done
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
check_orphans "Hooks" "$PROJECT_ROOT/.claude/hooks" \
|
|
91
|
+
"$PROJECT_ROOT/tooling/claude/seeds/.claude/hooks"
|
|
92
|
+
|
|
93
|
+
for dir in "$PROJECT_ROOT"/tooling/*/configs/.github/workflows; do
|
|
94
|
+
check_orphans "Workflows" "$PROJECT_ROOT/.github/workflows" "$dir"
|
|
95
|
+
done
|
|
96
|
+
|
|
97
|
+
check_orphans "Husky" "$PROJECT_ROOT/.husky" \
|
|
98
|
+
"$PROJECT_ROOT/tooling/base/configs/.husky"
|
|
99
|
+
|
|
100
|
+
# A hook that reached the seed tree with no wiring in the seeded settings.json
|
|
101
|
+
# is installed dead, which fails the same way an unseeded hook does.
|
|
102
|
+
SEED_HOOKS_DIR="$PROJECT_ROOT/tooling/claude/seeds/.claude/hooks"
|
|
103
|
+
SEED_SETTINGS="$PROJECT_ROOT/tooling/claude/seeds/.claude/settings.json"
|
|
104
|
+
|
|
105
|
+
if [ -d "$SEED_HOOKS_DIR" ]; then
|
|
106
|
+
if [ ! -f "$SEED_SETTINGS" ]; then
|
|
107
|
+
failures="$failures Seed settings: no settings.json at ${SEED_SETTINGS#"$PROJECT_ROOT/"} to confirm a seeded hook is wired"$'\n'
|
|
108
|
+
elif ! command -v jq >/dev/null 2>&1; then
|
|
109
|
+
failures="$failures Seed settings: jq is not installed, seeded-hook wiring unverifiable"$'\n'
|
|
110
|
+
else
|
|
111
|
+
# Every wired command as its own path segment set, rather than a substring
|
|
112
|
+
# search over the raw file, so a hook name that is a substring of another
|
|
113
|
+
# hook's filename (`log.sh` inside `pr-create-log.sh`) cannot pass on a
|
|
114
|
+
# neighbor's wiring.
|
|
115
|
+
wired=" $(jq -r '.. | .command? // empty' "$SEED_SETTINGS" | tr '/\n' ' ') "
|
|
116
|
+
for hook_file in "$SEED_HOOKS_DIR"/*.sh; do
|
|
117
|
+
[ -f "$hook_file" ] || continue
|
|
118
|
+
name=$(basename "$hook_file")
|
|
119
|
+
case "$wired" in
|
|
120
|
+
*" $name "*) continue ;;
|
|
121
|
+
esac
|
|
122
|
+
failures="$failures Seed settings: $name is seeded and wired into no command in ${SEED_SETTINGS#"$PROJECT_ROOT/"}"$'\n'
|
|
123
|
+
done
|
|
124
|
+
fi
|
|
125
|
+
fi
|
|
126
|
+
|
|
127
|
+
if [ -n "$failures" ]; then
|
|
128
|
+
echo "A capability reaches one side of the seed or config boundary and not the other:" >&2
|
|
129
|
+
printf '%s' "$failures" >&2
|
|
130
|
+
echo "Seed or configure the capability, or mark the source line with # $MARKER <reason>." >&2
|
|
131
|
+
exit 1
|
|
132
|
+
fi
|
|
@@ -112,6 +112,14 @@ ensure_sandbox_anchor_repo() {
|
|
|
112
112
|
log_info "Created ${GITHUB_ORG}/${repo_name} as a private repository."
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
# Mirrors the merge-base resolution the diff-baseline port already carries
|
|
116
|
+
# across five skill bodies, so a checkout whose local main trails origin/main
|
|
117
|
+
# does not pull in skill bodies other merged branches changed.
|
|
118
|
+
resolve_sandbox_skill_diff_base() {
|
|
119
|
+
git -C "$PROJECT_ROOT" merge-base HEAD origin/main 2>/dev/null ||
|
|
120
|
+
git -C "$PROJECT_ROOT" merge-base HEAD main 2>/dev/null
|
|
121
|
+
}
|
|
122
|
+
|
|
115
123
|
# A remote is useless without an author, so the scenarios that reach one always
|
|
116
124
|
# configure both. configure_sandbox_git_identity stays callable on its own for
|
|
117
125
|
# the scenarios that never push. The probe runs first so an absent remote is
|
|
@@ -283,8 +283,12 @@ tag_sandbox_baseline() {
|
|
|
283
283
|
}
|
|
284
284
|
|
|
285
285
|
inject_changed_skills() {
|
|
286
|
+
local base
|
|
287
|
+
base=$(resolve_sandbox_skill_diff_base)
|
|
288
|
+
base="${base:-main}"
|
|
289
|
+
|
|
286
290
|
local changed untracked
|
|
287
|
-
changed=$(git -C "$PROJECT_ROOT" diff
|
|
291
|
+
changed=$(git -C "$PROJECT_ROOT" diff "$base" --name-only -- 'claude/skills/**/SKILL.md' 2>/dev/null)
|
|
288
292
|
untracked=$(git -C "$PROJECT_ROOT" ls-files --others --exclude-standard -- 'claude/skills/**/SKILL.md' 2>/dev/null)
|
|
289
293
|
|
|
290
294
|
local combined
|
|
@@ -293,8 +297,8 @@ inject_changed_skills() {
|
|
|
293
297
|
[ -z "$combined" ] && return
|
|
294
298
|
|
|
295
299
|
while IFS= read -r skill_path; do
|
|
296
|
-
# The diff against
|
|
297
|
-
# there is nothing left to inject for a name this branch removed.
|
|
300
|
+
# The diff against the base lists a deleted skill alongside a changed one,
|
|
301
|
+
# and there is nothing left to inject for a name this branch removed.
|
|
298
302
|
[ -f "$PROJECT_ROOT/$skill_path" ] || continue
|
|
299
303
|
|
|
300
304
|
local skill_name
|
|
@@ -302,6 +306,9 @@ inject_changed_skills() {
|
|
|
302
306
|
local target_dir="$SANDBOX/.claude/skills/$skill_name"
|
|
303
307
|
mkdir -p "$target_dir"
|
|
304
308
|
cp "$PROJECT_ROOT/$skill_path" "$target_dir/SKILL.md"
|
|
309
|
+
if [ -n "$SANDBOX_SKIP_AUTO_COMMIT" ]; then
|
|
310
|
+
echo ".claude/skills/$skill_name/SKILL.md" >>"$SANDBOX/.git/info/exclude"
|
|
311
|
+
fi
|
|
305
312
|
log_info "Injected dev skill: $skill_name"
|
|
306
313
|
done <<<"$combined"
|
|
307
314
|
}
|
package/src/commands/markdown.ts
CHANGED
|
@@ -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
|
|
81
|
-
'depth weight are judgments a reader settles,
|
|
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
|
|
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,
|
package/src/gate/measures.ts
CHANGED
|
@@ -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
|
|
package/src/gate/stages.ts
CHANGED
|
@@ -296,6 +296,20 @@ export const STAGES: readonly Stage[] = [
|
|
|
296
296
|
],
|
|
297
297
|
success: 'Seed prose cites no toolkit CLI',
|
|
298
298
|
},
|
|
299
|
+
{
|
|
300
|
+
// A hook, a workflow, or a husky script reaching one side of the seed or
|
|
301
|
+
// config boundary and not the other is a capability withheld with no
|
|
302
|
+
// recorded reason, per the criterion in .claude/ARCHITECTURE.md.
|
|
303
|
+
id: 'capability-seeding',
|
|
304
|
+
label: 'Capability seeding',
|
|
305
|
+
checks: [
|
|
306
|
+
script(
|
|
307
|
+
'check-capability-seeding.sh',
|
|
308
|
+
'A capability reaches one side of the seed or config boundary and not the other.',
|
|
309
|
+
),
|
|
310
|
+
],
|
|
311
|
+
success: 'Capability seeding clean',
|
|
312
|
+
},
|
|
299
313
|
{
|
|
300
314
|
// A stack entry naming a rule folder takes every rule in it, which is what
|
|
301
315
|
// stops a new rule from needing a second edit to reach a target. The
|
package/src/markdown/gate.ts
CHANGED
|
@@ -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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
+
}
|
package/src/markdown/scan.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
51
|
+
export const LINK = new RegExp(String.raw`\[([^\]]*)\]\(${LINK_TARGET}\)`, 'g')
|
|
36
52
|
|
|
37
53
|
export interface BodyLine {
|
|
38
54
|
readonly number: number
|
package/standards/markdown.md
CHANGED
|
@@ -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
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
# Reminds a worker session of the pull-request announcement it owes and logs
|
|
4
|
+
# the creation as a denominator for the miss rate `claude-worker` cannot
|
|
5
|
+
# measure on its own. Follows path-form.sh's precedent: a PostToolUse hook
|
|
6
|
+
# returning hookSpecificOutput.additionalContext is the shipped route back
|
|
7
|
+
# into a session's own turn.
|
|
8
|
+
#
|
|
9
|
+
# The hook cannot know whether the announcement went out, only that a pull
|
|
10
|
+
# request now exists to announce. It states the obligation and counts the
|
|
11
|
+
# creation, and claims nothing about the send.
|
|
12
|
+
|
|
13
|
+
# Claude Code sends a payload and closes stdin. A bare read with nothing
|
|
14
|
+
# feeding it blocks forever and holds the session open, so the read is
|
|
15
|
+
# bounded. `read` rather than `timeout cat`, which macOS does not ship.
|
|
16
|
+
IFS= read -r -d '' -t 2 input
|
|
17
|
+
[ -n "$input" ] || {
|
|
18
|
+
printf '%s reads a Claude Code hook payload on stdin and cannot be run by hand.\n' "${0##*/}" >&2
|
|
19
|
+
exit 1
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
command=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')
|
|
23
|
+
case "$command" in
|
|
24
|
+
*"gh pr create"*) ;;
|
|
25
|
+
*) exit 0 ;;
|
|
26
|
+
esac
|
|
27
|
+
|
|
28
|
+
# gh pr create prints the new pull request's URL to stdout on success, so
|
|
29
|
+
# matching it is what tells a creation apart from a failed or refused call.
|
|
30
|
+
stdout=$(printf '%s' "$input" | jq -r '.tool_response.stdout // empty')
|
|
31
|
+
url=$(printf '%s' "$stdout" | grep -Eo 'https://github\.com/[^[:space:]]+/pull/[0-9]+' | tail -1)
|
|
32
|
+
[ -n "$url" ] || exit 0
|
|
33
|
+
|
|
34
|
+
# CLAUDE_PROJECT_DIR is the session's own worktree rather than the main root,
|
|
35
|
+
# so a worker building in a linked worktree would log into a folder that dies
|
|
36
|
+
# with the worktree. The log is a denominator across a wave rather than a
|
|
37
|
+
# per-session record, so strip back to the main root the way tasks-index.sh
|
|
38
|
+
# and memory-index.sh already derive theirs, off a path suffix.
|
|
39
|
+
root="${CLAUDE_PROJECT_DIR:-.}"
|
|
40
|
+
case "$root" in
|
|
41
|
+
*/.claude/worktrees/*) root="${root%/.claude/worktrees/*}" ;;
|
|
42
|
+
esac
|
|
43
|
+
# The log is scratch, so it follows the scratch folder to whichever record root
|
|
44
|
+
# the project carries rather than creating a second one beside it.
|
|
45
|
+
if [ -d "$root/.canon" ]; then
|
|
46
|
+
log_dir="$root/.canon/tmp/pr-create-log"
|
|
47
|
+
else
|
|
48
|
+
log_dir="$root/.claude/.tmp/pr-create-log"
|
|
49
|
+
fi
|
|
50
|
+
mkdir -p "$log_dir"
|
|
51
|
+
session=$(printf '%s' "$input" | jq -r '.session_id // "unknown"')
|
|
52
|
+
printf -- '- %s session=%s pr=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$session" "$url" >>"$log_dir/log.md"
|
|
53
|
+
|
|
54
|
+
msg="Pull request $url just opened. If this session holds a worker's channel obligation, announce it to the controller now, per claude-worker."
|
|
55
|
+
jq -nc --arg msg "$msg" '{hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext:$msg}}'
|
|
@@ -41,6 +41,15 @@
|
|
|
41
41
|
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/path-form.sh"
|
|
42
42
|
}
|
|
43
43
|
]
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"matcher": "Bash",
|
|
47
|
+
"hooks": [
|
|
48
|
+
{
|
|
49
|
+
"type": "command",
|
|
50
|
+
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/pr-create-log.sh"
|
|
51
|
+
}
|
|
52
|
+
]
|
|
44
53
|
}
|
|
45
54
|
]
|
|
46
55
|
}
|