@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
package/src/commands/tooling.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { resolve } from 'node:path'
|
|
2
2
|
import type { Command } from 'commander'
|
|
3
|
-
import { execScript } from '@/exec'
|
|
4
3
|
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
|
|
5
4
|
import {
|
|
6
5
|
injectConfigs,
|
|
@@ -21,6 +20,7 @@ import { readReference, resolveReference } from '@/tooling/read'
|
|
|
21
20
|
import { scan, type ScanResult } from '@/tooling/scan'
|
|
22
21
|
import { recordedStacks, recordToolingChain } from '@/tooling/stamp'
|
|
23
22
|
import { subfolderPath } from '@/tooling/subfolder'
|
|
23
|
+
import { verifyStack } from '@/tooling/verify-stack'
|
|
24
24
|
import {
|
|
25
25
|
intro,
|
|
26
26
|
isNonInteractive,
|
|
@@ -34,8 +34,6 @@ import {
|
|
|
34
34
|
select,
|
|
35
35
|
} from '@/ui'
|
|
36
36
|
|
|
37
|
-
const PASS_THROUGH_VERBS = ['verify'] as const
|
|
38
|
-
|
|
39
37
|
interface SyncOptions {
|
|
40
38
|
readonly skip?: string
|
|
41
39
|
readonly check?: boolean
|
|
@@ -60,6 +58,10 @@ interface PruneOptions {
|
|
|
60
58
|
readonly nested?: boolean
|
|
61
59
|
}
|
|
62
60
|
|
|
61
|
+
interface VerifyOptions {
|
|
62
|
+
readonly keep?: boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
63
65
|
interface ListOptions {
|
|
64
66
|
readonly json?: boolean
|
|
65
67
|
}
|
|
@@ -207,18 +209,39 @@ export function register(program: Command): void {
|
|
|
207
209
|
process.exitCode = printReference(stack)
|
|
208
210
|
})
|
|
209
211
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
212
|
+
tooling
|
|
213
|
+
.command('verify')
|
|
214
|
+
.description('Scaffold a stack fresh, sync it, and run its checks')
|
|
215
|
+
.argument('<stack>', 'Tooling stack name (e.g. vite-react, astro)')
|
|
216
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
217
|
+
.option('--keep', 'Keep the tmp dir after a clean run for inspection')
|
|
218
|
+
.addHelpText(
|
|
219
|
+
'after',
|
|
220
|
+
[
|
|
221
|
+
'',
|
|
222
|
+
'Scaffolds <stack> into .canon/tmp/runs/verify-<stack>/, runs the',
|
|
223
|
+
"manifest's [verify] prepare, syncs the chain from this checkout, then",
|
|
224
|
+
'runs lint:fix, check, test:e2e, and screenshot where the scaffold',
|
|
225
|
+
'declares them. A scaffold with no package.json after Sync fails.',
|
|
226
|
+
'',
|
|
227
|
+
'Exit codes:',
|
|
228
|
+
' 0 every phase passed',
|
|
229
|
+
' 1 a phase failed, or the stack cannot be verified',
|
|
230
|
+
'',
|
|
231
|
+
'Examples:',
|
|
232
|
+
' canon tooling verify vite-react',
|
|
233
|
+
' canon tooling verify astro --keep',
|
|
234
|
+
'',
|
|
235
|
+
].join('\n'),
|
|
236
|
+
)
|
|
237
|
+
.action(async (stack: string, opts: VerifyOptions) => {
|
|
238
|
+
const outcome = await verifyStack({
|
|
239
|
+
root: PROJECT_ROOT,
|
|
240
|
+
stack,
|
|
241
|
+
keep: opts.keep === true,
|
|
220
242
|
})
|
|
221
|
-
|
|
243
|
+
process.exitCode = outcome.exitCode
|
|
244
|
+
})
|
|
222
245
|
}
|
|
223
246
|
|
|
224
247
|
/**
|
|
@@ -227,8 +250,8 @@ export function register(program: Command): void {
|
|
|
227
250
|
* quote emitted output a consuming skill could not parse.
|
|
228
251
|
*
|
|
229
252
|
* The frame opens after the `--json` return. The bash emitted a closing `└`
|
|
230
|
-
* from its EXIT trap with no `┌` above it, because the
|
|
231
|
-
*
|
|
253
|
+
* from its EXIT trap with no `┌` above it, because the pass-through loop that
|
|
254
|
+
* ran it opened no frame first.
|
|
232
255
|
*/
|
|
233
256
|
function runList(opts: ListOptions): number {
|
|
234
257
|
// The warning is a frame-interior line, so it goes out after `intro` on the
|
|
@@ -122,8 +122,11 @@ const QUANTIFIES_PATH = new RegExp(
|
|
|
122
122
|
'i',
|
|
123
123
|
)
|
|
124
124
|
|
|
125
|
-
/**
|
|
126
|
-
|
|
125
|
+
/**
|
|
126
|
+
* A code span naming a check this repository could run: a shell script, or a
|
|
127
|
+
* gate module whose measures read the tree in-process.
|
|
128
|
+
*/
|
|
129
|
+
const SCRIPT_SPAN = /^(?:scripts\/[\w./-]+\.sh|src\/gate\/[\w./-]+\.ts)$/
|
|
127
130
|
/** A code span invoking the CLI, which may or may not name a registered audit. */
|
|
128
131
|
const CANON_SPAN = /^canon\s+(.+)$/
|
|
129
132
|
|
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
|
2
|
+
import { join, relative, sep } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type { Measure, MeasureReport } from '@/gate/measures'
|
|
5
|
+
import { seedRoots } from '@/tooling/seeds'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The checks guarding what crosses from this repository into a target: the
|
|
9
|
+
* ignore set a manifest ships, the capabilities a seed carries, the plugin's
|
|
10
|
+
* reach, the prose a seed installs, and the paths a shipped skill cites.
|
|
11
|
+
*
|
|
12
|
+
* Each reads the tree in-process and reports `unmeasured` wherever its walk
|
|
13
|
+
* covered nothing, since a verdict over an empty walk reports the pass the
|
|
14
|
+
* check exists to withhold.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const GITIGNORE = '.gitignore'
|
|
18
|
+
const CLAUDE_MANIFEST = 'tooling/claude/manifest.toml'
|
|
19
|
+
const IGNORE_SECTION = '# Claude'
|
|
20
|
+
const NO_SEED_MARKER = 'canon-no-seed:'
|
|
21
|
+
const TOOLKIT_TOKEN = 'canon'
|
|
22
|
+
const BANNED_SKILL_PATH = /(^|[^A-Za-z0-9._/-])wiki\//
|
|
23
|
+
|
|
24
|
+
const posixRel = (root: string, path: string): string =>
|
|
25
|
+
relative(root, path).split(sep).join('/')
|
|
26
|
+
|
|
27
|
+
const pass = (): MeasureReport => ({ emissions: [] })
|
|
28
|
+
|
|
29
|
+
const unmeasured = (text: string): MeasureReport => ({
|
|
30
|
+
emissions: [],
|
|
31
|
+
unmeasured: text,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The stage's one-line `failure` frames as a single line, so the header, the
|
|
36
|
+
* offender lines, and the remediation sentence travel as borrowed output, the
|
|
37
|
+
* way the script's own stderr did.
|
|
38
|
+
*/
|
|
39
|
+
const failureOf = (
|
|
40
|
+
failure: string,
|
|
41
|
+
header: string,
|
|
42
|
+
lines: readonly string[],
|
|
43
|
+
remediation: string,
|
|
44
|
+
): MeasureReport => ({
|
|
45
|
+
emissions: [
|
|
46
|
+
{ kind: 'output', text: [header, ...lines, remediation].join('\n') },
|
|
47
|
+
],
|
|
48
|
+
failure,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
function isDirectory(path: string): boolean {
|
|
52
|
+
try {
|
|
53
|
+
return statSync(path).isDirectory()
|
|
54
|
+
} catch {
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isFile(path: string): boolean {
|
|
60
|
+
try {
|
|
61
|
+
return statSync(path).isFile()
|
|
62
|
+
} catch {
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Plain files directly inside a folder, the way a shell `*` lists them: no
|
|
69
|
+
* dotfile and no directory, so `.husky/_` is never read as a capability.
|
|
70
|
+
*/
|
|
71
|
+
function listFiles(dir: string): string[] {
|
|
72
|
+
if (!isDirectory(dir)) return []
|
|
73
|
+
return readdirSync(dir)
|
|
74
|
+
.filter((name) => !name.startsWith('.'))
|
|
75
|
+
.sort()
|
|
76
|
+
.map((name) => join(dir, name))
|
|
77
|
+
.filter(isFile)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function stackDirs(root: string): string[] {
|
|
81
|
+
const tooling = join(root, 'tooling')
|
|
82
|
+
if (!isDirectory(tooling)) return []
|
|
83
|
+
return readdirSync(tooling)
|
|
84
|
+
.filter((name) => !name.startsWith('.'))
|
|
85
|
+
.sort()
|
|
86
|
+
.map((name) => join(tooling, name))
|
|
87
|
+
.filter(isDirectory)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const basename = (path: string): string => path.slice(path.lastIndexOf('/') + 1)
|
|
91
|
+
|
|
92
|
+
const hasReason = (path: string): boolean =>
|
|
93
|
+
readFileSync(path, 'utf8').includes(NO_SEED_MARKER)
|
|
94
|
+
|
|
95
|
+
const dropTrailingSlash = (value: string): string =>
|
|
96
|
+
value.endsWith('/') ? value.slice(0, -1) : value
|
|
97
|
+
|
|
98
|
+
function gitignorePatterns(text: string): string[] {
|
|
99
|
+
return text
|
|
100
|
+
.split('\n')
|
|
101
|
+
.map((line) => line.replace(/\s+$/, ''))
|
|
102
|
+
.filter((line) => line !== '' && !/^\s*#/.test(line))
|
|
103
|
+
.map(dropTrailingSlash)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function manifestEntries(text: string): string[] | undefined {
|
|
107
|
+
let parsed: unknown
|
|
108
|
+
try {
|
|
109
|
+
parsed = Bun.TOML.parse(text)
|
|
110
|
+
} catch {
|
|
111
|
+
return undefined
|
|
112
|
+
}
|
|
113
|
+
const table = (parsed as { gitignore?: Record<string, unknown> }).gitignore
|
|
114
|
+
const entries = table?.[IGNORE_SECTION]
|
|
115
|
+
if (!Array.isArray(entries)) return []
|
|
116
|
+
return entries
|
|
117
|
+
.filter((entry): entry is string => typeof entry === 'string')
|
|
118
|
+
.map(dropTrailingSlash)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The claude manifest is the only route a target's ignore set travels, and it
|
|
123
|
+
* is hand-maintained beside this repository's own `.gitignore` with nothing
|
|
124
|
+
* comparing the two. Parity is exact, with no exception list behind it: an
|
|
125
|
+
* empty one cannot be exercised, and a check nobody can test is a check nobody
|
|
126
|
+
* should trust.
|
|
127
|
+
*
|
|
128
|
+
* Both lists are compared with the trailing slash dropped. The `.gitignore` side
|
|
129
|
+
* reads the whole file rather than one header, since a claude-scoped entry filed
|
|
130
|
+
* under a header of its own would otherwise read as missing from a list that
|
|
131
|
+
* carries it. It is scoped to `.claude/*` and `.canon` as a bare root as well as
|
|
132
|
+
* a prefix, because the manifest says nothing about `node_modules/` or `.env`
|
|
133
|
+
* and `.canon` is one line covering a whole tree.
|
|
134
|
+
*/
|
|
135
|
+
export const ignoreParity: Measure = async (ctx) => {
|
|
136
|
+
const gitignorePath = join(ctx.root, GITIGNORE)
|
|
137
|
+
if (!isFile(gitignorePath)) {
|
|
138
|
+
return unmeasured(
|
|
139
|
+
`No .gitignore at ${GITIGNORE}, ignore parity unverifiable.`,
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const manifestPath = join(ctx.root, CLAUDE_MANIFEST)
|
|
144
|
+
if (!isFile(manifestPath)) {
|
|
145
|
+
return unmeasured(
|
|
146
|
+
`No claude manifest at ${CLAUDE_MANIFEST}, ignore parity unverifiable.`,
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const ignored = gitignorePatterns(readFileSync(gitignorePath, 'utf8'))
|
|
151
|
+
const shipped = manifestEntries(readFileSync(manifestPath, 'utf8'))
|
|
152
|
+
|
|
153
|
+
// An empty read on either side is a parse that failed rather than a
|
|
154
|
+
// repository ignoring nothing, and reporting parity off it would say the two
|
|
155
|
+
// lists agree having compared none of their entries.
|
|
156
|
+
if (ignored.length === 0) {
|
|
157
|
+
return unmeasured(
|
|
158
|
+
'No patterns read from .gitignore, ignore parity unverifiable.',
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
if (shipped === undefined || shipped.length === 0) {
|
|
162
|
+
return unmeasured(
|
|
163
|
+
`No entries read from the "${IGNORE_SECTION}" array in ${CLAUDE_MANIFEST}, ignore parity unverifiable.`,
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const failures = [
|
|
168
|
+
...shipped
|
|
169
|
+
.filter((entry) => !ignored.includes(entry))
|
|
170
|
+
.map(
|
|
171
|
+
(entry) =>
|
|
172
|
+
` ${entry} is shipped by the manifest and absent from .gitignore`,
|
|
173
|
+
),
|
|
174
|
+
...ignored
|
|
175
|
+
.filter(
|
|
176
|
+
(pattern) =>
|
|
177
|
+
pattern === '.canon' ||
|
|
178
|
+
pattern.startsWith('.canon/') ||
|
|
179
|
+
pattern.startsWith('.claude/'),
|
|
180
|
+
)
|
|
181
|
+
.filter((pattern) => !shipped.includes(pattern))
|
|
182
|
+
.map(
|
|
183
|
+
(pattern) =>
|
|
184
|
+
` ${pattern} is ignored here and absent from the manifest`,
|
|
185
|
+
),
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
if (failures.length === 0) return pass()
|
|
189
|
+
|
|
190
|
+
return failureOf(
|
|
191
|
+
"The ignore set a target receives disagrees with this repository's own.",
|
|
192
|
+
"The ignore set a target receives disagrees with this repository's own:",
|
|
193
|
+
failures,
|
|
194
|
+
`Add the entry to the "${IGNORE_SECTION}" array in ${CLAUDE_MANIFEST} and to .gitignore. The two lists are compared exactly, and nothing here records an exception.`,
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Every `command` string at any depth, which is what `jq '.. | .command?'` read. */
|
|
199
|
+
function collectCommands(value: unknown, into: string[] = []): string[] {
|
|
200
|
+
if (Array.isArray(value)) {
|
|
201
|
+
for (const item of value) collectCommands(item, into)
|
|
202
|
+
} else if (value !== null && typeof value === 'object') {
|
|
203
|
+
for (const [key, child] of Object.entries(value)) {
|
|
204
|
+
if (key === 'command' && typeof child === 'string') into.push(child)
|
|
205
|
+
collectCommands(child, into)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return into
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* A hook, a workflow, or a husky script reaching one side of the seed or config
|
|
213
|
+
* boundary and not the other is a capability withheld with no recorded reason,
|
|
214
|
+
* per the criterion in `canon/context/tooling/seeds.md`.
|
|
215
|
+
*
|
|
216
|
+
* Presence on either side clears a name, and a `canon-no-seed:` comment in the
|
|
217
|
+
* file is the recorded reason. The same marker on a destination clears a
|
|
218
|
+
* target-only capability with no root counterpart by design. A seeded hook with
|
|
219
|
+
* no wiring in the seeded `settings.json` is installed dead, which fails the
|
|
220
|
+
* same way. Wiring compares whole path segments, so `log.sh` cannot pass on the
|
|
221
|
+
* wiring of `pr-create-log.sh`.
|
|
222
|
+
*/
|
|
223
|
+
export const capabilitySeeding: Measure = async (ctx) => {
|
|
224
|
+
const { root } = ctx
|
|
225
|
+
if (!isDirectory(join(root, 'tooling'))) {
|
|
226
|
+
return unmeasured(
|
|
227
|
+
'No tooling root at tooling, capability seeding unverifiable.',
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const stacks = stackDirs(root)
|
|
232
|
+
const failures: string[] = []
|
|
233
|
+
|
|
234
|
+
const checkCapability = (
|
|
235
|
+
label: string,
|
|
236
|
+
srcDir: string,
|
|
237
|
+
destFiles: readonly string[],
|
|
238
|
+
): void => {
|
|
239
|
+
const destNames = new Set(destFiles.map(basename))
|
|
240
|
+
for (const file of listFiles(join(root, srcDir))) {
|
|
241
|
+
if (destNames.has(basename(file)) || hasReason(file)) continue
|
|
242
|
+
failures.push(
|
|
243
|
+
` ${label}: ${posixRel(root, file)} reaches no seed or config and carries no ${NO_SEED_MARKER} reason`,
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const checkOrphans = (
|
|
249
|
+
label: string,
|
|
250
|
+
srcDir: string,
|
|
251
|
+
destDir: string,
|
|
252
|
+
): void => {
|
|
253
|
+
for (const file of listFiles(join(root, destDir))) {
|
|
254
|
+
if (isFile(join(root, srcDir, basename(file))) || hasReason(file)) {
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
failures.push(
|
|
258
|
+
` ${label}: ${posixRel(root, file)} is seeded or configured with no source at ${srcDir}/${basename(file)}`,
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const workflowDirs = [
|
|
264
|
+
...stacks.map((stack) => join(stack, 'configs/.github/workflows')),
|
|
265
|
+
...stacks.map((stack) => join(stack, 'seeds/.github/workflows')),
|
|
266
|
+
]
|
|
267
|
+
const seedHooksDir = 'tooling/claude/seeds/.claude/hooks'
|
|
268
|
+
|
|
269
|
+
checkCapability('Hooks', '.claude/hooks', listFiles(join(root, seedHooksDir)))
|
|
270
|
+
checkCapability(
|
|
271
|
+
'Workflows',
|
|
272
|
+
'.github/workflows',
|
|
273
|
+
workflowDirs.flatMap(listFiles),
|
|
274
|
+
)
|
|
275
|
+
checkCapability(
|
|
276
|
+
'Husky',
|
|
277
|
+
'.husky',
|
|
278
|
+
listFiles(join(root, 'tooling/base/configs/.husky')),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
// The reverse direction: a destination whose source here is gone reaches
|
|
282
|
+
// neither pass reading forward from the source, and it ships a target a
|
|
283
|
+
// capability this repository has already deleted.
|
|
284
|
+
checkOrphans('Hooks', '.claude/hooks', seedHooksDir)
|
|
285
|
+
for (const dir of workflowDirs) {
|
|
286
|
+
checkOrphans('Workflows', '.github/workflows', posixRel(root, dir))
|
|
287
|
+
}
|
|
288
|
+
checkOrphans('Husky', '.husky', 'tooling/base/configs/.husky')
|
|
289
|
+
|
|
290
|
+
const seedSettings = join(root, 'tooling/claude/seeds/.claude/settings.json')
|
|
291
|
+
const seedHooks = listFiles(join(root, seedHooksDir)).filter((file) =>
|
|
292
|
+
file.endsWith('.sh'),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
if (isDirectory(join(root, seedHooksDir))) {
|
|
296
|
+
if (!isFile(seedSettings)) {
|
|
297
|
+
failures.push(
|
|
298
|
+
` Seed settings: no settings.json at ${posixRel(root, seedSettings)} to confirm a seeded hook is wired`,
|
|
299
|
+
)
|
|
300
|
+
} else {
|
|
301
|
+
let settings: unknown
|
|
302
|
+
try {
|
|
303
|
+
settings = JSON.parse(readFileSync(seedSettings, 'utf8'))
|
|
304
|
+
} catch {
|
|
305
|
+
settings = undefined
|
|
306
|
+
failures.push(
|
|
307
|
+
` Seed settings: ${posixRel(root, seedSettings)} is not valid JSON, so seeded-hook wiring cannot be read`,
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
if (settings !== undefined) {
|
|
311
|
+
const wired = new Set(
|
|
312
|
+
collectCommands(settings).flatMap((command) =>
|
|
313
|
+
command.split(/[/\s]+/).filter((segment) => segment !== ''),
|
|
314
|
+
),
|
|
315
|
+
)
|
|
316
|
+
for (const file of seedHooks) {
|
|
317
|
+
if (wired.has(basename(file))) continue
|
|
318
|
+
failures.push(
|
|
319
|
+
` Seed settings: ${basename(file)} is seeded and wired into no command in ${posixRel(root, seedSettings)}`,
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (failures.length === 0) return pass()
|
|
327
|
+
|
|
328
|
+
return failureOf(
|
|
329
|
+
'A capability reaches one side of the seed or config boundary and not the other.',
|
|
330
|
+
'A capability reaches one side of the seed or config boundary and not the other:',
|
|
331
|
+
failures,
|
|
332
|
+
`Seed or configure the capability, or mark the source line with # ${NO_SEED_MARKER} <reason>.`,
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Every file under `claude/` resolved through its symlinks and rejected when it
|
|
338
|
+
* lands under `internal/`.
|
|
339
|
+
*
|
|
340
|
+
* The plugin reaches `standards/` through a symlink, which an installer
|
|
341
|
+
* dereferences with no code in the path to filter. Walking with symlinks
|
|
342
|
+
* followed is what an install copies, so this is the only place the boundary
|
|
343
|
+
* can be measured. The walk tracks the real directories on its current descent
|
|
344
|
+
* and does not enter one a second time, the way `find -L` stops on a loop.
|
|
345
|
+
*/
|
|
346
|
+
export const pluginBoundary: Measure = async (ctx) => {
|
|
347
|
+
const pluginRoot = join(ctx.root, 'claude')
|
|
348
|
+
if (!isDirectory(pluginRoot)) {
|
|
349
|
+
return unmeasured('No plugin root at claude, boundary unverifiable.')
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const internalRoot = join(realpathSync(ctx.root), 'internal')
|
|
353
|
+
const leaked: string[] = []
|
|
354
|
+
let walked = 0
|
|
355
|
+
|
|
356
|
+
const walk = (dir: string, ancestors: readonly string[]): void => {
|
|
357
|
+
const real = realpathSync(dir)
|
|
358
|
+
if (ancestors.includes(real)) return
|
|
359
|
+
const chain = [...ancestors, real]
|
|
360
|
+
|
|
361
|
+
for (const name of readdirSync(dir).sort()) {
|
|
362
|
+
const path = join(dir, name)
|
|
363
|
+
let stat
|
|
364
|
+
try {
|
|
365
|
+
stat = statSync(path)
|
|
366
|
+
} catch {
|
|
367
|
+
continue
|
|
368
|
+
}
|
|
369
|
+
if (stat.isDirectory()) {
|
|
370
|
+
walk(path, chain)
|
|
371
|
+
continue
|
|
372
|
+
}
|
|
373
|
+
if (!stat.isFile()) continue
|
|
374
|
+
|
|
375
|
+
walked += 1
|
|
376
|
+
const resolved = realpathSync(path)
|
|
377
|
+
if (resolved.startsWith(`${internalRoot}${sep}`)) {
|
|
378
|
+
leaked.push(
|
|
379
|
+
` ${posixRel(ctx.root, path)} -> ${posixRel(realpathSync(ctx.root), resolved)}`,
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
walk(pluginRoot, [])
|
|
386
|
+
|
|
387
|
+
if (walked === 0) {
|
|
388
|
+
return unmeasured('No file under claude, boundary unverifiable.')
|
|
389
|
+
}
|
|
390
|
+
if (leaked.length === 0) return pass()
|
|
391
|
+
|
|
392
|
+
return failureOf(
|
|
393
|
+
'Plugin ships toolkit-internal content.',
|
|
394
|
+
'Plugin ships toolkit-internal content:',
|
|
395
|
+
leaked,
|
|
396
|
+
'Author internal content under internal/, which nothing under claude/ reaches.',
|
|
397
|
+
)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Regular files under a folder, symlinks left unfollowed the way `find -type f` leaves them. */
|
|
401
|
+
function walkFiles(dir: string): string[] {
|
|
402
|
+
const found: string[] = []
|
|
403
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
404
|
+
const path = join(dir, entry.name)
|
|
405
|
+
if (entry.isDirectory()) found.push(...walkFiles(path))
|
|
406
|
+
else if (entry.isFile()) found.push(path)
|
|
407
|
+
}
|
|
408
|
+
return found.sort()
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function grepLines(
|
|
412
|
+
root: string,
|
|
413
|
+
file: string,
|
|
414
|
+
pattern: RegExp,
|
|
415
|
+
): string[] | undefined {
|
|
416
|
+
const text = readFileSync(file, 'utf8')
|
|
417
|
+
if (text.includes('\0')) return undefined
|
|
418
|
+
const lines = text.split('\n')
|
|
419
|
+
if (lines.at(-1) === '') lines.pop()
|
|
420
|
+
return lines.flatMap((line, index) =>
|
|
421
|
+
pattern.test(line) ? [`${posixRel(root, file)}:${index + 1}:${line}`] : [],
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Seed prose is installed into every scaffolded project and read there as
|
|
427
|
+
* instruction about that project, so a line naming this repository's CLI hands a
|
|
428
|
+
* target a verb it may not be able to run. This gates for the reason the seed
|
|
429
|
+
* standards stage gates: a defect authored once propagates into every project
|
|
430
|
+
* scaffolded after it.
|
|
431
|
+
*
|
|
432
|
+
* Markdown alone, since the seed tree also ships hooks that call the CLI on
|
|
433
|
+
* purpose. A token followed by `/` is the tracked surface root, a folder the
|
|
434
|
+
* seed itself installs, so every other spelling reports. No seed root, or roots
|
|
435
|
+
* carrying no markdown, is unmeasured rather than a pass, which agrees with
|
|
436
|
+
* `seedStandards` on the no-root case.
|
|
437
|
+
*/
|
|
438
|
+
export const seedIndependence: Measure = async (ctx) => {
|
|
439
|
+
if (!isDirectory(join(ctx.root, 'tooling'))) {
|
|
440
|
+
return unmeasured(
|
|
441
|
+
'No tooling root at tooling, seed independence unverifiable.',
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const roots = seedRoots(ctx.root)
|
|
446
|
+
if (roots.length === 0) {
|
|
447
|
+
return unmeasured(
|
|
448
|
+
'No seed root carries .claude/ or canon/, nothing to check.',
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const pattern = new RegExp(`${TOOLKIT_TOKEN}([^/]|$)`)
|
|
453
|
+
const cited: string[] = []
|
|
454
|
+
let measured = 0
|
|
455
|
+
|
|
456
|
+
for (const seedRoot of roots) {
|
|
457
|
+
for (const file of walkFiles(join(ctx.root, seedRoot))) {
|
|
458
|
+
if (!file.endsWith('.md')) continue
|
|
459
|
+
measured += 1
|
|
460
|
+
for (const hit of grepLines(ctx.root, file, pattern) ?? []) {
|
|
461
|
+
cited.push(` ${hit}`)
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (measured === 0) {
|
|
467
|
+
return unmeasured(
|
|
468
|
+
'Seed roots resolved but carry no markdown, seed independence unverifiable.',
|
|
469
|
+
)
|
|
470
|
+
}
|
|
471
|
+
if (cited.length === 0) return pass()
|
|
472
|
+
|
|
473
|
+
return failureOf(
|
|
474
|
+
'Seed prose cites the toolkit CLI.',
|
|
475
|
+
'Seed prose cites the toolkit CLI:',
|
|
476
|
+
cited,
|
|
477
|
+
`A scaffolded project may not have ${TOOLKIT_TOKEN} installed. State the capability the line needs rather than the binary that supplies it.`,
|
|
478
|
+
)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* A shipped skill runs from a target project, where the toolkit's own `wiki/`
|
|
483
|
+
* does not exist. Anchoring on a non-path character keeps a target's
|
|
484
|
+
* `.claude/wiki/` legal. A tree with no `claude/skills/`, or one holding no
|
|
485
|
+
* file, is unmeasured rather than clean.
|
|
486
|
+
*/
|
|
487
|
+
export const skillPaths: Measure = async (ctx) => {
|
|
488
|
+
const skills = join(ctx.root, 'claude/skills')
|
|
489
|
+
if (!isDirectory(skills)) {
|
|
490
|
+
return unmeasured('No skills folder at claude/skills, no skill was read.')
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const files = walkFiles(skills)
|
|
494
|
+
if (files.length === 0) {
|
|
495
|
+
return unmeasured('No file under claude/skills, no skill was read.')
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const matches = files.flatMap(
|
|
499
|
+
(file) => grepLines(ctx.root, file, BANNED_SKILL_PATH) ?? [],
|
|
500
|
+
)
|
|
501
|
+
if (matches.length === 0) return pass()
|
|
502
|
+
|
|
503
|
+
return failureOf(
|
|
504
|
+
'Shipped skills reference a repo-local path.',
|
|
505
|
+
'Shipped skills reference a repo-local path that does not exist in a target project:',
|
|
506
|
+
[...matches, ''],
|
|
507
|
+
'Reach supporting prose through a canon docs command, a standard cited at the flat root, or inlined text.',
|
|
508
|
+
)
|
|
509
|
+
}
|
package/src/gate/measures.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
type ShippedReference,
|
|
32
32
|
} from '@/shipped/references'
|
|
33
33
|
import { surfaceDir } from '@/surface-root'
|
|
34
|
+
import { seedRoots } from '@/tooling/seeds'
|
|
34
35
|
import { plural } from '@/ui'
|
|
35
36
|
import {
|
|
36
37
|
README_PARAPHRASE_MARKER,
|
|
@@ -585,26 +586,14 @@ export const markdownBans: Measure = async (ctx) => {
|
|
|
585
586
|
* required section and index drift, and leaves the thresholds advisory for the
|
|
586
587
|
* reason the stage above leaves its own so.
|
|
587
588
|
*
|
|
588
|
-
* The roots are discovered rather than listed, through
|
|
589
|
-
*
|
|
590
|
-
*
|
|
591
|
-
*
|
|
589
|
+
* The roots are discovered rather than listed, through `seedRoots`, which the
|
|
590
|
+
* Seed independence measure reads too, so a stack seeding `.claude/` later is
|
|
591
|
+
* covered with no edit here and the two stages cannot disagree about which
|
|
592
|
+
* roots exist.
|
|
592
593
|
*/
|
|
593
594
|
export const seedStandards: Measure = async (ctx) => {
|
|
594
|
-
const roots =
|
|
595
|
-
|
|
596
|
-
join(ctx.root, 'scripts/core/list-seed-roots.sh'),
|
|
597
|
-
])
|
|
598
|
-
|
|
599
|
-
if (roots.exitCode !== 0) {
|
|
600
|
-
return {
|
|
601
|
-
emissions: [],
|
|
602
|
-
unmeasured: 'The seed roots could not be listed, so no seed was read.',
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
const seedRoots = roots.stdout.split('\n').filter((line) => line !== '')
|
|
607
|
-
if (seedRoots.length === 0) {
|
|
595
|
+
const roots = seedRoots(ctx.root)
|
|
596
|
+
if (roots.length === 0) {
|
|
608
597
|
return {
|
|
609
598
|
emissions: [],
|
|
610
599
|
unmeasured:
|
|
@@ -615,7 +604,7 @@ export const seedStandards: Measure = async (ctx) => {
|
|
|
615
604
|
const emissions: Emission[] = []
|
|
616
605
|
let measured = 0
|
|
617
606
|
|
|
618
|
-
for (const seedRoot of
|
|
607
|
+
for (const seedRoot of roots) {
|
|
619
608
|
const run = await ctx.cli([
|
|
620
609
|
'context',
|
|
621
610
|
'audit',
|