dsh-dbhub-live 5.0.1 → 5.2.0-dev.3

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.
Files changed (50) hide show
  1. package/AGENTS.md +95 -209
  2. package/README.en.md +251 -169
  3. package/README.md +251 -169
  4. package/doc/ARCHITECTURE.md +108 -0
  5. package/doc/CLIENT.md +124 -0
  6. package/doc/DESENSITIZE.md +56 -0
  7. package/doc/DEVELOPMENT.md +139 -0
  8. package/doc/IMPACT-MATRIX.md +36 -0
  9. package/doc/RELEASE.md +93 -0
  10. package/doc/REQUIREMENTS.md +177 -105
  11. package/doc/images/chat-query.png +0 -0
  12. package/doc/images/row-editor.png +0 -0
  13. package/doc/images/settings-page.png +0 -0
  14. package/lib/adhoc.mjs +412 -34
  15. package/lib/capability.mjs +157 -0
  16. package/lib/client.js +1243 -80
  17. package/lib/config.mjs +370 -21
  18. package/lib/i18n.mjs +119 -4
  19. package/lib/index.mjs +401 -43
  20. package/lib/mcp.mjs +496 -391
  21. package/lib/meta.mjs +267 -0
  22. package/lib/options.mjs +25 -5
  23. package/lib/policy.mjs +92 -0
  24. package/lib/toml.mjs +188 -0
  25. package/lib/tools.mjs +1093 -827
  26. package/lib/update.mjs +244 -0
  27. package/package.json +34 -10
  28. package/scripts/desensitize-check.mjs +10 -3
  29. package/scripts/desensitize.config.mjs +255 -237
  30. package/scripts/docs-check.mjs +214 -0
  31. package/scripts/release-preflight.mjs +180 -0
  32. package/scripts/render-check.mjs +1366 -843
  33. package/test/adhoc-toml.test.mjs +365 -0
  34. package/test/bridge.test.mjs +227 -185
  35. package/test/capability.test.mjs +231 -0
  36. package/test/client-format.test.mjs +515 -352
  37. package/test/configure-options.test.mjs +244 -0
  38. package/test/configure-ssh.test.mjs +232 -0
  39. package/test/copy-from.test.mjs +155 -0
  40. package/test/env-options.test.mjs +456 -0
  41. package/test/meta.test.mjs +234 -0
  42. package/test/options.test.mjs +160 -123
  43. package/test/policy.test.mjs +132 -0
  44. package/test/row-cache.test.mjs +237 -138
  45. package/test/ssh-probe.test.mjs +834 -0
  46. package/test/test-op-fastpath.test.mjs +208 -0
  47. package/test/toml.test.mjs +352 -0
  48. package/test/update.test.mjs +270 -0
  49. package/test/util.test.mjs +17 -0
  50. package/test/zero-knowledge.test.mjs +120 -78
