@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/REQUIREMENT.md +51 -0
- package/claude/skills/api-design/SKILL.md +102 -0
- package/claude/skills/api-design/references/adopted.md +40 -0
- package/claude/skills/codebase-layout/REQUIREMENT.md +1 -1
- package/claude/skills/codebase-layout/SKILL.md +2 -1
- package/claude/skills/git-followup/SKILL.md +1 -1
- package/claude/skills/git-pr/references/evidence.md +1 -1
- package/claude/skills/review-craft/REQUIREMENT.md +8 -2
- package/claude/skills/review-craft/SKILL.md +63 -7
- package/claude/skills/review-craft/references/adopted.md +49 -0
- package/claude/skills/review-ui/REQUIREMENT.md +3 -0
- package/claude/skills/review-ui/SKILL.md +15 -1
- package/claude/skills/review-ui/references/driving.md +3 -3
- package/claude/skills/review-ui/references/post.md +16 -3
- package/claude/skills/systematic-debugging/REQUIREMENT.md +14 -0
- package/claude/skills/systematic-debugging/SKILL.md +45 -17
- package/claude/skills/systematic-debugging/references/adopted.md +44 -0
- package/docs/agents/capture.md +2 -2
- package/docs/agents/commands.md +3 -1
- package/docs/agents/hooks.md +56 -0
- package/docs/agents/index.md +1 -0
- package/docs/agents/install-and-sync.md +1 -1
- package/docs/agents/markdown-audit.md +1 -1
- package/docs/agents/pr-evidence.md +33 -17
- package/docs/agents/records-push.md +1 -1
- package/docs/agents/records-validate.md +1 -1
- package/docs/agents/scripting.md +6 -3
- package/docs/agents/tasks-archive.md +4 -2
- package/docs/agents/tasks-plans.md +8 -5
- package/docs/agents/tasks.md +1 -1
- package/docs/agents/worktrees.md +2 -2
- package/docs/target/scenarios.md +2 -2
- package/docs/workflow/skill-map.md +1 -0
- package/package.json +1 -1
- package/scripts/core/regen-agent-fixture.sh +3 -3
- package/scripts/core/regen-hero.ts +119 -0
- package/scripts/core/regen-web-favicon.ts +1 -1
- package/src/capture/frame-values.ts +359 -0
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/skills-audit.ts +3 -0
- package/src/cli.ts +2 -0
- package/src/commands/claude.ts +1 -1
- package/src/commands/docs.ts +51 -11
- package/src/commands/hooks.ts +67 -0
- package/src/commands/pr.ts +146 -76
- package/src/commands/standards.ts +33 -2
- package/src/commands/tasks.ts +57 -2
- package/src/commands/tooling.ts +5 -5
- package/src/commands/upgrade.ts +3 -2
- package/src/commands/worktrees.ts +4 -3
- package/src/counts/catalogs.ts +3 -3
- package/src/design/tokens.ts +1 -1
- package/src/docs/list.ts +103 -0
- package/src/docs/read.ts +63 -19
- package/src/gate/stages.ts +1 -1
- package/src/gov/stacks.ts +1 -1
- package/src/help.ts +2 -1
- package/src/hooks/post-merge.ts +331 -0
- package/src/pr/evidence.ts +21 -0
- package/src/records/validate.ts +1 -38
- package/src/standards/list.ts +33 -0
- package/src/standards/read.ts +13 -0
- package/src/standards/scope.ts +56 -0
- package/src/sync/check.ts +38 -4
- package/src/tasks/archive.ts +195 -3
- package/src/tasks/record.ts +25 -2
- package/src/tooling/inject.ts +4 -3
- package/src/tooling/manifest.ts +62 -8
- package/src/tooling/scan.ts +4 -3
- package/src/tooling/stamp.ts +11 -2
- package/src/tooling/subfolder.ts +7 -3
- package/standards/tasks.md +2 -2
- package/tooling/astro/reference.md +1 -1
- package/tooling/base/configs/scripts/verify.json +3 -0
- package/tooling/base/configs/scripts/verify.sh +1 -110
- package/tooling/base/configs/scripts/verify.ts +378 -0
- package/tooling/base/manifest.toml +1 -0
- package/tooling/base/reference.md +5 -3
- package/tooling/go/configs/scripts/verify.json +10 -0
- package/tooling/go/reference.md +5 -4
- package/tooling/php/configs/scripts/verify.json +7 -0
- package/tooling/php/reference.md +5 -4
- package/tooling/python/configs/scripts/verify.json +4 -0
- package/tooling/python/reference.md +3 -3
- package/tooling/web/configs/scripts/verify.json +3 -0
- package/tooling/web/reference.md +3 -3
- package/scripts/core/regen-hero.sh +0 -419
- package/scripts/docs/list.sh +0 -184
- package/scripts/lib/frontmatter.sh +0 -30
- package/scripts/standards/list.sh +0 -115
- package/scripts/tooling/create.sh +0 -109
- package/src/commands/pass-through.ts +0 -39
- package/tooling/go/configs/scripts/verify.sh +0 -142
- package/tooling/php/configs/scripts/verify.sh +0 -147
- package/tooling/python/configs/scripts/verify.sh +0 -141
- package/tooling/web/configs/scripts/verify.sh +0 -153
package/src/tooling/stamp.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
}
|
package/src/tooling/subfolder.ts
CHANGED
|
@@ -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 {
|
|
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 (
|
|
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
|
|
94
|
+
for (const rel of seedFiles(manifest)) {
|
|
91
95
|
if (rel.startsWith('.cspell/') && rel.endsWith('.txt')) lists.add(rel)
|
|
92
96
|
}
|
|
93
97
|
}
|
package/standards/tasks.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
|
@@ -1,111 +1,2 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
|
|
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
|
+
}
|
|
@@ -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.
|
|
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.
|
|
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.
|
package/tooling/go/reference.md
CHANGED
|
@@ -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
|
|
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.
|
|
29
|
+
## verify.json
|
|
30
30
|
|
|
31
|
-
-
|
|
32
|
-
-
|
|
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
|