docs: scaffold the Integration Kit — front page, outline, and the checks
Phase 5 slice 0 (MODULE_SYSTEM.md §2.11.1). The repo's governance, the front page, the book's outline, and the CI that keeps the whole thing from rotting. README.md What the reader is building, all three parts, and the draft banner: the kit is finished when someone outside this project builds a working module by following it alone, and that has not happened. Says the sidecar rule plainly (MODULE_API.md §2.7) rather than leaving it to chapter 3, because a reader who skims the front page and starts coding should still get that one right. book/README.md The outline of four chapters, landed before the prose so the shape can be argued with. Chapters are named but NOT linked — a link to a file that does not exist is what the link check is for, and an outline should not be the first thing to fail it. CONTRIBUTING.md The rule that governs every change here: the kit never re-specifies a contract. Also the prose conventions, and why the pinned ref points at core's `edge` rather than `main`. SECURITY.md Scoped for a repo that runs nothing: the two things that ARE reportable are a template that teaches an insecure pattern (it is meant to be copied) and a chapter that teaches something dangerous. scripts/checkLinks.js Relative links resolve; anchors match a real heading; no link pins a reader to a commit snapshot of a moving document. Nothing is fetched — a self-hosted Gitea would fail on a credential-less runner and teach us to ignore red. Fences and code spans are stripped by a line walk, not a regexp. Its first run found a real one: a PR template's relative links resolve from the REPO ROOT, because that is where their text ends up when Gitea inlines them into a pull request body. Encoded, with the reason. scripts/checkCoreApi.js The anti-rot check. Asserts template/module.json's `coreApi` EQUALS the pinned core's MODULE_API_VERSION — equality, not "satisfies", because a range check stays green across a contract bump and green would then mean "the template still loads" instead of "someone has re-read the book". Both failure branches and the pass were exercised against a real core checkout. ci/core-ref.json The pin, same convention as Module-uo's. Points at `edge`: core's `main` has no server/src/modules/ until the cutover, and that pin is one of the things the cutover has to revisit. .gitea/workflows/pr-checks.yml Two jobs. `links` always runs; `template` is conditional on template/module.json existing, so the repo is gated now and the job arms itself when slice 1 lands, with no edit to the workflow. Same guard Module-uo used through its planning phase. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
80
scripts/checkCoreApi.js
Normal file
80
scripts/checkCoreApi.js
Normal file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env node
|
||||
// The kit declares exactly one contract version, in `template/module.json`'s
|
||||
// `coreApi` — the same field a reader copies. This asserts it still names the
|
||||
// version the pinned core actually exports.
|
||||
//
|
||||
// WHY EQUALITY AND NOT "SATISFIES": a range check is what CORE does at load time,
|
||||
// and it is right there — a module built against 1.4.0 should keep loading into
|
||||
// 1.5.0. It is the wrong question here. This kit's job is to be *current*: if core
|
||||
// moved to 1.5.0, `^1.4.0` still satisfies, the build stays green, and nobody ever
|
||||
// re-reads the chapters. Green would mean "the template still loads", when what we
|
||||
// need it to mean is "someone has looked at this since the contract changed".
|
||||
//
|
||||
// So the failure is deliberate and expected on every core bump, and the fix is a
|
||||
// human reading the book — not a version string.
|
||||
//
|
||||
// Usage: node scripts/checkCoreApi.js --core <path to a core checkout>
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..')
|
||||
|
||||
function arg(name) {
|
||||
const i = process.argv.indexOf(name)
|
||||
return i === -1 ? null : process.argv[i + 1]
|
||||
}
|
||||
|
||||
const corePath = arg('--core')
|
||||
if (!corePath) {
|
||||
console.error('usage: node scripts/checkCoreApi.js --core <path to a core checkout>')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const manifestPath = path.join(ROOT, 'template', 'module.json')
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
// Slice 0 landed this check before the template it checks. Not an error: the
|
||||
// workflow guards on the same file, and this message is what a local run says.
|
||||
console.log('checkCoreApi: no template/module.json yet — nothing to check')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const versionFile = path.resolve(corePath, 'server/src/modules/version.js')
|
||||
if (!fs.existsSync(versionFile)) {
|
||||
console.error(`checkCoreApi: ${versionFile} does not exist.`)
|
||||
console.error(' Either --core does not point at a website checkout, or the pin in')
|
||||
console.error(' ci/core-ref.json names a ref with no module system in it (core `main`')
|
||||
console.error(' has none until the cutover — see that file).')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Core's version.js is a plain CommonJS module with no dependencies, so it can be
|
||||
// required straight out of an uninstalled checkout.
|
||||
const { MODULE_API_VERSION: core } = require(versionFile)
|
||||
|
||||
const declared = String(JSON.parse(fs.readFileSync(manifestPath, 'utf8')).coreApi || '')
|
||||
// A `coreApi` is a RANGE (`^1.4.0`); the version it is built on is its base.
|
||||
const base = declared.replace(/^[\^~>=<\s]+/, '').trim()
|
||||
|
||||
if (!base) {
|
||||
console.error(`checkCoreApi: template/module.json declares no coreApi (got ${JSON.stringify(declared)})`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (base !== core) {
|
||||
console.error('checkCoreApi: the kit is written against a different core than it is pinned to.')
|
||||
console.error('')
|
||||
console.error(` template/module.json coreApi = ${declared} (base ${base})`)
|
||||
console.error(` pinned core MODULE_API_VERSION = ${core}`)
|
||||
console.error('')
|
||||
console.error(' This is the anti-rot check firing, not a broken build. Someone has to:')
|
||||
console.error(' 1. read MODULE_API.md §1.1 for what changed in the new version;')
|
||||
console.error(' 2. read the book and the template for anything that is now untrue;')
|
||||
console.error(' 3. update template/module.json and ci/core-ref.json together.')
|
||||
console.error('')
|
||||
console.error(' Bumping the two files without doing step 2 is the one way to make this')
|
||||
console.error(' check worthless.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`checkCoreApi: coreApi ${declared} matches the pinned core's ${core} — OK`)
|
||||
174
scripts/checkLinks.js
Normal file
174
scripts/checkLinks.js
Normal file
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env node
|
||||
// Every relative link in this repo's markdown must resolve to a file that exists,
|
||||
// and every in-page anchor must match a heading in the file it points at.
|
||||
//
|
||||
// WHAT THIS DOES NOT DO: fetch anything. External URLs are not checked, on
|
||||
// purpose. This kit points at a self-hosted Gitea, so an HTTP check would fail on
|
||||
// a runner without credentials, flake when the host is busy, and teach us to
|
||||
// ignore red. What breaks in practice is a relative path after a file moves, and
|
||||
// that is answerable offline with certainty.
|
||||
//
|
||||
// It DOES check that every http(s) link into the RunicGateway host names a
|
||||
// branch, because `.../src/branch/main/...` survives and `.../src/commit/<sha>/...`
|
||||
// pins a reader to a snapshot of a document we want them reading the current
|
||||
// version of.
|
||||
//
|
||||
// Usage: node scripts/checkLinks.js (repo root)
|
||||
// node scripts/checkLinks.js --quiet
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..')
|
||||
const QUIET = process.argv.includes('--quiet')
|
||||
|
||||
// Directories that hold no prose we own.
|
||||
const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist'])
|
||||
|
||||
/** Every markdown file in the repo, repo-relative, sorted. */
|
||||
function markdownFiles(dir = ROOT, out = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue
|
||||
markdownFiles(path.join(dir, entry.name), out)
|
||||
} else if (entry.name.toLowerCase().endsWith('.md')) {
|
||||
out.push(path.relative(ROOT, path.join(dir, entry.name)).split(path.sep).join('/'))
|
||||
}
|
||||
}
|
||||
return out.sort()
|
||||
}
|
||||
|
||||
// Fenced code blocks are stripped before links are read: a fence can legitimately
|
||||
// contain a path that does not exist (a directory listing of a project the reader
|
||||
// has not created yet), and flagging those would make the check useless in exactly
|
||||
// the document type this repo is made of. Stripped by walking lines and toggling
|
||||
// on a fence marker, rather than by regexp — a fence's own content can contain
|
||||
// anything, including a line that looks like the end of one.
|
||||
function stripFences(text) {
|
||||
const out = []
|
||||
let fence = null
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const m = /^\s*(```+|~~~+)/.exec(line)
|
||||
if (fence) {
|
||||
if (m && m[1][0] === fence[0] && m[1].length >= fence.length) fence = null
|
||||
out.push('')
|
||||
continue
|
||||
}
|
||||
if (m) {
|
||||
fence = m[1]
|
||||
out.push('')
|
||||
continue
|
||||
}
|
||||
out.push(line)
|
||||
}
|
||||
return out.join('\n')
|
||||
}
|
||||
|
||||
/** Inline `[text](target)` links and `[ref]: target` definitions, with line numbers. */
|
||||
function linksIn(text) {
|
||||
const found = []
|
||||
const lines = stripFences(text).split(/\r?\n/)
|
||||
lines.forEach((line, i) => {
|
||||
// Skip inline code spans: `[a](b)` inside backticks is an example, not a link.
|
||||
const bare = line.replace(/`[^`]*`/g, '')
|
||||
for (const m of bare.matchAll(/\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) {
|
||||
found.push({ target: m[1], line: i + 1 })
|
||||
}
|
||||
const def = /^\s{0,3}\[[^\]]+\]:\s*(\S+)/.exec(bare)
|
||||
if (def) found.push({ target: def[1], line: i + 1 })
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
/** GitHub/Gitea-style heading slugs, for anchor checking. */
|
||||
function anchorsIn(text) {
|
||||
const slugs = new Set()
|
||||
for (const line of stripFences(text).split(/\r?\n/)) {
|
||||
const m = /^\s{0,3}#{1,6}\s+(.*?)\s*#*\s*$/.exec(line)
|
||||
if (!m) continue
|
||||
const slug = m[1]
|
||||
.replace(/`/g, '')
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/gu, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
if (slug) slugs.add(slug)
|
||||
}
|
||||
return slugs
|
||||
}
|
||||
|
||||
const files = markdownFiles()
|
||||
const anchorCache = new Map()
|
||||
function anchorsOf(relPath) {
|
||||
if (!anchorCache.has(relPath)) {
|
||||
anchorCache.set(relPath, anchorsIn(fs.readFileSync(path.join(ROOT, relPath), 'utf8')))
|
||||
}
|
||||
return anchorCache.get(relPath)
|
||||
}
|
||||
|
||||
const problems = []
|
||||
let checked = 0
|
||||
|
||||
for (const file of files) {
|
||||
const text = fs.readFileSync(path.join(ROOT, file), 'utf8')
|
||||
const selfAnchors = anchorsIn(text)
|
||||
|
||||
for (const { target, line } of linksIn(text)) {
|
||||
const where = `${file}:${line}`
|
||||
|
||||
if (/^(mailto:|tel:)/i.test(target)) continue
|
||||
|
||||
if (/^https?:\/\//i.test(target)) {
|
||||
checked++
|
||||
// Not fetched — but a permalink to a moving document is still wrong.
|
||||
if (/gitea\.whitlocktech\.com\/.*\/src\/commit\//.test(target)) {
|
||||
problems.push(`${where}: links to a commit snapshot, not a branch — ${target}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (target.startsWith('#')) {
|
||||
checked++
|
||||
const slug = decodeURIComponent(target.slice(1)).toLowerCase()
|
||||
if (!selfAnchors.has(slug)) problems.push(`${where}: no heading matches ${target}`)
|
||||
continue
|
||||
}
|
||||
|
||||
checked++
|
||||
const [rawPath, rawAnchor] = target.split('#')
|
||||
// A PR/issue template's text is INLINED into a pull request or issue body, and
|
||||
// Gitea resolves relative links in those against the repo root — not against
|
||||
// `.gitea/`, where the file itself lives. So `[CONTRIBUTING.md](CONTRIBUTING.md)`
|
||||
// is correct in a template and would be wrong anywhere else. Resolve those from
|
||||
// the root, or this check reports every template link as broken and gets muted.
|
||||
const base = file.startsWith('.gitea/') ? ROOT : path.dirname(path.join(ROOT, file))
|
||||
const resolved = path.resolve(base, decodeURIComponent(rawPath))
|
||||
const rel = path.relative(ROOT, resolved).split(path.sep).join('/')
|
||||
|
||||
if (rel.startsWith('..')) {
|
||||
problems.push(`${where}: points outside the repo — ${target}`)
|
||||
continue
|
||||
}
|
||||
if (!fs.existsSync(resolved)) {
|
||||
problems.push(`${where}: no such file — ${target}`)
|
||||
continue
|
||||
}
|
||||
if (rawAnchor && resolved.toLowerCase().endsWith('.md')) {
|
||||
const slug = decodeURIComponent(rawAnchor).toLowerCase()
|
||||
if (!anchorsOf(rel).has(slug)) {
|
||||
problems.push(`${where}: ${rawPath} has no heading matching #${rawAnchor}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
console.error(`checkLinks: ${problems.length} problem(s) in ${files.length} file(s):\n`)
|
||||
for (const p of problems) console.error(` ${p}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!QUIET) {
|
||||
console.log(`checkLinks: ${checked} link(s) across ${files.length} markdown file(s) — OK`)
|
||||
}
|
||||
Reference in New Issue
Block a user