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.
@@ -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
+ )