Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions __tests__/rust-hybrid-doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
90 changes: 90 additions & 0 deletions docs/plans/2026-07-02-corrupted-doctor-bundle-v2-roadmap.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
12 changes: 12 additions & 0 deletions docs/plans/2026-07-02-corrupted-doctor-bundle-v2-roadmap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!-- ROADMAP_SECTION_START -->
## 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 准备
<!-- ROADMAP_SECTION_END -->
74 changes: 72 additions & 2 deletions src/diagnostics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
buildRustHybridFallbackSummary,
formatRustHybridFallbackHealthLines,
} from './fallback-summary';
import { classifyGraphHealth, type GraphHealthDiagnosticRecord } from './graph-health';

export type DiagnosticRecordKind = 'last-run' | 'last-failure';

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down