@erclx/canon 5.7.0 → 5.9.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.
Files changed (77) hide show
  1. package/README.md +4 -2
  2. package/claude/.claude-plugin/plugin.json +2 -2
  3. package/claude/skills/auto-ship/SKILL.md +2 -0
  4. package/claude/skills/draft-and-pick/REQUIREMENT.md +2 -1
  5. package/claude/skills/draft-and-pick/SKILL.md +9 -6
  6. package/claude/skills/git-followup/SKILL.md +1 -1
  7. package/claude/skills/git-pr/references/evidence.md +1 -1
  8. package/claude/skills/git-ship/REQUIREMENT.md +3 -2
  9. package/claude/skills/git-ship/SKILL.md +25 -2
  10. package/claude/skills/review-address/SKILL.md +6 -4
  11. package/claude/skills/review-craft/REQUIREMENT.md +8 -2
  12. package/claude/skills/review-craft/SKILL.md +63 -7
  13. package/claude/skills/review-craft/references/adopted.md +49 -0
  14. package/docs/agents/commands.md +2 -0
  15. package/docs/agents/hooks.md +56 -0
  16. package/docs/agents/index.md +1 -0
  17. package/docs/agents/install-and-sync.md +1 -1
  18. package/docs/agents/markdown-audit.md +1 -1
  19. package/docs/agents/pr-evidence.md +33 -17
  20. package/docs/agents/records-push.md +1 -1
  21. package/docs/agents/records-validate.md +1 -1
  22. package/docs/agents/scripting.md +6 -0
  23. package/docs/agents/tasks-archive.md +4 -2
  24. package/docs/agents/tasks.md +1 -1
  25. package/docs/agents/worktrees.md +2 -2
  26. package/docs/target/scenarios.md +2 -2
  27. package/package.json +2 -2
  28. package/src/claude/skills-audit.ts +1 -0
  29. package/src/cli.ts +2 -0
  30. package/src/commands/claude.ts +1 -1
  31. package/src/commands/docs.ts +51 -11
  32. package/src/commands/hooks.ts +67 -0
  33. package/src/commands/pr.ts +146 -76
  34. package/src/commands/standards.ts +33 -2
  35. package/src/commands/tasks.ts +45 -0
  36. package/src/commands/tooling.ts +3 -3
  37. package/src/commands/upgrade.ts +3 -2
  38. package/src/commands/worktrees.ts +4 -3
  39. package/src/docs/list.ts +103 -0
  40. package/src/docs/read.ts +63 -19
  41. package/src/gov/stacks.ts +1 -1
  42. package/src/help.ts +2 -0
  43. package/src/hooks/post-merge.ts +331 -0
  44. package/src/pr/evidence.ts +21 -0
  45. package/src/records/validate.ts +1 -38
  46. package/src/standards/list.ts +33 -0
  47. package/src/standards/read.ts +13 -0
  48. package/src/standards/scope.ts +56 -0
  49. package/src/sync/check.ts +38 -4
  50. package/src/tasks/archive.ts +168 -3
  51. package/src/tooling/inject.ts +4 -3
  52. package/src/tooling/manifest.ts +62 -8
  53. package/src/tooling/scan.ts +4 -3
  54. package/src/tooling/stamp.ts +11 -2
  55. package/src/tooling/subfolder.ts +7 -3
  56. package/tooling/astro/reference.md +1 -1
  57. package/tooling/base/configs/scripts/verify.json +3 -0
  58. package/tooling/base/configs/scripts/verify.sh +1 -110
  59. package/tooling/base/configs/scripts/verify.ts +378 -0
  60. package/tooling/base/manifest.toml +1 -0
  61. package/tooling/base/reference.md +5 -3
  62. package/tooling/go/configs/scripts/verify.json +10 -0
  63. package/tooling/go/reference.md +5 -4
  64. package/tooling/php/configs/scripts/verify.json +7 -0
  65. package/tooling/php/reference.md +5 -4
  66. package/tooling/python/configs/scripts/verify.json +4 -0
  67. package/tooling/python/reference.md +3 -3
  68. package/tooling/web/configs/scripts/verify.json +3 -0
  69. package/tooling/web/reference.md +3 -3
  70. package/scripts/docs/list.sh +0 -184
  71. package/scripts/lib/frontmatter.sh +0 -30
  72. package/scripts/standards/list.sh +0 -115
  73. package/src/commands/pass-through.ts +0 -39
  74. package/tooling/go/configs/scripts/verify.sh +0 -142
  75. package/tooling/php/configs/scripts/verify.sh +0 -147
  76. package/tooling/python/configs/scripts/verify.sh +0 -141
  77. package/tooling/web/configs/scripts/verify.sh +0 -153
