diff --git a/src/commands/traces/export.ts b/src/commands/traces/export.ts index 4a02eab..b1428a2 100644 --- a/src/commands/traces/export.ts +++ b/src/commands/traces/export.ts @@ -77,11 +77,20 @@ export async function runExport(deps: ExportDeps): Promise { logProgress(`Writing bundle to ${outputDir} …`, writers); mkdirSync(outputDir, { recursive: true }); + // A flagged trace adds a 5th file, `finding.json` (the full FindingDetail — + // finding id + per-detector results + RCA). Computed up front so the manifest + // written to disk, the stderr summary, and the --json summary all report the + // exact same file list — the server's manifest doesn't know about the finding, + // which the CLI fetches separately, so it must be patched here. + const files: string[] = finding !== null ? [...BUNDLE_FILES, "finding.json"] : [...BUNDLE_FILES]; + const contents: Record<(typeof BUNDLE_FILES)[number], unknown> = { "trace.json": response.trace, "spans.json": response.spans, "git_context.json": response.git_context, - "manifest.json": response.manifest, + // Spread-then-override preserves key order and leaves bundle_version, + // project_id, trace_id untouched; only `files` reflects the finding. + "manifest.json": { ...response.manifest, files }, }; // The trace is fetched before the directory is created, so a fetch failure // leaves nothing on disk. A mid-write I/O error here can still leave a partial @@ -90,13 +99,9 @@ export async function runExport(deps: ExportDeps): Promise { writeFileSync(join(outputDir, file), toJsonFile(contents[file]), "utf8"); } - // A flagged trace adds a 5th file, `finding.json` (the full FindingDetail — - // finding id + per-detector results + RCA). The four core files stay unchanged. - const files: string[] = [...BUNDLE_FILES]; const findingPath = join(outputDir, "finding.json"); if (finding !== null) { writeFileSync(findingPath, toJsonFile(finding), "utf8"); - files.push("finding.json"); } else { // A --force overwrite of a directory from an earlier *flagged* export could // leave behind a finding.json for a different trace; remove it so the bundle diff --git a/tests/commands/traces.export.test.ts b/tests/commands/traces.export.test.ts index 3c8311e..83dae0b 100644 --- a/tests/commands/traces.export.test.ts +++ b/tests/commands/traces.export.test.ts @@ -457,6 +457,56 @@ describe("runExport", () => { expect(err.data).not.toContain("Flagged"); }); + it("patches manifest.json's files to include finding.json for a flagged trace, matching the --json summary", async () => { + const response = makeResponse(); + const outputDir = join(tmpRoot, "bundle"); + const { writers, out } = makeWriters(); + + await runExport({ + client: fakeClient(response, makeFinding()), + traceId: "abc123", + outputDir, + force: false, + json: true, + writers, + }); + + const manifest = JSON.parse(readFileSync(join(outputDir, "manifest.json"), "utf8")); + const expectedFiles = [ + "trace.json", + "spans.json", + "git_context.json", + "manifest.json", + "finding.json", + ]; + expect(manifest.files).toEqual(expectedFiles); + const parsed = JSON.parse(out.data); + expect(manifest.files).toEqual(parsed.files); + // Everything else passes through untouched. + expect(manifest.bundle_version).toBe(response.manifest.bundle_version); + expect(manifest.project_id).toBe(response.manifest.project_id); + expect(manifest.trace_id).toBe(response.manifest.trace_id); + }); + + it("writes manifest.json byte-identical to the server's manifest for an unflagged trace", async () => { + const response = makeResponse(); + const outputDir = join(tmpRoot, "bundle"); + const { writers } = makeWriters(); + + await runExport({ + client: fakeClient(response, null), + traceId: "abc123", + outputDir, + force: false, + json: false, + writers, + }); + + const raw = readFileSync(join(outputDir, "manifest.json"), "utf8"); + expect(raw).toBe(`${JSON.stringify(response.manifest, null, 2)}\n`); + expect(JSON.parse(raw)).toEqual(response.manifest); + }); + it("writes no finding.json when the finding lookup fails (best-effort)", async () => { const response = makeResponse(); const outputDir = join(tmpRoot, "bundle"); @@ -498,6 +548,46 @@ describe("runExport", () => { expect(existsSync(join(outputDir, "trace.json"))).toBe(true); }); + it("--force re-export from flagged to unflagged drops finding.json and shrinks manifest.files back to 4", async () => { + const response = makeResponse(); + const outputDir = join(tmpRoot, "bundle"); + const { writers } = makeWriters(); + + // Seed the directory as a prior *flagged* export would have left it: a + // finding.json plus a 5-entry manifest. + await runExport({ + client: fakeClient(response, makeFinding()), + traceId: "abc123", + outputDir, + force: false, + json: false, + writers, + }); + expect(existsSync(join(outputDir, "finding.json"))).toBe(true); + expect(JSON.parse(readFileSync(join(outputDir, "manifest.json"), "utf8")).files).toContain( + "finding.json", + ); + + // Re-export the same trace, now unflagged, with --force. + await runExport({ + client: fakeClient(response, null), + traceId: "abc123", + outputDir, + force: true, + json: false, + writers, + }); + + expect(existsSync(join(outputDir, "finding.json"))).toBe(false); + const manifest = JSON.parse(readFileSync(join(outputDir, "manifest.json"), "utf8")); + expect(manifest.files).toEqual([ + "trace.json", + "spans.json", + "git_context.json", + "manifest.json", + ]); + }); + it("propagates a fetch failure and creates no bundle dir or files", async () => { const outputDir = join(tmpRoot, "bundle"); const { writers } = makeWriters();