@erclx/canon 4.113.0 → 4.114.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/codebase-layout/SKILL.md +1 -1
- package/claude/skills/target-setup/SKILL.md +2 -0
- package/claude/skills/target-setup/references/detect.md +2 -2
- package/claude/skills/target-setup/references/verify.md +26 -10
- package/claude/skills/test-craft/SKILL.md +1 -1
- package/docs/agents/commands.md +1 -1
- package/docs/agents/counts.md +3 -3
- package/docs/agents/install-and-sync.md +2 -0
- package/docs/agents/records.md +22 -15
- package/docs/agents/teach.md +6 -4
- package/docs/target-projects.md +9 -2
- package/docs/workflow/ai-workflow.md +1 -1
- package/governance/rules/lang/130-go.md +40 -0
- package/governance/rules/lang/140-php.md +31 -0
- package/governance/rules/lib/335-testing-go.md +35 -0
- package/governance/rules/lib/336-testing-php.md +30 -0
- package/governance/rules/lib/360-security-server.md +2 -0
- package/governance/rules/lib/370-database.md +2 -0
- package/governance/rules/ui/410-a11y.md +1 -0
- package/governance/rules/ui/440-surface-capture.md +2 -0
- package/governance/stacks/nextjs.toml +2 -0
- package/governance/stacks/react.toml +1 -1
- package/package.json +1 -1
- package/src/commands/records.ts +18 -1
- package/src/commands/tooling.ts +25 -2
- package/src/design/components.ts +17 -0
- package/src/design/tokens.ts +1 -1
- package/src/gate/measures.ts +13 -2
- package/src/records/backup.ts +197 -27
- package/src/records/push-guard.ts +71 -0
- package/src/teach/nav.ts +75 -25
- package/src/tooling/inject.ts +43 -4
- package/src/tooling/scan.ts +35 -0
- package/src/tooling/subfolder.ts +110 -0
- package/tooling/base/configs/.husky/post-merge +5 -1
- package/tooling/python/configs/scripts/verify.sh +32 -15
- package/tooling/web/configs/scripts/verify.sh +32 -15
package/src/teach/nav.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
TEACH_MISSION,
|
|
19
19
|
TEACH_REFERENCE,
|
|
20
20
|
TEACH_STYLESHEET,
|
|
21
|
+
TEACH_STYLESHEET_BASE,
|
|
21
22
|
teachDir,
|
|
22
23
|
type TeachRefused,
|
|
23
24
|
type WorkspaceDetail,
|
|
@@ -35,12 +36,23 @@ const BRAND_MARK = 'assets/brand/mark.svg'
|
|
|
35
36
|
*/
|
|
36
37
|
const HAND_WRITTEN_ICON = `<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='10 10 80 80'%3E%3Cpath d='M34,20 L15,28 L15,72 L34,80 Z M66,20 L85,28 L85,72 L66,80 Z' fill='rgb(224,114,75)' /%3E%3Crect x='44' y='15' width='12' height='70' rx='2' fill='rgb(224,114,75)' /%3E%3C/svg%3E" />`
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
/**
|
|
40
|
+
* The stylesheet link lessons used to carry by hand outside every region. The
|
|
41
|
+
* `style` region now links the base sheet itself, so keeping this one would
|
|
42
|
+
* load the base twice.
|
|
43
|
+
*/
|
|
44
|
+
const HAND_WRITTEN_STYLESHEET = `<link rel="stylesheet" href="../${TEACH_ASSETS}/${TEACH_STYLESHEET}" />`
|
|
45
|
+
|
|
46
|
+
const HAND_WRITTEN_HEAD = [HAND_WRITTEN_ICON, HAND_WRITTEN_STYLESHEET]
|
|
47
|
+
|
|
48
|
+
function dropHandWrittenHead(html: string): string {
|
|
49
|
+
return HAND_WRITTEN_HEAD.reduce(
|
|
50
|
+
(text, link) => text.replaceAll(link, ''),
|
|
51
|
+
html
|
|
52
|
+
.split('\n')
|
|
53
|
+
.filter((line) => !HAND_WRITTEN_HEAD.includes(line.trim()))
|
|
54
|
+
.join('\n'),
|
|
55
|
+
)
|
|
44
56
|
}
|
|
45
57
|
|
|
46
58
|
/**
|
|
@@ -504,6 +516,11 @@ function titleCase(slug: string): string {
|
|
|
504
516
|
].join(' ')
|
|
505
517
|
}
|
|
506
518
|
|
|
519
|
+
/** The mission's own title, falling back to the slug where it names none. */
|
|
520
|
+
function workspaceLabel(workspace: WorkspaceSummary): string {
|
|
521
|
+
return workspace.title ?? titleCase(workspace.topic)
|
|
522
|
+
}
|
|
523
|
+
|
|
507
524
|
interface JumpEntry {
|
|
508
525
|
readonly ordinal: string
|
|
509
526
|
readonly label: string
|
|
@@ -629,7 +646,7 @@ function renderSidebar(sidebar: Sidebar): string {
|
|
|
629
646
|
|
|
630
647
|
return `<aside class="sb">
|
|
631
648
|
<div class="sb-top">
|
|
632
|
-
<details class="sb-ws" name="mast"><summary><span class="ws-name">${escapeHtml(sidebar.heading)}</span><span class="car">${CARET}</span></summary><div class="sb-wl"><ul class="jump-list">${renderJumpList(sidebar.switcher)}</ul></div></details>
|
|
649
|
+
<details class="sb-ws" name="mast"><summary><span class="ws-name" title="${escapeHtml(sidebar.heading)}">${escapeHtml(sidebar.heading)}</span><span class="car">${CARET}</span></summary><div class="sb-wl"><ul class="jump-list">${renderJumpList(sidebar.switcher)}</ul></div></details>
|
|
633
650
|
</div>
|
|
634
651
|
<div class="sb-scroll">
|
|
635
652
|
<div class="sb-meta">${escapeHtml(sidebar.meta)}</div>
|
|
@@ -724,7 +741,7 @@ function workspaceJumpEntries(
|
|
|
724
741
|
): JumpEntry[] {
|
|
725
742
|
return workspaces.map((workspace) => ({
|
|
726
743
|
ordinal: ordinalOf(workspace),
|
|
727
|
-
label:
|
|
744
|
+
label: workspaceLabel(workspace),
|
|
728
745
|
href: hrefForWorkspace(workspace, prefix),
|
|
729
746
|
at: workspace.slug === currentSlug,
|
|
730
747
|
trailing: String(workspace.lessons),
|
|
@@ -935,7 +952,7 @@ function renderRootPage(workspaces: readonly WorkspaceSummary[]): string {
|
|
|
935
952
|
meta: `${workspaces.length} workspace${workspaces.length === 1 ? '' : 's'}`,
|
|
936
953
|
items: workspaces.map((workspace) => ({
|
|
937
954
|
ordinal: ordinalOf(workspace),
|
|
938
|
-
label:
|
|
955
|
+
label: workspaceLabel(workspace),
|
|
939
956
|
href: hrefForWorkspace(workspace, '') ?? '#',
|
|
940
957
|
at: false,
|
|
941
958
|
})),
|
|
@@ -951,7 +968,7 @@ function renderRootPage(workspaces: readonly WorkspaceSummary[]): string {
|
|
|
951
968
|
? 'Live'
|
|
952
969
|
: 'Open'
|
|
953
970
|
const blurb = `${workspace.lessons} lesson(s) · ${workspace.reference} reference page(s) · ${workspace.terms} term(s)`
|
|
954
|
-
const inner = `<span class="num">${ordinalOf(workspace)}</span><b>${escapeHtml(
|
|
971
|
+
const inner = `<span class="num">${ordinalOf(workspace)}</span><b>${escapeHtml(workspaceLabel(workspace))}</b><span class="state">${state}</span><span class="blurb">${blurb}</span>`
|
|
955
972
|
|
|
956
973
|
return href === undefined
|
|
957
974
|
? `<li class="soon"><a href="#" aria-disabled="true" tabindex="-1">${inner}</a></li>`
|
|
@@ -1014,7 +1031,7 @@ async function renderContentsPage(
|
|
|
1014
1031
|
)
|
|
1015
1032
|
: undefined
|
|
1016
1033
|
|
|
1017
|
-
const title =
|
|
1034
|
+
const title = workspaceLabel(detail)
|
|
1018
1035
|
|
|
1019
1036
|
const segments: CrumbSegment[] = [
|
|
1020
1037
|
{
|
|
@@ -1026,7 +1043,7 @@ async function renderContentsPage(
|
|
|
1026
1043
|
},
|
|
1027
1044
|
},
|
|
1028
1045
|
{
|
|
1029
|
-
label:
|
|
1046
|
+
label: workspaceLabel(detail),
|
|
1030
1047
|
jump: {
|
|
1031
1048
|
ariaLabel: 'Jump to a lesson',
|
|
1032
1049
|
entries: lessonJumpEntries(metas, '', undefined),
|
|
@@ -1035,7 +1052,7 @@ async function renderContentsPage(
|
|
|
1035
1052
|
]
|
|
1036
1053
|
|
|
1037
1054
|
const sidebar: Sidebar = {
|
|
1038
|
-
heading:
|
|
1055
|
+
heading: workspaceLabel(detail),
|
|
1039
1056
|
switcher: workspaceJumpEntries(workspaces, '../', detail.slug),
|
|
1040
1057
|
meta: `${metas.length} lesson${metas.length === 1 ? '' : 's'}`,
|
|
1041
1058
|
items: metas.map((meta, index) => ({
|
|
@@ -1139,21 +1156,44 @@ interface LessonRefused {
|
|
|
1139
1156
|
readonly missing: Region
|
|
1140
1157
|
}
|
|
1141
1158
|
|
|
1142
|
-
/**
|
|
1143
|
-
* Splices the four chrome regions into one lesson file, in a fixed order so a
|
|
1144
|
-
* missing marker is always reported against the same region a session can
|
|
1145
|
-
* check first. Nothing is written when any region is missing, which is what
|
|
1146
|
-
* keeps a partially-spliced file off disk.
|
|
1147
|
-
*/
|
|
1148
1159
|
/**
|
|
1149
1160
|
* A lesson embeds the workspace stylesheet, and an `@import` resolves against
|
|
1150
1161
|
* the lesson's own folder there rather than the assets folder, so it never
|
|
1151
|
-
* loaded anything. The base sheet reaches the page through the `<link
|
|
1162
|
+
* loaded anything. The base sheet reaches the page through the `<link>` the
|
|
1163
|
+
* `style` region carries ahead of the embedded rules.
|
|
1152
1164
|
*/
|
|
1153
1165
|
function stripImports(css: string): string {
|
|
1154
1166
|
return css.replace(/^@import[^;]*;[ \t]*\n?/gm, '')
|
|
1155
1167
|
}
|
|
1156
1168
|
|
|
1169
|
+
const HEADER_CLOSE = '<!-- /canon:teach:header -->'
|
|
1170
|
+
const FOOTNAV_OPEN = '<!-- canon:teach:footnav -->'
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* The authored body between the header and footnav regions, wrapped in
|
|
1174
|
+
* `<main>` when it holds none. The skill has a session write the body bare,
|
|
1175
|
+
* and the column width hangs off `main`. A body already holding one is left
|
|
1176
|
+
* alone, which keeps a second run byte-identical.
|
|
1177
|
+
*/
|
|
1178
|
+
function wrapMain(html: string): string {
|
|
1179
|
+
const start = html.indexOf(HEADER_CLOSE)
|
|
1180
|
+
const end = html.indexOf(FOOTNAV_OPEN, start)
|
|
1181
|
+
if (start === -1 || end === -1) return html
|
|
1182
|
+
|
|
1183
|
+
const bodyStart = start + HEADER_CLOSE.length
|
|
1184
|
+
const body = html.slice(bodyStart, end)
|
|
1185
|
+
if (/<main[\s>]/.test(body)) return html
|
|
1186
|
+
|
|
1187
|
+
return `${html.slice(0, bodyStart)}\n<main>\n${body.trim()}\n</main>\n${html.slice(end)}`
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* Splices the four chrome regions into one lesson file, in a fixed order so a
|
|
1192
|
+
* missing marker is always reported against the same region a session can
|
|
1193
|
+
* check first, then wraps the authored body in `<main>` where it holds none.
|
|
1194
|
+
* Nothing is written when any region is missing, which is what keeps a
|
|
1195
|
+
* partially-spliced file off disk.
|
|
1196
|
+
*/
|
|
1157
1197
|
async function rewriteLesson(
|
|
1158
1198
|
root: string,
|
|
1159
1199
|
detail: WorkspaceDetail,
|
|
@@ -1164,7 +1204,7 @@ async function rewriteLesson(
|
|
|
1164
1204
|
): Promise<LessonRewritten | LessonRefused> {
|
|
1165
1205
|
const file = metas[index].file
|
|
1166
1206
|
const path = join(root, detail.path, TEACH_LESSONS, file)
|
|
1167
|
-
let html =
|
|
1207
|
+
let html = dropHandWrittenHead(await readFile(path, 'utf8'))
|
|
1168
1208
|
|
|
1169
1209
|
const teachPrefix = '../../'
|
|
1170
1210
|
const workspacePrefix = '../'
|
|
@@ -1180,7 +1220,7 @@ async function rewriteLesson(
|
|
|
1180
1220
|
},
|
|
1181
1221
|
},
|
|
1182
1222
|
{
|
|
1183
|
-
label:
|
|
1223
|
+
label: workspaceLabel(detail),
|
|
1184
1224
|
href: `${workspacePrefix}index.html`,
|
|
1185
1225
|
jump: {
|
|
1186
1226
|
ariaLabel: 'Jump to a lesson',
|
|
@@ -1190,7 +1230,7 @@ async function rewriteLesson(
|
|
|
1190
1230
|
{ label: `Lesson ${index + 1} of ${metas.length}` },
|
|
1191
1231
|
],
|
|
1192
1232
|
{
|
|
1193
|
-
heading:
|
|
1233
|
+
heading: workspaceLabel(detail),
|
|
1194
1234
|
switcher: workspaceJumpEntries(workspaces, teachPrefix, detail.slug),
|
|
1195
1235
|
meta: `${metas.length} lesson${metas.length === 1 ? '' : 's'}`,
|
|
1196
1236
|
items: metas.map((meta, i) => ({
|
|
@@ -1210,6 +1250,16 @@ async function rewriteLesson(
|
|
|
1210
1250
|
// the class the script reveals feedback with.
|
|
1211
1251
|
const legacy = html.includes(LEGACY_OPTION)
|
|
1212
1252
|
|
|
1253
|
+
// Linked ahead of the embedded rules so those still win the cascade.
|
|
1254
|
+
// Embedding it instead repeats a sheet carrying its fonts inline in every
|
|
1255
|
+
// lesson. A workspace seeded before the pair has no base sheet to link.
|
|
1256
|
+
const hasBase = existsSync(
|
|
1257
|
+
join(root, detail.path, TEACH_ASSETS, TEACH_STYLESHEET_BASE),
|
|
1258
|
+
)
|
|
1259
|
+
const baseLink = hasBase
|
|
1260
|
+
? `<link rel="stylesheet" href="../${TEACH_ASSETS}/${TEACH_STYLESHEET_BASE}">\n`
|
|
1261
|
+
: ''
|
|
1262
|
+
|
|
1213
1263
|
const regions: ReadonlyArray<readonly [Region, string]> = [
|
|
1214
1264
|
// The stepper follows the workspace stylesheet so it wins the cascade at
|
|
1215
1265
|
// equal specificity, which is what reaches a workspace seeded before it.
|
|
@@ -1218,7 +1268,7 @@ async function rewriteLesson(
|
|
|
1218
1268
|
// lesson would lack.
|
|
1219
1269
|
[
|
|
1220
1270
|
'style',
|
|
1221
|
-
`${teachFavicon()}\n<style>\n${legacy ? css : `${css}\n${QUIZ_CSS}`}\n</style>\n${headScript('lesson')}`,
|
|
1271
|
+
`${teachFavicon()}\n${baseLink}<style>\n${legacy ? css : `${css}\n${QUIZ_CSS}`}\n</style>\n${headScript('lesson')}`,
|
|
1222
1272
|
],
|
|
1223
1273
|
['header', header],
|
|
1224
1274
|
['footnav', `${renderFootNav(metas, index)}\n${PANE_CLOSE}`],
|
|
@@ -1231,7 +1281,7 @@ async function rewriteLesson(
|
|
|
1231
1281
|
html = spliced
|
|
1232
1282
|
}
|
|
1233
1283
|
|
|
1234
|
-
await writeFile(path, html)
|
|
1284
|
+
await writeFile(path, wrapMain(html))
|
|
1235
1285
|
return { ok: true, file }
|
|
1236
1286
|
}
|
|
1237
1287
|
|
package/src/tooling/inject.ts
CHANGED
|
@@ -10,19 +10,30 @@ import { ancestorsFirst, listFiles, type Manifest } from '@/tooling/manifest'
|
|
|
10
10
|
import {
|
|
11
11
|
applyScripts,
|
|
12
12
|
collectDeps,
|
|
13
|
+
collectScripts,
|
|
13
14
|
readPackage,
|
|
14
15
|
serializePackage,
|
|
15
16
|
} from '@/tooling/package'
|
|
16
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
hasSpellConfig,
|
|
19
|
+
isSubfolderTarget,
|
|
20
|
+
isWithheldInSubfolder,
|
|
21
|
+
SUBFOLDER_SPELL_CONFIG,
|
|
22
|
+
subfolderSpellConfig,
|
|
23
|
+
} from '@/tooling/subfolder'
|
|
24
|
+
import { logAdd, logRemove, logStep, logWarn } from '@/ui'
|
|
17
25
|
|
|
18
26
|
export async function injectConfigs(
|
|
19
27
|
chain: readonly Manifest[],
|
|
20
28
|
target: string,
|
|
21
29
|
): Promise<string[]> {
|
|
22
30
|
const applied: string[] = []
|
|
31
|
+
const isSubfolder = isSubfolderTarget(target)
|
|
23
32
|
|
|
24
33
|
for (const manifest of ancestorsFirst(chain)) {
|
|
25
|
-
const files = listFiles(manifest.configsDir)
|
|
34
|
+
const files = listFiles(manifest.configsDir).filter(
|
|
35
|
+
(rel) => !(isSubfolder && isWithheldInSubfolder(rel)),
|
|
36
|
+
)
|
|
26
37
|
if (files.length === 0) continue
|
|
27
38
|
|
|
28
39
|
logStep(`Applying ${manifest.name} configs`)
|
|
@@ -100,6 +111,16 @@ export async function injectSeeds(
|
|
|
100
111
|
}
|
|
101
112
|
}
|
|
102
113
|
|
|
114
|
+
const spellConfig = isSubfolderTarget(target)
|
|
115
|
+
? subfolderSpellConfig(chain)
|
|
116
|
+
: undefined
|
|
117
|
+
if (spellConfig !== undefined && !hasSpellConfig(target)) {
|
|
118
|
+
logStep('Applying subfolder spell config')
|
|
119
|
+
await writeFile(join(target, SUBFOLDER_SPELL_CONFIG), spellConfig)
|
|
120
|
+
logAdd(SUBFOLDER_SPELL_CONFIG)
|
|
121
|
+
applied.push(SUBFOLDER_SPELL_CONFIG)
|
|
122
|
+
}
|
|
123
|
+
|
|
103
124
|
return applied
|
|
104
125
|
}
|
|
105
126
|
|
|
@@ -158,8 +179,9 @@ export async function pruneGitignore(
|
|
|
158
179
|
|
|
159
180
|
/**
|
|
160
181
|
* Installs missing dev dependencies, fills package scripts, and merges
|
|
161
|
-
* gitignore entries.
|
|
162
|
-
*
|
|
182
|
+
* gitignore entries. A target with no package.json gets the gitignore alone,
|
|
183
|
+
* plus a warning naming every install it skipped, since seeding a package.json
|
|
184
|
+
* would pick a package manager and a project name on the target's behalf.
|
|
163
185
|
*/
|
|
164
186
|
export async function injectManifest(
|
|
165
187
|
chain: readonly Manifest[],
|
|
@@ -192,4 +214,21 @@ export async function injectManifest(
|
|
|
192
214
|
}
|
|
193
215
|
|
|
194
216
|
await injectGitignore(chain, target)
|
|
217
|
+
|
|
218
|
+
if (!pkg) warnSkippedInstalls(chain, target)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function warnSkippedInstalls(chain: readonly Manifest[], target: string): void {
|
|
222
|
+
const deps = collectDeps(chain, {}).map((dep) => dep.spec)
|
|
223
|
+
const scripts = collectScripts(chain, {}).map((script) => script.key)
|
|
224
|
+
if (deps.length === 0 && scripts.length === 0) return
|
|
225
|
+
|
|
226
|
+
logStep('Skipped installs')
|
|
227
|
+
if (deps.length > 0) {
|
|
228
|
+
logWarn(`Dev dependencies not installed: ${deps.join(', ')}`)
|
|
229
|
+
}
|
|
230
|
+
if (scripts.length > 0) {
|
|
231
|
+
logWarn(`Scripts not added: ${scripts.join(', ')}`)
|
|
232
|
+
}
|
|
233
|
+
logWarn(`No package.json: run 'bun init' in ${target}, then sync again`)
|
|
195
234
|
}
|
package/src/tooling/scan.ts
CHANGED
|
@@ -10,6 +10,13 @@ import {
|
|
|
10
10
|
readPackage,
|
|
11
11
|
type ScriptState,
|
|
12
12
|
} from '@/tooling/package'
|
|
13
|
+
import {
|
|
14
|
+
hasSpellConfig,
|
|
15
|
+
isSubfolderTarget,
|
|
16
|
+
isWithheldInSubfolder,
|
|
17
|
+
SUBFOLDER_SPELL_CONFIG,
|
|
18
|
+
subfolderSpellConfig,
|
|
19
|
+
} from '@/tooling/subfolder'
|
|
13
20
|
|
|
14
21
|
export interface ConfigState {
|
|
15
22
|
readonly rel: string
|
|
@@ -27,8 +34,15 @@ export interface EntryState {
|
|
|
27
34
|
readonly state: 'missing' | 'present'
|
|
28
35
|
}
|
|
29
36
|
|
|
37
|
+
export interface WithheldState {
|
|
38
|
+
readonly rel: string
|
|
39
|
+
readonly stack: string
|
|
40
|
+
readonly present: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
export interface ScanResult {
|
|
31
44
|
readonly configs: readonly ConfigState[]
|
|
45
|
+
readonly withheld: readonly WithheldState[]
|
|
32
46
|
readonly seeds: readonly SeedState[]
|
|
33
47
|
readonly scripts: readonly ScriptState[]
|
|
34
48
|
readonly deps: readonly DepState[]
|
|
@@ -48,9 +62,16 @@ function isIdentical(a: string, b: string): boolean {
|
|
|
48
62
|
* category and mirrors the bash: configs, seeds, and scripts resolve nearest
|
|
49
63
|
* stack first, while dependencies and gitignore entries resolve from the
|
|
50
64
|
* furthest ancestor inward.
|
|
65
|
+
*
|
|
66
|
+
* A target below its git toplevel is scanned as a subfolder: a path GitHub
|
|
67
|
+
* reads only at the root moves to `withheld` and counts as no change, and the
|
|
68
|
+
* nested spell config joins the seeds. The scope is read here so every caller
|
|
69
|
+
* of `scan` agrees without passing a flag.
|
|
51
70
|
*/
|
|
52
71
|
export function scan(chain: readonly Manifest[], target: string): ScanResult {
|
|
72
|
+
const isSubfolder = isSubfolderTarget(target)
|
|
53
73
|
const configs: ConfigState[] = []
|
|
74
|
+
const withheld: WithheldState[] = []
|
|
54
75
|
const seenConfigs = new Set<string>()
|
|
55
76
|
|
|
56
77
|
for (const manifest of chain) {
|
|
@@ -60,6 +81,12 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
|
|
|
60
81
|
|
|
61
82
|
const source = join(manifest.configsDir, rel)
|
|
62
83
|
const dest = join(target, rel)
|
|
84
|
+
|
|
85
|
+
if (isSubfolder && isWithheldInSubfolder(rel)) {
|
|
86
|
+
withheld.push({ rel, stack: manifest.name, present: existsSync(dest) })
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
|
|
63
90
|
const state = !existsSync(dest)
|
|
64
91
|
? 'new'
|
|
65
92
|
: isIdentical(source, dest)
|
|
@@ -87,6 +114,13 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
|
|
|
87
114
|
}
|
|
88
115
|
}
|
|
89
116
|
|
|
117
|
+
if (isSubfolder && subfolderSpellConfig(chain) !== undefined) {
|
|
118
|
+
seeds.push({
|
|
119
|
+
rel: SUBFOLDER_SPELL_CONFIG,
|
|
120
|
+
state: hasSpellConfig(target) ? 'present' : 'missing',
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
90
124
|
const packagePath = join(target, 'package.json')
|
|
91
125
|
const pkg = readPackage(packagePath)
|
|
92
126
|
const scripts = pkg ? collectScripts(chain, pkg) : []
|
|
@@ -107,6 +141,7 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
|
|
|
107
141
|
|
|
108
142
|
return {
|
|
109
143
|
configs,
|
|
144
|
+
withheld,
|
|
110
145
|
seeds,
|
|
111
146
|
scripts,
|
|
112
147
|
deps,
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { existsSync, readdirSync, realpathSync } from 'node:fs'
|
|
2
|
+
import { basename, join, relative } from 'node:path'
|
|
3
|
+
import { gitEnv } from '@/git-env'
|
|
4
|
+
import { listFiles, type Manifest } from '@/tooling/manifest'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The path the generated subfolder spell config is written to. Written
|
|
8
|
+
* copy-once, since a project that already configured cspell here owns it.
|
|
9
|
+
*/
|
|
10
|
+
export const SUBFOLDER_SPELL_CONFIG = 'cspell.json'
|
|
11
|
+
|
|
12
|
+
const WITHHELD_PREFIX = '.github/'
|
|
13
|
+
|
|
14
|
+
const SPELL_CONFIG_NAMES = ['cspell.json', '.cspell.json']
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether the target sits below the root of the git repository holding it. A
|
|
18
|
+
* target outside any repository, or one git cannot read, counts as a root, so
|
|
19
|
+
* a scratch folder with no repository keeps the behavior a root sync has.
|
|
20
|
+
*
|
|
21
|
+
* Synchronous because `scan` and `buildToolingReport` are, which is why this
|
|
22
|
+
* does not reuse the async worktree reader.
|
|
23
|
+
*/
|
|
24
|
+
export function isSubfolderTarget(target: string): boolean {
|
|
25
|
+
return subfolderPath(target) !== undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The target's path relative to its git toplevel, which is what a root CI
|
|
30
|
+
* job names as its `working-directory`. Undefined wherever
|
|
31
|
+
* `isSubfolderTarget` reads a root.
|
|
32
|
+
*/
|
|
33
|
+
export function subfolderPath(target: string): string | undefined {
|
|
34
|
+
const result = Bun.spawnSync(
|
|
35
|
+
['git', '-C', target, 'rev-parse', '--show-toplevel'],
|
|
36
|
+
{ env: gitEnv(), stdout: 'pipe', stderr: 'ignore' },
|
|
37
|
+
)
|
|
38
|
+
if (result.exitCode !== 0) return undefined
|
|
39
|
+
|
|
40
|
+
const toplevel = result.stdout.toString().trim()
|
|
41
|
+
if (toplevel === '') return undefined
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const path = relative(realpathSync(toplevel), realpathSync(target))
|
|
45
|
+
return path === '' ? undefined : path
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* GitHub reads workflows only at the repository root, so a `.github/` copied
|
|
53
|
+
* into a subfolder is a file nothing runs. `scan` and `injectConfigs` both
|
|
54
|
+
* read this, so the set the diff withholds and the set the sync skips cannot
|
|
55
|
+
* disagree.
|
|
56
|
+
*/
|
|
57
|
+
export function isWithheldInSubfolder(rel: string): boolean {
|
|
58
|
+
return rel.startsWith(WITHHELD_PREFIX)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether the target already carries a cspell config of its own, in any of
|
|
63
|
+
* the file names cspell searches for.
|
|
64
|
+
*/
|
|
65
|
+
export function hasSpellConfig(target: string): boolean {
|
|
66
|
+
if (SPELL_CONFIG_NAMES.some((name) => existsSync(join(target, name)))) {
|
|
67
|
+
return true
|
|
68
|
+
}
|
|
69
|
+
if (!existsSync(target)) return false
|
|
70
|
+
return readdirSync(target).some((name) => name.startsWith('cspell.config.'))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Builds the nested cspell config that registers a subfolder's seeded word
|
|
75
|
+
* lists. A root `cspell '**'` merges it with the root config for files under
|
|
76
|
+
* the subfolder. Each dictionary takes a `local-` name, since reusing a root
|
|
77
|
+
* dictionary's name shadows it inside the subfolder and a root word then fails
|
|
78
|
+
* there.
|
|
79
|
+
*
|
|
80
|
+
* Returns nothing when the chain carries `base`, whose own seed already writes
|
|
81
|
+
* a config, or when no stack seeds a word list.
|
|
82
|
+
*/
|
|
83
|
+
export function subfolderSpellConfig(
|
|
84
|
+
chain: readonly Manifest[],
|
|
85
|
+
): string | undefined {
|
|
86
|
+
if (chain.some((manifest) => manifest.name === 'base')) return undefined
|
|
87
|
+
|
|
88
|
+
const lists = new Set<string>()
|
|
89
|
+
for (const manifest of chain) {
|
|
90
|
+
for (const rel of listFiles(manifest.seedsDir)) {
|
|
91
|
+
if (rel.startsWith('.cspell/') && rel.endsWith('.txt')) lists.add(rel)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (lists.size === 0) return undefined
|
|
95
|
+
|
|
96
|
+
const definitions = [...lists].sort().map((path) => ({
|
|
97
|
+
name: `local-${basename(path, '.txt')}`,
|
|
98
|
+
path,
|
|
99
|
+
addWords: true,
|
|
100
|
+
}))
|
|
101
|
+
|
|
102
|
+
const config = {
|
|
103
|
+
version: '0.2',
|
|
104
|
+
dictionaryDefinitions: definitions,
|
|
105
|
+
dictionaries: definitions.map((definition) => definition.name),
|
|
106
|
+
ignorePaths: ['.cspell/**'],
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return `${JSON.stringify(config, null, 2)}\n`
|
|
110
|
+
}
|
|
@@ -82,7 +82,11 @@ if command -v canon >/dev/null 2>&1; then
|
|
|
82
82
|
# ran the one-time setup. Nothing to report.
|
|
83
83
|
if [ "$reason" != "no-repository" ]; then
|
|
84
84
|
printf '\n🗄️ Records not backed up: %s\n' "${reason:-unknown}"
|
|
85
|
-
|
|
85
|
+
if [ "$reason" = "unsafe-payload" ]; then
|
|
86
|
+
printf 'Run canon records push to see the blocked paths.\n\n'
|
|
87
|
+
else
|
|
88
|
+
printf 'Run canon records push when the remote is reachable.\n\n'
|
|
89
|
+
fi
|
|
86
90
|
fi
|
|
87
91
|
fi
|
|
88
92
|
fi
|
|
@@ -16,6 +16,7 @@ log_error() {
|
|
|
16
16
|
exit 1
|
|
17
17
|
}
|
|
18
18
|
log_step() { echo -e "${GREY}│${NC}\n${GREY}├${NC} ${WHITE}$1${NC}"; }
|
|
19
|
+
log_skip() { echo -e "${GREY}│ - $1${NC}"; }
|
|
19
20
|
|
|
20
21
|
pipe_output() { while IFS= read -r line; do echo -e "${GREY}│${NC} $line"; done; }
|
|
21
22
|
|
|
@@ -24,6 +25,32 @@ check_dependencies() {
|
|
|
24
25
|
command -v uv >/dev/null 2>&1 || log_error "uv is not installed"
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
check_package() {
|
|
29
|
+
[ -f package.json ] || log_error "No package.json here. Run 'bun init', then sync the stack again"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
has_script() {
|
|
33
|
+
bun -e 'process.exit(require(process.cwd() + "/package.json").scripts?.[process.argv[1]] ? 0 : 1)' "$1"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# A subfolder synced with --skip base declares none of base's scripts, since
|
|
37
|
+
# the repository root owns them. Each base phase runs only where its script is
|
|
38
|
+
# declared, so this script passes on its own in that subfolder.
|
|
39
|
+
run_base_phase() {
|
|
40
|
+
local title=$1
|
|
41
|
+
local script=$2
|
|
42
|
+
local err_msg=$3
|
|
43
|
+
local pass_msg=$4
|
|
44
|
+
|
|
45
|
+
log_step "$title"
|
|
46
|
+
if ! has_script "$script"; then
|
|
47
|
+
log_skip "Skipped: $script is not declared here, so the repository root runs it"
|
|
48
|
+
return
|
|
49
|
+
fi
|
|
50
|
+
run_check "bun run $script" "$err_msg"
|
|
51
|
+
log_info "$pass_msg"
|
|
52
|
+
}
|
|
53
|
+
|
|
27
54
|
run_check() {
|
|
28
55
|
local cmd=$1
|
|
29
56
|
local err_msg=$2
|
|
@@ -37,6 +64,7 @@ run_check() {
|
|
|
37
64
|
|
|
38
65
|
main() {
|
|
39
66
|
check_dependencies
|
|
67
|
+
check_package
|
|
40
68
|
|
|
41
69
|
if [ "$NESTED" = false ]; then echo -e "${GREY}┌${NC}"; fi
|
|
42
70
|
|
|
@@ -48,21 +76,10 @@ main() {
|
|
|
48
76
|
run_check "bun run lint" "Lint failed"
|
|
49
77
|
log_info "Lint passed"
|
|
50
78
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
log_step "Format check"
|
|
56
|
-
run_check "bun run check:format" "Format check failed"
|
|
57
|
-
log_info "Format check passed"
|
|
58
|
-
|
|
59
|
-
log_step "Spelling"
|
|
60
|
-
run_check "bun run check:spell" "Spell check failed"
|
|
61
|
-
log_info "Spell check passed"
|
|
62
|
-
|
|
63
|
-
log_step "Shell"
|
|
64
|
-
run_check "bun run check:shell" "Shell check failed"
|
|
65
|
-
log_info "Shell check passed"
|
|
79
|
+
run_base_phase "Formatting" "format" "Format failed" "Format applied"
|
|
80
|
+
run_base_phase "Format check" "check:format" "Format check failed" "Format check passed"
|
|
81
|
+
run_base_phase "Spelling" "check:spell" "Spell check failed" "Spell check passed"
|
|
82
|
+
run_base_phase "Shell" "check:shell" "Shell check failed" "Shell check passed"
|
|
66
83
|
|
|
67
84
|
log_step "Unit tests"
|
|
68
85
|
run_check "bun run test:run" "Unit tests failed"
|
|
@@ -16,6 +16,7 @@ log_error() {
|
|
|
16
16
|
exit 1
|
|
17
17
|
}
|
|
18
18
|
log_step() { echo -e "${GREY}│${NC}\n${GREY}├${NC} ${WHITE}$1${NC}"; }
|
|
19
|
+
log_skip() { echo -e "${GREY}│ - $1${NC}"; }
|
|
19
20
|
|
|
20
21
|
pipe_output() { while IFS= read -r line; do echo -e "${GREY}│${NC} $line"; done; }
|
|
21
22
|
|
|
@@ -23,6 +24,32 @@ check_dependencies() {
|
|
|
23
24
|
command -v bun >/dev/null 2>&1 || log_error "bun is not installed"
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
check_package() {
|
|
28
|
+
[ -f package.json ] || log_error "No package.json here. Run 'bun init', then sync the stack again"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
has_script() {
|
|
32
|
+
bun -e 'process.exit(require(process.cwd() + "/package.json").scripts?.[process.argv[1]] ? 0 : 1)' "$1"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# A subfolder synced with --skip base declares none of base's scripts, since
|
|
36
|
+
# the repository root owns them. Each base phase runs only where its script is
|
|
37
|
+
# declared, so this script passes on its own in that subfolder.
|
|
38
|
+
run_base_phase() {
|
|
39
|
+
local title=$1
|
|
40
|
+
local script=$2
|
|
41
|
+
local err_msg=$3
|
|
42
|
+
local pass_msg=$4
|
|
43
|
+
|
|
44
|
+
log_step "$title"
|
|
45
|
+
if ! has_script "$script"; then
|
|
46
|
+
log_skip "Skipped: $script is not declared here, so the repository root runs it"
|
|
47
|
+
return
|
|
48
|
+
fi
|
|
49
|
+
run_check "bun run $script" "$err_msg"
|
|
50
|
+
log_info "$pass_msg"
|
|
51
|
+
}
|
|
52
|
+
|
|
26
53
|
run_check() {
|
|
27
54
|
local cmd=$1
|
|
28
55
|
local err_msg=$2
|
|
@@ -36,6 +63,7 @@ run_check() {
|
|
|
36
63
|
|
|
37
64
|
main() {
|
|
38
65
|
check_dependencies
|
|
66
|
+
check_package
|
|
39
67
|
|
|
40
68
|
if [ "$NESTED" = false ]; then echo -e "${GREY}┌${NC}"; fi
|
|
41
69
|
|
|
@@ -47,21 +75,10 @@ main() {
|
|
|
47
75
|
run_check "bun run lint" "Lint failed"
|
|
48
76
|
log_info "Lint passed"
|
|
49
77
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
log_step "Format check"
|
|
55
|
-
run_check "bun run check:format" "Format check failed"
|
|
56
|
-
log_info "Format check passed"
|
|
57
|
-
|
|
58
|
-
log_step "Spelling"
|
|
59
|
-
run_check "bun run check:spell" "Spell check failed"
|
|
60
|
-
log_info "Spell check passed"
|
|
61
|
-
|
|
62
|
-
log_step "Shell"
|
|
63
|
-
run_check "bun run check:shell" "Shell check failed"
|
|
64
|
-
log_info "Shell check passed"
|
|
78
|
+
run_base_phase "Formatting" "format" "Format failed" "Format applied"
|
|
79
|
+
run_base_phase "Format check" "check:format" "Format check failed" "Format check passed"
|
|
80
|
+
run_base_phase "Spelling" "check:spell" "Spell check failed" "Spell check passed"
|
|
81
|
+
run_base_phase "Shell" "check:shell" "Shell check failed" "Shell check passed"
|
|
65
82
|
|
|
66
83
|
log_step "Unit tests"
|
|
67
84
|
run_check "bun run test:run" "Unit tests failed"
|