@erclx/canon 5.12.0 → 5.14.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 +1 -1
- package/claude/skills/api-design/SKILL.md +4 -3
- package/claude/skills/api-design/references/adopted.md +1 -1
- package/claude/skills/auto-ship/REQUIREMENT.md +1 -0
- package/claude/skills/auto-ship/SKILL.md +2 -2
- package/claude/skills/build-in-slices/REQUIREMENT.md +45 -0
- package/claude/skills/build-in-slices/SKILL.md +94 -0
- package/claude/skills/build-in-slices/references/adopted.md +41 -0
- package/claude/skills/code-craft/REQUIREMENT.md +48 -0
- package/claude/skills/code-craft/SKILL.md +107 -0
- package/claude/skills/code-craft/references/adopted.md +52 -0
- package/claude/skills/codebase-layout/REQUIREMENT.md +1 -1
- package/claude/skills/deprecation-migration/REQUIREMENT.md +62 -0
- package/claude/skills/deprecation-migration/SKILL.md +115 -0
- package/claude/skills/deprecation-migration/references/adopted.md +50 -0
- package/claude/skills/git-followup/REQUIREMENT.md +1 -0
- package/claude/skills/git-followup/SKILL.md +3 -0
- package/claude/skills/git-pr/REQUIREMENT.md +1 -0
- package/claude/skills/git-pr/SKILL.md +1 -1
- package/claude/skills/git-pr/references/evidence.md +2 -2
- package/claude/skills/git-ship/REQUIREMENT.md +4 -0
- package/claude/skills/git-ship/SKILL.md +13 -2
- package/claude/skills/review-craft/REQUIREMENT.md +3 -0
- package/claude/skills/review-craft/SKILL.md +1 -1
- package/claude/skills/review-craft/references/adopted.md +8 -0
- package/claude/skills/review-craft/references/security.md +5 -0
- package/claude/skills/review-ui/REQUIREMENT.md +6 -2
- package/claude/skills/review-ui/SKILL.md +23 -4
- package/claude/skills/review-ui/references/driving.md +5 -3
- package/claude/skills/review-ui/references/post.md +3 -2
- package/claude/skills/role-worker/REQUIREMENT.md +1 -0
- package/claude/skills/role-worker/SKILL.md +4 -0
- package/claude/skills/test-first/REQUIREMENT.md +3 -0
- package/claude/skills/test-first/SKILL.md +2 -1
- package/docs/agents/commands.md +1 -1
- package/docs/agents/index.md +1 -0
- package/docs/agents/markdown-audit.md +1 -1
- package/docs/agents/pr-evidence.md +5 -0
- package/docs/agents/pr-frames.md +112 -0
- package/docs/agents/pr-preview.md +40 -12
- package/docs/workflow/skill-map.md +15 -12
- package/governance/rules/code/000-code.md +5 -1
- package/package.json +1 -1
- package/src/claude/cases/misc.ts +15 -0
- package/src/claude/skills-audit.ts +3 -0
- package/src/commands/pr.ts +559 -7
- package/src/commands/tooling.ts +39 -16
- package/src/context/architecture.ts +5 -2
- package/src/gate/boundaries.ts +509 -0
- package/src/gate/measures.ts +8 -19
- package/src/gate/stages.ts +12 -28
- package/src/pr/frames.ts +323 -0
- package/src/pr/preview.ts +66 -0
- package/src/tooling/manifest.ts +18 -3
- package/src/tooling/seeds.ts +37 -0
- package/src/tooling/verify-stack.ts +249 -0
- package/tooling/base/seeds/.cspell/tech-stack.txt +2 -0
- package/tooling/claude/manifest.toml +3 -3
- package/tooling/go/manifest.toml +3 -0
- package/tooling/go/reference.md +1 -0
- package/tooling/php/manifest.toml +3 -0
- package/tooling/php/reference.md +1 -0
- package/tooling/python/manifest.toml +3 -0
- package/tooling/python/reference.md +1 -1
- package/tooling/python/seeds/.cspell/tech-stack.txt +0 -1
- package/scripts/core/check-capability-seeding.sh +0 -134
- package/scripts/core/check-ignore-parity.sh +0 -141
- package/scripts/core/check-plugin-boundary.sh +0 -45
- package/scripts/core/check-seed-independence.sh +0 -62
- package/scripts/core/check-skill-paths.sh +0 -24
- package/scripts/core/list-seed-roots.sh +0 -18
- package/scripts/lib/tooling.sh +0 -37
- package/scripts/tooling/verify.sh +0 -184
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { execa, type Options } from 'execa'
|
|
4
|
+
import { isStackExcluded, loadManifest } from '@/tooling/manifest'
|
|
5
|
+
import { intro, logError, logInfo, logStep, logWarn, outro } from '@/ui'
|
|
6
|
+
|
|
7
|
+
const SCREENSHOT_TIMEOUT_MS = 60_000
|
|
8
|
+
const TAIL_LINES = 20
|
|
9
|
+
|
|
10
|
+
export interface VerifyOptions {
|
|
11
|
+
readonly root: string
|
|
12
|
+
readonly stack: string
|
|
13
|
+
readonly keep?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PhaseResult {
|
|
17
|
+
readonly name: string
|
|
18
|
+
readonly passed: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface VerifyOutcome {
|
|
22
|
+
readonly results: readonly PhaseResult[]
|
|
23
|
+
readonly exitCode: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface Run {
|
|
27
|
+
readonly dir: string
|
|
28
|
+
readonly results: PhaseResult[]
|
|
29
|
+
isFailed: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Scaffolds a stack into `.canon/tmp/runs/verify-<stack>/`, syncs its chain
|
|
34
|
+
* from the checkout at `root`, and runs the scaffold's own `lint:fix`,
|
|
35
|
+
* `check`, `test:e2e`, and `screenshot` scripts. Every line goes to stderr.
|
|
36
|
+
*
|
|
37
|
+
* A failed phase is recorded and the run carries on, so the Results block
|
|
38
|
+
* reports every phase rather than the first failure. The tmp dir is removed
|
|
39
|
+
* only on a clean run without `keep`.
|
|
40
|
+
*/
|
|
41
|
+
export async function verifyStack(opts: VerifyOptions): Promise<VerifyOutcome> {
|
|
42
|
+
intro(`canon tooling verify ${opts.stack}`)
|
|
43
|
+
// A signal skips the `finally` below, so the handler closes the frame on
|
|
44
|
+
// that path and leaves the tmp dir in place for inspection.
|
|
45
|
+
let isOpen = true
|
|
46
|
+
const close = (): void => {
|
|
47
|
+
if (isOpen) outro()
|
|
48
|
+
isOpen = false
|
|
49
|
+
}
|
|
50
|
+
const handleSignal = (): void => {
|
|
51
|
+
close()
|
|
52
|
+
process.exit(130)
|
|
53
|
+
}
|
|
54
|
+
process.once('SIGINT', handleSignal)
|
|
55
|
+
process.once('SIGTERM', handleSignal)
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
return await run(opts)
|
|
59
|
+
} finally {
|
|
60
|
+
process.off('SIGINT', handleSignal)
|
|
61
|
+
process.off('SIGTERM', handleSignal)
|
|
62
|
+
close()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function run({
|
|
67
|
+
root,
|
|
68
|
+
stack,
|
|
69
|
+
keep = false,
|
|
70
|
+
}: VerifyOptions): Promise<VerifyOutcome> {
|
|
71
|
+
const refuse = (message: string): VerifyOutcome => {
|
|
72
|
+
logError(message)
|
|
73
|
+
return { results: [], exitCode: 1 }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (isStackExcluded(stack)) {
|
|
77
|
+
return refuse(`Stack '${stack}' is excluded from tooling.`)
|
|
78
|
+
}
|
|
79
|
+
const manifest = loadManifest(root, stack)
|
|
80
|
+
if (manifest === undefined) {
|
|
81
|
+
return refuse(`No manifest at tooling/${stack}/manifest.toml`)
|
|
82
|
+
}
|
|
83
|
+
if (manifest.scaffold === undefined) {
|
|
84
|
+
return refuse(`Stack '${stack}' has no scaffold command in manifest.`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const runsDir = join(root, '.canon/tmp/runs')
|
|
88
|
+
const name = `verify-${stack}`
|
|
89
|
+
const state: Run = { dir: join(runsDir, name), results: [], isFailed: false }
|
|
90
|
+
|
|
91
|
+
mkdirSync(runsDir, { recursive: true })
|
|
92
|
+
rmSync(state.dir, { recursive: true, force: true })
|
|
93
|
+
|
|
94
|
+
logStep(`Scaffolding ${stack}`)
|
|
95
|
+
// Manifests carry shell syntax (`&&`, `cd`, quoted globs), so the command
|
|
96
|
+
// runs under a shell rather than as a split argv.
|
|
97
|
+
const scaffold = await shell(
|
|
98
|
+
manifest.scaffold.replaceAll('{{name}}', name),
|
|
99
|
+
runsDir,
|
|
100
|
+
)
|
|
101
|
+
if (scaffold.failed) {
|
|
102
|
+
logWarn('Scaffold failed')
|
|
103
|
+
writeTail(scaffold.all)
|
|
104
|
+
state.isFailed = true
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!existsSync(state.dir)) {
|
|
108
|
+
return refuse(`Scaffold did not produce ${state.dir}`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
await shell(
|
|
112
|
+
'git init -q && git add . && git commit -m "chore: verify scaffold" -q --no-verify --allow-empty',
|
|
113
|
+
state.dir,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
const { prepare } = manifest
|
|
117
|
+
if (prepare !== undefined) {
|
|
118
|
+
await phase(state, 'Prepare', () => shell(prepare, state.dir))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const cli = join(root, 'src/cli.ts')
|
|
122
|
+
logInfo(`Sync resolves via bun ${cli}, not PATH canon`)
|
|
123
|
+
await phase(state, 'Sync', () =>
|
|
124
|
+
execa('bun', [cli, 'tooling', 'sync', stack, '.', '--write'], {
|
|
125
|
+
...captured(state.dir),
|
|
126
|
+
env: { CI: '1', CANON_NON_INTERACTIVE: '1' },
|
|
127
|
+
}),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
const packagePath = join(state.dir, 'package.json')
|
|
131
|
+
if (existsSync(packagePath)) {
|
|
132
|
+
await runScripts(state, readScriptKeys(packagePath))
|
|
133
|
+
} else {
|
|
134
|
+
logError(
|
|
135
|
+
`No package.json after Sync for ${stack}, so no phase past Sync can run.`,
|
|
136
|
+
)
|
|
137
|
+
state.isFailed = true
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
logStep('Results')
|
|
141
|
+
for (const result of state.results) {
|
|
142
|
+
if (result.passed) logInfo(result.name)
|
|
143
|
+
else logWarn(result.name)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!keep && !state.isFailed) {
|
|
147
|
+
rmSync(state.dir, { recursive: true, force: true })
|
|
148
|
+
} else {
|
|
149
|
+
logInfo(`Kept ${state.dir}`)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { results: state.results, exitCode: state.isFailed ? 1 : 0 }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* `lint:fix` runs first and reformats the scaffold, so `check` reads the
|
|
157
|
+
* reformatted tree.
|
|
158
|
+
*/
|
|
159
|
+
async function runScripts(state: Run, scripts: Set<string>): Promise<void> {
|
|
160
|
+
await phase(state, 'lint:fix', () => bunRun('lint:fix', state.dir))
|
|
161
|
+
await phase(state, 'check', () => bunRun('check', state.dir))
|
|
162
|
+
|
|
163
|
+
if (scripts.has('test:e2e')) {
|
|
164
|
+
await phase(state, 'test:e2e', () => bunRun('test:e2e', state.dir))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!scripts.has('screenshot')) return
|
|
168
|
+
const isShot = await phase(state, 'screenshot', () =>
|
|
169
|
+
bunRun('screenshot', state.dir, SCREENSHOT_TIMEOUT_MS),
|
|
170
|
+
)
|
|
171
|
+
// A killed or failed screenshot run is already a failure, and its partial
|
|
172
|
+
// output says nothing about the artifacts.
|
|
173
|
+
if (!isShot) return
|
|
174
|
+
|
|
175
|
+
const shots = countScreenshots(join(state.dir, 'screenshots'))
|
|
176
|
+
if (shots === 0) logWarn('screenshot produced no png files')
|
|
177
|
+
record(state, 'screenshot-artifacts', shots > 0)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
type Spawned = Awaited<ReturnType<typeof execa>>
|
|
181
|
+
|
|
182
|
+
async function phase(
|
|
183
|
+
state: Run,
|
|
184
|
+
name: string,
|
|
185
|
+
spawn: () => Promise<Spawned>,
|
|
186
|
+
): Promise<boolean> {
|
|
187
|
+
logStep(name)
|
|
188
|
+
const result = await spawn()
|
|
189
|
+
if (!result.failed) {
|
|
190
|
+
logInfo(`${name} passed`)
|
|
191
|
+
record(state, name, true)
|
|
192
|
+
return true
|
|
193
|
+
}
|
|
194
|
+
logWarn(result.timedOut ? `${name} timed out` : `${name} failed`)
|
|
195
|
+
writeTail(result.all)
|
|
196
|
+
record(state, name, false)
|
|
197
|
+
return false
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function record(state: Run, name: string, passed: boolean): void {
|
|
201
|
+
state.results.push({ name, passed })
|
|
202
|
+
if (!passed) state.isFailed = true
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function captured(cwd: string): Options {
|
|
206
|
+
return { cwd, all: true, reject: false, stdin: 'ignore' }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function shell(command: string, cwd: string): Promise<Spawned> {
|
|
210
|
+
return execa(command, {
|
|
211
|
+
...captured(cwd),
|
|
212
|
+
shell: 'bash',
|
|
213
|
+
env: { CI: '1' },
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function bunRun(
|
|
218
|
+
script: string,
|
|
219
|
+
cwd: string,
|
|
220
|
+
timeout?: number,
|
|
221
|
+
): Promise<Spawned> {
|
|
222
|
+
return execa('bun', ['run', script], {
|
|
223
|
+
...captured(cwd),
|
|
224
|
+
env: { CI: '1' },
|
|
225
|
+
...(timeout !== undefined ? { timeout } : {}),
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function writeTail(output: unknown): void {
|
|
230
|
+
if (typeof output !== 'string' || output === '') return
|
|
231
|
+
const lines = output.replace(/\n$/, '').split('\n').slice(-TAIL_LINES)
|
|
232
|
+
process.stderr.write(`${lines.join('\n')}\n`)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function readScriptKeys(packagePath: string): Set<string> {
|
|
236
|
+
try {
|
|
237
|
+
const parsed = JSON.parse(readFileSync(packagePath, 'utf8')) as {
|
|
238
|
+
scripts?: Record<string, unknown>
|
|
239
|
+
}
|
|
240
|
+
return new Set(Object.keys(parsed.scripts ?? {}))
|
|
241
|
+
} catch {
|
|
242
|
+
return new Set()
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function countScreenshots(dir: string): number {
|
|
247
|
+
if (!existsSync(dir)) return 0
|
|
248
|
+
return [...new Bun.Glob('**/*.png').scanSync({ cwd: dir, dot: true })].length
|
|
249
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Consumed only by `canon claude init`. Claude is not a discoverable tooling stack
|
|
2
|
-
# and is excluded from `canon tooling` discovery via
|
|
2
|
+
# and is excluded from `canon tooling` discovery via `EXCLUDED_STACKS` in src/tooling/manifest.ts.
|
|
3
3
|
|
|
4
4
|
[stack]
|
|
5
5
|
name = "claude"
|
|
@@ -8,8 +8,8 @@ runtime = ""
|
|
|
8
8
|
scaffold = ""
|
|
9
9
|
|
|
10
10
|
# Checked against this repository's own `.gitignore` by
|
|
11
|
-
#
|
|
12
|
-
# now that both sides ship the same two entries.
|
|
11
|
+
# the Ignore parity stage of `bun run check`, which carries no sanctioned
|
|
12
|
+
# divergence now that both sides ship the same two entries.
|
|
13
13
|
#
|
|
14
14
|
# One root entry rather than a folder apiece, so a target that gains a record
|
|
15
15
|
# folder later receives no manifest edit and no sync. `.claude/worktrees/` is
|
package/tooling/go/manifest.toml
CHANGED
|
@@ -11,3 +11,6 @@ scaffold = "mkdir {{name}} && cd {{name}} && go mod init example.com/{{name}} &&
|
|
|
11
11
|
"test" = "go test ./..."
|
|
12
12
|
"test:run" = "go test -v ./..."
|
|
13
13
|
"build" = "go build ./..."
|
|
14
|
+
|
|
15
|
+
[verify]
|
|
16
|
+
prepare = "bun init -y && (command -v golangci-lint >/dev/null || go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest)"
|
package/tooling/go/reference.md
CHANGED
|
@@ -32,6 +32,7 @@ The go stack also ships `smoke_test.go` at the module root as a copy-once seed.
|
|
|
32
32
|
- Probe `go` and `golangci-lint` first, and name the `go install` command when `golangci-lint` is missing.
|
|
33
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.
|
|
34
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`.
|
|
35
|
+
- The manifest's `[verify] prepare` runs checklist steps 3 and 6 for `canon tooling verify go`: `bun init -y`, then the `golangci-lint` `go install` when the binary is not already on PATH. The scaffold writes no `package.json`, and a verify that reaches its end with none fails.
|
|
35
36
|
|
|
36
37
|
## CI
|
|
37
38
|
|
|
@@ -13,3 +13,6 @@ scaffold = "mkdir {{name}} && cd {{name}} && composer init -n --name example/{{n
|
|
|
13
13
|
|
|
14
14
|
[gitignore]
|
|
15
15
|
"# PHP" = ["vendor/", ".php-cs-fixer.cache", ".phpunit.cache/", ".phpunit.result.cache"]
|
|
16
|
+
|
|
17
|
+
[verify]
|
|
18
|
+
prepare = "bun init -y && composer require --dev friendsofphp/php-cs-fixer phpstan/phpstan phpunit/phpunit"
|
package/tooling/php/reference.md
CHANGED
|
@@ -44,6 +44,7 @@ The php stack also ships `tests/SmokeTest.php` as a copy-once seed, so `bun run
|
|
|
44
44
|
- Probe `php` and `composer` first.
|
|
45
45
|
- Hold a precondition on `vendor/bin/phpunit`, so the run stops and names `composer install` when it is missing.
|
|
46
46
|
- Require Typecheck (`phpstan analyse`), Lint (`php-cs-fixer check`), and Unit tests (`phpunit --testdox`). Base's runner adds the four base phases wherever the folder declares their scripts, then the markdown bans stage.
|
|
47
|
+
- The manifest's `[verify] prepare` runs checklist steps 2 and 5 for `canon tooling verify php`: `bun init -y`, then the `composer require --dev` line. The scaffold writes no `package.json`, and a verify that reaches its end with none fails.
|
|
47
48
|
|
|
48
49
|
## Gitignore
|
|
49
50
|
|
|
@@ -39,7 +39,7 @@ The manifest declares no `[dependencies.dev]`. Manifest injection currently call
|
|
|
39
39
|
|
|
40
40
|
## Verify command
|
|
41
41
|
|
|
42
|
-
`canon tooling verify
|
|
42
|
+
`canon tooling verify python` runs the manifest's `[verify] prepare` before Sync, which is checklist steps 2, 5, and 6: `bun init -y`, `uv add --dev ruff mypy pytest pytest-cov`, and `uv sync`. `uv init` writes no `package.json`, and a verify that reaches its end with none fails, so the prepare is what lets `bun run lint:fix` and `bun run check` run at all. Python's `verify.json` requires the `lint`, `typecheck`, and `test:run` scripts that delegate to `uv run`, so the run needs `uv` on the machine and a network for the installs. The end-to-end test and screenshot phases are gated on `package.json` script keys that python does not declare, so they skip.
|
|
43
43
|
|
|
44
44
|
## Anti-patterns
|
|
45
45
|
|
|
@@ -1,134 +0,0 @@
|
|
|
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
|
-
"$PROJECT_ROOT"/tooling/*/seeds/.github/workflows/*
|
|
65
|
-
|
|
66
|
-
check_capability "Husky" "$PROJECT_ROOT/.husky" \
|
|
67
|
-
"$PROJECT_ROOT"/tooling/base/configs/.husky/*
|
|
68
|
-
|
|
69
|
-
# The reverse direction: a destination file whose source here is gone reaches
|
|
70
|
-
# neither check_capability above nor the wiring pass below, both of which read
|
|
71
|
-
# forward from the source, and it ships a target a hook, workflow, or husky
|
|
72
|
-
# script this repository has already deleted. A destination carrying the same
|
|
73
|
-
# marker clears just as a source does, which is the escape hatch a target-only
|
|
74
|
-
# capability needs, such as a stack-specific workflow with no root counterpart
|
|
75
|
-
# by design.
|
|
76
|
-
check_orphans() {
|
|
77
|
-
local label="$1" src_dir="$2" dest_dir="$3"
|
|
78
|
-
|
|
79
|
-
[ -d "$dest_dir" ] || return 0
|
|
80
|
-
|
|
81
|
-
local dest_file name
|
|
82
|
-
for dest_file in "$dest_dir"/*; do
|
|
83
|
-
[ -f "$dest_file" ] || continue
|
|
84
|
-
name=$(basename "$dest_file")
|
|
85
|
-
[ -f "$src_dir/$name" ] && continue
|
|
86
|
-
has_reason "$dest_file" && continue
|
|
87
|
-
failures="$failures $label: ${dest_file#"$PROJECT_ROOT/"} is seeded or configured with no source at ${src_dir#"$PROJECT_ROOT/"}/$name"$'\n'
|
|
88
|
-
done
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
check_orphans "Hooks" "$PROJECT_ROOT/.claude/hooks" \
|
|
92
|
-
"$PROJECT_ROOT/tooling/claude/seeds/.claude/hooks"
|
|
93
|
-
|
|
94
|
-
for dir in "$PROJECT_ROOT"/tooling/*/configs/.github/workflows \
|
|
95
|
-
"$PROJECT_ROOT"/tooling/*/seeds/.github/workflows; do
|
|
96
|
-
check_orphans "Workflows" "$PROJECT_ROOT/.github/workflows" "$dir"
|
|
97
|
-
done
|
|
98
|
-
|
|
99
|
-
check_orphans "Husky" "$PROJECT_ROOT/.husky" \
|
|
100
|
-
"$PROJECT_ROOT/tooling/base/configs/.husky"
|
|
101
|
-
|
|
102
|
-
# A hook that reached the seed tree with no wiring in the seeded settings.json
|
|
103
|
-
# is installed dead, which fails the same way an unseeded hook does.
|
|
104
|
-
SEED_HOOKS_DIR="$PROJECT_ROOT/tooling/claude/seeds/.claude/hooks"
|
|
105
|
-
SEED_SETTINGS="$PROJECT_ROOT/tooling/claude/seeds/.claude/settings.json"
|
|
106
|
-
|
|
107
|
-
if [ -d "$SEED_HOOKS_DIR" ]; then
|
|
108
|
-
if [ ! -f "$SEED_SETTINGS" ]; then
|
|
109
|
-
failures="$failures Seed settings: no settings.json at ${SEED_SETTINGS#"$PROJECT_ROOT/"} to confirm a seeded hook is wired"$'\n'
|
|
110
|
-
elif ! command -v jq >/dev/null 2>&1; then
|
|
111
|
-
failures="$failures Seed settings: jq is not installed, seeded-hook wiring unverifiable"$'\n'
|
|
112
|
-
else
|
|
113
|
-
# Every wired command as its own path segment set, rather than a substring
|
|
114
|
-
# search over the raw file, so a hook name that is a substring of another
|
|
115
|
-
# hook's filename (`log.sh` inside `pr-create-log.sh`) cannot pass on a
|
|
116
|
-
# neighbor's wiring.
|
|
117
|
-
wired=" $(jq -r '.. | .command? // empty' "$SEED_SETTINGS" | tr '/\n' ' ') "
|
|
118
|
-
for hook_file in "$SEED_HOOKS_DIR"/*.sh; do
|
|
119
|
-
[ -f "$hook_file" ] || continue
|
|
120
|
-
name=$(basename "$hook_file")
|
|
121
|
-
case "$wired" in
|
|
122
|
-
*" $name "*) continue ;;
|
|
123
|
-
esac
|
|
124
|
-
failures="$failures Seed settings: $name is seeded and wired into no command in ${SEED_SETTINGS#"$PROJECT_ROOT/"}"$'\n'
|
|
125
|
-
done
|
|
126
|
-
fi
|
|
127
|
-
fi
|
|
128
|
-
|
|
129
|
-
if [ -n "$failures" ]; then
|
|
130
|
-
echo "A capability reaches one side of the seed or config boundary and not the other:" >&2
|
|
131
|
-
printf '%s' "$failures" >&2
|
|
132
|
-
echo "Seed or configure the capability, or mark the source line with # $MARKER <reason>." >&2
|
|
133
|
-
exit 1
|
|
134
|
-
fi
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
set -e
|
|
3
|
-
set -o pipefail
|
|
4
|
-
|
|
5
|
-
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
6
|
-
PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
|
|
7
|
-
|
|
8
|
-
GITIGNORE="$PROJECT_ROOT/.gitignore"
|
|
9
|
-
MANIFEST="$PROJECT_ROOT/tooling/claude/manifest.toml"
|
|
10
|
-
SECTION="# Claude"
|
|
11
|
-
|
|
12
|
-
# Parity is exact, with no exception list behind it, and the move to `.canon/`
|
|
13
|
-
# is what removed the one there was. Its two members each named a path inside
|
|
14
|
-
# `.claude/` that this repository ignored and the manifest withheld, so a target
|
|
15
|
-
# kept tracking it. A single root entry can withhold nothing inside itself: git
|
|
16
|
-
# does not descend into an excluded directory, so a re-inclusion like
|
|
17
|
-
# `!.canon/diagrams/` matches nothing. Retiring both is what buys the one line.
|
|
18
|
-
#
|
|
19
|
-
# The diagrams divergence is the one that cost something. A target used to track
|
|
20
|
-
# its diagrams and keep the context audit's default coverage of them, and it now
|
|
21
|
-
# ignores them with the rest of its records, so that coverage is gone unless the
|
|
22
|
-
# project names the folder itself. `canon/context/tooling/manifests.md` carries the
|
|
23
|
-
# narrative.
|
|
24
|
-
#
|
|
25
|
-
# An exception mechanism is not kept against a future divergence, because an
|
|
26
|
-
# empty one cannot be exercised and a check nobody can test is a check nobody
|
|
27
|
-
# should trust. It comes back with its first real member or not at all.
|
|
28
|
-
|
|
29
|
-
if [ ! -f "$GITIGNORE" ]; then
|
|
30
|
-
echo "No .gitignore at ${GITIGNORE#"$PROJECT_ROOT/"}, ignore parity unverifiable." >&2
|
|
31
|
-
exit 1
|
|
32
|
-
fi
|
|
33
|
-
|
|
34
|
-
if [ ! -f "$MANIFEST" ]; then
|
|
35
|
-
echo "No claude manifest at ${MANIFEST#"$PROJECT_ROOT/"}, ignore parity unverifiable." >&2
|
|
36
|
-
exit 1
|
|
37
|
-
fi
|
|
38
|
-
|
|
39
|
-
# A gitignore pattern and a manifest entry describe the same folder whether or
|
|
40
|
-
# not either spells the trailing slash, so presence is compared with it dropped.
|
|
41
|
-
# No live pair differs that way now that both lists carry the same two entries,
|
|
42
|
-
# and the normalization stays because either side may be written without one.
|
|
43
|
-
normalize() {
|
|
44
|
-
printf '%s\n' "${1%/}"
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
# Every pattern the file carries, comments and blank lines dropped. The whole
|
|
48
|
-
# file rather than one header, since a claude-scoped entry filed under a header
|
|
49
|
-
# of its own would otherwise read as missing from a list that carries it.
|
|
50
|
-
gitignore_patterns() {
|
|
51
|
-
awk '{ sub(/[[:space:]]+$/, "") } $0 ~ /^[[:space:]]*#/ || $0 == "" { next } { print }' "$GITIGNORE"
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
# Every entry of the named array in the manifest's `[gitignore]` table. The
|
|
55
|
-
# array is accumulated to its closing bracket before the quoted strings are read
|
|
56
|
-
# out, so a formatter wrapping it across lines does not silently empty this.
|
|
57
|
-
manifest_entries() {
|
|
58
|
-
awk -v key="\"$SECTION\"" '
|
|
59
|
-
/^\[/ { table = $0 }
|
|
60
|
-
table != "[gitignore]" { next }
|
|
61
|
-
index($0, key) == 1 { collecting = 1; buf = "" }
|
|
62
|
-
collecting { buf = buf $0 }
|
|
63
|
-
collecting && index($0, "]") > 0 {
|
|
64
|
-
collecting = 0
|
|
65
|
-
buf = substr(buf, index(buf, "["))
|
|
66
|
-
count = split(buf, parts, "\"")
|
|
67
|
-
for (i = 2; i <= count; i += 2) print parts[i]
|
|
68
|
-
}
|
|
69
|
-
' "$MANIFEST"
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
contains() {
|
|
73
|
-
local needle=$1
|
|
74
|
-
shift
|
|
75
|
-
local candidate
|
|
76
|
-
for candidate in "$@"; do
|
|
77
|
-
[ "$candidate" = "$needle" ] || continue
|
|
78
|
-
return 0
|
|
79
|
-
done
|
|
80
|
-
return 1
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
ignored=()
|
|
84
|
-
while IFS= read -r pattern; do
|
|
85
|
-
[ -n "$pattern" ] || continue
|
|
86
|
-
ignored+=("$(normalize "$pattern")")
|
|
87
|
-
done < <(gitignore_patterns)
|
|
88
|
-
|
|
89
|
-
shipped=()
|
|
90
|
-
while IFS= read -r entry; do
|
|
91
|
-
[ -n "$entry" ] || continue
|
|
92
|
-
shipped+=("$(normalize "$entry")")
|
|
93
|
-
done < <(manifest_entries)
|
|
94
|
-
|
|
95
|
-
# An empty read on either side is a parse that failed rather than a repository
|
|
96
|
-
# ignoring nothing, and reporting parity off it would say the two lists agree
|
|
97
|
-
# having compared none of their entries.
|
|
98
|
-
if [ ${#ignored[@]} -eq 0 ]; then
|
|
99
|
-
echo "No patterns read from .gitignore, ignore parity unverifiable." >&2
|
|
100
|
-
exit 1
|
|
101
|
-
fi
|
|
102
|
-
|
|
103
|
-
if [ ${#shipped[@]} -eq 0 ]; then
|
|
104
|
-
echo "No entries read from the \"$SECTION\" array in ${MANIFEST#"$PROJECT_ROOT/"}, ignore parity unverifiable." >&2
|
|
105
|
-
exit 1
|
|
106
|
-
fi
|
|
107
|
-
|
|
108
|
-
failures=""
|
|
109
|
-
|
|
110
|
-
# Entries the manifest ships that this repository does not ignore. A target is
|
|
111
|
-
# told to ignore a folder the toolkit itself tracks, which no decision sanctions,
|
|
112
|
-
# so this direction carries no exception list.
|
|
113
|
-
for entry in "${shipped[@]}"; do
|
|
114
|
-
contains "$entry" "${ignored[@]}" && continue
|
|
115
|
-
failures="$failures $entry is shipped by the manifest and absent from .gitignore"$'\n'
|
|
116
|
-
done
|
|
117
|
-
|
|
118
|
-
# Claude-scoped patterns this repository ignores that the manifest does not
|
|
119
|
-
# ship. Scoped to the two record roots because the manifest is the claude stack
|
|
120
|
-
# and says nothing about `node_modules/` or `.env`.
|
|
121
|
-
#
|
|
122
|
-
# `.canon` is read as a bare root as well as a prefix, since it is one line
|
|
123
|
-
# covering a whole tree where `.claude/` names a folder inside a root that also
|
|
124
|
-
# holds tracked content. Leaving it out is what would let a new entry sit outside
|
|
125
|
-
# the only stage comparing the two lists, which is the direction that goes
|
|
126
|
-
# silently blind.
|
|
127
|
-
for pattern in "${ignored[@]}"; do
|
|
128
|
-
case "$pattern" in
|
|
129
|
-
.claude/* | .canon | .canon/*) ;;
|
|
130
|
-
*) continue ;;
|
|
131
|
-
esac
|
|
132
|
-
contains "$pattern" "${shipped[@]}" && continue
|
|
133
|
-
failures="$failures $pattern is ignored here and absent from the manifest"$'\n'
|
|
134
|
-
done
|
|
135
|
-
|
|
136
|
-
if [ -n "$failures" ]; then
|
|
137
|
-
echo "The ignore set a target receives disagrees with this repository's own:" >&2
|
|
138
|
-
printf '%s' "$failures" >&2
|
|
139
|
-
echo "Add the entry to the \"$SECTION\" array in ${MANIFEST#"$PROJECT_ROOT/"} and to .gitignore. The two lists are compared exactly, and nothing here records an exception." >&2
|
|
140
|
-
exit 1
|
|
141
|
-
fi
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
set -e
|
|
3
|
-
set -o pipefail
|
|
4
|
-
|
|
5
|
-
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
6
|
-
PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
|
|
7
|
-
|
|
8
|
-
INTERNAL_ROOT="$PROJECT_ROOT/internal"
|
|
9
|
-
PLUGIN_ROOT="$PROJECT_ROOT/claude"
|
|
10
|
-
|
|
11
|
-
# `find` writes to stderr and returns non-zero for a missing root, inside a
|
|
12
|
-
# process substitution whose status nothing reads. Without this the walk covers
|
|
13
|
-
# nothing and the check reports the boundary clean having never measured it.
|
|
14
|
-
if [ ! -d "$PLUGIN_ROOT" ]; then
|
|
15
|
-
echo "No plugin root at ${PLUGIN_ROOT#"$PROJECT_ROOT/"}, boundary unverifiable." >&2
|
|
16
|
-
exit 1
|
|
17
|
-
fi
|
|
18
|
-
|
|
19
|
-
# `realpath` resolves each walked path below. Absent, `set -e` aborts on the
|
|
20
|
-
# substitution and the stage reports a leak for a tool that is not installed,
|
|
21
|
-
# which is the same unmeasured-tree-with-a-verdict shape the guard above closes.
|
|
22
|
-
if ! command -v realpath >/dev/null 2>&1; then
|
|
23
|
-
echo "realpath is not installed, boundary unverifiable." >&2
|
|
24
|
-
exit 1
|
|
25
|
-
fi
|
|
26
|
-
|
|
27
|
-
# The plugin reaches `standards/` through a symlink, which an
|
|
28
|
-
# installer dereferences with no code in the path to filter. Walking the plugin
|
|
29
|
-
# tree with symlinks followed is what an install actually copies, so resolving
|
|
30
|
-
# each file and rejecting anything under `internal/` asserts the boundary at the
|
|
31
|
-
# only place it can be measured.
|
|
32
|
-
leaked=""
|
|
33
|
-
while IFS= read -r file; do
|
|
34
|
-
resolved=$(realpath "$file")
|
|
35
|
-
case "$resolved" in
|
|
36
|
-
"$INTERNAL_ROOT"/*) leaked="$leaked ${file#"$PROJECT_ROOT/"} -> ${resolved#"$PROJECT_ROOT/"}"$'\n' ;;
|
|
37
|
-
esac
|
|
38
|
-
done < <(find -L "$PLUGIN_ROOT" -type f)
|
|
39
|
-
|
|
40
|
-
if [ -n "$leaked" ]; then
|
|
41
|
-
echo "Plugin ships toolkit-internal content:" >&2
|
|
42
|
-
printf '%s' "$leaked" >&2
|
|
43
|
-
echo "Author internal content under internal/, which nothing under claude/ reaches." >&2
|
|
44
|
-
exit 1
|
|
45
|
-
fi
|