From 18727fd3a4a0dfe83797b1f7e831325dc36131bd Mon Sep 17 00:00:00 2001 From: bntvllnt <32437578+bntvllnt@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:48:13 +0100 Subject: [PATCH 1/3] fix: eliminate dead export false positives (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs caused ~33% false positive rate in find_dead_exports: 1. Duplicate imports to same target silently dropped — second import's symbols never entered consumed set (e.g., `import type {X}` after `import {A,B,C}` from same file) 2. Same-file calls invisible — parser excluded intra-file calls, analyzer only checked import edges Fixes: - Graph: merge duplicate edge symbols (both graphology + edges[] array) - Parser: remove same-file call exclusion guard - Analyzer: integrate call graph into consumed symbols check with class method normalization (ClassName.method → ClassName) 7 regression tests covering: truly dead exports, type-only imports, duplicate import merge, same-file calls, class methods, mixed dead/alive, edge merge sync. --- ...6-03-10-fix-dead-export-false-positives.md | 225 ++++++++++++++++++ src/analyzer/index.test.ts | 152 +++++++++++- src/analyzer/index.ts | 13 + src/graph/index.ts | 13 + src/parser/index.ts | 2 +- 5 files changed, 403 insertions(+), 2 deletions(-) create mode 100644 specs/active/2026-03-10-fix-dead-export-false-positives.md diff --git a/specs/active/2026-03-10-fix-dead-export-false-positives.md b/specs/active/2026-03-10-fix-dead-export-false-positives.md new file mode 100644 index 0000000..f5d9fad --- /dev/null +++ b/specs/active/2026-03-10-fix-dead-export-false-positives.md @@ -0,0 +1,225 @@ +# Fix: Dead Export Detection False Positives + +**Issue**: [#6](https://github.com/bntvllnt/codebase-intelligence/issues/6) +**Branch**: `fix/dead-export-false-positives` +**Date**: 2026-03-10 +**Spec review**: Applied 2026-03-10 (7 items from 4-perspective adversarial review) + +## Problem + +`find_dead_exports` has ~33% false positive rate (2/6). Two bugs: + +1. **Duplicate imports dropped** — second import to same target silently skipped, losing symbols +2. **Same-file calls invisible** — parser skips intra-file calls, analyzer only checks import edges + +### False Positives + +| Export | File | Why Not Dead | +|--------|------|-------------| +| `SearchIndex` | `search/index.ts` | `import type` in `mcp/index.ts:8` | +| `registerTools` | `mcp/index.ts` | Called at line 812 within `startMcpServer()` | + +### True Positives (4) + +`tokenize`, `setGraph`, `getGraph`, `detectEntryPoints` + +## Semantics Change + +**Old definition**: dead = "no external file imports this symbol" +**New definition**: dead = "no import edges AND no call edges reference this symbol (including same-file)" + +This means exports like `tokenize` (used only within `search/index.ts`) will no longer be flagged dead. This is intentional — if an export is called anywhere, removing it requires code changes, so it's not safe to delete. + +## Root Cause (Confirmed) + +``` +BUG 1: Duplicate edge dropped BUG 2: Same-file calls skipped +──────────────────────────────── ────────────────────────────── +mcp/index.ts: mcp/index.ts: + L7: import {A,B,C} from search ──▶ edge created, symbols:[A,B,C] + L8: import type {X} from search ──▶ SKIPPED (edge exists) parser/index.ts:345 + X never enters consumed declRelPath !== callerFile → skip + same-file calls never recorded + +graph/index.ts:65 analyzer/index.ts:36-40 + if (!graph.hasEdge(src, tgt)) consumedSymbols from edges only + → only FIRST edge per pair → no call graph integration +``` + +## Implementation + +### Task 1: Merge duplicate edge symbols (Graph) + +**File**: `src/graph/index.ts:59-81` + +When edge already exists for source→target, merge new symbols into existing edge instead of skipping. **Must update BOTH the graphology edge attributes AND the `edges[]` array atomically** — dead export detection reads `edges[]`, PageRank reads graphology. + +``` +Current (line 65): + if (!graph.hasEdge(src, target)) { addEdge(...) } + +Fix: + if (!graph.hasEdge(src, target)) { + addEdge(...) + } else { + // Find existing edge entry in edges[] + const existing = edges.find(e => e.source === src && e.target === target) + // Merge symbols (union, no duplicates) + const merged = [...new Set([...existing.symbols, ...imp.symbols])] + existing.symbols = merged + existing.weight = merged.length || 1 + // isTypeOnly: false if EITHER import is value (not type-only) + existing.isTypeOnly = existing.isTypeOnly && imp.isTypeOnly + // Update graphology edge attributes to match + graph.setEdgeAttribute(src, target, 'symbols', merged) + graph.setEdgeAttribute(src, target, 'weight', merged.length || 1) + graph.setEdgeAttribute(src, target, 'isTypeOnly', existing.isTypeOnly) + } +``` + +**Merge rules:** +- `symbols`: union (deduplicated) +- `weight`: `mergedSymbols.length || 1` +- `isTypeOnly`: `existing && new` (only true if BOTH imports are type-only) + +**Side effect**: PageRank scores shift slightly for files with previously-dropped duplicate imports. Weight increases → hub files get marginally higher PageRank. Acceptable — more accurate than before. + +Fixes: `SearchIndex` false positive. + +### Task 2: Include same-file calls in parser (Parser) + +**File**: `src/parser/index.ts:345` + +Remove the `declRelPath !== callerFile` guard so same-file calls are recorded in `callSites`. + +``` +Current (line 345): + if (declRelPath !== callerFile && !declRelPath.startsWith("..") && ...) + +Fix: + if (!declRelPath.startsWith("..") && !path.isAbsolute(declRelPath)) +``` + +**Side effects** (all beneficial or neutral): +- `symbol_context` — shows intra-file callers/callees (more complete) +- `impact_analysis` — blast radius includes same-file dependents (more accurate but noisier) +- `get_processes` — `detectEntryPoints` may return fewer results (internal helpers gain inbound edges, losing "entry point" status). Verify existing tests still pass. +- `callEdges` / `symbolNodes` arrays grow. Negligible perf impact for typical codebases. + +### Task 3: Use call graph for dead export detection (Analyzer) — BLOCKED BY Task 2 + +**File**: `src/analyzer/index.ts:36-41` + +After building `consumedSymbols` from import edges, also add symbols consumed via call graph edges. `callEdges` are already available in `built: BuiltGraph` (confirmed: `BuiltGraph.callEdges` at `graph/index.ts:10`). + +```typescript +// After existing consumedSymbols loop (line 41): +// Also count symbols consumed via call graph (includes same-file calls from Task 2) +for (const callEdge of built.callEdges) { + // Extract file path from callEdge.target ("file::symbol" format) + const sepIdx = callEdge.target.indexOf("::"); + if (sepIdx === -1) continue; + const targetFile = callEdge.target.substring(0, sepIdx); + + // Normalize: class method "AuthService.validate" → class name "AuthService" + const rawSymbol = callEdge.calleeSymbol; + const consumedName = rawSymbol.includes(".") ? rawSymbol.split(".")[0] : rawSymbol; + + const existing = consumedSymbols.get(targetFile) ?? new Set(); + existing.add(consumedName); + consumedSymbols.set(targetFile, existing); +} +``` + +**Critical: class method normalization.** `calleeSymbol` for method calls is `"ClassName.methodName"` but exports only contain `"ClassName"`. Must strip method suffix or class exports remain false positives. + +Fixes: `registerTools` false positive. + +### Task 4: Regression tests + +**File**: `src/analyzer/index.test.ts` + +Real fixture files through real pipeline (no mocks): + +| Test | Fixture | Assert | +|------|---------|--------| +| Type-only import consumed | A: `import type { X } from "./b"`, B: exports `X` | `X` NOT in deadExports | +| Duplicate import merged | A: `import { Y }` + `import type { Z }` from B | both `Y`, `Z` NOT dead | +| Same-file call consumed | A: exports `foo`, `bar`; `bar` calls `foo` | `foo` NOT dead | +| Class method consumed | A: `new B().method()`, B: exports class `B` | `B` NOT dead | +| Truly dead export | A: exports `baz`, nobody imports or calls it | `baz` IS dead | +| Mixed dead/alive | File with some consumed, some dead exports | only dead ones reported | +| Edge merge sync | After merge, graphology attrs === edges[] entry | symbols, weight, isTypeOnly match | + +**Fixture dir**: `tests/fixtures/dead-exports/` + +### Task 5: Self-analysis verification + +Run `find_dead_exports` against this repo after fix: +- Expected: `registerTools` and `SearchIndex` NOT flagged +- Expected: `setGraph`, `getGraph` still flagged (truly dead) +- `tokenize` and `detectEntryPoints` may no longer be dead (if called same-file) — correct per new semantics + +## Expected True Dead Exports After Fix + +| Export | File | Status | +|--------|------|--------| +| `setGraph` | `server/graph-store.ts` | DEAD (exported, never imported or called) | +| `getGraph` | `server/graph-store.ts` | DEAD (exported, never imported or called) | +| `tokenize` | `search/index.ts` | Likely NOT dead (called same-file by `createSearchIndex`) | +| `detectEntryPoints` | `process/index.ts` | Likely NOT dead (called same-file by `traceProcesses`) | + +## State Machine + +N/A — stateless computation fix. + +## Files to Change + +| File | Change | Lines | +|------|--------|-------| +| `src/graph/index.ts` | Merge duplicate edge symbols (both stores) | ~65-81 | +| `src/parser/index.ts` | Include same-file calls | ~345 | +| `src/analyzer/index.ts` | Add call graph to consumed check + class normalization | ~36-41 | +| `src/analyzer/index.test.ts` | Dead export regression tests | new | +| `tests/fixtures/dead-exports/` | Fixture .ts files | new | + +## Quality Gates + +- [ ] Lint (changed files) +- [ ] Typecheck (full project) +- [ ] Build +- [ ] Tests (all + new regression) +- [ ] Self-analysis: 0 false positives on known cases + +## Risks + +| Risk | Mitigation | +|------|-----------| +| Dual store desync (graphology vs edges[]) | Task 4 regression test asserts both match after merge | +| Class method `calleeSymbol` doesn't match export name | Task 3 normalizes: strip `.method` suffix | +| `isTypeOnly` collision on merge | Merge rule: `false` if either import is value | +| PageRank shift from weight changes | Acceptable — more accurate. Existing tests may need threshold adjustment | +| `get_processes` returns fewer entry points | Verify existing tests. Internal helpers losing entry status is correct | +| Same-file calls inflate `impact_analysis` blast radius | Accept — more accurate. Document in tool description if noisy | + +## Known Gaps (Out of Scope) + +These false positive categories are NOT addressed by this fix: + +| Gap | Description | Tracking | +|-----|------------|---------| +| Barrel `export *` | `symbols: ['*']` never matches concrete export names | File as separate issue | +| Interface dispatch | Polymorphic calls resolve to interface, not implementation | Inherent TS limitation | +| Dynamic calls | `obj[method]()`, HOF parameters — unresolvable statically | Accept | +| Destructured requires | `const { foo } = require(...)` — not tracked | Rare in ESM codebases | + +## Task Dependencies + +``` +Task 1 (graph merge) ──────────┐ + ├──▶ Task 3 (analyzer) ──▶ Task 4 (tests) ──▶ Task 5 (verify) +Task 2 (parser same-file) ─────┘ + BLOCKS Task 3 +``` + +Tasks 1 and 2 are independent. Task 3 BLOCKS on both (hard dependency). Task 4 validates all. diff --git a/src/analyzer/index.test.ts b/src/analyzer/index.test.ts index bba0088..b574018 100644 --- a/src/analyzer/index.test.ts +++ b/src/analyzer/index.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { analyzeGraph } from "./index.js"; import { buildGraph } from "../graph/index.js"; import { cloudGroup } from "../cloud-group.js"; -import type { ParsedFile } from "../types/index.js"; +import type { ParsedFile, ParsedExport } from "../types/index.js"; function makeFile(relativePath: string, overrides?: Partial): ParsedFile { return { @@ -248,6 +248,156 @@ describe("analyzeGraph", () => { }); }); +describe("dead export detection", () => { + function exp(name: string, type: ParsedExport["type"] = "function"): ParsedExport { + return { name, type, loc: 1, isDefault: false, complexity: 1 }; + } + + function callSite( + callerFile: string, + callerSymbol: string, + calleeFile: string, + calleeSymbol: string, + ): ParsedFile["callSites"][number] { + return { callerFile, callerSymbol, calleeFile, calleeSymbol, confidence: "type-resolved" }; + } + + it("detects truly dead exports", () => { + const files = [ + makeFile("a.ts", { + exports: [exp("usedFn"), exp("deadFn")], + }), + makeFile("b.ts", { + imports: [imp("a.ts", ["usedFn"])], + }), + ]; + const built = buildGraph(files); + const result = analyzeGraph(built, files); + + const dead = result.fileMetrics.get("a.ts")?.deadExports; + expect(dead).toContain("deadFn"); + expect(dead).not.toContain("usedFn"); + }); + + it("type-only imports count as consumed (not dead)", () => { + const files = [ + makeFile("types.ts", { + exports: [exp("MyType", "interface")], + }), + makeFile("consumer.ts", { + imports: [imp("types.ts", ["MyType"], true)], + }), + ]; + const built = buildGraph(files); + const result = analyzeGraph(built, files); + + const dead = result.fileMetrics.get("types.ts")?.deadExports; + expect(dead).not.toContain("MyType"); + }); + + it("duplicate imports to same target merge symbols", () => { + const files = [ + makeFile("lib.ts", { + exports: [exp("valFn"), exp("TypeA", "interface")], + }), + makeFile("user.ts", { + imports: [ + imp("lib.ts", ["valFn"], false), + imp("lib.ts", ["TypeA"], true), + ], + }), + ]; + const built = buildGraph(files); + const result = analyzeGraph(built, files); + + const dead = result.fileMetrics.get("lib.ts")?.deadExports; + expect(dead).not.toContain("valFn"); + expect(dead).not.toContain("TypeA"); + }); + + it("same-file calls count as consumed (not dead)", () => { + const files = [ + makeFile("module.ts", { + exports: [exp("helper"), exp("main")], + callSites: [callSite("module.ts", "main", "module.ts", "helper")], + }), + ]; + const built = buildGraph(files); + const result = analyzeGraph(built, files); + + const dead = result.fileMetrics.get("module.ts")?.deadExports; + expect(dead).not.toContain("helper"); + }); + + it("class method calls mark the class as consumed", () => { + const files = [ + makeFile("service.ts", { + exports: [exp("AuthService", "class")], + }), + makeFile("handler.ts", { + imports: [imp("service.ts", ["AuthService"])], + callSites: [callSite("handler.ts", "", "service.ts", "AuthService.validate")], + }), + ]; + const built = buildGraph(files); + const result = analyzeGraph(built, files); + + const dead = result.fileMetrics.get("service.ts")?.deadExports; + expect(dead).not.toContain("AuthService"); + }); + + it("mixed dead and alive exports only flags dead ones", () => { + const files = [ + makeFile("mixed.ts", { + exports: [exp("alive1"), exp("alive2", "interface"), exp("dead1"), exp("dead2")], + }), + makeFile("consumer.ts", { + imports: [ + imp("mixed.ts", ["alive1"], false), + imp("mixed.ts", ["alive2"], true), + ], + }), + ]; + const built = buildGraph(files); + const result = analyzeGraph(built, files); + + const dead = result.fileMetrics.get("mixed.ts")?.deadExports; + expect(dead).toContain("dead1"); + expect(dead).toContain("dead2"); + expect(dead).not.toContain("alive1"); + expect(dead).not.toContain("alive2"); + }); + + it("duplicate edge merge syncs graphology attrs and edges array", () => { + const files = [ + makeFile("target.ts", { + exports: [exp("valFn"), exp("TypeB", "interface")], + }), + makeFile("source.ts", { + imports: [ + imp("target.ts", ["valFn"], false), + imp("target.ts", ["TypeB"], true), + ], + }), + ]; + const built = buildGraph(files); + + // edges array should have merged symbols + const edge = built.edges.find((e) => e.source === "source.ts" && e.target === "target.ts"); + expect(edge).toBeDefined(); + expect(edge?.symbols).toContain("valFn"); + expect(edge?.symbols).toContain("TypeB"); + expect(edge?.weight).toBe(2); + // isTypeOnly should be false (value import present) + expect(edge?.isTypeOnly).toBe(false); + + // graphology edge attrs should match + const graphSymbols = built.graph.getEdgeAttribute("source.ts", "target.ts", "symbols") as string[]; + expect(graphSymbols).toContain("valFn"); + expect(graphSymbols).toContain("TypeB"); + }); +}); + describe("cloudGroup", () => { it("collapses src/ to second segment", () => { expect(cloudGroup("src/components/")).toBe("components"); diff --git a/src/analyzer/index.ts b/src/analyzer/index.ts index 5e1490b..087d769 100644 --- a/src/analyzer/index.ts +++ b/src/analyzer/index.ts @@ -40,6 +40,19 @@ export function analyzeGraph(built: BuiltGraph, parsedFiles?: ParsedFile[]): Cod consumedSymbols.set(edge.target, existing); } + // Also count symbols consumed via call graph (includes same-file calls) + for (const callEdge of built.callEdges) { + const sepIdx = callEdge.target.indexOf("::"); + if (sepIdx === -1) continue; + const targetFile = callEdge.target.substring(0, sepIdx); + // Normalize: class method "AuthService.validate" → class name "AuthService" + const rawSymbol = callEdge.calleeSymbol; + const consumedName = rawSymbol.includes(".") ? rawSymbol.split(".")[0] : rawSymbol; + const existing = consumedSymbols.get(targetFile) ?? new Set(); + existing.add(consumedName); + consumedSymbols.set(targetFile, existing); + } + // Core metrics const pageRanks = computePageRank(graph); const betweennessScores = computeBetweenness(graph); diff --git a/src/graph/index.ts b/src/graph/index.ts index 58bb8c2..78ea44a 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -76,6 +76,19 @@ export function buildGraph(files: ParsedFile[]): BuiltGraph { isTypeOnly: imp.isTypeOnly, weight: imp.symbols.length || 1, }); + } else { + // Merge symbols from duplicate import into existing edge + const existing = edges.find((e) => e.source === file.relativePath && e.target === target); + if (existing) { + const merged = [...new Set([...existing.symbols, ...imp.symbols])]; + existing.symbols = merged; + existing.weight = merged.length || 1; + existing.isTypeOnly = existing.isTypeOnly && imp.isTypeOnly; + // Sync graphology edge attributes + graph.setEdgeAttribute(file.relativePath, target, "symbols", merged); + graph.setEdgeAttribute(file.relativePath, target, "weight", merged.length || 1); + graph.setEdgeAttribute(file.relativePath, target, "isTypeOnly", existing.isTypeOnly); + } } } } diff --git a/src/parser/index.ts b/src/parser/index.ts index 20f800f..9248a4d 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -342,7 +342,7 @@ function extractCallSites(sourceFile: ts.SourceFile, checker: ts.TypeChecker, ro const declFile = decl.getSourceFile().fileName; const declRelPath = path.relative(rootDir, declFile); - if (declRelPath !== callerFile && !declRelPath.startsWith("..") && !path.isAbsolute(declRelPath)) { + if (!declRelPath.startsWith("..") && !path.isAbsolute(declRelPath)) { calleeFile = declRelPath; calleeSymbol = resolved.getName(); From d0b9bf23406ae226c7907e64cd01b9494979a12d Mon Sep 17 00:00:00 2001 From: bntvllnt <32437578+bntvllnt@users.noreply.github.com> Date: Wed, 11 Mar 2026 00:02:46 +0100 Subject: [PATCH 2/3] fix: address code review findings - graph: replace silent `if (existing)` guard with invariant assertion - graph: extract merge values as consts for order-safe mutation - test: assert `main` is dead in same-file call test (negative case) - test: add call-graph-only class test (no import edge) - test: verify all graphology attrs in edge merge sync test --- src/analyzer/index.test.ts | 23 +++++++++++++++++++++++ src/graph/index.ts | 21 ++++++++++++--------- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/analyzer/index.test.ts b/src/analyzer/index.test.ts index b574018..b50a3b1 100644 --- a/src/analyzer/index.test.ts +++ b/src/analyzer/index.test.ts @@ -327,6 +327,8 @@ describe("dead export detection", () => { const dead = result.fileMetrics.get("module.ts")?.deadExports; expect(dead).not.toContain("helper"); + // main is exported but never called by anyone — should be dead + expect(dead).toContain("main"); }); it("class method calls mark the class as consumed", () => { @@ -346,6 +348,23 @@ describe("dead export detection", () => { expect(dead).not.toContain("AuthService"); }); + it("class consumed via call graph only (no import edge)", () => { + const files = [ + makeFile("service.ts", { + exports: [exp("MyClass", "class")], + }), + makeFile("caller.ts", { + // No import edge — only a call site references the class + callSites: [callSite("caller.ts", "init", "service.ts", "MyClass.create")], + }), + ]; + const built = buildGraph(files); + const result = analyzeGraph(built, files); + + const dead = result.fileMetrics.get("service.ts")?.deadExports; + expect(dead).not.toContain("MyClass"); + }); + it("mixed dead and alive exports only flags dead ones", () => { const files = [ makeFile("mixed.ts", { @@ -393,8 +412,12 @@ describe("dead export detection", () => { // graphology edge attrs should match const graphSymbols = built.graph.getEdgeAttribute("source.ts", "target.ts", "symbols") as string[]; + const graphWeight = built.graph.getEdgeAttribute("source.ts", "target.ts", "weight") as number; + const graphIsTypeOnly = built.graph.getEdgeAttribute("source.ts", "target.ts", "isTypeOnly") as boolean; expect(graphSymbols).toContain("valFn"); expect(graphSymbols).toContain("TypeB"); + expect(graphWeight).toBe(2); + expect(graphIsTypeOnly).toBe(false); }); }); diff --git a/src/graph/index.ts b/src/graph/index.ts index 78ea44a..4ef6d89 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -79,16 +79,19 @@ export function buildGraph(files: ParsedFile[]): BuiltGraph { } else { // Merge symbols from duplicate import into existing edge const existing = edges.find((e) => e.source === file.relativePath && e.target === target); - if (existing) { - const merged = [...new Set([...existing.symbols, ...imp.symbols])]; - existing.symbols = merged; - existing.weight = merged.length || 1; - existing.isTypeOnly = existing.isTypeOnly && imp.isTypeOnly; - // Sync graphology edge attributes - graph.setEdgeAttribute(file.relativePath, target, "symbols", merged); - graph.setEdgeAttribute(file.relativePath, target, "weight", merged.length || 1); - graph.setEdgeAttribute(file.relativePath, target, "isTypeOnly", existing.isTypeOnly); + if (!existing) { + throw new Error(`Invariant violation: edge ${file.relativePath} → ${target} exists in graph but missing from edges[]`); } + const merged = [...new Set([...existing.symbols, ...imp.symbols])]; + const mergedWeight = merged.length || 1; + const mergedIsTypeOnly = existing.isTypeOnly && imp.isTypeOnly; + existing.symbols = merged; + existing.weight = mergedWeight; + existing.isTypeOnly = mergedIsTypeOnly; + // Sync graphology edge attributes + graph.setEdgeAttribute(file.relativePath, target, "symbols", merged); + graph.setEdgeAttribute(file.relativePath, target, "weight", mergedWeight); + graph.setEdgeAttribute(file.relativePath, target, "isTypeOnly", mergedIsTypeOnly); } } } From 1d85e270298c3352e037f5ac6ea34d4f7c2b1342 Mon Sep 17 00:00:00 2001 From: bntvllnt <32437578+bntvllnt@users.noreply.github.com> Date: Wed, 11 Mar 2026 00:13:01 +0100 Subject: [PATCH 3/3] chore: archive spec to shipped, update history log --- specs/history.log | 1 + .../2026-03-10-fix-dead-export-false-positives.md | 0 2 files changed, 1 insertion(+) rename specs/{active => shipped}/2026-03-10-fix-dead-export-false-positives.md (100%) diff --git a/specs/history.log b/specs/history.log index c9f6619..afa90d3 100644 --- a/specs/history.log +++ b/specs/history.log @@ -1,2 +1,3 @@ 2026-02-18 | shipped | 3d-code-mapper-v1 | 10h→2h | 1d | 3D codebase visualizer with 6 views, 6 MCP tools, 75 tests 2026-03-02 | shipped | mcp-parity-readme-sync | 3h→2h | 1d | 100% MCP-REST parity: +2 tools, enhanced 3 tools, 15 tool descriptions, README sync, 21 new tests +2026-03-11 | shipped | fix-dead-export-false-positives | 2h→1.5h | 1d | Fix 33% false positive rate: merge duplicate imports, include same-file calls, call graph consumption. 8 regression tests. diff --git a/specs/active/2026-03-10-fix-dead-export-false-positives.md b/specs/shipped/2026-03-10-fix-dead-export-false-positives.md similarity index 100% rename from specs/active/2026-03-10-fix-dead-export-false-positives.md rename to specs/shipped/2026-03-10-fix-dead-export-false-positives.md