package/src/sync/check.ts CHANGED
@@ -28,7 +28,12 @@ import {
28
28
  type StampDomain,
29
29
  } from '@/sync/stamp'
30
30
  import { isDirectory } from '@/target'
31
- import { loadManifest } from '@/tooling/manifest'
31
+ import {
32
+ isWholeStack,
33
+ loadManifest,
34
+ type Manifest,
35
+ resolveChain,
36
+ } from '@/tooling/manifest'
32
37
  import { scan } from '@/tooling/scan'
33
38
  import { readSkew, type SkewReport } from '@/version/skew'
34
39
 
@@ -253,9 +258,7 @@ export function buildToolingReport(
253
258
  stamp: Stamp | undefined,
254
259
  ): ToolingReport {
255
260
  const chain = stampedChain(stamp, 'tooling')
256
- const manifests = chain
257
- .map((name) => loadManifest(toolkitRoot, name))
258
- .filter((manifest) => manifest !== undefined)
261
+ const manifests = resolveRecorded(toolkitRoot, chain)
259
262
 
260
263
  if (manifests.length === 0) return { ...UNMEASURED_TOOLING, chain }
261
264
 
@@ -281,6 +284,37 @@ export function buildToolingReport(
281
284
  }
282
285
  }
283
286
 
287
+ /**
288
+ * Loads each recorded stack whole. When the recorded chain stops short of its
289
+ * last stack's parent, the install skipped that parent, so the chain is
290
+ * re-resolved with it as the skip to bring back any per-root configs the
291
+ * skipped stack still shipped. The re-resolution is kept only when its whole
292
+ * stacks are exactly the recorded ones.
293
+ */
294
+ function resolveRecorded(
295
+ toolkitRoot: string,
296
+ recorded: readonly string[],
297
+ ): Manifest[] {
298
+ const loaded = recorded
299
+ .map((name) => loadManifest(toolkitRoot, name))
300
+ .filter((manifest) => manifest !== undefined)
301
+
302
+ const parent = loaded.at(-1)?.parent
303
+ if (
304
+ loaded.length !== recorded.length ||
305
+ parent === undefined ||
306
+ recorded.includes(parent)
307
+ ) {
308
+ return loaded
309
+ }
310
+
311
+ const resolved = resolveChain(toolkitRoot, recorded[0], {
312
+ skipStack: parent,
313
+ })
314
+ const whole = resolved.filter(isWholeStack).map((manifest) => manifest.name)
315
+ return whole.join('\0') === recorded.join('\0') ? resolved : loaded
316
+ }
317
+
284
318
  /**
285
319
  * Whether the target is a toolkit-managed project at all. Seeds are enumerated
286
320
  * from the source rather than from what a target installed, so without this gate
@@ -84,6 +84,23 @@ export interface ReadyMove {
84
84
  readonly to: string
85
85
  }
86
86
 
87
+ /** A task file rewritten because it linked the task that moved. */
88
+ export interface InboundRelink {
89
+ readonly file: string
90
+ readonly links: number
91
+ }
92
+
93
+ /** A task file the pass could not read or write, left as it was. */
94
+ export interface RelinkFailure {
95
+ readonly file: string
96
+ readonly message: string
97
+ }
98
+
99
+ export interface RepointOutcome {
100
+ readonly relinked: readonly InboundRelink[]
101
+ readonly failed: readonly RelinkFailure[]
102
+ }
103
+
87
104
  export interface ArchiveSuccess {
88
105
  readonly ok: true
89
106
  readonly stem: string
@@ -97,6 +114,10 @@ export interface ArchiveSuccess {
97
114
  readonly ready: ReadyMove | undefined
98
115
  readonly closed: number
99
116
  readonly cut: number
117
+ /** Every task file whose links to the moved task were rewritten. */
118
+ readonly relinked: readonly InboundRelink[]
119
+ /** Every task file the pass could not read or write, left as it was. */
120
+ readonly relinkFailed: readonly RelinkFailure[]
100
121
  }
101
122
 
102
123
  export interface ArchiveRefused {
@@ -118,6 +139,10 @@ export interface DeclineSuccess {
118
139
  readonly indexRegenerated: boolean
119
140
  /** Every live plan the task cited that no other live task still holds, in line order. */
120
141
  readonly plans: readonly PlanMove[]
142
+ /** Every task file whose links to the moved task were rewritten. */
143
+ readonly relinked: readonly InboundRelink[]
144
+ /** Every task file the pass could not read or write, left as it was. */
145
+ readonly relinkFailed: readonly RelinkFailure[]
121
146
  }
122
147
 
123
148
  export interface DeclineRefused {
@@ -942,6 +967,7 @@ export async function archiveTask(
942
967
  : retargeted,
943
968
  )
944
969
 
970
+ const { relinked, failed } = await repointInboundLinks(root, from, to)
945
971
  const priorityRowRemoved = await clearPriorityRow(dir, stem)
946
972
  const regen = await regenOne(dir, { dryRun: false })
947
973
 
@@ -956,6 +982,8 @@ export async function archiveTask(
956
982
  ready: ready || undefined,
957
983
  closed: closed.length,
958
984
  cut: cut.length,
985
+ relinked,
986
+ relinkFailed: failed,
959
987
  }
960
988
  }
961
989
 
@@ -1106,6 +1134,8 @@ export function linkTo(taskDir: string, plan: string): string {
1106
1134
  const MARKDOWN_LINK_PATTERN = /(\]\()([^)\s]+)/g
1107
1135
  /** The capture group makes `split` keep each code span, at the odd positions. */
1108
1136
  const INLINE_CODE_PATTERN = /(`+[^`]*`+)/
1137
+ /** A reference-style definition, whose target is the whole line past the label. */
1138
+ const REFERENCE_DEFINITION_PATTERN = /^(\s{0,3}\[[^\]]+\]:[ \t]*)(\S+)[ \t]*$/
1109
1139
  const BARE_ORIGIN_PATTERN =
1110
1140
  /^((?:Plan|Ready|Groundwork|Intake):[ \t]*)([^\s[\]()]+)[ \t]*$/
1111
1141
 
@@ -1113,12 +1143,18 @@ function isRelativeTarget(target: string): boolean {
1113
1143
  return !/^(?:[a-z][a-z0-9+.-]*:|\/|#)/i.test(target)
1114
1144
  }
1115
1145
 
1146
+ function splitSuffix(target: string): { path: string; suffix: string } {
1147
+ const suffixAt = target.search(/[#?]/)
1148
+
1149
+ return suffixAt === -1
1150
+ ? { path: target, suffix: '' }
1151
+ : { path: target.slice(0, suffixAt), suffix: target.slice(suffixAt) }
1152
+ }
1153
+
1116
1154
  function rebaseTarget(target: string, fromDir: string, toDir: string): string {
1117
1155
  if (!isRelativeTarget(target)) return target
1118
1156
 
1119
- const suffixAt = target.search(/[#?]/)
1120
- const path = suffixAt === -1 ? target : target.slice(0, suffixAt)
1121
- const suffix = suffixAt === -1 ? '' : target.slice(suffixAt)
1157
+ const { path, suffix } = splitSuffix(target)
1122
1158
  if (path === '') return target
1123
1159
 
1124
1160
  const rebased = relative(toDir, resolve(fromDir, path)).split(sep).join('/')
@@ -1166,6 +1202,132 @@ export function rebaseRelativeLinks(
1166
1202
  .join('\n')
1167
1203
  }
1168
1204
 
1205
+ /**
1206
+ * A link resolving to `from`, rewritten to read `to` from the citing file's own
1207
+ * folder. Resolution decides the match, so a link naming the same stem in
1208
+ * another folder is left as written.
1209
+ */
1210
+ function repointTarget(
1211
+ target: string,
1212
+ fileDir: string,
1213
+ from: string,
1214
+ to: string,
1215
+ ): string | undefined {
1216
+ if (!isRelativeTarget(target)) return undefined
1217
+
1218
+ const { path, suffix } = splitSuffix(target)
1219
+ if (path === '' || resolve(fileDir, path) !== from) return undefined
1220
+
1221
+ return `${relative(fileDir, to).split(sep).join('/')}${suffix}`
1222
+ }
1223
+
1224
+ function repointLinks(
1225
+ text: string,
1226
+ fileDir: string,
1227
+ from: string,
1228
+ to: string,
1229
+ ): { text: string; links: number } {
1230
+ const lines = text.split('\n')
1231
+ const fenced = fenceMask(lines)
1232
+ let links = 0
1233
+
1234
+ const repointed = lines.map((line, index) => {
1235
+ if (fenced[index]) return line
1236
+
1237
+ const definition = REFERENCE_DEFINITION_PATTERN.exec(line)
1238
+ if (definition) {
1239
+ const next = repointTarget(definition[2], fileDir, from, to)
1240
+ if (next === undefined) return line
1241
+
1242
+ links += 1
1243
+ return `${definition[1]}${next}`
1244
+ }
1245
+
1246
+ return line
1247
+ .split(INLINE_CODE_PATTERN)
1248
+ .map((part, position) =>
1249
+ position % 2 === 1
1250
+ ? part
1251
+ : part.replace(
1252
+ MARKDOWN_LINK_PATTERN,
1253
+ (whole: string, open: string, target: string) => {
1254
+ const next = repointTarget(target, fileDir, from, to)
1255
+ if (next === undefined) return whole
1256
+
1257
+ links += 1
1258
+ return `${open}${next}`
1259
+ },
1260
+ ),
1261
+ )
1262
+ .join('')
1263
+ })
1264
+
1265
+ return { text: repointed.join('\n'), links }
1266
+ }
1267
+
1268
+ /**
1269
+ * Rewrites every link in the task folders that resolves to the moved task, so
1270
+ * a citation follows the file. The moved file and the reserved siblings are
1271
+ * skipped: the first was rebased on its own, and `citedStem` reads a blocker in
1272
+ * `priority.md` only while its target carries no `/`. Fenced samples and code
1273
+ * spans stay verbatim, the way `rebaseRelativeLinks` leaves them.
1274
+ *
1275
+ * It runs after the rename and ahead of the ordering-row removal and the index
1276
+ * regen, which the unattended post-merge hook cannot redo once the task has
1277
+ * moved. A read or write that fails is therefore reported in `failed` and never
1278
+ * thrown, so the steps after it always run.
1279
+ */
1280
+ export async function repointInboundLinks(
1281
+ root: string,
1282
+ from: string,
1283
+ to: string,
1284
+ ): Promise<RepointOutcome> {
1285
+ const moved = resolve(from)
1286
+ const destination = resolve(to)
1287
+ const relinked: InboundRelink[] = []
1288
+ const failed: RelinkFailure[] = []
1289
+
1290
+ for (const dir of [tasksDir(root), archiveDir(root), declinedDir(root)]) {
1291
+ if (!existsSync(dir)) continue
1292
+
1293
+ let names: string[]
1294
+ try {
1295
+ names = (await readdir(dir)).filter((name) => name.endsWith('.md'))
1296
+ } catch (error) {
1297
+ failed.push({ file: dir, message: describeError(error) })
1298
+ continue
1299
+ }
1300
+ names.sort()
1301
+
1302
+ for (const name of names) {
1303
+ const file = join(dir, name)
1304
+ if (isReservedStem(name.slice(0, -'.md'.length))) continue
1305
+ if (resolve(file) === destination) continue
1306
+
1307
+ try {
1308
+ const repointed = repointLinks(
1309
+ await readFile(file, 'utf8'),
1310
+ dir,
1311
+ moved,
1312
+ destination,
1313
+ )
1314
+ if (repointed.links === 0) continue
1315
+
1316
+ await writeFile(file, repointed.text)
1317
+ relinked.push({ file, links: repointed.links })
1318
+ } catch (error) {
1319
+ failed.push({ file, message: describeError(error) })
1320
+ }
1321
+ }
1322
+ }
1323
+
1324
+ return { relinked, failed }
1325
+ }
1326
+
1327
+ function describeError(error: unknown): string {
1328
+ return error instanceof Error ? error.message : String(error)
1329
+ }
1330
+
1169
1331
  async function clearPriorityRow(dir: string, stem: string): Promise<boolean> {
1170
1332
  const path = join(dir, 'priority.md')
1171
1333
  if (!existsSync(path)) return false
@@ -1239,6 +1401,7 @@ export async function declineTask(
1239
1401
  )
1240
1402
  await writeFile(to, declined)
1241
1403
 
1404
+ const { relinked, failed } = await repointInboundLinks(root, from, to)
1242
1405
  const priorityRowRemoved = await clearPriorityRow(dir, stem)
1243
1406
  const backlogRowRemoved = await clearBacklogRow(dir, stem)
1244
1407
  const regen = await regenOne(dir, { dryRun: false })
@@ -1252,5 +1415,7 @@ export async function declineTask(
1252
1415
  backlogRowRemoved,
1253
1416
  indexRegenerated: regen.action === 'written',
1254
1417
  plans: plans.moves,
1418
+ relinked,
1419
+ relinkFailed: failed,
1255
1420
  }
1256
1421
  }
@@ -8,9 +8,10 @@ import { resolveSurfacePath } from '@/surface-root'
8
8
  import { mergeSections, pruneSections } from '@/tooling/gitignore'
9
9
  import {
10
10
  ancestorsFirst,
11
+ configFiles,
11
12
  configPaths,
12
- listFiles,
13
13
  type Manifest,
14
+ seedFiles,
14
15
  } from '@/tooling/manifest'
15
16
  import {
16
17
  applyScripts,
@@ -36,7 +37,7 @@ export async function injectConfigs(
36
37
  const isSubfolder = isSubfolderTarget(target)
37
38
 
38
39
  for (const manifest of ancestorsFirst(chain)) {
39
- const files = listFiles(manifest.configsDir).filter(
40
+ const files = configFiles(manifest).filter(
40
41
  (rel) => !(isSubfolder && isWithheldInSubfolder(rel)),
41
42
  )
42
43
  if (files.length === 0) continue
@@ -106,7 +107,7 @@ export async function injectSeeds(
106
107
  const shadowed = configPaths(chain)
107
108
 
108
109
  for (const manifest of ancestorsFirst(chain)) {
109
- const files = listFiles(manifest.seedsDir).filter(
110
+ const files = seedFiles(manifest).filter(
110
111
  (rel) =>
111
112
  !shadowed.has(rel) && !(isSubfolder && isWithheldInSubfolder(rel)),
112
113
  )
@@ -18,6 +18,18 @@ export interface Manifest {
18
18
  readonly scriptOverrides: Readonly<Record<string, string>>
19
19
  readonly gitignore: readonly GitignoreSection[]
20
20
  readonly devPackages: readonly string[]
21
+ /**
22
+ * Config paths this stack still ships into a folder that skips it, read
23
+ * from `[sync] per_root`. Each is a per-root entry point a subfolder needs
24
+ * as much as the repository root does.
25
+ */
26
+ readonly perRoot?: readonly string[]
27
+ /**
28
+ * Set only on a stack kept in a chain that skips it. The stack then ships
29
+ * these configs and nothing else: no seeds, scripts, dependencies, or
30
+ * gitignore entries.
31
+ */
32
+ readonly onlyConfigs?: readonly string[]
21
33
  }
22
34
 
23
35
  export interface ChainOptions {
@@ -50,7 +62,28 @@ export function listFiles(dir: string): string[] {
50
62
  * `injectSeeds` both read this, so the report and the write cannot disagree.
51
63
  */
52
64
  export function configPaths(chain: readonly Manifest[]): Set<string> {
53
- return new Set(chain.flatMap((manifest) => listFiles(manifest.configsDir)))
65
+ return new Set(chain.flatMap(configFiles))
66
+ }
67
+
68
+ /**
69
+ * The config paths a manifest ships, narrowed to `onlyConfigs` on a stack the
70
+ * chain skipped. `scan` and `injectConfigs` both read this, so the diff and
71
+ * the write cannot disagree on what a skipped stack still carries.
72
+ */
73
+ export function configFiles(manifest: Manifest): string[] {
74
+ const files = listFiles(manifest.configsDir)
75
+ const only = manifest.onlyConfigs
76
+ return only === undefined ? files : files.filter((rel) => only.includes(rel))
77
+ }
78
+
79
+ /** The seed paths a manifest ships. A skipped stack ships none. */
80
+ export function seedFiles(manifest: Manifest): string[] {
81
+ return manifest.onlyConfigs === undefined ? listFiles(manifest.seedsDir) : []
82
+ }
83
+
84
+ /** Whether the chain carries this stack whole rather than skipped. */
85
+ export function isWholeStack(manifest: Manifest): boolean {
86
+ return manifest.onlyConfigs === undefined
54
87
  }
55
88
 
56
89
  /**
@@ -100,6 +133,7 @@ export function loadManifest(
100
133
  const parent =
101
134
  typeof stackTable.extends === 'string' ? stackTable.extends : ''
102
135
  const scriptsTable = asTable(parsed.scripts)
136
+ const perRoot = readStrings(asTable(parsed.sync).per_root)
103
137
  const dir = join(toolingDir(root), stack)
104
138
 
105
139
  return {
@@ -112,6 +146,7 @@ export function loadManifest(
112
146
  scriptOverrides: pickStrings(asTable(scriptsTable.override)),
113
147
  gitignore: readGitignoreSections(asTable(parsed.gitignore)),
114
148
  devPackages: readDevPackages(parsed.dependencies),
149
+ ...(perRoot.length > 0 ? { perRoot } : {}),
115
150
  }
116
151
  }
117
152
 
@@ -120,8 +155,11 @@ export function loadManifest(
120
155
  * values before child values reverse the result. Replaces the seventeen
121
156
  * copies of `grep '^extends' | cut -d'"' -f2` the bash carried.
122
157
  *
123
- * A stack matching `skipStack` truncates the chain at that point, mirroring
124
- * the `[ "$stack" = "${SKIP_STACK:-}" ] && return` guard.
158
+ * A stack matching `skipStack` drops from the chain along with every
159
+ * ancestor past it, except that one declaring `[sync] per_root` stays as a
160
+ * restricted manifest shipping those configs alone. A `--skip base` subfolder
161
+ * keeps its own verify entry point that way, while husky, prettier, and the
162
+ * rest stay single at the repository root.
125
163
  */
126
164
  export function resolveChain(
127
165
  root: string,
@@ -131,22 +169,35 @@ export function resolveChain(
131
169
  const chain: Manifest[] = []
132
170
  const seen = new Set<string>()
133
171
  let current: string | undefined = stack
172
+ let isSkipping = false
134
173
 
135
174
  while (current !== undefined) {
136
- if (current === options.skipStack) break
175
+ if (current === options.skipStack) isSkipping = true
137
176
  if (seen.has(current)) break
138
177
  seen.add(current)
139
178
 
140
179
  const manifest: Manifest | undefined = loadManifest(root, current)
141
180
  if (!manifest) break
142
181
 
143
- chain.push(manifest)
182
+ if (!isSkipping) chain.push(manifest)
183
+ else if (manifest.perRoot !== undefined) chain.push(restrict(manifest))
144
184
  current = manifest.parent
145
185
  }
146
186
 
147
187
  return chain
148
188
  }
149
189
 
190
+ function restrict(manifest: Manifest): Manifest {
191
+ return {
192
+ ...manifest,
193
+ onlyConfigs: manifest.perRoot,
194
+ scripts: {},
195
+ scriptOverrides: {},
196
+ gitignore: [],
197
+ devPackages: [],
198
+ }
199
+ }
200
+
150
201
  function asTable(value: unknown): Record<string, unknown> {
151
202
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
152
203
  return {}
@@ -177,9 +228,12 @@ function readGitignoreSections(
177
228
  }
178
229
 
179
230
  function readDevPackages(dependencies: unknown): string[] {
180
- const dev = asTable(asTable(dependencies).dev)
181
- if (!Array.isArray(dev.packages)) return []
182
- return dev.packages.filter(
231
+ return readStrings(asTable(asTable(dependencies).dev).packages)
232
+ }
233
+
234
+ function readStrings(value: unknown): string[] {
235
+ if (!Array.isArray(value)) return []
236
+ return value.filter(
183
237
  (entry): entry is string => typeof entry === 'string' && entry !== '',
184
238
  )
185
239
  }
@@ -4,9 +4,10 @@ import { resolveSurfacePath } from '@/surface-root'
4
4
  import { mergeSections } from '@/tooling/gitignore'
5
5
  import {
6
6
  ancestorsFirst,
7
+ configFiles,
7
8
  configPaths,
8
- listFiles,
9
9
  type Manifest,
10
+ seedFiles,
10
11
  } from '@/tooling/manifest'
11
12
  import {
12
13
  collectDeps,
@@ -83,7 +84,7 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
83
84
  const seenConfigs = new Set<string>()
84
85
 
85
86
  for (const manifest of chain) {
86
- for (const rel of listFiles(manifest.configsDir)) {
87
+ for (const rel of configFiles(manifest)) {
87
88
  if (seenConfigs.has(rel)) continue
88
89
  seenConfigs.add(rel)
89
90
 
@@ -109,7 +110,7 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
109
110
  const seenSeeds = configPaths(chain)
110
111
 
111
112
  for (const manifest of chain) {
112
- for (const rel of listFiles(manifest.seedsDir)) {
113
+ for (const rel of seedFiles(manifest)) {
113
114
  if (seenSeeds.has(rel)) continue
114
115
  seenSeeds.add(rel)
115
116
 
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { writeChainStamp } from '@/sync/stamp'
4
- import type { Manifest } from '@/tooling/manifest'
4
+ import { isWholeStack, type Manifest } from '@/tooling/manifest'
5
5
  import { readPackage } from '@/tooling/package'
6
6
 
7
7
  /**
@@ -33,12 +33,21 @@ export async function recordToolingChain(
33
33
  await writeChainStamp(
34
34
  target,
35
35
  { domain: 'tooling', toolkitRoot },
36
- chain.map((manifest) => manifest.name),
36
+ recordedStacks(chain),
37
37
  now,
38
38
  )
39
39
  return true
40
40
  }
41
41
 
42
+ /**
43
+ * The stack names a chain stamp records. A skipped stack kept for its
44
+ * per-root configs stays out, since the report re-resolves each recorded name
45
+ * as a whole manifest and would then measure every config the skip withheld.
46
+ */
47
+ export function recordedStacks(chain: readonly Manifest[]): string[] {
48
+ return chain.filter(isWholeStack).map((manifest) => manifest.name)
49
+ }
50
+
42
51
  function isRecord(value: unknown): value is Record<string, unknown> {
43
52
  return typeof value === 'object' && value !== null && !Array.isArray(value)
44
53
  }
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readdirSync, realpathSync } from 'node:fs'
2
2
  import { basename, join, relative } from 'node:path'
3
3
  import { gitEnv } from '@/git-env'
4
- import { listFiles, type Manifest } from '@/tooling/manifest'
4
+ import { isWholeStack, type Manifest, seedFiles } from '@/tooling/manifest'
5
5
 
6
6
  /**
7
7
  * The path the generated subfolder spell config is written to. Written
@@ -83,11 +83,15 @@ export function hasSpellConfig(target: string): boolean {
83
83
  export function subfolderSpellConfig(
84
84
  chain: readonly Manifest[],
85
85
  ): string | undefined {
86
- if (chain.some((manifest) => manifest.name === 'base')) return undefined
86
+ if (
87
+ chain.some((manifest) => manifest.name === 'base' && isWholeStack(manifest))
88
+ ) {
89
+ return undefined
90
+ }
87
91
 
88
92
  const lists = new Set<string>()
89
93
  for (const manifest of chain) {
90
- for (const rel of listFiles(manifest.seedsDir)) {
94
+ for (const rel of seedFiles(manifest)) {
91
95
  if (rel.startsWith('.cspell/') && rel.endsWith('.txt')) lists.add(rel)
92
96
  }
93
97
  }
@@ -69,7 +69,7 @@ The social card is a route the project keeps rather than a page a render writes
69
69
  - `card.config.mjs`: a second Astro config whose `srcDir` points at `./card-src`, which the main config's page router never reads. That is what excludes the card from the published build structurally rather than by a filename convention, the same mechanism `scenarios.astro` reaches for with `import.meta.env.DEV`. Its base port is `4421` plus `WORKTREE_PORT_OFFSET`, outside the band `astro.config.mjs` serves from, so a card server and a dev server of one worktree never contend.
70
70
  - `card-src/pages/og-card.astro`: the route, shipped as a working placeholder so it serves before any pick has been taken. It imports the project's own global stylesheet, which is what lets the composition read real tokens and real fonts instead of typed copies. It reads `--color-background` and `--color-text`, the names `canon design css` emits, and sets no `font-family` at all so the card inherits the project's body font rather than reading a font token the design system does not emit.
71
71
  - `scripts/check-card-exclusion.sh`: fails when the route's `<meta name="og-card-marker">` tag reaches `dist/`, and fails when the route dropped that tag, since the marker is what the leak test reads for.
72
- - `bun run card:dev` serves the route. `bun run card:check` runs the exclusion check, which the web layer's `scripts/verify.sh` also runs after its build stage whenever the script is installed.
72
+ - `bun run card:dev` serves the route. `bun run card:check` runs the exclusion check, which base's verify runner, `scripts/verify.ts`, also runs after its build stage whenever the script is installed.
73
73
 
74
74
  ### Why each piece is shaped that way
75
75
 
@@ -0,0 +1,3 @@
1
+ {
2
+ "require": ["format", "check:format", "check:spell", "check:shell"]
3
+ }
@@ -1,111 +1,2 @@
1
1
  #!/bin/bash
2
- set -e
3
- set -o pipefail
4
-
5
- GREEN='\033[0;32m'
6
- RED='\033[0;31m'
7
- WHITE='\033[1;37m'
8
- GREY='\033[0;90m'
9
- NC='\033[0m'
10
-
11
- NESTED="${VERIFY_NESTED:-false}"
12
-
13
- log_info() { echo -e "${GREY}│${NC} ${GREEN}✓${NC} $1"; }
14
- log_error() {
15
- echo -e "${GREY}│${NC} ${RED}✗${NC} $1"
16
- exit 1
17
- }
18
- log_step() { echo -e "${GREY}│${NC}\n${GREY}├${NC} ${WHITE}$1${NC}"; }
19
-
20
- pipe_output() { while IFS= read -r line; do echo -e "${GREY}│${NC} $line"; done; }
21
-
22
- check_dependencies() {
23
- command -v bun >/dev/null 2>&1 || log_error "bun is not installed"
24
- }
25
-
26
- check_markdown_bans() {
27
- if ! command -v canon >/dev/null 2>&1; then
28
- log_info "Skipped: no canon binary on PATH. Install with \`bun install --global @erclx/canon\`."
29
- return 0
30
- fi
31
-
32
- local files=()
33
- while IFS= read -r file; do
34
- files+=("$file")
35
- done < <(git ls-files --cached --others --exclude-standard -- '*.md' ':(exclude)CHANGELOG.md' ':(exclude)**/CHANGELOG.md')
36
-
37
- if [ ${#files[@]} -eq 0 ]; then
38
- log_info "No markdown file to check outside the exclusion set."
39
- return 0
40
- fi
41
-
42
- local output code
43
- if output=$(canon markdown audit "${files[@]}" 2>&1); then
44
- code=0
45
- else
46
- code=$?
47
- fi
48
-
49
- case "$code" in
50
- 0)
51
- log_info "No banned character"
52
- ;;
53
- 1)
54
- log_info "Skipped: canon markdown audit refused and measured nothing."
55
- ;;
56
- 2)
57
- echo "$output" | pipe_output
58
- log_error "Markdown prose carries a banned character, or a relative link resolves to nothing on disk"
59
- ;;
60
- 3)
61
- echo "$output" | pipe_output
62
- log_error "The markdown audit shipped an empty ban set, so the corpus was walked and nothing was looked for"
63
- ;;
64
- *)
65
- log_error "canon markdown audit exited $code, which is neither a pass nor a finding"
66
- ;;
67
- esac
68
- }
69
-
70
- run_check() {
71
- local cmd=$1
72
- local err_msg=$2
73
- local output
74
- if ! output=$(eval "$cmd" 2>&1); then
75
- echo "$output" | pipe_output
76
- log_error "$err_msg"
77
- fi
78
- echo "$output" | pipe_output
79
- }
80
-
81
- main() {
82
- check_dependencies
83
-
84
- if [ "$NESTED" = false ]; then echo -e "${GREY}┌${NC}"; fi
85
-
86
- echo -e "${GREY}├${NC} ${WHITE}Formatting${NC}"
87
- run_check "bun run format" "Format failed"
88
- log_info "Format applied"
89
-
90
- log_step "Format check"
91
- run_check "bun run check:format" "Format check failed"
92
- log_info "Format check passed"
93
-
94
- log_step "Spelling"
95
- run_check "bun run check:spell" "Spell check failed"
96
- log_info "Spell check passed"
97
-
98
- log_step "Shell"
99
- run_check "bun run check:shell" "Shell check failed"
100
- log_info "Shell check passed"
101
-
102
- log_step "Markdown bans"
103
- check_markdown_bans
104
-
105
- if [ "$NESTED" = false ]; then
106
- echo -e "${GREY}└${NC}\n"
107
- echo -e "${GREEN}✓ Verification passed${NC}"
108
- fi
109
- }
110
-
111
- main "$@"
2
+ exec bun "$(dirname "$0")/verify.ts" "$@"