From 289dc3fc4322ba46d6f64b0cc00e6b52ef021913 Mon Sep 17 00:00:00 2001 From: BBD Date: Thu, 2 Jul 2026 21:59:08 +0800 Subject: [PATCH] fix: create doctor bundles for corrupted graph dbs --- CHANGELOG.md | 4 + __tests__/rust-hybrid-doctor.test.ts | 63 +++++++++++++ ...02-corrupted-doctor-bundle-v2-roadmap.json | 90 +++++++++++++++++++ ...7-02-corrupted-doctor-bundle-v2-roadmap.md | 12 +++ src/diagnostics/index.ts | 74 ++++++++++++++- 5 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 docs/plans/2026-07-02-corrupted-doctor-bundle-v2-roadmap.json create mode 100644 docs/plans/2026-07-02-corrupted-doctor-bundle-v2-roadmap.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 936e6cfc..868f74eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `zcodegraph status` and `zcodegraph doctor` now share graph health language, so users and scripts can distinguish healthy, degraded, stale, failed, unavailable, and corrupted indexes with exact next-step commands. - `rust-hybrid` indexing now understands Python calls, imported names, and module-level values, so Python search, callers/callees, and file dependents work through the Rust indexer. +### Fixes + +- `zcodegraph doctor --engine rust-hybrid --bundle` can now create a local diagnostic bundle for a corrupted graph database without opening the broken database or collecting source paths. (#671) + ## [0.10.2] - 2026-06-30 ### New Features diff --git a/__tests__/rust-hybrid-doctor.test.ts b/__tests__/rust-hybrid-doctor.test.ts index b3441a44..51c3d83c 100644 --- a/__tests__/rust-hybrid-doctor.test.ts +++ b/__tests__/rust-hybrid-doctor.test.ts @@ -285,6 +285,69 @@ describe('rust-hybrid doctor diagnostic bundles', () => { expect(replay).not.toContain(tempDir); }, 30_000); + it('creates a corrupted database bundle without opening the malformed DB', () => { + const lastRunPath = path.join(tempDir, '.zcodegraph', 'diagnostics', 'last-run.json'); + fs.mkdirSync(path.dirname(lastRunPath), { recursive: true }); + fs.writeFileSync(lastRunPath, `${JSON.stringify({ + schemaVersion: 1, + kind: 'last-run', + engine: 'rust-hybrid', + command: { name: 'index', args: ['--engine', 'rust-hybrid'] }, + startedAt: '2026-07-02T00:00:00.000Z', + endedAt: '2026-07-02T00:00:01.000Z', + elapsedMs: 1000, + exitCode: 0, + fallbackState: null, + previousIndexPreserved: null, + projectRootHash: 'test-project-root-hash', + rss: { peakRssBytes: null, unavailableReason: 'not-collected-in-this-run' }, + sanitizedOutput: { + stdoutTail: { unavailableReason: 'not-captured-in-this-run' }, + stderrTail: { unavailableReason: 'not-captured-in-this-run' }, + }, + errors: [], + }, null, 2)}\n`); + fs.writeFileSync(path.join(tempDir, '.zcodegraph', 'zcodegraph.db'), 'not sqlite'); + + const doctor = runCli(tempDir, ['doctor', '--engine', 'rust-hybrid', '--bundle', '--last-run']); + expect(doctor.status, `stdout:\n${doctor.stdout}\nstderr:\n${doctor.stderr}`).toBe(0); + expect(doctor.stdout).toContain('Graph health: corrupted'); + const bundleDir = path.resolve(tempDir, latestBundlePath(doctor.stdout)); + + const status = readJson(path.join(bundleDir, 'status.json')); + expect(status).toMatchObject({ + available: false, + health: { + state: 'corrupted', + usable: false, + }, + database: { + present: true, + sizeBytes: expect.any(Number), + mtime: expect.any(String), + openError: expect.any(String), + }, + diagnostics: { + lastRun: { exists: true, endedAt: '2026-07-02T00:00:01.000Z' }, + lastFailure: { exists: false }, + }, + }); + expect(status.database.openError).toMatch(/file is not a database|database disk image is malformed/); + expect(status.database.openError).not.toContain(tempDir); + expect(status.health.nextCommands).toContain('rm -rf .zcodegraph && zcodegraph init'); + + const graphStats = readJson(path.join(bundleDir, 'graph-stats.json')); + expect(graphStats).toEqual({ + available: false, + unavailableReason: 'corrupted', + }); + const bundleText = fs.readdirSync(bundleDir) + .map((file) => fs.readFileSync(path.join(bundleDir, file), 'utf-8')) + .join('\n'); + expect(bundleText).not.toContain(tempDir); + expect(bundleText).not.toContain('secretSourceNeedle'); + }, 30_000); + it('rejects source slices for diagnostic bundle v1', () => { const result = runCli(tempDir, ['doctor', '--engine', 'rust-hybrid', '--bundle', '--last-run', '--include-source-slice']); expect(result.status).toBe(1); diff --git a/docs/plans/2026-07-02-corrupted-doctor-bundle-v2-roadmap.json b/docs/plans/2026-07-02-corrupted-doctor-bundle-v2-roadmap.json new file mode 100644 index 00000000..f1984bcd --- /dev/null +++ b/docs/plans/2026-07-02-corrupted-doctor-bundle-v2-roadmap.json @@ -0,0 +1,90 @@ +{ + "title": "corrupted DB doctor bundle v2", + "description": "Issue #671: doctor --bundle must create a privacy-preserving bundle even when the graph database is malformed and cannot be opened.", + "version": 1, + "nodes": { + "1": { + "id": "1", + "label": "corrupted DB doctor bundle v2", + "status": "completed", + "mode": "explore", + "parent": null, + "children": [ + "1-1", + "1-2", + "1-3", + "1-4", + "1-5" + ], + "decisions": [ + { + "q": "P0 起点是什么?", + "answer": "以 issue #671 的 corrupted DB doctor bundle v2 作为本轮 P0。", + "note": "先补齐 status 能判 corrupted 之后 doctor 仍能打包的闭环,再考虑 degraded summary 持久化。" + } + ], + "notes": "" + }, + "1-1": { + "id": "1-1", + "label": "复现 corrupted DB doctor bundle 失败路径", + "status": "completed", + "mode": "exploit", + "parent": "1", + "children": [], + "decisions": [], + "notes": "Added failing doctor bundle regression: malformed .zcodegraph/zcodegraph.db currently aborts with 'file is not a database' before bundle creation." + }, + "1-2": { + "id": "1-2", + "label": "设计不打开 DB 的 corrupted bundle 采集路径", + "status": "completed", + "mode": "exploit", + "parent": "1", + "children": [], + "decisions": [ + { + "q": "corrupted bundle 的触发边界是什么?", + "answer": "doctor bundle 不应为了补齐 graph stats 而打开当前 DB;如果 DB malformed,应写入 DB 元数据和 sanitized open error。", + "note": "即使 last-run 记录缺少 statusSummary,也不能在 bundle 阶段调用 CodeGraph.open。" + } + ], + "notes": "Design decision recorded: bundle fallback status must classify DB corruption from filesystem metadata and sanitized open errors instead of requiring CodeGraph.open during bundle creation." + }, + "1-3": { + "id": "1-3", + "label": "实现 corrupted DB 隐私安全诊断 bundle", + "status": "completed", + "mode": "exploit", + "parent": "1", + "children": [], + "decisions": [], + "notes": "Implemented corrupted status fallback inside diagnostics: bundle creation now records DB file metadata, sanitized open error, graph health, diagnostic record availability, and graph-stats unavailableReason=corrupted without throwing." + }, + "1-4": { + "id": "1-4", + "label": "验证 status 非破坏性与 doctor bundle 门禁", + "status": "completed", + "mode": "exploit", + "parent": "1", + "children": [], + "decisions": [], + "notes": "Verification passed: npx vitest run __tests__/status-json.test.ts __tests__/rust-hybrid-doctor.test.ts; npm run build; git diff --check." + }, + "1-5": { + "id": "1-5", + "label": "收尾 issue 关联 changelog 与 PR 准备", + "status": "completed", + "mode": "exploit", + "parent": "1", + "children": [], + "decisions": [], + "notes": "Changelog entry added for #671. Branch is ready for review/PR after local verification; no release or publish action taken." + } + }, + "metadata": { + "created": "2026-07-02 21:49:42", + "updated": "2026-07-02 21:58:08", + "md_file": "docs/plans/2026-07-02-corrupted-doctor-bundle-v2-roadmap.md" + } +} \ No newline at end of file diff --git a/docs/plans/2026-07-02-corrupted-doctor-bundle-v2-roadmap.md b/docs/plans/2026-07-02-corrupted-doctor-bundle-v2-roadmap.md new file mode 100644 index 00000000..7e7e2a61 --- /dev/null +++ b/docs/plans/2026-07-02-corrupted-doctor-bundle-v2-roadmap.md @@ -0,0 +1,12 @@ + +## ZJ Roadmap + +> 数据文件: `2026-07-02-corrupted-doctor-bundle-v2-roadmap.json` | 最后更新: 2026-07-02 21:58:08 + +[x][X+] 1. corrupted DB doctor bundle v2 +├── [x][Y+] 1-1. 复现 corrupted DB doctor bundle 失败路径 +├── [x][Y+] 1-2. 设计不打开 DB 的 corrupted bundle 采集路径 +├── [x][Y+] 1-3. 实现 corrupted DB 隐私安全诊断 bundle +├── [x][Y+] 1-4. 验证 status 非破坏性与 doctor bundle 门禁 +└── [x][Y+] 1-5. 收尾 issue 关联 changelog 与 PR 准备 + diff --git a/src/diagnostics/index.ts b/src/diagnostics/index.ts index 66620882..29ed1619 100644 --- a/src/diagnostics/index.ts +++ b/src/diagnostics/index.ts @@ -9,6 +9,7 @@ import { buildRustHybridFallbackSummary, formatRustHybridFallbackHealthLines, } from './fallback-summary'; +import { classifyGraphHealth, type GraphHealthDiagnosticRecord } from './graph-health'; export type DiagnosticRecordKind = 'last-run' | 'last-failure'; @@ -159,11 +160,75 @@ function safeRelativePathDetails(projectRoot: string, filePath: string | undefin }; } +function diagnosticRecordSummary(projectRoot: string, kind: DiagnosticRecordKind): GraphHealthDiagnosticRecord { + const file = diagnosticRecordPath(projectRoot, kind); + if (!fs.existsSync(file)) return { exists: false }; + try { + const parsed = JSON.parse(fs.readFileSync(file, 'utf-8')) as { endedAt?: unknown }; + return { + exists: true, + endedAt: typeof parsed.endedAt === 'string' ? parsed.endedAt : null, + }; + } catch { + return { exists: true, endedAt: null }; + } +} + +function databaseFileMetadata(projectRoot: string, openError: string): unknown { + const databasePath = path.join(projectRoot, '.zcodegraph', 'zcodegraph.db'); + const sanitizedOpenError = sanitizeDiagnosticText(openError, projectRoot); + try { + const stat = fs.statSync(databasePath); + return { + present: true, + sizeBytes: stat.size, + mtime: stat.mtime.toISOString(), + openError: sanitizedOpenError, + }; + } catch { + return { + present: false, + sizeBytes: null, + mtime: null, + openError: sanitizedOpenError, + }; + } +} + +function corruptedStatusSummary(projectRoot: string, openError: string): unknown { + const databasePath = path.join(projectRoot, '.zcodegraph', 'zcodegraph.db'); + const databasePresent = fs.existsSync(databasePath); + const lastRun = diagnosticRecordSummary(projectRoot, 'last-run'); + const lastFailure = diagnosticRecordSummary(projectRoot, 'last-failure'); + return { + available: false, + unavailableReason: 'corrupted', + health: classifyGraphHealth({ + initialized: true, + databasePath, + databasePresent, + openError: sanitizeDiagnosticText(openError, projectRoot), + lastRun, + lastFailure, + }), + database: databaseFileMetadata(projectRoot, openError), + diagnostics: { + lastRun, + lastFailure, + }, + }; +} + function statusSummary(projectRoot: string): unknown { if (!fs.existsSync(path.join(projectRoot, '.zcodegraph', 'zcodegraph.db'))) { return { available: false, unavailableReason: 'index-db-not-found' }; } - const cg = CodeGraph.openSync(projectRoot); + let cg: CodeGraph; + try { + cg = CodeGraph.openSync(projectRoot); + } catch (err) { + return corruptedStatusSummary(projectRoot, err instanceof Error ? err.message : String(err)); + } try { const stats = cg.getStats(); const buildInfo = cg.getIndexBuildInfo(); @@ -375,7 +440,10 @@ export function createDiagnosticBundle(projectRoot: string, options: { writeJson(path.join(bundleDir, 'status.json'), status); const statusObject = status as { available?: boolean; fileCount?: number; nodeCount?: number; edgeCount?: number; nodesByKind?: unknown; filesByLanguage?: unknown }; writeJson(path.join(bundleDir, 'graph-stats.json'), statusObject.available === false - ? { available: false, unavailableReason: 'status-unavailable' } + ? { + available: false, + unavailableReason: (status as { unavailableReason?: string }).unavailableReason ?? 'status-unavailable', + } : { fileCount: statusObject.fileCount, nodeCount: statusObject.nodeCount, @@ -493,6 +561,8 @@ export function buildDiagnosticBundleSummary(projectRoot: string, relativeBundle if (!aggregateTaxonomy) { if (source === 'last-failure') { lines.push('Graph health: failed'); + } else if (!graph.available && graph.unavailableReason === 'corrupted') { + lines.push('Graph health: corrupted'); } return { engine,