@erclx/canon 5.13.0 → 5.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/SKILL.md +3 -2
- 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 +3 -0
- package/claude/skills/git-ship/SKILL.md +12 -1
- package/claude/skills/review-pr/SKILL.md +1 -1
- package/claude/skills/review-pr/references/close-out.md +1 -1
- package/claude/skills/review-pr/references/post.md +1 -1
- package/claude/skills/review-ui/REQUIREMENT.md +5 -2
- package/claude/skills/review-ui/SKILL.md +12 -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-orchestrator/SKILL.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +10 -10
- package/claude/skills/role-orchestrator/scripts/poll.ts +628 -0
- package/claude/skills/role-orchestrator/scripts/repo.ts +69 -0
- package/claude/skills/role-orchestrator/scripts/watch.ts +267 -0
- 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-reads.md +1 -1
- package/docs/agents/sandbox.md +3 -3
- package/docs/agents/state-scoped-risk.md +2 -2
- package/docs/workflow/skill-map.md +15 -14
- package/package.json +4 -5
- package/scripts/core/check-color-source.sh +11 -1
- package/scripts/core/install-check.sh +13 -4
- package/scripts/lib/sandbox-dispatch.sh +1 -1
- package/scripts/lib/sandbox-fixtures.sh +2 -2
- package/scripts/lib/sandbox-path.sh +1 -1
- package/scripts/manage-sandbox.sh +2 -2
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/skills-audit.ts +1 -0
- package/src/commands/pr.ts +452 -0
- package/src/commands/sandbox.ts +2 -2
- package/src/context/architecture.ts +5 -2
- package/src/context/citations.ts +1 -1
- 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/sandbox/census.ts +1 -1
- package/src/sandbox/coverage.ts +1 -1
- package/src/sandbox/expect.ts +0 -1
- package/src/shipped/references.ts +8 -10
- package/src/targets/pulls.ts +2 -2
- package/src/tooling/manifest.ts +2 -3
- package/src/tooling/seeds.ts +37 -0
- package/tooling/claude/manifest.toml +3 -3
- package/tsconfig.json +1 -1
- package/claude/skills/role-orchestrator/scripts/poll.sh +0 -504
- package/claude/skills/role-orchestrator/scripts/watch.sh +0 -181
- 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
|
@@ -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',
|
package/src/gate/stages.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import {
|
|
2
|
+
capabilitySeeding,
|
|
3
|
+
ignoreParity,
|
|
4
|
+
pluginBoundary,
|
|
5
|
+
seedIndependence,
|
|
6
|
+
skillPaths,
|
|
7
|
+
} from '@/gate/boundaries'
|
|
1
8
|
import {
|
|
2
9
|
architectureRecord,
|
|
3
10
|
auditSet,
|
|
@@ -99,7 +106,6 @@ export const TEST_CORPORA_PATTERNS = [
|
|
|
99
106
|
'^standards/markdown\\.md$',
|
|
100
107
|
'^tooling/base/reference\\.md$',
|
|
101
108
|
'^tooling/web/configs/scripts/worktree-port\\.sh$',
|
|
102
|
-
'^scripts/core/check-ignore-parity\\.sh$',
|
|
103
109
|
]
|
|
104
110
|
|
|
105
111
|
const TESTS_SCOPE = new RegExp(
|
|
@@ -277,34 +283,19 @@ export const STAGES: readonly Stage[] = [
|
|
|
277
283
|
// produces either list, so there is nothing to regenerate and diff.
|
|
278
284
|
id: 'ignore-parity',
|
|
279
285
|
label: 'Ignore parity',
|
|
280
|
-
checks: [
|
|
281
|
-
script(
|
|
282
|
-
'check-ignore-parity.sh',
|
|
283
|
-
"The ignore set a target receives disagrees with this repository's own.",
|
|
284
|
-
),
|
|
285
|
-
],
|
|
286
|
+
checks: [{ kind: 'measure', measure: ignoreParity }],
|
|
286
287
|
success: 'Ignore parity clean',
|
|
287
288
|
},
|
|
288
289
|
{
|
|
289
290
|
id: 'skill-paths',
|
|
290
291
|
label: 'Skill paths',
|
|
291
|
-
checks: [
|
|
292
|
-
script(
|
|
293
|
-
'check-skill-paths.sh',
|
|
294
|
-
'Shipped skills reference a repo-local path.',
|
|
295
|
-
),
|
|
296
|
-
],
|
|
292
|
+
checks: [{ kind: 'measure', measure: skillPaths }],
|
|
297
293
|
success: 'Skill paths clean',
|
|
298
294
|
},
|
|
299
295
|
{
|
|
300
296
|
id: 'plugin-boundary',
|
|
301
297
|
label: 'Plugin boundary',
|
|
302
|
-
checks: [
|
|
303
|
-
script(
|
|
304
|
-
'check-plugin-boundary.sh',
|
|
305
|
-
'Plugin ships toolkit-internal content.',
|
|
306
|
-
),
|
|
307
|
-
],
|
|
298
|
+
checks: [{ kind: 'measure', measure: pluginBoundary }],
|
|
308
299
|
success: 'Plugin boundary clean',
|
|
309
300
|
},
|
|
310
301
|
{
|
|
@@ -315,9 +306,7 @@ export const STAGES: readonly Stage[] = [
|
|
|
315
306
|
// propagates into every project scaffolded after it.
|
|
316
307
|
id: 'seed-independence',
|
|
317
308
|
label: 'Seed independence',
|
|
318
|
-
checks: [
|
|
319
|
-
script('check-seed-independence.sh', 'Seed prose cites the toolkit CLI.'),
|
|
320
|
-
],
|
|
309
|
+
checks: [{ kind: 'measure', measure: seedIndependence }],
|
|
321
310
|
success: 'Seed prose cites no toolkit CLI',
|
|
322
311
|
},
|
|
323
312
|
{
|
|
@@ -326,12 +315,7 @@ export const STAGES: readonly Stage[] = [
|
|
|
326
315
|
// recorded reason, per the criterion in canon/context/tooling/seeds.md.
|
|
327
316
|
id: 'capability-seeding',
|
|
328
317
|
label: 'Capability seeding',
|
|
329
|
-
checks: [
|
|
330
|
-
script(
|
|
331
|
-
'check-capability-seeding.sh',
|
|
332
|
-
'A capability reaches one side of the seed or config boundary and not the other.',
|
|
333
|
-
),
|
|
334
|
-
],
|
|
318
|
+
checks: [{ kind: 'measure', measure: capabilitySeeding }],
|
|
335
319
|
success: 'Capability seeding clean',
|
|
336
320
|
},
|
|
337
321
|
{
|