@erclx/canon 4.114.0 → 4.115.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/document-health/references/axes.md +1 -1
- package/claude/skills/target-setup/SKILL.md +1 -1
- package/claude/skills/target-setup/references/detect.md +43 -9
- package/claude/skills/teach-workspace/SKILL.md +5 -3
- package/docs/agents/audits.md +4 -2
- package/docs/agents/commands.md +1 -1
- package/docs/agents/context-audit-checks.md +6 -6
- package/docs/agents/context-audit.md +1 -1
- package/docs/agents/context-classify.md +1 -1
- package/docs/agents/teach.md +6 -0
- package/docs/target-projects.md +4 -2
- package/package.json +1 -1
- package/src/audits/catalog.ts +52 -0
- package/src/commands/context.ts +30 -10
- package/src/commands/teach.ts +4 -1
- package/src/context/audit.ts +54 -18
- package/src/teach/nav.ts +203 -9
- package/standards/teach.md +13 -12
- package/tooling/go/configs/.golangci.yml +25 -0
- package/tooling/go/configs/scripts/verify.sh +95 -0
- package/tooling/go/manifest.toml +13 -0
- package/tooling/go/reference.md +60 -0
- package/tooling/go/seeds/.cspell/tech-stack.txt +16 -0
- package/tooling/go/seeds/smoke_test.go +9 -0
- package/tooling/php/configs/.php-cs-fixer.dist.php +18 -0
- package/tooling/php/configs/phpstan.neon +5 -0
- package/tooling/php/configs/phpunit.xml +18 -0
- package/tooling/php/configs/scripts/verify.sh +100 -0
- package/tooling/php/manifest.toml +15 -0
- package/tooling/php/reference.md +74 -0
- package/tooling/php/seeds/.cspell/tech-stack.txt +5 -0
- package/tooling/php/seeds/tests/SmokeTest.php +13 -0
package/src/teach/nav.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
-
import { readFile, writeFile } from 'node:fs/promises'
|
|
3
|
-
import { join, relative } from 'node:path'
|
|
2
|
+
import { readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { join, posix, relative } from 'node:path'
|
|
4
4
|
import {
|
|
5
5
|
TEACH_SIDEBAR_BREAKPOINT,
|
|
6
6
|
TEACH_STYLESHEET_COMPONENTS,
|
|
7
7
|
} from '@/design/components'
|
|
8
8
|
import { buildDesignCss } from '@/design/css'
|
|
9
9
|
import { FAVICON_COLORS, faviconLink, renderFavicon } from '@/design/favicon'
|
|
10
|
+
import { stripFrontmatter } from '@/frontmatter'
|
|
10
11
|
import { parseFrontmatter, readField } from '@/indexes/frontmatter'
|
|
11
12
|
import { PROJECT_ROOT } from '@/project-root'
|
|
12
13
|
import { TEACH_FONT_FACES } from '@/teach/fonts'
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
listWorkspaces,
|
|
15
16
|
readWorkspace,
|
|
16
17
|
TEACH_ASSETS,
|
|
18
|
+
TEACH_GLOSSARY,
|
|
17
19
|
TEACH_LESSONS,
|
|
18
20
|
TEACH_MISSION,
|
|
19
21
|
TEACH_REFERENCE,
|
|
@@ -695,6 +697,8 @@ function pageHead(
|
|
|
695
697
|
title: string,
|
|
696
698
|
cssHref: string | undefined,
|
|
697
699
|
embeddedCss: string | undefined,
|
|
700
|
+
page: 'index' | 'lesson' = 'index',
|
|
701
|
+
extraHead = '',
|
|
698
702
|
): string {
|
|
699
703
|
const style =
|
|
700
704
|
embeddedCss === undefined
|
|
@@ -706,10 +710,10 @@ function pageHead(
|
|
|
706
710
|
<head>
|
|
707
711
|
<meta charset="utf-8">
|
|
708
712
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
709
|
-
<title>${escapeHtml(title)}</title>
|
|
713
|
+
${extraHead}<title>${escapeHtml(title)}</title>
|
|
710
714
|
${teachFavicon()}
|
|
711
715
|
${style}
|
|
712
|
-
${headScript(
|
|
716
|
+
${headScript(page)}
|
|
713
717
|
</head>
|
|
714
718
|
`
|
|
715
719
|
}
|
|
@@ -1015,13 +1019,15 @@ async function readLessonMetas(
|
|
|
1015
1019
|
* on disk: the mission, the lessons already written, the reference pages, and
|
|
1016
1020
|
* the glossary. A workspace with no `index.html` yet gets one the same way a
|
|
1017
1021
|
* workspace that already had one gets its rewrite, since both read the same
|
|
1018
|
-
* sources.
|
|
1022
|
+
* sources. A reference row links the rendered sibling, or the markdown itself
|
|
1023
|
+
* on a Bun that cannot render one, so no row points at a file never written.
|
|
1019
1024
|
*/
|
|
1020
1025
|
async function renderContentsPage(
|
|
1021
1026
|
root: string,
|
|
1022
1027
|
workspaces: readonly WorkspaceSummary[],
|
|
1023
1028
|
detail: WorkspaceDetail,
|
|
1024
1029
|
metas: readonly LessonMeta[],
|
|
1030
|
+
canRenderMarkdown: boolean,
|
|
1025
1031
|
): Promise<string> {
|
|
1026
1032
|
const missionPath = join(root, detail.path, TEACH_MISSION)
|
|
1027
1033
|
const description = existsSync(missionPath)
|
|
@@ -1076,9 +1082,10 @@ async function renderContentsPage(
|
|
|
1076
1082
|
detail.referenceFiles.map(async (file, index) => {
|
|
1077
1083
|
const title = await readTitle(
|
|
1078
1084
|
join(root, detail.path, TEACH_REFERENCE, file),
|
|
1079
|
-
|
|
1085
|
+
referenceFallbackTitle(file),
|
|
1080
1086
|
)
|
|
1081
|
-
|
|
1087
|
+
const target = canRenderMarkdown ? renderedReferenceName(file) : file
|
|
1088
|
+
return `<li><a href="${TEACH_REFERENCE}/${target}"><span class="num">R${index + 1}</span><b>${escapeHtml(title)}</b></a></li>`
|
|
1082
1089
|
}),
|
|
1083
1090
|
)
|
|
1084
1091
|
).join('')
|
|
@@ -1117,6 +1124,157 @@ ${renderScripts(false, true)}
|
|
|
1117
1124
|
`
|
|
1118
1125
|
}
|
|
1119
1126
|
|
|
1127
|
+
/**
|
|
1128
|
+
* Marks a reference page as nav's own output, which is the one test nav applies
|
|
1129
|
+
* before deleting a page whose markdown source is gone. A hand-written HTML
|
|
1130
|
+
* file beside the reference pages carries none and is never touched.
|
|
1131
|
+
*/
|
|
1132
|
+
const REFERENCE_GENERATOR = '<meta name="generator" content="canon teach nav">'
|
|
1133
|
+
|
|
1134
|
+
/** A formatter closes the tag as `/>`, which still marks the page as generated. */
|
|
1135
|
+
const REFERENCE_GENERATOR_PATTERN =
|
|
1136
|
+
/<meta name="generator" content="canon teach nav"\s*\/?>/
|
|
1137
|
+
|
|
1138
|
+
function renderedReferenceName(file: string): string {
|
|
1139
|
+
return file.replace(/\.md$/, '.html')
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function referenceFallbackTitle(file: string): string {
|
|
1143
|
+
return titleCase(file.replace(/\.md$/, ''))
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* A relative href landing on another reference page's markdown is retargeted
|
|
1148
|
+
* to its rendered sibling, and one landing on the workspace glossary goes to
|
|
1149
|
+
* the glossary the contents page renders under `#gloss`, dropping any fragment
|
|
1150
|
+
* since the entries there carry no per-term anchor. The resources page has no
|
|
1151
|
+
* rendered form, so a blanket suffix swap would break a link to it.
|
|
1152
|
+
*/
|
|
1153
|
+
function retargetReferenceLinks(
|
|
1154
|
+
html: string,
|
|
1155
|
+
referenceFiles: ReadonlySet<string>,
|
|
1156
|
+
): string {
|
|
1157
|
+
return html.replace(
|
|
1158
|
+
/href="([^"#?:]+)\.md([#?][^"]*)?"/g,
|
|
1159
|
+
(match, stem: string, suffix: string | undefined) => {
|
|
1160
|
+
const target = posix.normalize(posix.join(TEACH_REFERENCE, `${stem}.md`))
|
|
1161
|
+
if (target === TEACH_GLOSSARY) return 'href="../index.html#gloss"'
|
|
1162
|
+
|
|
1163
|
+
const [folder, file, ...rest] = target.split('/')
|
|
1164
|
+
if (folder !== TEACH_REFERENCE || rest.length > 0) return match
|
|
1165
|
+
if (!referenceFiles.has(file)) return match
|
|
1166
|
+
return `href="${stem}.html${suffix ?? ''}"`
|
|
1167
|
+
},
|
|
1168
|
+
)
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* One reference page as a styled page carrying the workspace chrome. The
|
|
1173
|
+
* markdown beside it stays the durable, promotable half, and this file is
|
|
1174
|
+
* regenerated from it on every run. Raw HTML in the markdown is escaped rather
|
|
1175
|
+
* than passed through, since a reference page is plain markdown by contract.
|
|
1176
|
+
*/
|
|
1177
|
+
async function renderReferencePage(
|
|
1178
|
+
root: string,
|
|
1179
|
+
workspaces: readonly WorkspaceSummary[],
|
|
1180
|
+
detail: WorkspaceDetail,
|
|
1181
|
+
metas: readonly LessonMeta[],
|
|
1182
|
+
file: string,
|
|
1183
|
+
): Promise<string> {
|
|
1184
|
+
const source = await readFile(
|
|
1185
|
+
join(root, detail.path, TEACH_REFERENCE, file),
|
|
1186
|
+
'utf8',
|
|
1187
|
+
)
|
|
1188
|
+
const title =
|
|
1189
|
+
readField(parseFrontmatter(source), 'title') ?? referenceFallbackTitle(file)
|
|
1190
|
+
|
|
1191
|
+
const rendered = retargetReferenceLinks(
|
|
1192
|
+
Bun.markdown.html(stripFrontmatter(source), {
|
|
1193
|
+
noHtmlBlocks: true,
|
|
1194
|
+
noHtmlSpans: true,
|
|
1195
|
+
}),
|
|
1196
|
+
new Set(detail.referenceFiles),
|
|
1197
|
+
)
|
|
1198
|
+
const body = /<h1[\s>]/.test(rendered)
|
|
1199
|
+
? rendered
|
|
1200
|
+
: `<h1>${escapeHtml(title)}</h1>\n${rendered}`
|
|
1201
|
+
|
|
1202
|
+
const teachPrefix = '../../'
|
|
1203
|
+
const workspacePrefix = '../'
|
|
1204
|
+
|
|
1205
|
+
const header = renderHeader(
|
|
1206
|
+
[
|
|
1207
|
+
{
|
|
1208
|
+
label: 'Workspaces',
|
|
1209
|
+
href: `${teachPrefix}index.html`,
|
|
1210
|
+
jump: {
|
|
1211
|
+
ariaLabel: 'Switch workspace',
|
|
1212
|
+
entries: workspaceJumpEntries(workspaces, teachPrefix, detail.slug),
|
|
1213
|
+
},
|
|
1214
|
+
},
|
|
1215
|
+
{
|
|
1216
|
+
label: workspaceLabel(detail),
|
|
1217
|
+
href: `${workspacePrefix}index.html`,
|
|
1218
|
+
jump: {
|
|
1219
|
+
ariaLabel: 'Jump to a lesson',
|
|
1220
|
+
entries: lessonJumpEntries(metas, workspacePrefix, undefined),
|
|
1221
|
+
},
|
|
1222
|
+
},
|
|
1223
|
+
{ label: title },
|
|
1224
|
+
],
|
|
1225
|
+
{
|
|
1226
|
+
heading: workspaceLabel(detail),
|
|
1227
|
+
switcher: workspaceJumpEntries(workspaces, teachPrefix, detail.slug),
|
|
1228
|
+
meta: `${metas.length} lesson${metas.length === 1 ? '' : 's'}`,
|
|
1229
|
+
items: metas.map((meta, index) => ({
|
|
1230
|
+
ordinal: String(index + 1).padStart(2, '0'),
|
|
1231
|
+
label: meta.title,
|
|
1232
|
+
href: `${workspacePrefix}${TEACH_LESSONS}/${meta.file}`,
|
|
1233
|
+
at: false,
|
|
1234
|
+
})),
|
|
1235
|
+
foot: workspaceFoot(detail),
|
|
1236
|
+
},
|
|
1237
|
+
)
|
|
1238
|
+
|
|
1239
|
+
// The sidebar opens as on a lesson, since this page lists no lessons itself.
|
|
1240
|
+
return `${pageHead(title, `${workspacePrefix}${TEACH_ASSETS}/${TEACH_STYLESHEET}`, undefined, 'lesson', `${REFERENCE_GENERATOR}\n`)}<body>
|
|
1241
|
+
${header}
|
|
1242
|
+
<main class="ref">
|
|
1243
|
+
${body.trim()}
|
|
1244
|
+
</main>
|
|
1245
|
+
${PANE_CLOSE}
|
|
1246
|
+
${renderScripts(false, false)}
|
|
1247
|
+
</body>
|
|
1248
|
+
</html>
|
|
1249
|
+
`
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Deletes a generated reference page whose markdown source is gone, so a
|
|
1254
|
+
* renamed or removed page leaves nothing no contents row reaches.
|
|
1255
|
+
*/
|
|
1256
|
+
async function removeOrphanReferencePages(
|
|
1257
|
+
root: string,
|
|
1258
|
+
detail: WorkspaceDetail,
|
|
1259
|
+
): Promise<void> {
|
|
1260
|
+
const dir = join(root, detail.path, TEACH_REFERENCE)
|
|
1261
|
+
if (!existsSync(dir)) return
|
|
1262
|
+
|
|
1263
|
+
const sources = new Set(detail.referenceFiles.map(renderedReferenceName))
|
|
1264
|
+
const orphans = (await readdir(dir)).filter(
|
|
1265
|
+
(file) => file.endsWith('.html') && !sources.has(file),
|
|
1266
|
+
)
|
|
1267
|
+
|
|
1268
|
+
await Promise.all(
|
|
1269
|
+
orphans.map(async (file) => {
|
|
1270
|
+
const path = join(dir, file)
|
|
1271
|
+
if (REFERENCE_GENERATOR_PATTERN.test(await readFile(path, 'utf8'))) {
|
|
1272
|
+
await rm(path)
|
|
1273
|
+
}
|
|
1274
|
+
}),
|
|
1275
|
+
)
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1120
1278
|
const REGIONS = ['style', 'header', 'footnav', 'scripts'] as const
|
|
1121
1279
|
type Region = (typeof REGIONS)[number]
|
|
1122
1280
|
|
|
@@ -1296,10 +1454,22 @@ export interface NavGenerated {
|
|
|
1296
1454
|
readonly root: string
|
|
1297
1455
|
readonly contents: readonly string[]
|
|
1298
1456
|
readonly lessons: number
|
|
1299
|
-
/**
|
|
1457
|
+
/** Reference pages rendered to their `.html` sibling. */
|
|
1458
|
+
readonly reference: number
|
|
1459
|
+
/**
|
|
1460
|
+
* A lesson file missing a chrome marker, refused rather than rewritten, or a
|
|
1461
|
+
* reference page left unrendered on a Bun carrying no markdown renderer.
|
|
1462
|
+
*/
|
|
1300
1463
|
readonly skipped: readonly LessonSkipped[]
|
|
1301
1464
|
}
|
|
1302
1465
|
|
|
1466
|
+
const MARKDOWN_RENDERER = 'Bun.markdown'
|
|
1467
|
+
|
|
1468
|
+
/** An older Bun carries no `Bun.markdown`, and calling it there throws. */
|
|
1469
|
+
function hasMarkdownRenderer(): boolean {
|
|
1470
|
+
return typeof Bun.markdown?.html === 'function'
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1303
1473
|
export type NavOutcome = NavGenerated | TeachRefused
|
|
1304
1474
|
|
|
1305
1475
|
/**
|
|
@@ -1343,6 +1513,8 @@ export async function generateNav(
|
|
|
1343
1513
|
const contents: string[] = []
|
|
1344
1514
|
const skipped: LessonSkipped[] = []
|
|
1345
1515
|
let lessons = 0
|
|
1516
|
+
let reference = 0
|
|
1517
|
+
const canRenderMarkdown = hasMarkdownRenderer()
|
|
1346
1518
|
|
|
1347
1519
|
for (const slug of targetSlugs) {
|
|
1348
1520
|
const found = await readWorkspace(root, slug)
|
|
@@ -1357,10 +1529,31 @@ export async function generateNav(
|
|
|
1357
1529
|
const contentsPath = join(root, detail.path, 'index.html')
|
|
1358
1530
|
await writeFile(
|
|
1359
1531
|
contentsPath,
|
|
1360
|
-
await renderContentsPage(
|
|
1532
|
+
await renderContentsPage(
|
|
1533
|
+
root,
|
|
1534
|
+
listed.workspaces,
|
|
1535
|
+
detail,
|
|
1536
|
+
metas,
|
|
1537
|
+
canRenderMarkdown,
|
|
1538
|
+
),
|
|
1361
1539
|
)
|
|
1362
1540
|
contents.push(relative(root, contentsPath))
|
|
1363
1541
|
|
|
1542
|
+
for (const file of detail.referenceFiles) {
|
|
1543
|
+
const source = join(detail.path, TEACH_REFERENCE, file)
|
|
1544
|
+
if (!canRenderMarkdown) {
|
|
1545
|
+
skipped.push({ file: source, missing: MARKDOWN_RENDERER })
|
|
1546
|
+
continue
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
await writeFile(
|
|
1550
|
+
join(root, detail.path, TEACH_REFERENCE, renderedReferenceName(file)),
|
|
1551
|
+
await renderReferencePage(root, listed.workspaces, detail, metas, file),
|
|
1552
|
+
)
|
|
1553
|
+
reference += 1
|
|
1554
|
+
}
|
|
1555
|
+
await removeOrphanReferencePages(root, detail)
|
|
1556
|
+
|
|
1364
1557
|
const css = stripImports(await readFile(cssPath, 'utf8'))
|
|
1365
1558
|
|
|
1366
1559
|
for (let index = 0; index < metas.length; index += 1) {
|
|
@@ -1389,6 +1582,7 @@ export async function generateNav(
|
|
|
1389
1582
|
root: relative(root, rootPath),
|
|
1390
1583
|
contents,
|
|
1391
1584
|
lessons,
|
|
1585
|
+
reference,
|
|
1392
1586
|
skipped,
|
|
1393
1587
|
}
|
|
1394
1588
|
}
|
package/standards/teach.md
CHANGED
|
@@ -44,18 +44,19 @@ A workspace failing these is non-conforming even when it satisfies every shape r
|
|
|
44
44
|
|
|
45
45
|
## Layout
|
|
46
46
|
|
|
47
|
-
| Path
|
|
48
|
-
|
|
|
49
|
-
| `MISSION.md`
|
|
50
|
-
| `RESOURCES.md`
|
|
51
|
-
| `GLOSSARY.md`
|
|
52
|
-
| `NOTES.md`
|
|
53
|
-
| `reference/<slug>.md`
|
|
54
|
-
| `
|
|
55
|
-
| `
|
|
56
|
-
| `
|
|
57
|
-
|
|
58
|
-
|
|
47
|
+
| Path | Holds | Required |
|
|
48
|
+
| ----------------------- | ----------------------------------------------------------------------- | -------- |
|
|
49
|
+
| `MISSION.md` | The subject, the learner's starting point, and success | Always |
|
|
50
|
+
| `RESOURCES.md` | Sources read and sources found but not opened | Always |
|
|
51
|
+
| `GLOSSARY.md` | Terms the subject defines, one entry each | Always |
|
|
52
|
+
| `NOTES.md` | Session scratch that belongs to no other file | Optional |
|
|
53
|
+
| `reference/<slug>.md` | Durable reference pages, the promotable half | Optional |
|
|
54
|
+
| `reference/<slug>.html` | Each reference page rendered by `canon teach nav`, never edited by hand | Optional |
|
|
55
|
+
| `learning-records/` | Numbered records of what the learner was taken through | Optional |
|
|
56
|
+
| `lessons/` | Numbered lessons, generated and disposable | Optional |
|
|
57
|
+
| `assets/` | Files several lessons share, including the stylesheet | Optional |
|
|
58
|
+
|
|
59
|
+
Only the markdown half answers to a conformance check: `MISSION.md`, `RESOURCES.md`, `GLOSSARY.md`, `NOTES.md`, the markdown under `reference/`, and `learning-records/`. A lesson is generated markup that nothing downstream cites, so this standard fixes its filename and its location and says nothing about what it contains. A rendered reference page is the same kind of output, rewritten from its markdown on every `nav` run.
|
|
59
60
|
|
|
60
61
|
## Frontmatter
|
|
61
62
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
version: '2'
|
|
2
|
+
|
|
3
|
+
linters:
|
|
4
|
+
default: standard
|
|
5
|
+
enable:
|
|
6
|
+
- bodyclose
|
|
7
|
+
- errorlint
|
|
8
|
+
- gosec
|
|
9
|
+
- misspell
|
|
10
|
+
- noctx
|
|
11
|
+
- revive
|
|
12
|
+
- sqlclosecheck
|
|
13
|
+
- rowserrcheck
|
|
14
|
+
- unconvert
|
|
15
|
+
exclusions:
|
|
16
|
+
paths:
|
|
17
|
+
- node_modules
|
|
18
|
+
|
|
19
|
+
formatters:
|
|
20
|
+
enable:
|
|
21
|
+
- gofmt
|
|
22
|
+
- goimports
|
|
23
|
+
exclusions:
|
|
24
|
+
paths:
|
|
25
|
+
- node_modules
|
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
log_skip() { echo -e "${GREY}│ - $1${NC}"; }
|
|
20
|
+
|
|
21
|
+
pipe_output() { while IFS= read -r line; do echo -e "${GREY}│${NC} $line"; done; }
|
|
22
|
+
|
|
23
|
+
check_dependencies() {
|
|
24
|
+
command -v bun >/dev/null 2>&1 || log_error "bun is not installed"
|
|
25
|
+
command -v go >/dev/null 2>&1 || log_error "go is not installed"
|
|
26
|
+
command -v golangci-lint >/dev/null 2>&1 || log_error "golangci-lint is not installed. Run 'go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest'"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
check_package() {
|
|
30
|
+
[ -f package.json ] || log_error "No package.json here. Run 'bun init', then sync the stack again"
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
has_script() {
|
|
34
|
+
bun -e 'process.exit(require(process.cwd() + "/package.json").scripts?.[process.argv[1]] ? 0 : 1)' "$1"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# A subfolder synced with --skip base declares none of base's scripts, since
|
|
38
|
+
# the repository root owns them. Each base phase runs only where its script is
|
|
39
|
+
# declared, so this script passes on its own in that subfolder.
|
|
40
|
+
run_base_phase() {
|
|
41
|
+
local title=$1
|
|
42
|
+
local script=$2
|
|
43
|
+
local err_msg=$3
|
|
44
|
+
local pass_msg=$4
|
|
45
|
+
|
|
46
|
+
log_step "$title"
|
|
47
|
+
if ! has_script "$script"; then
|
|
48
|
+
log_skip "Skipped: $script is not declared here, so the repository root runs it"
|
|
49
|
+
return
|
|
50
|
+
fi
|
|
51
|
+
run_check "bun run $script" "$err_msg"
|
|
52
|
+
log_info "$pass_msg"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
run_check() {
|
|
56
|
+
local cmd=$1
|
|
57
|
+
local err_msg=$2
|
|
58
|
+
local output
|
|
59
|
+
if ! output=$(eval "$cmd" 2>&1); then
|
|
60
|
+
echo "$output" | pipe_output
|
|
61
|
+
log_error "$err_msg"
|
|
62
|
+
fi
|
|
63
|
+
echo "$output" | pipe_output
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
main() {
|
|
67
|
+
check_dependencies
|
|
68
|
+
check_package
|
|
69
|
+
|
|
70
|
+
if [ "$NESTED" = false ]; then echo -e "${GREY}┌${NC}"; fi
|
|
71
|
+
|
|
72
|
+
echo -e "${GREY}├${NC} ${WHITE}Typecheck${NC}"
|
|
73
|
+
run_check "bun run typecheck" "Typecheck failed"
|
|
74
|
+
log_info "Typecheck passed"
|
|
75
|
+
|
|
76
|
+
log_step "Lint"
|
|
77
|
+
run_check "bun run lint" "Lint failed"
|
|
78
|
+
log_info "Lint passed"
|
|
79
|
+
|
|
80
|
+
run_base_phase "Formatting" "format" "Format failed" "Format applied"
|
|
81
|
+
run_base_phase "Format check" "check:format" "Format check failed" "Format check passed"
|
|
82
|
+
run_base_phase "Spelling" "check:spell" "Spell check failed" "Spell check passed"
|
|
83
|
+
run_base_phase "Shell" "check:shell" "Shell check failed" "Shell check passed"
|
|
84
|
+
|
|
85
|
+
log_step "Unit tests"
|
|
86
|
+
run_check "bun run test:run" "Unit tests failed"
|
|
87
|
+
log_info "Unit tests passed"
|
|
88
|
+
|
|
89
|
+
if [ "$NESTED" = false ]; then
|
|
90
|
+
echo -e "${GREY}└${NC}\n"
|
|
91
|
+
echo -e "${GREEN}✓ Verification passed${NC}"
|
|
92
|
+
fi
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
main "$@"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
[stack]
|
|
2
|
+
name = "go"
|
|
3
|
+
extends = "base"
|
|
4
|
+
runtime = "go"
|
|
5
|
+
scaffold = "mkdir {{name}} && cd {{name}} && go mod init example.com/{{name}} && go mod edit -ignore=./node_modules"
|
|
6
|
+
|
|
7
|
+
[scripts]
|
|
8
|
+
"lint" = "golangci-lint run ./..."
|
|
9
|
+
"lint:fix" = "golangci-lint fmt ./... && golangci-lint run --fix ./..."
|
|
10
|
+
"typecheck" = "go vet ./..."
|
|
11
|
+
"test" = "go test ./..."
|
|
12
|
+
"test:run" = "go test -v ./..."
|
|
13
|
+
"build" = "go build ./..."
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Tooling Go reference
|
|
2
|
+
|
|
3
|
+
> Extends: `base`. Apply base stack first.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The go stack covers Go modules linted and formatted by `golangci-lint` v2, type-checked by `go vet`, and tested by `go test`. It ships a golden `.golangci.yml`, a Go-aware `verify.sh`, and a copy-once smoke test.
|
|
8
|
+
|
|
9
|
+
## Scaffold checklist
|
|
10
|
+
|
|
11
|
+
1. Scaffold with `go mod init <module-path>`. The module path is the project's own identity, so this stack never ships a `go.mod`.
|
|
12
|
+
2. Keep `./...` out of `node_modules`: `go mod edit -ignore=./node_modules`. At a root sync the base layer installs `node_modules/` inside the module, and a package there can ship a `.go` file that `go vet`, `go test`, and `golangci-lint` would otherwise walk. The directive needs Go 1.25 or later.
|
|
13
|
+
3. Seed `package.json` so the base layer's bun-side tools (husky, prettier, cspell, commitlint) have a target to install into: `bun init -y`.
|
|
14
|
+
4. Install base tooling: `canon tooling sync base . --write`
|
|
15
|
+
5. Install go tooling: `canon tooling sync go . --write`
|
|
16
|
+
6. Install the linter: `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest`. The manifest declares no `[dependencies.dev]`, because manifest injection runs `bun add -D`, which cannot install a Go tool. This step stays manual.
|
|
17
|
+
7. Run `bun run lint:fix` then `bun run check`.
|
|
18
|
+
|
|
19
|
+
The go stack also ships `smoke_test.go` at the module root as a copy-once seed. A module with no `.go` file fails `golangci-lint run`, `go vet`, and `go test` on an empty package list, so the seed is what lets `bun run check` pass on a fresh scaffold. It declares `package main`, so delete or rename it once the root package takes another name, or the build reports two packages in one folder.
|
|
20
|
+
|
|
21
|
+
## golangci-lint
|
|
22
|
+
|
|
23
|
+
- Use config `version: '2'`.
|
|
24
|
+
- Start from `default: standard`.
|
|
25
|
+
- Enable `bodyclose`, `errorlint`, `gosec`, `misspell`, `noctx`, `revive`, `sqlclosecheck`, `rowserrcheck`, and `unconvert`. The two SQL linters report only on `database/sql` call sites, so they cost nothing in a module without a database.
|
|
26
|
+
- Enable formatters `gofmt` and `goimports`. `lint:fix` runs `golangci-lint fmt` ahead of `golangci-lint run --fix`.
|
|
27
|
+
- Exclude `node_modules` from both linters and formatters. The `go.mod` `ignore` directive in the checklist covers every Go tool, but `go.mod` belongs to the project and sync cannot write it, so the config keeps `golangci-lint run` from reporting and `golangci-lint fmt` from rewriting a vendored `.go` file in a module that never added the directive.
|
|
28
|
+
|
|
29
|
+
## verify.sh
|
|
30
|
+
|
|
31
|
+
- Run Typecheck (`go vet`), Lint (`golangci-lint run`), and Unit tests (`go test -v`), plus the four base phases wherever the folder declares their scripts.
|
|
32
|
+
- Keep `build` out of `verify.sh`. `go build ./...` exits 1 with `no packages to build` on a module holding only a test file, and a seeded `main.go` would assume every module is a binary rather than a library.
|
|
33
|
+
- Keep `-race` out of `test:run`. The race detector needs cgo and a C toolchain, so it belongs in CI rather than in a local `bun run check`.
|
|
34
|
+
|
|
35
|
+
## CI
|
|
36
|
+
|
|
37
|
+
- Install Go with `actions/setup-go`, reading the version from `go.mod` through `go-version-file`.
|
|
38
|
+
- Install the linter with the same `go install` line as the scaffold checklist.
|
|
39
|
+
- Run `go test -race ./...` in CI.
|
|
40
|
+
|
|
41
|
+
## Anti-patterns
|
|
42
|
+
|
|
43
|
+
- Do not ship `go.mod` or `go.sum` from tooling. Both belong to the project, and a golden copy would overwrite its module path on every sync.
|
|
44
|
+
- Do not pin the linter through a `tools.go` file or a `tool` directive in `go.mod`. The module is project-owned, so the install stays in the checklist.
|
|
45
|
+
- Do not ship a `Dockerfile`, `.dockerignore`, or `compose.yaml` under tooling. Those are deployment choices and belong in the project itself.
|
|
46
|
+
|
|
47
|
+
## Development docs (extend)
|
|
48
|
+
|
|
49
|
+
Append to the `## Scripts` table:
|
|
50
|
+
|
|
51
|
+
| `bun run lint` | Run `golangci-lint run`. |
|
|
52
|
+
| `bun run lint:fix` | Format with `golangci-lint fmt`, then auto-fix with `golangci-lint run --fix`. |
|
|
53
|
+
| `bun run typecheck` | Run `go vet`. |
|
|
54
|
+
| `bun run test` | Run `go test`. |
|
|
55
|
+
| `bun run test:run` | Run `go test -v` (used by `verify`). |
|
|
56
|
+
| `bun run build` | Run `go build`. |
|
|
57
|
+
|
|
58
|
+
## CI docs (extend)
|
|
59
|
+
|
|
60
|
+
In `canon/context/ci.md`, the Typecheck row's assertion reads: `` `go vet ./...` passes ``. Add a Lint row whose assertion reads: `` `golangci-lint run` passes ``. Add a Tests row whose assertion reads: `` `go test -race ./...` exits 0 ``.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
$finder = (new PhpCsFixer\Finder())
|
|
6
|
+
->in([__DIR__ . '/src', __DIR__ . '/tests']);
|
|
7
|
+
|
|
8
|
+
return (new PhpCsFixer\Config())
|
|
9
|
+
->setRiskyAllowed(true)
|
|
10
|
+
->setRules([
|
|
11
|
+
'@PER-CS' => true,
|
|
12
|
+
'declare_strict_types' => true,
|
|
13
|
+
'strict_comparison' => true,
|
|
14
|
+
'no_unused_imports' => true,
|
|
15
|
+
'ordered_imports' => true,
|
|
16
|
+
])
|
|
17
|
+
->setFinder($finder)
|
|
18
|
+
->setCacheFile(__DIR__ . '/.php-cs-fixer.cache');
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
3
|
+
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
|
4
|
+
bootstrap="vendor/autoload.php"
|
|
5
|
+
colors="true"
|
|
6
|
+
failOnWarning="true"
|
|
7
|
+
failOnRisky="true">
|
|
8
|
+
<testsuites>
|
|
9
|
+
<testsuite name="unit">
|
|
10
|
+
<directory>tests</directory>
|
|
11
|
+
</testsuite>
|
|
12
|
+
</testsuites>
|
|
13
|
+
<source>
|
|
14
|
+
<include>
|
|
15
|
+
<directory>src</directory>
|
|
16
|
+
</include>
|
|
17
|
+
</source>
|
|
18
|
+
</phpunit>
|