@erclx/canon 5.8.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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/git-followup/SKILL.md +1 -1
- package/claude/skills/git-pr/references/evidence.md +1 -1
- package/claude/skills/review-craft/REQUIREMENT.md +8 -2
- package/claude/skills/review-craft/SKILL.md +63 -7
- package/claude/skills/review-craft/references/adopted.md +49 -0
- package/docs/agents/commands.md +2 -0
- package/docs/agents/hooks.md +56 -0
- package/docs/agents/index.md +1 -0
- package/docs/agents/install-and-sync.md +1 -1
- package/docs/agents/markdown-audit.md +1 -1
- package/docs/agents/pr-evidence.md +33 -17
- package/docs/agents/records-push.md +1 -1
- package/docs/agents/records-validate.md +1 -1
- package/docs/agents/scripting.md +6 -0
- package/docs/agents/tasks-archive.md +4 -2
- package/docs/agents/tasks.md +1 -1
- package/docs/agents/worktrees.md +2 -2
- package/docs/target/scenarios.md +2 -2
- package/package.json +1 -1
- package/src/claude/skills-audit.ts +1 -0
- package/src/cli.ts +2 -0
- package/src/commands/claude.ts +1 -1
- package/src/commands/docs.ts +51 -11
- package/src/commands/hooks.ts +67 -0
- package/src/commands/pr.ts +146 -76
- package/src/commands/standards.ts +33 -2
- package/src/commands/tasks.ts +45 -0
- package/src/commands/tooling.ts +3 -3
- package/src/commands/upgrade.ts +3 -2
- package/src/commands/worktrees.ts +4 -3
- package/src/docs/list.ts +103 -0
- package/src/docs/read.ts +63 -19
- package/src/gov/stacks.ts +1 -1
- package/src/help.ts +2 -0
- package/src/hooks/post-merge.ts +331 -0
- package/src/pr/evidence.ts +21 -0
- package/src/records/validate.ts +1 -38
- package/src/standards/list.ts +33 -0
- package/src/standards/read.ts +13 -0
- package/src/standards/scope.ts +56 -0
- package/src/sync/check.ts +38 -4
- package/src/tasks/archive.ts +168 -3
- package/src/tooling/inject.ts +4 -3
- package/src/tooling/manifest.ts +62 -8
- package/src/tooling/scan.ts +4 -3
- package/src/tooling/stamp.ts +11 -2
- package/src/tooling/subfolder.ts +7 -3
- package/tooling/astro/reference.md +1 -1
- package/tooling/base/configs/scripts/verify.json +3 -0
- package/tooling/base/configs/scripts/verify.sh +1 -110
- package/tooling/base/configs/scripts/verify.ts +378 -0
- package/tooling/base/manifest.toml +1 -0
- package/tooling/base/reference.md +5 -3
- package/tooling/go/configs/scripts/verify.json +10 -0
- package/tooling/go/reference.md +5 -4
- package/tooling/php/configs/scripts/verify.json +7 -0
- package/tooling/php/reference.md +5 -4
- package/tooling/python/configs/scripts/verify.json +4 -0
- package/tooling/python/reference.md +3 -3
- package/tooling/web/configs/scripts/verify.json +3 -0
- package/tooling/web/reference.md +3 -3
- package/scripts/docs/list.sh +0 -184
- package/scripts/lib/frontmatter.sh +0 -30
- package/scripts/standards/list.sh +0 -115
- package/src/commands/pass-through.ts +0 -39
- package/tooling/go/configs/scripts/verify.sh +0 -142
- package/tooling/php/configs/scripts/verify.sh +0 -147
- package/tooling/python/configs/scripts/verify.sh +0 -141
- package/tooling/web/configs/scripts/verify.sh +0 -153
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { parseFrontmatter, readField } from '@/indexes/frontmatter'
|
|
3
|
+
import { resolveAllStandards } from '@/standards/read'
|
|
4
|
+
import { appliesTo } from '@/standards/scope'
|
|
5
|
+
|
|
6
|
+
export interface StandardEntry {
|
|
7
|
+
readonly name: string
|
|
8
|
+
readonly description: string
|
|
9
|
+
readonly appliesTo: string[]
|
|
10
|
+
/** The whole file, frontmatter included. */
|
|
11
|
+
readonly content: string
|
|
12
|
+
/** The root-labeled path `canon standards <name>` would read. */
|
|
13
|
+
readonly source: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Lists every standard a resolve reaches, the first root winning, so a project
|
|
18
|
+
* standard shows beside the toolkit's and a local override of a package name
|
|
19
|
+
* shows once, as the local copy.
|
|
20
|
+
*/
|
|
21
|
+
export function listStandardEntries(root: string): StandardEntry[] {
|
|
22
|
+
return resolveAllStandards(root).map(({ name, path, source }) => {
|
|
23
|
+
const content = readFileSync(path, 'utf8')
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
name,
|
|
27
|
+
description: readField(parseFrontmatter(content), 'description') ?? '',
|
|
28
|
+
appliesTo: appliesTo(content),
|
|
29
|
+
content,
|
|
30
|
+
source,
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
}
|
package/src/standards/read.ts
CHANGED
|
@@ -95,6 +95,19 @@ export function listStandards(root: string): string[] {
|
|
|
95
95
|
return [...names].sort()
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Pairs each listed name with the copy a resolve would read, so a catalog shows
|
|
100
|
+
* what `canon standards <name>` prints rather than every file a root holds.
|
|
101
|
+
*/
|
|
102
|
+
export function resolveAllStandards(
|
|
103
|
+
root: string,
|
|
104
|
+
): Array<ResolvedStandard & { readonly name: string }> {
|
|
105
|
+
return listStandards(root).flatMap((name) => {
|
|
106
|
+
const resolved = resolveStandard(root, name)
|
|
107
|
+
return resolved ? [{ name, ...resolved }] : []
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
98
111
|
export function readStandard(standard: ResolvedStandard): string {
|
|
99
112
|
return stripFrontmatter(readFileSync(standard.path, 'utf8'))
|
|
100
113
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { linesOutsideFences } from '@/markdown/scan'
|
|
2
|
+
|
|
3
|
+
const SCOPE_HEADING = /^##[ \t]+Scope[ \t]*$/
|
|
4
|
+
const ANY_HEADING = /^#{1,6}[ \t]+\S/
|
|
5
|
+
const CODE_SPAN = /`([^`]+)`/g
|
|
6
|
+
|
|
7
|
+
/** What an attribute standard says of itself, since it names no path. */
|
|
8
|
+
export const ATTRIBUTE_MARKER = 'attribute standard'
|
|
9
|
+
|
|
10
|
+
export interface Scope {
|
|
11
|
+
/** The first non-blank line under the heading, which is the statement. */
|
|
12
|
+
readonly statement: string
|
|
13
|
+
readonly lines: readonly string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function readScope(text: string): Scope | undefined {
|
|
17
|
+
const lines = linesOutsideFences(text)
|
|
18
|
+
const opened = lines.findIndex((line) => SCOPE_HEADING.test(line.trim()))
|
|
19
|
+
if (opened === -1) return undefined
|
|
20
|
+
|
|
21
|
+
const body: string[] = []
|
|
22
|
+
|
|
23
|
+
for (const line of lines.slice(opened + 1)) {
|
|
24
|
+
if (ANY_HEADING.test(line.trim())) break
|
|
25
|
+
body.push(line)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const statement = body.find((line) => line.trim().length > 0)
|
|
29
|
+
|
|
30
|
+
return { statement: statement?.trim() ?? '', lines: body }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The paths a scope statement declares: backticked spans in the first sentence
|
|
35
|
+
* alone. The catalog's `appliesTo` field and the records gate both read the
|
|
36
|
+
* statement through this, since one sentence read two ways would let a standard
|
|
37
|
+
* pass the gate while publishing a different jurisdiction to every consumer.
|
|
38
|
+
*/
|
|
39
|
+
export function governedPaths(statement: string): string[] {
|
|
40
|
+
const [sentence] = statement.split('. ')
|
|
41
|
+
return [...sentence.matchAll(CODE_SPAN)].map((match) => match[1])
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The jurisdiction a standard publishes. The marker is read only where the
|
|
46
|
+
* first sentence backticks nothing, so a statement naming a path publishes that
|
|
47
|
+
* path however the rest of it describes itself. An empty array means the
|
|
48
|
+
* statement did not parse, which consumers report rather than skip.
|
|
49
|
+
*/
|
|
50
|
+
export function appliesTo(text: string): string[] {
|
|
51
|
+
const statement = readScope(text)?.statement ?? ''
|
|
52
|
+
const paths = governedPaths(statement)
|
|
53
|
+
|
|
54
|
+
if (paths.length === 0 && statement.includes(ATTRIBUTE_MARKER)) return ['*']
|
|
55
|
+
return paths
|
|
56
|
+
}
|
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 {
|
|
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
|
package/src/tasks/archive.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/tooling/inject.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
110
|
+
const files = seedFiles(manifest).filter(
|
|
110
111
|
(rel) =>
|
|
111
112
|
!shadowed.has(rel) && !(isSubfolder && isWithheldInSubfolder(rel)),
|
|
112
113
|
)
|
package/src/tooling/manifest.ts
CHANGED
|
@@ -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(
|
|
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`
|
|
124
|
-
*
|
|
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)
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
}
|
package/src/tooling/scan.ts
CHANGED
|
@@ -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
|
|
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
|
|
113
|
+
for (const rel of seedFiles(manifest)) {
|
|
113
114
|
if (seenSeeds.has(rel)) continue
|
|
114
115
|
seenSeeds.add(rel)
|
|
115
116
|
|
package/src/tooling/stamp.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { writeChainStamp } from '@/sync/stamp'
|
|
4
|
-
import type
|
|
4
|
+
import { isWholeStack, type Manifest } from '@/tooling/manifest'
|
|
5
5
|
import { readPackage } from '@/tooling/package'
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -33,12 +33,21 @@ export async function recordToolingChain(
|
|
|
33
33
|
await writeChainStamp(
|
|
34
34
|
target,
|
|
35
35
|
{ domain: 'tooling', toolkitRoot },
|
|
36
|
-
chain
|
|
36
|
+
recordedStacks(chain),
|
|
37
37
|
now,
|
|
38
38
|
)
|
|
39
39
|
return true
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* The stack names a chain stamp records. A skipped stack kept for its
|
|
44
|
+
* per-root configs stays out, since the report re-resolves each recorded name
|
|
45
|
+
* as a whole manifest and would then measure every config the skip withheld.
|
|
46
|
+
*/
|
|
47
|
+
export function recordedStacks(chain: readonly Manifest[]): string[] {
|
|
48
|
+
return chain.filter(isWholeStack).map((manifest) => manifest.name)
|
|
49
|
+
}
|
|
50
|
+
|
|
42
51
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
43
52
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
44
53
|
}
|
package/src/tooling/subfolder.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync, realpathSync } from 'node:fs'
|
|
2
2
|
import { basename, join, relative } from 'node:path'
|
|
3
3
|
import { gitEnv } from '@/git-env'
|
|
4
|
-
import {
|
|
4
|
+
import { isWholeStack, type Manifest, seedFiles } from '@/tooling/manifest'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* The path the generated subfolder spell config is written to. Written
|
|
@@ -83,11 +83,15 @@ export function hasSpellConfig(target: string): boolean {
|
|
|
83
83
|
export function subfolderSpellConfig(
|
|
84
84
|
chain: readonly Manifest[],
|
|
85
85
|
): string | undefined {
|
|
86
|
-
if (
|
|
86
|
+
if (
|
|
87
|
+
chain.some((manifest) => manifest.name === 'base' && isWholeStack(manifest))
|
|
88
|
+
) {
|
|
89
|
+
return undefined
|
|
90
|
+
}
|
|
87
91
|
|
|
88
92
|
const lists = new Set<string>()
|
|
89
93
|
for (const manifest of chain) {
|
|
90
|
-
for (const rel of
|
|
94
|
+
for (const rel of seedFiles(manifest)) {
|
|
91
95
|
if (rel.startsWith('.cspell/') && rel.endsWith('.txt')) lists.add(rel)
|
|
92
96
|
}
|
|
93
97
|
}
|
|
@@ -69,7 +69,7 @@ The social card is a route the project keeps rather than a page a render writes
|
|
|
69
69
|
- `card.config.mjs`: a second Astro config whose `srcDir` points at `./card-src`, which the main config's page router never reads. That is what excludes the card from the published build structurally rather than by a filename convention, the same mechanism `scenarios.astro` reaches for with `import.meta.env.DEV`. Its base port is `4421` plus `WORKTREE_PORT_OFFSET`, outside the band `astro.config.mjs` serves from, so a card server and a dev server of one worktree never contend.
|
|
70
70
|
- `card-src/pages/og-card.astro`: the route, shipped as a working placeholder so it serves before any pick has been taken. It imports the project's own global stylesheet, which is what lets the composition read real tokens and real fonts instead of typed copies. It reads `--color-background` and `--color-text`, the names `canon design css` emits, and sets no `font-family` at all so the card inherits the project's body font rather than reading a font token the design system does not emit.
|
|
71
71
|
- `scripts/check-card-exclusion.sh`: fails when the route's `<meta name="og-card-marker">` tag reaches `dist/`, and fails when the route dropped that tag, since the marker is what the leak test reads for.
|
|
72
|
-
- `bun run card:dev` serves the route. `bun run card:check` runs the exclusion check, which
|
|
72
|
+
- `bun run card:dev` serves the route. `bun run card:check` runs the exclusion check, which base's verify runner, `scripts/verify.ts`, also runs after its build stage whenever the script is installed.
|
|
73
73
|
|
|
74
74
|
### Why each piece is shaped that way
|
|
75
75
|
|