dsh-dbhub-live 5.2.0-dev.3 → 5.2.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/AGENTS.md +116 -116
- package/README.en.md +22 -22
- package/README.md +23 -23
- package/doc/ARCHITECTURE.md +108 -108
- package/doc/CLIENT.md +124 -124
- package/doc/DESENSITIZE.md +56 -56
- package/doc/DEVELOPMENT.md +139 -139
- package/doc/IMPACT-MATRIX.md +36 -36
- package/doc/RELEASE.md +93 -93
- package/lib/meta.mjs +267 -267
- package/lib/policy.mjs +92 -92
- package/lib/update.mjs +244 -244
- package/package.json +1 -1
- package/scripts/desensitize-check.mjs +331 -331
- package/scripts/docs-check.mjs +214 -214
- package/scripts/release-preflight.mjs +180 -180
- package/test/meta.test.mjs +234 -234
- package/test/policy.test.mjs +132 -132
- package/test/update.test.mjs +270 -270
package/scripts/docs-check.mjs
CHANGED
|
@@ -1,214 +1,214 @@
|
|
|
1
|
-
// Documentation consistency gate for dsh-dbhub-live.
|
|
2
|
-
//
|
|
3
|
-
// The repository keeps its knowledge in layers: AGENTS.md holds conclusions and
|
|
4
|
-
// points at `doc/` chapters that hold the reasoning. That only works while the
|
|
5
|
-
// pointers are real. This gate makes drift mechanical:
|
|
6
|
-
//
|
|
7
|
-
// 1. every relative markdown link in AGENTS.md and doc/*.md resolves to an
|
|
8
|
-
// existing file (and, when it carries `#anchor`, to an existing heading);
|
|
9
|
-
// 2. every `doc/` document AGENTS.md links is shipped in package.json `files`
|
|
10
|
-
// (the package contains AGENTS.md, so its links must not dangle);
|
|
11
|
-
// 3. every shipped `doc/*.md` path in `files` exists on disk;
|
|
12
|
-
// 4. AGENTS.md still points at the impact matrix, and every repo path quoted
|
|
13
|
-
// in that matrix (doc/IMPACT-MATRIX.md) exists.
|
|
14
|
-
//
|
|
15
|
-
// Usage: npm run check:docs (exit 0 = consistent, exit 1 = drift)
|
|
16
|
-
|
|
17
|
-
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
18
|
-
import { dirname, join, posix, relative, sep } from 'node:path'
|
|
19
|
-
import { fileURLToPath } from 'node:url'
|
|
20
|
-
|
|
21
|
-
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
22
|
-
const ENTRY = 'AGENTS.md'
|
|
23
|
-
const DOC_DIR = 'doc'
|
|
24
|
-
const MATRIX_DOC = 'doc/IMPACT-MATRIX.md'
|
|
25
|
-
|
|
26
|
-
const findings = []
|
|
27
|
-
const record = (where, message) => findings.push(`${where}: ${message}`)
|
|
28
|
-
|
|
29
|
-
/** Repository-relative POSIX path for a native path. */
|
|
30
|
-
const toPosix = (p) => p.split(sep).join('/')
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* GitHub-style heading slug: lowercase, drop HTML and every character that is
|
|
34
|
-
* not a letter, a number, `-`, `_` or a space, then spaces become hyphens.
|
|
35
|
-
* @param heading - raw heading text without the leading `#`s.
|
|
36
|
-
*/
|
|
37
|
-
function slugify(heading) {
|
|
38
|
-
return heading
|
|
39
|
-
.replace(/<[^>]*>/g, '')
|
|
40
|
-
.toLowerCase()
|
|
41
|
-
.replace(/[^\p{L}\p{N}\-_ ]/gu, '')
|
|
42
|
-
.trim()
|
|
43
|
-
.replace(/\s+/g, '-')
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** @returns heading slugs of a markdown document. */
|
|
47
|
-
function headingSlugs(text) {
|
|
48
|
-
const slugs = new Set()
|
|
49
|
-
for (const line of text.split('\n')) {
|
|
50
|
-
const match = /^#{1,6}\s+(.*)$/.exec(line.trimEnd())
|
|
51
|
-
if (!match) continue
|
|
52
|
-
slugs.add(slugify(match[1].replace(/#+\s*$/, '').trim()))
|
|
53
|
-
}
|
|
54
|
-
return slugs
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** @returns markdown links as `{ label, target }`. */
|
|
58
|
-
function markdownLinks(text) {
|
|
59
|
-
const links = []
|
|
60
|
-
for (const match of text.matchAll(/\[([^\]]*)\]\(([^)\s]+)\)/g)) {
|
|
61
|
-
links.push({ label: match[1], target: match[2] })
|
|
62
|
-
}
|
|
63
|
-
return links
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const fileCache = new Map()
|
|
67
|
-
function readDoc(absPath) {
|
|
68
|
-
if (!fileCache.has(absPath)) fileCache.set(absPath, readFileSync(absPath, 'utf8'))
|
|
69
|
-
return fileCache.get(absPath)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// ── 1. link + anchor resolution ──────────────────────────────────────────────
|
|
73
|
-
|
|
74
|
-
// Local-only drafts are gitignored and never shipped (see package.json `files`
|
|
75
|
-
// and .gitignore), so the gate stays deterministic between a working copy and a
|
|
76
|
-
// clean CI checkout by scanning only documented, distributable documents.
|
|
77
|
-
const LOCAL_DOC_RE = /^(PLAN-.*\.md|implementation-notes\.md|RELEASE-NOTES-.*\.md)$/
|
|
78
|
-
|
|
79
|
-
const scanned = [ENTRY]
|
|
80
|
-
const docDirAbs = join(ROOT, DOC_DIR)
|
|
81
|
-
if (existsSync(docDirAbs)) {
|
|
82
|
-
for (const name of readdirSync(docDirAbs)) {
|
|
83
|
-
if (!name.endsWith('.md') || LOCAL_DOC_RE.test(name)) continue
|
|
84
|
-
scanned.push(`${DOC_DIR}/${name}`)
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
let linkCount = 0
|
|
89
|
-
for (const source of scanned) {
|
|
90
|
-
const abs = join(ROOT, source)
|
|
91
|
-
if (!existsSync(abs)) {
|
|
92
|
-
record(source, 'document is missing')
|
|
93
|
-
continue
|
|
94
|
-
}
|
|
95
|
-
for (const { target } of markdownLinks(readDoc(abs))) {
|
|
96
|
-
if (/^[a-z][a-z0-9+.-]*:/i.test(target)) continue // external URL
|
|
97
|
-
if (target.startsWith('<')) continue // placeholder
|
|
98
|
-
|
|
99
|
-
const [rawPath, anchor] = target.split('#')
|
|
100
|
-
const sameFile = rawPath === ''
|
|
101
|
-
const resolved = sameFile
|
|
102
|
-
? abs
|
|
103
|
-
: join(dirname(abs), decodeURIComponent(rawPath))
|
|
104
|
-
const label = sameFile ? source : `${source} → ${toPosix(relative(ROOT, resolved))}`
|
|
105
|
-
linkCount += 1
|
|
106
|
-
|
|
107
|
-
if (!existsSync(resolved)) {
|
|
108
|
-
record(source, `link target "${target}" does not exist`)
|
|
109
|
-
continue
|
|
110
|
-
}
|
|
111
|
-
if (statSync(resolved).isDirectory()) continue
|
|
112
|
-
if (!anchor) continue
|
|
113
|
-
if (!resolved.endsWith('.md')) {
|
|
114
|
-
record(source, `link "${target}" carries an anchor but the target is not markdown`)
|
|
115
|
-
continue
|
|
116
|
-
}
|
|
117
|
-
const slugs = headingSlugs(readDoc(resolved))
|
|
118
|
-
const wanted = decodeURIComponent(anchor).replace(/^#/, '').toLowerCase()
|
|
119
|
-
if (!slugs.has(wanted)) {
|
|
120
|
-
record(label, `anchor "${wanted}" matches no heading`)
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// ── 2 + 3. package.json `files` vs linked docs ───────────────────────────────
|
|
126
|
-
|
|
127
|
-
const pkgPath = join(ROOT, 'package.json')
|
|
128
|
-
let pkg
|
|
129
|
-
try {
|
|
130
|
-
pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
131
|
-
} catch (error) {
|
|
132
|
-
record('package.json', `cannot be parsed (${error.message})`)
|
|
133
|
-
}
|
|
134
|
-
const shipped = new Set(Array.isArray(pkg?.files) ? pkg.files : [])
|
|
135
|
-
|
|
136
|
-
const linkedDocs = new Set()
|
|
137
|
-
for (const { target } of markdownLinks(readDoc(join(ROOT, ENTRY)))) {
|
|
138
|
-
if (/^[a-z][a-z0-9+.-]*:/i.test(target)) continue
|
|
139
|
-
const clean = target.split('#')[0]
|
|
140
|
-
if (!clean.endsWith('.md')) continue
|
|
141
|
-
const normalized = posix.normalize(clean)
|
|
142
|
-
if (normalized.startsWith(`${DOC_DIR}/`)) linkedDocs.add(normalized)
|
|
143
|
-
}
|
|
144
|
-
for (const doc of linkedDocs) {
|
|
145
|
-
if (!existsSync(join(ROOT, doc))) {
|
|
146
|
-
record(ENTRY, `linked document "${doc}" is missing on disk`)
|
|
147
|
-
continue
|
|
148
|
-
}
|
|
149
|
-
if (!shipped.has(doc)) {
|
|
150
|
-
record(ENTRY, `linked document "${doc}" is not in package.json "files" — the published package would have a dangling link`)
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
for (const entry of shipped) {
|
|
154
|
-
if (!entry.startsWith(`${DOC_DIR}/`) || !entry.endsWith('.md')) continue
|
|
155
|
-
if (!existsSync(join(ROOT, entry))) {
|
|
156
|
-
record('package.json', `"files" lists "${entry}" but the file is missing`)
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// ── 4. the impact matrix: pointed at, present, and honest about paths ───────
|
|
161
|
-
|
|
162
|
-
const agentsText = readDoc(join(ROOT, ENTRY))
|
|
163
|
-
if (!markdownLinks(agentsText).some(({ target }) => target.split('#')[0] === MATRIX_DOC)) {
|
|
164
|
-
record(ENTRY, `the reminder pointing at ${MATRIX_DOC} is missing — readers must be told where the impact matrix lives`)
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const matrixAbs = join(ROOT, MATRIX_DOC)
|
|
168
|
-
if (!existsSync(matrixAbs)) {
|
|
169
|
-
record(MATRIX_DOC, 'the impact matrix document is missing')
|
|
170
|
-
} else {
|
|
171
|
-
const lines = readDoc(matrixAbs).split('\n')
|
|
172
|
-
const headerIndex = lines.findIndex((line) => line.trim().startsWith('| 改动'))
|
|
173
|
-
if (headerIndex === -1) {
|
|
174
|
-
record(MATRIX_DOC, 'the matrix table header "| 改动 |" is missing')
|
|
175
|
-
} else {
|
|
176
|
-
// Count only the contiguous table, but validate every table-looking row in
|
|
177
|
-
// the document: a row appended below the table must not escape the gate.
|
|
178
|
-
let contiguous = 0
|
|
179
|
-
for (const line of lines.slice(headerIndex)) {
|
|
180
|
-
if (!line.trim().startsWith('|')) break
|
|
181
|
-
contiguous += 1
|
|
182
|
-
}
|
|
183
|
-
if (contiguous < 5) record(MATRIX_DOC, `the matrix only has ${contiguous - 2} data row(s) — did it get truncated?`)
|
|
184
|
-
|
|
185
|
-
let pathTokens = 0
|
|
186
|
-
for (const line of lines) {
|
|
187
|
-
if (!line.trim().startsWith('|')) continue
|
|
188
|
-
for (const match of line.matchAll(/`([^`]+)`/g)) {
|
|
189
|
-
const token = match[1].trim()
|
|
190
|
-
if (token.includes('*') || token.includes('<') || token.includes('|')) continue
|
|
191
|
-
if (!/\.(md|mjs|cjs|js|json|yml|yaml)$/.test(token)) continue
|
|
192
|
-
pathTokens += 1
|
|
193
|
-
if (!existsSync(join(ROOT, token))) {
|
|
194
|
-
record(MATRIX_DOC, `matrix quotes "${token}" but it does not exist`)
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
if (pathTokens === 0) record(MATRIX_DOC, 'matrix quotes no repository paths — did the table change shape?')
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// ── report ──────────────────────────────────────────────────────────────────
|
|
203
|
-
|
|
204
|
-
if (findings.length > 0) {
|
|
205
|
-
console.error(`[check:docs] ${findings.length} problem(s):`)
|
|
206
|
-
for (const line of findings) console.error(` - ${line}`)
|
|
207
|
-
console.error('\n[check:docs] fix the pointers (or the document they point at), then rerun.')
|
|
208
|
-
process.exit(1)
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
console.log(
|
|
212
|
-
`[check:docs] clean: ${scanned.length} document(s), ${linkCount} link(s), ` +
|
|
213
|
-
`${linkedDocs.size} shipped doc(s) — links, anchors, package contents and the impact matrix agree.`,
|
|
214
|
-
)
|
|
1
|
+
// Documentation consistency gate for dsh-dbhub-live.
|
|
2
|
+
//
|
|
3
|
+
// The repository keeps its knowledge in layers: AGENTS.md holds conclusions and
|
|
4
|
+
// points at `doc/` chapters that hold the reasoning. That only works while the
|
|
5
|
+
// pointers are real. This gate makes drift mechanical:
|
|
6
|
+
//
|
|
7
|
+
// 1. every relative markdown link in AGENTS.md and doc/*.md resolves to an
|
|
8
|
+
// existing file (and, when it carries `#anchor`, to an existing heading);
|
|
9
|
+
// 2. every `doc/` document AGENTS.md links is shipped in package.json `files`
|
|
10
|
+
// (the package contains AGENTS.md, so its links must not dangle);
|
|
11
|
+
// 3. every shipped `doc/*.md` path in `files` exists on disk;
|
|
12
|
+
// 4. AGENTS.md still points at the impact matrix, and every repo path quoted
|
|
13
|
+
// in that matrix (doc/IMPACT-MATRIX.md) exists.
|
|
14
|
+
//
|
|
15
|
+
// Usage: npm run check:docs (exit 0 = consistent, exit 1 = drift)
|
|
16
|
+
|
|
17
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
18
|
+
import { dirname, join, posix, relative, sep } from 'node:path'
|
|
19
|
+
import { fileURLToPath } from 'node:url'
|
|
20
|
+
|
|
21
|
+
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
22
|
+
const ENTRY = 'AGENTS.md'
|
|
23
|
+
const DOC_DIR = 'doc'
|
|
24
|
+
const MATRIX_DOC = 'doc/IMPACT-MATRIX.md'
|
|
25
|
+
|
|
26
|
+
const findings = []
|
|
27
|
+
const record = (where, message) => findings.push(`${where}: ${message}`)
|
|
28
|
+
|
|
29
|
+
/** Repository-relative POSIX path for a native path. */
|
|
30
|
+
const toPosix = (p) => p.split(sep).join('/')
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* GitHub-style heading slug: lowercase, drop HTML and every character that is
|
|
34
|
+
* not a letter, a number, `-`, `_` or a space, then spaces become hyphens.
|
|
35
|
+
* @param heading - raw heading text without the leading `#`s.
|
|
36
|
+
*/
|
|
37
|
+
function slugify(heading) {
|
|
38
|
+
return heading
|
|
39
|
+
.replace(/<[^>]*>/g, '')
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.replace(/[^\p{L}\p{N}\-_ ]/gu, '')
|
|
42
|
+
.trim()
|
|
43
|
+
.replace(/\s+/g, '-')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** @returns heading slugs of a markdown document. */
|
|
47
|
+
function headingSlugs(text) {
|
|
48
|
+
const slugs = new Set()
|
|
49
|
+
for (const line of text.split('\n')) {
|
|
50
|
+
const match = /^#{1,6}\s+(.*)$/.exec(line.trimEnd())
|
|
51
|
+
if (!match) continue
|
|
52
|
+
slugs.add(slugify(match[1].replace(/#+\s*$/, '').trim()))
|
|
53
|
+
}
|
|
54
|
+
return slugs
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @returns markdown links as `{ label, target }`. */
|
|
58
|
+
function markdownLinks(text) {
|
|
59
|
+
const links = []
|
|
60
|
+
for (const match of text.matchAll(/\[([^\]]*)\]\(([^)\s]+)\)/g)) {
|
|
61
|
+
links.push({ label: match[1], target: match[2] })
|
|
62
|
+
}
|
|
63
|
+
return links
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const fileCache = new Map()
|
|
67
|
+
function readDoc(absPath) {
|
|
68
|
+
if (!fileCache.has(absPath)) fileCache.set(absPath, readFileSync(absPath, 'utf8'))
|
|
69
|
+
return fileCache.get(absPath)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── 1. link + anchor resolution ──────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
// Local-only drafts are gitignored and never shipped (see package.json `files`
|
|
75
|
+
// and .gitignore), so the gate stays deterministic between a working copy and a
|
|
76
|
+
// clean CI checkout by scanning only documented, distributable documents.
|
|
77
|
+
const LOCAL_DOC_RE = /^(PLAN-.*\.md|implementation-notes\.md|RELEASE-NOTES-.*\.md)$/
|
|
78
|
+
|
|
79
|
+
const scanned = [ENTRY]
|
|
80
|
+
const docDirAbs = join(ROOT, DOC_DIR)
|
|
81
|
+
if (existsSync(docDirAbs)) {
|
|
82
|
+
for (const name of readdirSync(docDirAbs)) {
|
|
83
|
+
if (!name.endsWith('.md') || LOCAL_DOC_RE.test(name)) continue
|
|
84
|
+
scanned.push(`${DOC_DIR}/${name}`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let linkCount = 0
|
|
89
|
+
for (const source of scanned) {
|
|
90
|
+
const abs = join(ROOT, source)
|
|
91
|
+
if (!existsSync(abs)) {
|
|
92
|
+
record(source, 'document is missing')
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
for (const { target } of markdownLinks(readDoc(abs))) {
|
|
96
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(target)) continue // external URL
|
|
97
|
+
if (target.startsWith('<')) continue // placeholder
|
|
98
|
+
|
|
99
|
+
const [rawPath, anchor] = target.split('#')
|
|
100
|
+
const sameFile = rawPath === ''
|
|
101
|
+
const resolved = sameFile
|
|
102
|
+
? abs
|
|
103
|
+
: join(dirname(abs), decodeURIComponent(rawPath))
|
|
104
|
+
const label = sameFile ? source : `${source} → ${toPosix(relative(ROOT, resolved))}`
|
|
105
|
+
linkCount += 1
|
|
106
|
+
|
|
107
|
+
if (!existsSync(resolved)) {
|
|
108
|
+
record(source, `link target "${target}" does not exist`)
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
if (statSync(resolved).isDirectory()) continue
|
|
112
|
+
if (!anchor) continue
|
|
113
|
+
if (!resolved.endsWith('.md')) {
|
|
114
|
+
record(source, `link "${target}" carries an anchor but the target is not markdown`)
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
const slugs = headingSlugs(readDoc(resolved))
|
|
118
|
+
const wanted = decodeURIComponent(anchor).replace(/^#/, '').toLowerCase()
|
|
119
|
+
if (!slugs.has(wanted)) {
|
|
120
|
+
record(label, `anchor "${wanted}" matches no heading`)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── 2 + 3. package.json `files` vs linked docs ───────────────────────────────
|
|
126
|
+
|
|
127
|
+
const pkgPath = join(ROOT, 'package.json')
|
|
128
|
+
let pkg
|
|
129
|
+
try {
|
|
130
|
+
pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
131
|
+
} catch (error) {
|
|
132
|
+
record('package.json', `cannot be parsed (${error.message})`)
|
|
133
|
+
}
|
|
134
|
+
const shipped = new Set(Array.isArray(pkg?.files) ? pkg.files : [])
|
|
135
|
+
|
|
136
|
+
const linkedDocs = new Set()
|
|
137
|
+
for (const { target } of markdownLinks(readDoc(join(ROOT, ENTRY)))) {
|
|
138
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(target)) continue
|
|
139
|
+
const clean = target.split('#')[0]
|
|
140
|
+
if (!clean.endsWith('.md')) continue
|
|
141
|
+
const normalized = posix.normalize(clean)
|
|
142
|
+
if (normalized.startsWith(`${DOC_DIR}/`)) linkedDocs.add(normalized)
|
|
143
|
+
}
|
|
144
|
+
for (const doc of linkedDocs) {
|
|
145
|
+
if (!existsSync(join(ROOT, doc))) {
|
|
146
|
+
record(ENTRY, `linked document "${doc}" is missing on disk`)
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
if (!shipped.has(doc)) {
|
|
150
|
+
record(ENTRY, `linked document "${doc}" is not in package.json "files" — the published package would have a dangling link`)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (const entry of shipped) {
|
|
154
|
+
if (!entry.startsWith(`${DOC_DIR}/`) || !entry.endsWith('.md')) continue
|
|
155
|
+
if (!existsSync(join(ROOT, entry))) {
|
|
156
|
+
record('package.json', `"files" lists "${entry}" but the file is missing`)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── 4. the impact matrix: pointed at, present, and honest about paths ───────
|
|
161
|
+
|
|
162
|
+
const agentsText = readDoc(join(ROOT, ENTRY))
|
|
163
|
+
if (!markdownLinks(agentsText).some(({ target }) => target.split('#')[0] === MATRIX_DOC)) {
|
|
164
|
+
record(ENTRY, `the reminder pointing at ${MATRIX_DOC} is missing — readers must be told where the impact matrix lives`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const matrixAbs = join(ROOT, MATRIX_DOC)
|
|
168
|
+
if (!existsSync(matrixAbs)) {
|
|
169
|
+
record(MATRIX_DOC, 'the impact matrix document is missing')
|
|
170
|
+
} else {
|
|
171
|
+
const lines = readDoc(matrixAbs).split('\n')
|
|
172
|
+
const headerIndex = lines.findIndex((line) => line.trim().startsWith('| 改动'))
|
|
173
|
+
if (headerIndex === -1) {
|
|
174
|
+
record(MATRIX_DOC, 'the matrix table header "| 改动 |" is missing')
|
|
175
|
+
} else {
|
|
176
|
+
// Count only the contiguous table, but validate every table-looking row in
|
|
177
|
+
// the document: a row appended below the table must not escape the gate.
|
|
178
|
+
let contiguous = 0
|
|
179
|
+
for (const line of lines.slice(headerIndex)) {
|
|
180
|
+
if (!line.trim().startsWith('|')) break
|
|
181
|
+
contiguous += 1
|
|
182
|
+
}
|
|
183
|
+
if (contiguous < 5) record(MATRIX_DOC, `the matrix only has ${contiguous - 2} data row(s) — did it get truncated?`)
|
|
184
|
+
|
|
185
|
+
let pathTokens = 0
|
|
186
|
+
for (const line of lines) {
|
|
187
|
+
if (!line.trim().startsWith('|')) continue
|
|
188
|
+
for (const match of line.matchAll(/`([^`]+)`/g)) {
|
|
189
|
+
const token = match[1].trim()
|
|
190
|
+
if (token.includes('*') || token.includes('<') || token.includes('|')) continue
|
|
191
|
+
if (!/\.(md|mjs|cjs|js|json|yml|yaml)$/.test(token)) continue
|
|
192
|
+
pathTokens += 1
|
|
193
|
+
if (!existsSync(join(ROOT, token))) {
|
|
194
|
+
record(MATRIX_DOC, `matrix quotes "${token}" but it does not exist`)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (pathTokens === 0) record(MATRIX_DOC, 'matrix quotes no repository paths — did the table change shape?')
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── report ──────────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
if (findings.length > 0) {
|
|
205
|
+
console.error(`[check:docs] ${findings.length} problem(s):`)
|
|
206
|
+
for (const line of findings) console.error(` - ${line}`)
|
|
207
|
+
console.error('\n[check:docs] fix the pointers (or the document they point at), then rerun.')
|
|
208
|
+
process.exit(1)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
console.log(
|
|
212
|
+
`[check:docs] clean: ${scanned.length} document(s), ${linkCount} link(s), ` +
|
|
213
|
+
`${linkedDocs.size} shipped doc(s) — links, anchors, package contents and the impact matrix agree.`,
|
|
214
|
+
)
|