diff --git a/packages/deepsec/src/__tests__/setup-coordinator.test.ts b/packages/deepsec/src/__tests__/setup-coordinator.test.ts index f3b312c..c772e05 100644 --- a/packages/deepsec/src/__tests__/setup-coordinator.test.ts +++ b/packages/deepsec/src/__tests__/setup-coordinator.test.ts @@ -13,6 +13,19 @@ import { setupErrorExitCode, } from "../setup/protocol.js"; +// Stub the osv enrichment so the coordinator path can be exercised without the +// external osv-scanner/uv/syft toolchain. `runOsvEnrich` returns a sentinel +// "directive" context (what buildContext now produces); mergeInfoWithOsvContext +// stays real so the non-osv preserve path still behaves exactly as in production. +const OSV_DIRECTIVE_SENTINEL = "### Dependency reachability (required) — DIRECTIVE SENTINEL"; +vi.mock("../setup/osv-enrich.js", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + runOsvEnrich: vi.fn(() => ({ context: OSV_DIRECTIVE_SENTINEL, hits: [] })), + }; +}); + const originalCwd = process.cwd(); afterEach(() => process.chdir(originalCwd)); @@ -245,6 +258,27 @@ describe("one-shot setup coordinator", () => { expect(fs.readFileSync(path.join(workspace, "data", "app", "INFO.md"), "utf8")).toBe( curatedInfo, ); + + // Enabling osv on a project that already has a complete INFO.md must NOT + // discard the freshly analyzed reachability verdicts by restoring the old + // copy with the raw grading directive appended. The fresh analysis wins. + fs.writeFileSync(path.join(workspace, "data", "app", "INFO.md"), curatedInfo); + const infoAfterOsv = await (async () => { + await runSetupWorkflow({ + workspaceDir: workspace, + projectId: "app", + projectRoot: project, + through: "threat-model", + osv: true, + services, + onLog: () => undefined, + }); + return fs.readFileSync(path.join(workspace, "data", "app", "INFO.md"), "utf8"); + })(); + expect(analyze).toHaveBeenCalledTimes(3); // osv flag invalidated the info checkpoint + expect(infoAfterOsv).not.toContain(OSV_DIRECTIVE_SENTINEL); // directive never persisted + expect(infoAfterOsv).not.toBe(curatedInfo); // old copy not restored over fresh analysis + expect(infoAfterOsv).toContain("## Threat model"); // fresh analysis content kept }); it("uses the new harness default model instead of a previous harness model", async () => { diff --git a/packages/deepsec/src/setup/coordinator.ts b/packages/deepsec/src/setup/coordinator.ts index 54f551f..a7555f1 100644 --- a/packages/deepsec/src/setup/coordinator.ts +++ b/packages/deepsec/src/setup/coordinator.ts @@ -551,7 +551,16 @@ export async function runSetupWorkflow( // appended section first, then append the freshly generated one (or none). // Otherwise refreshes accumulate duplicate sections and keep listing // dependencies that have since been patched or removed. - if (preserveInfo) { + // + // Exception: when osv is enabled, `osvEnrichment.context` is a *prompt + // directive* (it tells the analysis agent to emit a graded Dependency + // reachability block), not a self-contained section. Appending it to the old + // INFO.md would persist raw grading instructions and discard the real + // reachability verdicts the agent just wove into the freshly generated + // INFO.md. Enabling osv is a deliberate "re-analyze with dependencies in + // view" action, so keep the fresh analysis (verdicts included) rather than + // the preserved copy. The non-osv path keeps curated content as before. + if (preserveInfo && !osvEnabled) { atomicWriteFileSync(infoPath, mergeInfoWithOsvContext(preserveInfo, osvEnrichment.context)); } state.infoDigest = digest(fs.readFileSync(infoPath, "utf8"));