@@ -0,0 +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
+ )
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env node
2
+ // Release preflight gate.
3
+ //
4
+ // Answers one question, with evidence rather than memory: may this exact commit
5
+ // and version ship? It refuses unless a release test was run recently, against
6
+ // this commit, with this matrix, with no claim drift, and an AI reviewer ruled
7
+ // on it. Designed to be the step you cannot forget: it is wired into the release
8
+ // commands and into a pre-push hook.
9
+ //
10
+ // node scripts/release-preflight.mjs # the newest run
11
+ // node scripts/release-preflight.mjs --run <dir> # a specific run
12
+ // node scripts/release-preflight.mjs --max-age-hours 12 --json
13
+ import path from 'node:path'
14
+ import { REPO_ROOT, readJson } from '../release-test/lib/util.mjs'
15
+ import { run } from '../release-test/lib/proc.mjs'
16
+ import { packageMeta } from '../release-test/lib/artifact.mjs'
17
+ import { gitInfoAsync, dirtyFilesAsync } from '../release-test/lib/git.mjs'
18
+ import { loadMatrix, matrixClaimWarnings, matrixHash } from '../release-test/lib/matrix.mjs'
19
+ import { latestRun, runPath } from '../release-test/lib/runs.mjs'
20
+ import { checkRun } from '../release-test/lib/verdict.mjs'
21
+ import fs from 'node:fs'
22
+
23
+ const argv = process.argv.slice(2)
24
+ const arg = (name, fallback = null) => {
25
+ const index = argv.indexOf(`--${name}`)
26
+ return index === -1 ? fallback : argv[index + 1] ?? true
27
+ }
28
+ const has = (name) => argv.includes(`--${name}`)
29
+
30
+ const MAX_AGE_HOURS = Number(arg('max-age-hours', 24))
31
+ const JSON_OUT = has('json')
32
+ const c = {
33
+ reset: '\u001b[0m', dim: '\u001b[2m', bold: '\u001b[1m', red: '\u001b[31m', green: '\u001b[32m', yellow: '\u001b[33m', cyan: '\u001b[36m',
34
+ }
35
+ const say = (text) => process.stdout.write(`${text}\n`)
36
+ const ok = (text) => say(` ${c.green}✓${c.reset} ${text}`)
37
+ const bad = (text) => say(` ${c.red}✗${c.reset} ${text}`)
38
+ const warnline = (text) => say(` ${c.yellow}!${c.reset} ${text}`)
39
+
40
+ const problems = []
41
+ const warnings = []
42
+ const facts = {}
43
+
44
+ async function main() {
45
+ const pkg = packageMeta()
46
+ const git = await gitInfoAsync()
47
+ const matrix = loadMatrix()
48
+ const currentMatrixHash = matrixHash(matrix)
49
+ const claims = matrixClaimWarnings(matrix)
50
+
51
+ facts.plugin = `${pkg.name}@${pkg.version}`
52
+ facts.commit = git.commit
53
+ facts.branch = git.branch
54
+ facts.dirty = git.dirty
55
+ facts.matrixHash = currentMatrixHash
56
+
57
+ // 1. A run must exist at all.
58
+ const explicit = arg('run')
59
+ const resolved = explicit ? runPath(String(explicit)) : latestRun()?.dir ?? null
60
+ if (!resolved) {
61
+ problems.push('没有找到任何发布测试报告 —— 先跑 `npm run test:release`')
62
+ return finish()
63
+ }
64
+ const reportDir = path.resolve(resolved)
65
+ const report = readJson(path.join(reportDir, 'report.json'))
66
+ facts.run = report.runId
67
+ facts.reportStatus = report.status
68
+
69
+ // 2. The report must be about THIS version, built from THIS commit, recently.
70
+ if (report.plugin?.version !== pkg.version) {
71
+ problems.push(`报告测的是 ${report.plugin?.version},当前 package.json 是 ${pkg.version} —— 版本变了,必须重跑`)
72
+ }
73
+ // Compare TREE hashes: that is exactly the set of files the artifact was packed
74
+ // from. A squash/merge that lands the same content is fine; any real change is not.
75
+ // Generated documents are rewritten from the evidence of the run itself, so a
76
+ // report may legitimately be followed by a docs-only commit. Any other change
77
+ // means the artifact would differ and the report is stale.
78
+ // Generated docs (rewritten from this run's evidence) and test tooling (not
79
+ // shipped in the plugin's runtime path) cannot change what users execute.
80
+ const GENERATED = new Set(['doc/SUPPORT-MATRIX.md', 'README.md', 'README.en.md', 'release-test/mise.toml'])
81
+ const TOOLING_PREFIXES = ['release-test/', '.github/']
82
+ const TOOLING_FILES = new Set(['scripts/release-preflight.mjs'])
83
+ const isAllowedDiff = (file) => GENERATED.has(file) || TOOLING_FILES.has(file) || TOOLING_PREFIXES.some((prefix) => file.startsWith(prefix))
84
+ const sameTree = report.git?.tree && git.tree && report.git.tree === git.tree
85
+ if (!sameTree) {
86
+ const changed = report.git?.tree && git.tree ? await changedPaths(report.git.tree, git.tree) : null
87
+ if (changed && changed.length && changed.every(isAllowedDiff)) {
88
+ warnings.push(`报告之后只有生成文档变化(${changed.join(', ')})—— 视为同一产物,有效`)
89
+ } else if (report.git?.commit !== git.commit) {
90
+ problems.push(`报告对应的提交是 ${String(report.git?.commit).slice(0, 8)}(tree ${String(report.git?.tree).slice(0, 8)}),当前 HEAD 是 ${String(git.commit).slice(0, 8)}(tree ${String(git.tree).slice(0, 8)})—— 内容变了,必须重跑`)
91
+ } else {
92
+ problems.push('无法确认报告与当前工作树一致(缺少 tree 记录)—— 用当前版本的 harness 重跑')
93
+ }
94
+ } else if (report.git?.commit !== git.commit) {
95
+ warnings.push(`tree 相同但提交不同(报告 ${String(report.git?.commit).slice(0, 8)} → HEAD ${String(git.commit).slice(0, 8)}):内容一致,视为有效`)
96
+ }
97
+ if (report.git?.dirty) {
98
+ problems.push('那次测试是在脏工作区跑的:产物可能不包含已提交的代码 —— 先提交,再在干净工作区重跑')
99
+ }
100
+ const ageHours = (Date.now() - Date.parse(report.generatedAt)) / 3_600_000
101
+ facts.reportAgeHours = Math.round(ageHours * 10) / 10
102
+ if (!(ageHours <= MAX_AGE_HOURS)) {
103
+ problems.push(`报告已过期:${Math.round(ageHours)} 小时前(上限 ${MAX_AGE_HOURS})—— 重跑 \`npm run test:release\``)
104
+ }
105
+
106
+ // 3. The support matrix must not have changed since the run.
107
+ if (report.matrix?.hash !== currentMatrixHash) {
108
+ problems.push('matrix.json 在测试之后被改过 —— 矩阵变了旧报告不再有效,重跑')
109
+ }
110
+
111
+ // 4. Claim drift blocks the release even if tests are green.
112
+ if (claims.length) {
113
+ for (const claim of claims) problems.push(`声明偏差:${claim.detail}`)
114
+ }
115
+
116
+ // 5. No failing declared target, and every declared target was actually run.
117
+ const declared = matrix.targets.filter((t) => t.tier === 'declared').map((t) => t.version)
118
+ const tested = new Map((report.targets ?? []).map((t) => [t.version, t]))
119
+ for (const version of declared) {
120
+ const target = tested.get(version)
121
+ if (!target) problems.push(`声明支持的 ${version} 未出现在这次测试里(用了 --only/--tier 的局部运行不能作为发版依据)`)
122
+ else if (target.status === 'fail' || target.status === 'error') problems.push(`${version} 未通过:${target.status}${target.error ? ` — ${String(target.error).split('\n')[0]}` : ''}`)
123
+ else if (target.status === 'warn') warnings.push(`${version} 通过但有告警(见 AI 判定)`)
124
+ }
125
+ if (report.status === 'red') problems.push('报告总体为 red')
126
+
127
+ // 6. The AI reviewer must have ruled on this exact report.
128
+ const checked = checkRun(reportDir)
129
+ facts.verdictOverall = checked.verdict?.overall ?? null
130
+ if (!checked.ok) {
131
+ for (const problem of checked.problems) problems.push(`AI 判定无效:${problem}`)
132
+ problems.push('生成判定骨架并让 AI 复核:`node release-test/run.mjs verdict --template`')
133
+ } else {
134
+ for (const warning of checked.warnings) warnings.push(warning)
135
+ if (checked.verdict.overall === 'fail') problems.push('AI 判定为 fail —— 禁止发版')
136
+ }
137
+
138
+ // 7. Dirty tree now: the artifact the users get would include uncommitted code.
139
+ if (git.dirty) {
140
+ const files = await dirtyFilesAsync(5)
141
+ problems.push(`当前工作区不干净(${files.length} 个改动,例如 ${files.slice(0, 3).map((f) => f.trim()).join(';')})—— 发版必须从干净提交出`)
142
+ }
143
+
144
+ return finish()
145
+ }
146
+
147
+ function finish() {
148
+ const pass = problems.length === 0
149
+ if (JSON_OUT) {
150
+ process.stdout.write(JSON.stringify({ pass, facts, problems, warnings }, null, 2) + '\n')
151
+ } else {
152
+ say(`${c.bold}Release preflight${c.reset} — ${facts.plugin} @ ${String(facts.commit).slice(0, 8)}${facts.dirty ? ' (dirty)' : ''}`)
153
+ say(` run ${facts.run ?? '—'} · report ${facts.reportStatus ?? '—'} · age ${facts.reportAgeHours ?? '—'}h · verdict ${facts.verdictOverall ?? '—'}`)
154
+ if (pass) {
155
+ ok('可以发版:报告新鲜、矩阵一致、声明无偏差、AI 判定通过、工作区干净')
156
+ for (const warning of warnings) warnline(warning)
157
+ } else {
158
+ bad(`禁止发版(${problems.length} 项):`)
159
+ for (const problem of problems) say(` - ${problem}`)
160
+ for (const warning of warnings) warnline(warning)
161
+ say('')
162
+ say(`${c.dim}修完后重跑:npm run test:release && (AI 判定)node release-test/run.mjs verdict --check${c.reset}`)
163
+ }
164
+ }
165
+ process.exitCode = pass ? 0 : 1
166
+ }
167
+
168
+ await main()
169
+
170
+ /** Files that differ between two tree hashes (null when git refuses). */
171
+ async function changedPaths(treeA, treeB) {
172
+ const logFile = path.join(REPO_ROOT, '.release-test', 'preflight-git.log')
173
+ const result = await run('git', ['diff', '--name-only', treeA, treeB], { cwd: REPO_ROOT, logFile, timeoutMs: 30_000 })
174
+ if (result.code !== 0) return null
175
+ // run() captures output into the log with framing lines; keep only path lines.
176
+ return fs.readFileSync(logFile, 'utf8')
177
+ .split('\n')
178
+ .map((line) => line.trim())
179
+ .filter((line) => line && !line.startsWith('=====') && !line.startsWith('[') && !line.startsWith('$'))
180
+ }