diff --git a/CHANGELOG.md b/CHANGELOG.md index 868f74eb..0529d48e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,10 +13,12 @@ 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. +- `zcodegraph status` now shows decision-ready `rust-hybrid` fallback diagnostics, including top reason groups, graph usability, the exact doctor command, and the privacy-preserving bundle artifact to share with maintainers. (#668, #669, #670) ### 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) +- `zcodegraph doctor --engine rust-hybrid --bundle --last-run` now points degraded fallback reports to the per-file diagnostic artifact and next maintainer handoff step instead of leaving users at the bundle path alone. (#669, #670) ## [0.10.2] - 2026-06-30 diff --git a/README.md b/README.md index c8e86762..2bae38b5 100644 --- a/README.md +++ b/README.md @@ -636,6 +636,8 @@ shared health states are: - `healthy` — the graph is current and fully usable. - `degraded` — the graph is usable, but fallback diagnostics need review. + `zcodegraph status` shows the top fallback reason groups and the exact + `doctor` command to create the per-file diagnostic artifact. - `stale` — the graph is usable but out of date; run `zcodegraph sync` or `zcodegraph index --force`. - `failed` — the latest build failed; run @@ -646,13 +648,28 @@ shared health states are: **Indexing is slow** — Check that `node_modules` and other large directories are excluded. Use `--quiet` to reduce output overhead. -**Indexing completed with fallback or failed** — Run a local diagnostic bundle and attach it to your issue. Bundles do not include source code by default: +**Indexing completed with fallback or failed** — Start with `zcodegraph status` +to separate freshness from quality: `stale` means run `zcodegraph sync` or +`zcodegraph index --force`, while `degraded` means the graph is current enough +to use but some files or Rust-owned diagnostics need review. `init` creates the +project index, `index` rebuilds it, `sync` catches up source edits, `status` +answers whether the current graph can be trusted, and `doctor` packages the +evidence for maintainers. + +Run a local diagnostic bundle and attach it to your issue. Bundles do not +include source code by default: ```bash zcodegraph doctor --engine rust-hybrid --bundle --last-run zcodegraph doctor --engine rust-hybrid --bundle --last-failure ``` +For degraded fallback, `status` and `doctor` summarize the top reason groups in +human terms. The bundle's `per-file-diagnostics.json` records path hashes, +extensions, languages, reason categories, and sanitized messages so maintainers +can classify affected files without receiving plaintext source paths or source +slices. + If `doctor` or one of those flags is missing, capture command-resolution evidence before filing the issue: diff --git a/__tests__/rust-hybrid-doctor.test.ts b/__tests__/rust-hybrid-doctor.test.ts index 51c3d83c..79e20d32 100644 --- a/__tests__/rust-hybrid-doctor.test.ts +++ b/__tests__/rust-hybrid-doctor.test.ts @@ -209,6 +209,8 @@ describe('rust-hybrid doctor diagnostic bundles', () => { expect(doctor.stdout).toContain('The index is usable; fallback-degraded files or diagnostics are the only parts that need review.'); expect(doctor.stdout).toContain('Top fallback reasons:'); expect(doctor.stdout).toContain('1 Rust-owned files with extraction diagnostics'); + expect(doctor.stdout).toContain('Diagnostic artifact: per-file-diagnostics.json uses path hashes and reason categories without source slices.'); + expect(doctor.stdout).toContain('Next step: share this bundle path with the maintainer or attach the bundle contents requested by them.'); const bundleDir = path.resolve(tempDir, latestBundlePath(doctor.stdout)); const status = readJson(path.join(bundleDir, 'status.json')); diff --git a/__tests__/rust-index-engine-cli-fallback.test.ts b/__tests__/rust-index-engine-cli-fallback.test.ts index db44403e..0871f023 100644 --- a/__tests__/rust-index-engine-cli-fallback.test.ts +++ b/__tests__/rust-index-engine-cli-fallback.test.ts @@ -220,6 +220,62 @@ describe('zcodegraph rust-hybrid fallback degraded status and doctor output', () expect(result.stdout).toContain('zcodegraph doctor --engine rust-hybrid --bundle --last-run'); }, 30_000); + it('surfaces degraded fallback quality details in status output', () => { + fs.writeFileSync(path.join(tempDir, 'routing.yml'), 'app:\n path: /health\n'); + + const index = runZcodegraphCli(tempDir, ['index', '--quiet'], { + ZCODEGRAPH_RUST_CORE_BINARY: RUST_CORE_BIN, + }); + expect(index.status, `stdout:\n${index.stdout}\nstderr:\n${index.stderr}`).toBe(0); + + const status = runZcodegraphCli(tempDir, ['status']); + expect(status.status, `stdout:\n${status.stdout}\nstderr:\n${status.stderr}`).toBe(0); + expect(status.stdout).toContain('Graph Health:'); + expect(status.stdout).toContain('State: degraded'); + expect(status.stdout).toContain('Rust-hybrid Fallback:'); + expect(status.stdout).toContain('Fallback health: degraded'); + expect(status.stdout).toContain('Top fallback reasons:'); + expect(status.stdout).toContain('TypeScript fallback files'); + expect(status.stdout).toContain('per-file-diagnostics.json'); + expect(status.stdout).toContain('zcodegraph doctor --engine rust-hybrid --bundle --last-run'); + }, 30_000); + + it('exposes degraded fallback quality details in status json', () => { + fs.writeFileSync(path.join(tempDir, 'routing.yml'), 'app:\n path: /health\n'); + + const index = runZcodegraphCli(tempDir, ['index', '--quiet'], { + ZCODEGRAPH_RUST_CORE_BINARY: RUST_CORE_BIN, + }); + expect(index.status, `stdout:\n${index.stdout}\nstderr:\n${index.stderr}`).toBe(0); + + const statusResult = runZcodegraphCli(tempDir, ['status', '--json']); + expect(statusResult.status, `stdout:\n${statusResult.stdout}\nstderr:\n${statusResult.stderr}`).toBe(0); + const statusLine = statusResult.stdout.trim().split('\n').filter(Boolean).pop(); + expect(statusLine).toBeDefined(); + const status = JSON.parse(statusLine!) as { + fallbackDiagnostics: { + state: string; + graphUsabilityMessage: string; + doctorCommand: string; + artifactHint: string; + topReasons: Array<{ code: string; count: number; label: string }>; + }; + }; + expect(status.fallbackDiagnostics).toMatchObject({ + state: 'degraded', + doctorCommand: 'zcodegraph doctor --engine rust-hybrid --bundle --last-run', + artifactHint: 'per-file-diagnostics.json uses path hashes and reason categories without source slices.', + }); + expect(status.fallbackDiagnostics.graphUsabilityMessage).toContain('The index is usable'); + expect(status.fallbackDiagnostics.topReasons).toEqual([ + expect.objectContaining({ + code: 'language-level-typescript-fallback', + count: 1, + label: 'TypeScript fallback files', + }), + ]); + }, 30_000); + it('prints the degraded fallback health explanation during rust-hybrid init', () => { const initDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zcodegraph-rust-hybrid-fallback-init-')); try { diff --git a/docs/plans/2026-07-02-fallback-diagnostics-ux-roadmap.json b/docs/plans/2026-07-02-fallback-diagnostics-ux-roadmap.json new file mode 100644 index 00000000..bd6d5253 --- /dev/null +++ b/docs/plans/2026-07-02-fallback-diagnostics-ux-roadmap.json @@ -0,0 +1,84 @@ +{ + "title": "fallback diagnostics UX closeout", + "description": "Resolve issues #668, #669, and #670 by making degraded rust-hybrid fallback diagnostics actionable across index/init, status, doctor, docs, and issue closeout.", + "version": 1, + "nodes": { + "1": { + "id": "1", + "label": "fallback diagnostics UX closeout", + "status": "completed", + "mode": "explore", + "parent": null, + "children": [ + "1-1", + "1-2", + "1-3", + "1-4", + "1-5" + ], + "decisions": [ + { + "q": "这轮是否包含 Vue/TypeScript 索引覆盖提升?", + "answer": "不把 Vue/TypeScript 解析能力提升作为本轮 P0;本轮关闭诊断透明度和工作流可解释性。", + "note": "#670 的 Vue fallback reduction 可用文档和可测量后续项承接;否则本轮会从 UX 透明度扩张到语言覆盖迁移。" + } + ], + "notes": "" + }, + "1-1": { + "id": "1-1", + "label": "界定 668 669 670 的剩余交付边界", + "status": "completed", + "mode": "exploit", + "parent": "1", + "children": [], + "decisions": [], + "notes": "Remaining scope narrowed to degraded fallback diagnostic transparency. Vue/TypeScript parser coverage is out of this loop and should follow from measurable fallback reduction work." + }, + "1-2": { + "id": "1-2", + "label": "让 status 显示 last-run fallback 质量摘要", + "status": "completed", + "mode": "exploit", + "parent": "1", + "children": [], + "decisions": [], + "notes": "Status now prints a Rust-hybrid Fallback section with fallback health, top translated reasons, per-file-diagnostics artifact hint, and exact doctor command; status --json exposes fallbackDiagnostics." + }, + "1-3": { + "id": "1-3", + "label": "让 doctor 输出原因翻译与 artifact 指向", + "status": "completed", + "mode": "exploit", + "parent": "1", + "children": [], + "decisions": [], + "notes": "Doctor degraded summaries now name per-file-diagnostics.json as the privacy-preserving artifact and state the maintainer handoff next step." + }, + "1-4": { + "id": "1-4", + "label": "补充 global workflow 和 degraded guidance 文档", + "status": "completed", + "mode": "exploit", + "parent": "1", + "children": [], + "decisions": [], + "notes": "README troubleshooting now clarifies init/index/sync/status/doctor workflow, degraded vs stale, and per-file diagnostic bundle privacy semantics." + }, + "1-5": { + "id": "1-5", + "label": "验证并关闭 issues 668 669 670", + "status": "completed", + "mode": "exploit", + "parent": "1", + "children": [], + "decisions": [], + "notes": "Verification passed: fallback CLI tests, doctor bundle tests, status-json tests, npm run build, and git diff --check. Issues #668/#669/#670 should be closed after PR merge because their visible behavior depends on main." + } + }, + "metadata": { + "created": "2026-07-02 22:17:45", + "updated": "2026-07-02 22:27:56", + "md_file": "docs/plans/2026-07-02-fallback-diagnostics-ux-roadmap.md" + } +} \ No newline at end of file diff --git a/docs/plans/2026-07-02-fallback-diagnostics-ux-roadmap.md b/docs/plans/2026-07-02-fallback-diagnostics-ux-roadmap.md new file mode 100644 index 00000000..f2a10880 --- /dev/null +++ b/docs/plans/2026-07-02-fallback-diagnostics-ux-roadmap.md @@ -0,0 +1,12 @@ + +## ZJ Roadmap + +> 数据文件: `2026-07-02-fallback-diagnostics-ux-roadmap.json` | 最后更新: 2026-07-02 22:27:56 + +[x][X+] 1. fallback diagnostics UX closeout +├── [x][Y+] 1-1. 界定 668 669 670 的剩余交付边界 +├── [x][Y+] 1-2. 让 status 显示 last-run fallback 质量摘要 +├── [x][Y+] 1-3. 让 doctor 输出原因翻译与 artifact 指向 +├── [x][Y+] 1-4. 补充 global workflow 和 degraded guidance 文档 +└── [x][Y+] 1-5. 验证并关闭 issues 668 669 670 + diff --git a/src/bin/zcodegraph.ts b/src/bin/zcodegraph.ts index 7f8b528f..37902b9c 100644 --- a/src/bin/zcodegraph.ts +++ b/src/bin/zcodegraph.ts @@ -42,7 +42,14 @@ import { RustOwnedPerFileGapDiagnostic, } from '../indexing/rust-hybrid-contract'; import { buildDiagnosticBundleSummary, createDiagnosticBundle, diagnosticRecordPath, writeDiagnosticRunRecord } from '../diagnostics'; -import { buildRustHybridFallbackSummary, formatRustHybridFallbackDoctorHint } from '../diagnostics/fallback-summary'; +import { + buildRustHybridFallbackSummary, + formatRustHybridFallbackDoctorHint, + formatRustHybridFallbackHealthLines, + rustHybridFallbackReasonLabel, + RustHybridFallbackReasonCount, + RustHybridFallbackSummary, +} from '../diagnostics/fallback-summary'; import { classifyGraphHealth, formatGraphHealthLines, GraphHealth } from '../diagnostics/graph-health'; import { buildNode25BlockBanner, buildNodeTooOldBanner, MIN_NODE_MAJOR } from './node-version-check'; @@ -67,6 +74,81 @@ function resolveSqliteWriteMode(raw: string | undefined): 'disk' | 'final-flush' throw new Error(`Unsupported SQLite write mode "${raw}". Supported modes: disk, final-flush, memory-final-flush`); } +type RustHybridStatusFallbackDiagnostics = { + state: string; + graphUsabilityMessage: string; + fallbackFileCount: number; + missingFallbackFileCount: number; + reasonTaxonomy: Record; + topReasons: Array; + doctorCommand: string; + artifactHint: string; +} | null; + +function fallbackSummaryFromHybridMetadata(hybrid: unknown): RustHybridFallbackSummary | null { + if (!hybrid || typeof hybrid !== 'object') return null; + const metadata = hybrid as { + fallbackState?: string; + fallbackFileCount?: number; + missingFallbackFileCount?: number; + missingFallbackByLanguage?: Record; + fallbackReasonTaxonomy?: Record; + }; + const fallbackReasonTaxonomy = metadata.fallbackReasonTaxonomy ?? {}; + const fallbackFileCount = metadata.fallbackFileCount ?? 0; + const missingFallbackFileCount = metadata.missingFallbackFileCount ?? 0; + const summary = buildRustHybridFallbackSummary({ + success: true, + errors: [], + profile: { + typescriptFallbackAppend: { + fallbackFileCount, + missingFallbackFileCount, + missingFallbackByLanguage: metadata.missingFallbackByLanguage ?? {}, + }, + }, + }); + summary.fallbackReasonTaxonomy = fallbackReasonTaxonomy; + summary.topFallbackReasons = Object.entries(fallbackReasonTaxonomy) + .map(([code, count]) => ({ code, count })) + .sort((left, right) => right.count - left.count || left.code.localeCompare(right.code)); + summary.fallbackState = metadata.fallbackState === 'degraded' ? 'degraded' : summary.fallbackState; + if (summary.fallbackState === 'degraded') { + summary.graphUsabilityMessage = 'The index is usable; fallback-degraded files or diagnostics are the only parts that need review.'; + } + return summary; +} + +function statusFallbackDiagnostics(hybrid: unknown): RustHybridStatusFallbackDiagnostics { + const summary = fallbackSummaryFromHybridMetadata(hybrid); + if (!summary || summary.fallbackState !== 'degraded') return null; + return { + state: summary.fallbackState, + graphUsabilityMessage: summary.graphUsabilityMessage, + fallbackFileCount: summary.fallbackFileCount, + missingFallbackFileCount: summary.missingFallbackFileCount, + reasonTaxonomy: summary.fallbackReasonTaxonomy, + topReasons: summary.topFallbackReasons.map((reason) => ({ + ...reason, + label: rustHybridFallbackReasonLabel(reason.code), + })), + doctorCommand: 'zcodegraph doctor --engine rust-hybrid --bundle --last-run', + artifactHint: 'per-file-diagnostics.json uses path hashes and reason categories without source slices.', + }; +} + +function printRustHybridFallbackStatus(hybrid: unknown): void { + const summary = fallbackSummaryFromHybridMetadata(hybrid); + if (!summary || summary.fallbackState !== 'degraded') return; + console.log('Rust-hybrid Fallback:'); + for (const line of formatRustHybridFallbackHealthLines(summary)) { + console.log(` ${line.replace(/\n/g, '\n ')}`); + } + console.log(' Diagnostic artifact: per-file-diagnostics.json uses path hashes and reason categories without source slices.'); + console.log(' Next step: zcodegraph doctor --engine rust-hybrid --bundle --last-run'); + console.log(); +} + type ProfileCheckpointState = 'started' | 'completed'; type ProfileCheckpoint = { @@ -1249,6 +1331,7 @@ program currentExtractionVersion: EXTRACTION_VERSION, reindexRecommended, }, + fallbackDiagnostics: statusFallbackDiagnostics(buildInfo.hybrid), rust: getRustReadinessDiagnostics(projectPath, buildInfo), })); cg.destroy(); @@ -1265,6 +1348,7 @@ program console.log(); printGraphHealthSummary(health); + printRustHybridFallbackStatus(buildInfo.hybrid); // Index stats console.log(chalk.bold('Index Statistics:')); diff --git a/src/diagnostics/fallback-summary.ts b/src/diagnostics/fallback-summary.ts index 0c25e715..32f9a444 100644 --- a/src/diagnostics/fallback-summary.ts +++ b/src/diagnostics/fallback-summary.ts @@ -42,6 +42,10 @@ const FALLBACK_REASON_LABELS: Record = { 'rust-owned-gap-with-partial-write-blocked': 'Rust-owned files with partial Rust writes not fallback-appended', }; +export function rustHybridFallbackReasonLabel(code: string): string { + return FALLBACK_REASON_LABELS[code] ?? code; +} + function fallbackProfile(input: RustHybridFallbackSummaryInput): RustHybridFallbackProfile | undefined { return input.profile as RustHybridFallbackProfile | undefined; } @@ -87,7 +91,7 @@ export function buildRustHybridFallbackSummary(input: RustHybridFallbackSummaryI } function formatReasonCount(reason: RustHybridFallbackReasonCount): string { - return `${reason.count} ${FALLBACK_REASON_LABELS[reason.code] ?? reason.code}`; + return `${reason.count} ${rustHybridFallbackReasonLabel(reason.code)}`; } export function formatRustHybridFallbackHealthLines(summary: RustHybridFallbackSummary): string[] { diff --git a/src/diagnostics/index.ts b/src/diagnostics/index.ts index 29ed1619..46b33784 100644 --- a/src/diagnostics/index.ts +++ b/src/diagnostics/index.ts @@ -598,6 +598,10 @@ export function buildDiagnosticBundleSummary(projectRoot: string, relativeBundle fallbackSummary.graphUsabilityMessage = 'The index is usable; fallback-degraded files or diagnostics are the only parts that need review.'; } lines.push(...formatRustHybridFallbackHealthLines(fallbackSummary)); + if (fallbackSummary.fallbackState === 'degraded') { + lines.push('Diagnostic artifact: per-file-diagnostics.json uses path hashes and reason categories without source slices.'); + lines.push('Next step: share this bundle path with the maintainer or attach the bundle contents requested by them.'); + } return { engine, source,