Files
zhu1090093659 b4944353a5 refactor(scripts): drop the packages/skins root the satellites took
The skin, pet and community packages left this repository, so packages/skins/
never exists in a checkout of it. Every scanner that still walked it as a
second root did so defensively — family-packages.mjs and
coverage-gate.discoverPackages listed it first and guarded the walk with
existsSync, aggregate.mjs looked for a second aggregate manifest and a second
package index under it, e2e-mount-rewrite searched it for workspace packages,
and seven test files built fixtures under it. The walkers now name packages/ as
the single root and the fixtures and comments follow; modules that only carried
the root in a doc comment are reworded.

scripts/coverage-baseline.json loses the three packages no longer measured here;
its sixteen keys now name exactly the sixteen package directories on disk.

The in-repo fallback paths in scripts/market-build stay untouched: they are the
documented route for building from a checkout that still carries the packages,
and the fixture-based market-build tests populate them deliberately.
2026-09-24 09:48:23 +08:00

226 lines
10 KiB
JavaScript

#!/usr/bin/env node
/**
* verify-docs - documentation consistency gate for dsh-web.
*
* Enforces the rules in docs/AGENTS.md as one executable gate:
* 1. README triplets - every package has README.md + README.zh.md + README.i18n.yaml.
* 2. i18n pairing - recorded git blob hashes match current content; both sides carry
* their language switcher; heading/code/table/list signatures mirror each other.
* 3. Markdown links - relative link targets exist and #anchors name real headings.
* 4. Heading levels - the first line is H1; no bare H1 sub-sections follow it.
* 5. No scaffold placeholders in package.json descriptions.
*
* Usage:
* node scripts/verify-docs.mjs # check everything (CI mode)
* node scripts/verify-docs.mjs --list # report state, never fails
* node scripts/verify-docs.mjs --write <name...> # re-record pairing hashes after
* confirming both sides agree
*/
import { execFileSync } from 'node:child_process'
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { dirname, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { walkFamilyPackages } from './lib/family-packages.mjs'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const args = process.argv.slice(2)
const listOnly = args.includes('--list')
const writeMode = args.includes('--write')
const namedPairs = args.filter((a) => !a.startsWith('--'))
const failures = []
const rows = []
function fail(msg) { failures.push(msg) }
/** Git blob hash of a working-tree file (content-addressed, no staging needed). */
function blobHash(file) {
return execFileSync('git', ['hash-object', file], { cwd: root }).toString().trim()
}
/** Parse a pairing record into { README.md: hash, README.zh.md: hash }. */
function readPairing(yamlPath) {
const out = {}
for (const line of readFileSync(yamlPath, 'utf8').split(/\r?\n/)) {
const m = line.match(/^(README(?:\.zh)?\.md):\s*([0-9a-f]{40})\s*$/)
if (m) out[m[1]] = m[2]
}
return out
}
function writePairing(yamlPath, record) {
const header = [
'# Bilingual-pair consistency record (docs/i18n.md): the git blob hash of each'
, '# side as of the last confirmed-consistent state. Both languages carry equal authority;'
, '# after editing either side, bring the other along and re-record with:'
, '# node scripts/verify-docs.mjs --write <dir>'
].join('\n')
const body = ['README.md: ' + (record['README.md'] || ''), 'README.zh.md: ' + (record['README.zh.md'] || '')].join('\n')
writeFileSync(yamlPath, header + '\n' + body + '\n')
}
/**
* Structural signature: heading levels, fence language markers, table widths,
* list kinds. Headings compare by level only (text is translated); fenced
* block CONTENT is not compared — translating comments inside code samples is
* legitimate localization, the mirror rule guards against a missing section,
* not against translated sample text.
*/
function signature(text) {
const sig = { headings: [], fences: [], tables: [], lists: [] }
let inFence = null
for (const line of text.split(/\r?\n/)) {
const fm = line.match(/^(```+|~~~+)(\S*)\s*$/)
if (fm) {
if (!inFence) { inFence = fm[2] || 'plain'; sig.fences.push('open:' + inFence) }
else { sig.fences.push('close:' + inFence); inFence = null }
continue
}
if (inFence) continue
const hm = line.match(/^(#{1,6})\s+(.*)$/)
if (hm) { sig.headings.push(hm[1].length); continue }
if (/^\|.*\|\s*$/.test(line)) { sig.tables.push(line.split('|').length - 2); continue }
const lm = line.match(/^\s*(?:([-*+])\s+|(\d+)[.)]\s+)/)
if (lm) sig.lists.push(lm[1] ? 'u' : 'o')
}
return sig
}
function sigEqual(a, b) {
return JSON.stringify(a.headings) === JSON.stringify(b.headings)
&& JSON.stringify(a.fences) === JSON.stringify(b.fences)
&& JSON.stringify(a.tables) === JSON.stringify(b.tables)
&& JSON.stringify(a.lists) === JSON.stringify(b.lists)
}
/** GitHub-ish heading slug for #anchor checks. */
function slugify(heading) {
return heading.toLowerCase().replace(/[^\p{L}\p{N}\s-]/gu, '').trim().replace(/\s+/g, '-')
}
function headingSlugs(file) {
const slugs = new Set()
if (!existsSync(file)) return slugs
for (const line of readFileSync(file, 'utf8').split(/\r?\n/)) {
const m = line.match(/^#{1,6}\s+(.*)$/)
if (m) slugs.add(slugify(m[1]))
}
return slugs
}
/** All package directories (packages/* with a package.json). */
function packageDirs() {
return walkFamilyPackages(root).map(({ dir }) => dir)
}
function isExternal(url) {
return url.startsWith('//') || url.startsWith('/') || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)
}
export { signature, sigEqual, slugify, isExternal }
if (import.meta.main) {
/* ---------- 1 + 2 + 4. README triplets, pairing, heading levels ---------- */
const pairs = []
for (const dir of packageDirs()) {
const rel = relative(root, dir)
const en = resolve(dir, 'README.md')
const zh = resolve(dir, 'README.zh.md')
const yaml = resolve(dir, 'README.i18n.yaml')
if (!existsSync(en)) { fail(rel + ': missing README.md (every package needs the bilingual triplet)'); continue }
if (!existsSync(zh)) { fail(rel + ': missing README.zh.md'); continue }
if (!existsSync(yaml)) { fail(rel + ': missing README.i18n.yaml'); continue }
pairs.push({ rel, en, zh, yaml })
}
for (const p of pairs) {
const record = readPairing(p.yaml)
const hEn = blobHash(p.en)
const hZh = blobHash(p.zh)
if (writeMode && (!namedPairs.length || namedPairs.some((n) => p.rel.replace(/\\/g, '/').includes(n.replace(/\\/g, '/'))))) {
writePairing(p.yaml, { 'README.md': hEn, 'README.zh.md': hZh })
rows.push('re-record ' + p.rel)
continue
}
if (listOnly) {
const synced = record['README.md'] === hEn && record['README.zh.md'] === hZh
rows.push((synced ? 'ok ' : 'sync?') + ' ' + p.rel)
continue
}
const enText = readFileSync(p.en, 'utf8')
const zhText = readFileSync(p.zh, 'utf8')
if (record['README.md'] !== hEn) fail(p.rel + ': README.md changed since the pairing record - update the other side, then run `node scripts/verify-docs.mjs --write ' + p.rel + '`')
if (record['README.zh.md'] !== hZh) fail(p.rel + ': README.zh.md changed since the pairing record - update the other side, then re-record')
if (!/^English \| \[中文\]\(README\.zh\.md\)/m.test(enText)) fail(p.rel + ': README.md missing `English | [中文](README.zh.md)` switcher after H1')
if (!/^\[English\]\(README\.md\) \| 中文/m.test(zhText)) fail(p.rel + ': README.zh.md missing `[English](README.md) | 中文` switcher after H1')
if (!sigEqual(signature(enText), signature(zhText))) fail(p.rel + ': structural signature mismatch between README.md and README.zh.md')
const enLines = enText.split(/\r?\n/)
const zhLines = zhText.split(/\r?\n/)
if (!/^# /.test(enLines[0])) fail(p.rel + ': README.md must start with an H1 (package name)')
if (!/^# /.test(zhLines[0])) fail(p.rel + ': README.zh.md must start with an H1 (package name)')
for (const { lines: ls, rel2, label } of [{ lines: enLines, rel2: p.rel, label: 'README.md' }, { lines: zhLines, rel2: p.rel, label: 'README.zh.md' }]) {
let inFence = false
for (let i = 1; i < ls.length; i++) {
if (/^```|^~~~/.test(ls[i])) { inFence = !inFence; continue }
if (inFence) continue
if (/^# /.test(ls[i])) { fail(rel2 + ': ' + label + ' line ' + (i + 1) + ' uses H1 under a section - use H3 for install options'); break }
}
}
rows.push('ok ' + p.rel)
}
/* ---------- 3. Markdown links ---------- */
const docFiles = ['AGENTS.md', 'packages/AGENTS.md', 'docs/AGENTS.md', 'CONTRIBUTING.md']
for (const p of pairs) docFiles.push(relative(root, p.en), relative(root, p.zh))
for (const name of readdirSync(resolve(root, 'docs'))) {
if (name.endsWith('.md')) docFiles.push('docs/' + name)
}
for (const relPath of docFiles) {
const file = resolve(root, relPath)
if (!existsSync(file)) { fail(relPath + ': checked file missing'); continue }
const rawLines = readFileSync(file, 'utf8').split(/\r?\n/)
const lines = []
// Strip fenced code blocks and inline code so example links inside them are
// not treated as real references (e.g. the switcher samples in docs/i18n.md).
let inFence = false
for (const line of rawLines) {
if (/^```|^~~~/.test(line)) { inFence = !inFence; lines.push(''); continue }
lines.push(inFence ? '' : line.replace(/`[^`]*`/g, (m) => ' '.repeat(m.length)))
}
for (let i = 0; i < lines.length; i++) {
const re = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+[^)]*)?\)/g
let m
while ((m = re.exec(lines[i]))) {
const raw = m[1]
if (isExternal(raw)) continue
const idx = raw.indexOf('#')
const pathPart = idx >= 0 ? raw.slice(0, idx) : raw
const frag = idx >= 0 ? raw.slice(idx + 1) : ''
let target = file
if (pathPart) {
target = resolve(dirname(file), decodeURIComponent(pathPart))
if (!existsSync(target)) { fail(relPath + ':' + (i + 1) + ': broken link `' + raw + '`'); continue }
}
if (frag) {
const slugs = headingSlugs(target)
if (!slugs.has(slugify(decodeURIComponent(frag)))) fail(relPath + ':' + (i + 1) + ': unknown anchor `#' + frag + '` in ' + relative(root, target))
}
}
}
}
/* ---------- 5. No scaffold placeholders ---------- */
for (const dir of packageDirs()) {
const pkg = JSON.parse(readFileSync(resolve(dir, 'package.json'), 'utf8'))
if (pkg.description && /<edit me/i.test(pkg.description)) fail(relative(root, dir) + ': package.json description still contains a scaffold placeholder')
}
if (listOnly || writeMode) {
console.log(rows.join('\n') || '(no rows)')
process.exit(0)
}
if (failures.length) {
console.error('verify-docs failed:')
for (const f of failures) console.error(' - ' + f)
process.exit(1)
}
console.log('verify-docs: all documentation gates passed')
}