From 7b3c939fde8eb6a5902c3705b04aae79cbac9289 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Thu, 11 Jun 2026 09:10:35 +0300 Subject: [PATCH 1/4] feat(runtime): guard root switches and isolate test global state Codify one-root-per-process: initCodemap/configureResolver throw on mid-process root changes except audit worktree reindex (runtime-swap bracket). Validate user config at loadUserConfig; roll beforeEach/afterEach teardown across initCodemap suites. --- .changeset/runtime-test-isolation.md | 5 +++ docs/architecture.md | 4 +- docs/plans/runtime-test-isolation.md | 14 +++---- docs/plans/security-hardening-orchestrator.md | 26 ++++++------- scripts/agent-eval/parse-agent-log.test.ts | 3 ++ src/api.test.ts | 3 ++ src/api.ts | 4 +- src/application/affected-engine.test.ts | 4 ++ src/application/audit-engine.ts | 3 ++ src/application/boundary-rules.test.ts | 4 ++ .../callback-synthesis-integration.test.ts | 4 ++ src/application/churn-ingest.test.ts | 4 ++ src/application/context-engine.test.ts | 3 ++ src/application/get-changed-files.test.ts | 4 ++ src/application/http-server.test.ts | 3 ++ src/application/index-engine.test.ts | 4 ++ src/application/index-freshness.test.ts | 4 ++ src/application/ingest-coverage-run.test.ts | 4 ++ src/application/mcp-server.test.ts | 3 ++ src/application/output-budget.test.ts | 8 +++- src/application/query-baseline.test.ts | 4 ++ src/application/query-engine.test.ts | 4 ++ src/application/query-recipes.test.ts | 4 ++ src/application/recipe-recency.test.ts | 4 ++ src/application/resource-handlers.test.ts | 4 ++ .../run-call-resolve-synthesis.test.ts | 4 ++ src/application/run-index.test.ts | 4 ++ src/application/tool-handlers.test.ts | 3 ++ src/application/trace-engine.test.ts | 4 ++ src/cli/cmd-affected.test.ts | 4 ++ src/cli/cmd-cli-parity-e2e.test.ts | 4 ++ src/cli/cmd-validate.test.ts | 3 ++ src/config.test.ts | 5 +-- src/config.ts | 10 ++--- src/resolver.ts | 19 ++++++++++ src/runtime-swap.ts | 17 +++++++++ src/runtime.test.ts | 37 ++++++++++++++++++- src/runtime.ts | 20 ++++++++++ src/test-helpers/runtime-reset.ts | 26 +++++++++++++ src/worker-pool.test.ts | 4 ++ 40 files changed, 261 insertions(+), 34 deletions(-) create mode 100644 .changeset/runtime-test-isolation.md create mode 100644 src/runtime-swap.ts create mode 100644 src/test-helpers/runtime-reset.ts diff --git a/.changeset/runtime-test-isolation.md b/.changeset/runtime-test-isolation.md new file mode 100644 index 00000000..a2a173f0 --- /dev/null +++ b/.changeset/runtime-test-isolation.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/codemap": minor +--- + +`createCodemap()` and `loadUserConfig()` now fail fast: switching to a different project root in the same process throws (audit `--base` worktree reindex is exempt), and invalid config files throw at load time instead of on first resolve. diff --git a/docs/architecture.md b/docs/architecture.md index 30c9fbab..e5d40159 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -234,13 +234,13 @@ The npm package exports **`createCodemap`**, **`Codemap`** (`query`, `index`), * 2. **`await cm.index({ mode, files?, quiet? })`** — same pipeline as the CLI (incremental / full / targeted). 3. **`cm.query(sql)`** — read-only SQL against `.codemap/index.db` (opens the DB per call). -**Constraint:** `initCodemap` is global to the process; only one active indexed project at a time. +**Constraint:** One project root per process — a second `initCodemap` / `createCodemap` with a different `root` throws. Audit `--base` worktree reindex brackets root swaps via `runtime-swap.ts`. Re-init on the same root is allowed. ### User config Optional **`/config.{ts,js,json}`** (default `.codemap/config.*`; default export: object or async factory). **`--config `** overrides with an explicit file (absolute or relative to cwd). Example shape: [`codemap.config.example.json`](../codemap.config.example.json). **Self-healing (D11):** `/.gitignore` is reconciled to canonical on every codemap boot via **`ensureStateGitignore`** (`src/application/state-dir.ts`); JSON config is reconciled via **`ensureStateConfig`** (`src/application/state-config.ts` — prunes unknown keys with a warning, sorts alphabetically, write-only-on-drift). TS/JS configs are validate-only at load time. Bumping the canonical `STATE_GITIGNORE_BODY` constant or the Zod schema IS the migration — every consumer's project repairs itself on next boot. Single attachment point: **`src/cli/bootstrap-codemap.ts`** runs the reconcilers before `loadUserConfig`. -**Validation:** **`codemapUserConfigSchema`** ([Zod](https://zod.dev)) — strict object (unknown keys are rejected). **`defineConfig({ ... })`**, **`parseCodemapUserConfig`**, and **`resolveCodemapConfig`** (CLI and merged `createCodemap({ config })`) all go through the same schema. Invalid config throws **`TypeError`** with a short path/message list. +**Validation:** **`codemapUserConfigSchema`** ([Zod](https://zod.dev)) — strict object (unknown keys are rejected). **`defineConfig({ ... })`**, **`parseCodemapUserConfig`**, **`loadUserConfig`**, and **`resolveCodemapConfig`** (CLI and merged `createCodemap({ config })`) all go through the same schema. Invalid config throws **`TypeError`** with a short path/message list. **Exports:** `codemapUserConfigSchema`, `parseCodemapUserConfig`, `defineConfig`, and **`CodemapUserConfig`** (inferred type) from the package entry — see **`src/config.ts`** / **`dist/index.d.mts`**. diff --git a/docs/plans/runtime-test-isolation.md b/docs/plans/runtime-test-isolation.md index 068150a5..e13fc670 100644 --- a/docs/plans/runtime-test-isolation.md +++ b/docs/plans/runtime-test-isolation.md @@ -1,6 +1,6 @@ # PR 3 — runtime guards & test isolation -> **Status:** open (not started) · **PR:** 3 of 3 · **Effort:** S–M +> **Status:** open (in progress) · **PR:** 3 of 3 · **Effort:** S–M > > **Orchestrator:** [`security-hardening-orchestrator.md`](./security-hardening-orchestrator.md) > @@ -34,12 +34,12 @@ | ID | Task | Status | Verify | | --- | -------------------------------------------------------- | ------- | ------------------------------ | -| 5.1 | `runtime-swap.ts` + audit worktree bracket | pending | `bun test src/runtime.test.ts` | -| 5.2 | `initCodemap` / `configureResolver` throw on root switch | pending | runtime tests | -| 5.3 | `resetCodemapForTest` + `installCodemapTestTeardown` | pending | — | -| 5.4 | Teardown rollout on `initCodemap` test suites | pending | affected `*.test.ts` | -| 5.5 | `loadUserConfig` → `parseCodemapUserConfig` at load | pending | `bun test src/config.test.ts` | -| 5.6 | `api.ts` + architecture: throws-on-root-switch | pending | — | +| 5.1 | `runtime-swap.ts` + audit worktree bracket | done | `bun test src/runtime.test.ts` | +| 5.2 | `initCodemap` / `configureResolver` throw on root switch | done | runtime tests | +| 5.3 | `resetCodemapForTest` + `installCodemapTestTeardown` | done | — | +| 5.4 | Teardown rollout on `initCodemap` test suites | done | affected `*.test.ts` | +| 5.5 | `loadUserConfig` → `parseCodemapUserConfig` at load | done | `bun test src/config.test.ts` | +| 5.6 | `api.ts` + architecture: throws-on-root-switch | done | — | | 5.s | Commit + PR + CI | pending | `bun run check` | --- diff --git a/docs/plans/security-hardening-orchestrator.md b/docs/plans/security-hardening-orchestrator.md index e980b55a..8d4c1328 100644 --- a/docs/plans/security-hardening-orchestrator.md +++ b/docs/plans/security-hardening-orchestrator.md @@ -24,8 +24,8 @@ | PR | Plan | Status | Blocks | | ----- | --------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------- | | **1** | lifted → [`architecture.md`](../architecture.md) (plan retired) | **merged** ([#180](https://github.com/stainless-code/codemap/pull/180) · `a5caca8`) | — | -| **2** | lifted → [`architecture.md`](../architecture.md) (plan retired) | **PR open** (`fix/impact-inpath-homonyms`) | — | -| **3** | [`runtime-test-isolation.md`](./runtime-test-isolation.md) | **pending** | PR **1** merged (PR **2** optional) | +| **2** | lifted → [`architecture.md`](../architecture.md) (plan retired) | **merged** ([#181](https://github.com/stainless-code/codemap/pull/181) · `aae172f`) | — | +| **3** | [`runtime-test-isolation.md`](./runtime-test-isolation.md) | **in progress** (`fix/runtime-test-isolation`) | PR **1** merged (PR **2** optional) | | — | — | **deferred** | golden `schema.test.ts` + path guards | | — | — | **skip** | atomic `ensureStateConfig` writes | @@ -64,17 +64,17 @@ Evaluated 2026-06 against [roadmap § Floors](../roadmap.md#floors-v1-product-sh ## Session log -| Date | Event | Notes | -| ---------- | ----------- | ---------------------------------------------------------------------------- | -| 2026-06-10 | Triage | ROI on 7 slices; 3-PR program adopted. | -| 2026-06-10 | PR 1 impl | PR **1** committed on `fix/security-hardening-wave1`; harden pass in flight. | -| 2026-06-05 | PR 1 harden | `/harden-pr full` — plan retired; contracts in architecture/glossary. | -| 2026-06-05 | PR 1 merge | [#180](https://github.com/stainless-code/codemap/pull/180) → `a5caca8`. | -| 2026-06-05 | PR 2 start | `fix/impact-inpath-homonyms` — `inPath` + homonym walks in impact-engine. | -| 2026-06-05 | PR 2 harden | `/harden-pr full` — plan retired; CLI/MCP/docs parity. | -| — | PR 2 merge | _PR URL · merge SHA_ | -| — | PR 3 start | _from `main`_ | -| — | PR 3 merge | _fill · close orchestrator_ | +| Date | Event | Notes | +| ---------- | ----------- | ----------------------------------------------------------------------------- | +| 2026-06-10 | Triage | ROI on 7 slices; 3-PR program adopted. | +| 2026-06-10 | PR 1 impl | PR **1** committed on `fix/security-hardening-wave1`; harden pass in flight. | +| 2026-06-05 | PR 1 harden | `/harden-pr full` — plan retired; contracts in architecture/glossary. | +| 2026-06-05 | PR 1 merge | [#180](https://github.com/stainless-code/codemap/pull/180) → `a5caca8`. | +| 2026-06-05 | PR 2 start | `fix/impact-inpath-homonyms` — `inPath` + homonym walks in impact-engine. | +| 2026-06-05 | PR 2 harden | `/harden-pr full` — plan retired; CLI/MCP/docs parity. | +| 2026-06-05 | PR 2 merge | [#181](https://github.com/stainless-code/codemap/pull/181) → `aae172f`. | +| 2026-06-05 | PR 3 start | `fix/runtime-test-isolation` — root guards + test teardown + config validate. | +| — | PR 3 merge | _fill · close orchestrator_ | --- diff --git a/scripts/agent-eval/parse-agent-log.test.ts b/scripts/agent-eval/parse-agent-log.test.ts index 3a80ee47..6728b487 100644 --- a/scripts/agent-eval/parse-agent-log.test.ts +++ b/scripts/agent-eval/parse-agent-log.test.ts @@ -11,6 +11,7 @@ import { join } from "node:path"; import { resolveCodemapConfig } from "../../src/config"; import { initCodemap } from "../../src/runtime"; +import { installCodemapTestTeardown } from "../../src/test-helpers/runtime-reset"; import { resolveGoldenQuery } from "../query-golden/resolve-golden-query"; import { compareLogArms, summarizeLogComparison } from "./compare-live-logs"; import { runLiveMcpArm } from "./live-mcp-arm"; @@ -43,6 +44,8 @@ import { traditionalToolSequence, } from "./traditional-probe"; +installCodemapTestTeardown(); + const sampleLog = join( import.meta.dir, "../../fixtures/agent-eval/sample-cursor-log.json", diff --git a/src/api.test.ts b/src/api.test.ts index 2649b7d0..ea9089cd 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -4,6 +4,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { createCodemap } from "./api"; +import { installCodemapTestTeardown } from "./test-helpers/runtime-reset"; + +installCodemapTestTeardown(); describe("createCodemap", () => { test("query runs against the index database", async () => { diff --git a/src/api.ts b/src/api.ts index 23872d9a..8e0dfe8a 100644 --- a/src/api.ts +++ b/src/api.ts @@ -45,7 +45,9 @@ export interface CodemapInitOptions { * and returns a {@link Codemap} handle. * * @remarks - * Only one Codemap project per process: `initCodemap` is global; the last `createCodemap()` wins. + * One project root per process: a second `createCodemap()` with a different `root` throws. + * Re-initializing the same root is allowed. Audit `--base` worktree reindex is the only + * production path that may temporarily switch roots (`runtime-swap.ts`). */ export async function createCodemap( options: CodemapInitOptions = {}, diff --git a/src/application/affected-engine.test.ts b/src/application/affected-engine.test.ts index 5423f209..38cdedba 100644 --- a/src/application/affected-engine.test.ts +++ b/src/application/affected-engine.test.ts @@ -4,6 +4,10 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { installCodemapTestTeardown } from "../test-helpers/runtime-reset"; + +installCodemapTestTeardown(); + import { resolveCodemapConfig } from "../config"; import { closeDb, createTables, openDb } from "../db"; import { initCodemap } from "../runtime"; diff --git a/src/application/audit-engine.ts b/src/application/audit-engine.ts index 475bebc1..f3658e49 100644 --- a/src/application/audit-engine.ts +++ b/src/application/audit-engine.ts @@ -9,6 +9,7 @@ import { getTsconfigPath, initCodemap, } from "../runtime"; +import { enterRuntimeSwap, exitRuntimeSwap } from "../runtime-swap"; import { openCodemapDatabase } from "../sqlite-db"; import { isGitRepo, @@ -456,6 +457,7 @@ export function makeWorktreeReindex(): ReindexFn { // to /.codemap/index.db via the default state-dir. const savedConfig = getCodemapConfig(); let wtDb; + enterRuntimeSwap(); try { const wtUser = await loadUserConfig(worktreePath, undefined); initCodemap(resolveCodemapConfig(worktreePath, wtUser)); @@ -466,6 +468,7 @@ export function makeWorktreeReindex(): ReindexFn { wtDb?.close(); initCodemap(savedConfig); configureResolver(getProjectRoot(), getTsconfigPath()); + exitRuntimeSwap(); } }); // Catch on the chain itself so one failed reindex doesn't poison the diff --git a/src/application/boundary-rules.test.ts b/src/application/boundary-rules.test.ts index 30382f4b..8c184bf0 100644 --- a/src/application/boundary-rules.test.ts +++ b/src/application/boundary-rules.test.ts @@ -4,6 +4,10 @@ import { writeFileSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { installCodemapTestTeardown } from "../test-helpers/runtime-reset"; + +installCodemapTestTeardown(); + import { resolveCodemapConfig } from "../config"; import { closeDb, createTables, openDb, reconcileBoundaryRules } from "../db"; import { initCodemap } from "../runtime"; diff --git a/src/application/callback-synthesis-integration.test.ts b/src/application/callback-synthesis-integration.test.ts index 84f7ea72..a495d6ff 100644 --- a/src/application/callback-synthesis-integration.test.ts +++ b/src/application/callback-synthesis-integration.test.ts @@ -2,6 +2,10 @@ import { describe, expect, it } from "bun:test"; import { join } from "node:path"; import { createCodemap } from "../api"; +import { installCodemapTestTeardown } from "../test-helpers/runtime-reset"; + +installCodemapTestTeardown(); + const MINIMAL_ROOT = join(import.meta.dir, "../../fixtures/minimal"); const JSX_PATHS = [ "src/bench/jsx-synthesis/PageShell.tsx", diff --git a/src/application/churn-ingest.test.ts b/src/application/churn-ingest.test.ts index 015e908c..b3b1975c 100644 --- a/src/application/churn-ingest.test.ts +++ b/src/application/churn-ingest.test.ts @@ -4,6 +4,10 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { installCodemapTestTeardown } from "../test-helpers/runtime-reset"; + +installCodemapTestTeardown(); + import { resolveCodemapConfig } from "../config"; import { closeDb, diff --git a/src/application/context-engine.test.ts b/src/application/context-engine.test.ts index a828bffb..62bb5c7c 100644 --- a/src/application/context-engine.test.ts +++ b/src/application/context-engine.test.ts @@ -21,6 +21,7 @@ import { } from "../db"; import type { DependencyRow, SymbolRow } from "../db"; import { initCodemap } from "../runtime"; +import { installCodemapTestTeardown } from "../test-helpers/runtime-reset"; import { buildContextEnvelope, capRecipeSqlLimit, @@ -33,6 +34,8 @@ import { } from "./context-engine"; import * as indexEngine from "./index-engine"; +installCodemapTestTeardown(); + let benchDir: string; beforeEach(() => { diff --git a/src/application/get-changed-files.test.ts b/src/application/get-changed-files.test.ts index 6ee2811b..6f3ee08e 100644 --- a/src/application/get-changed-files.test.ts +++ b/src/application/get-changed-files.test.ts @@ -4,6 +4,10 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { installCodemapTestTeardown } from "../test-helpers/runtime-reset"; + +installCodemapTestTeardown(); + import { resolveCodemapConfig } from "../config"; import { closeDb, createTables, getMeta, openDb, setMeta } from "../db"; import { initCodemap } from "../runtime"; diff --git a/src/application/http-server.test.ts b/src/application/http-server.test.ts index 3886a942..7d8fa39d 100644 --- a/src/application/http-server.test.ts +++ b/src/application/http-server.test.ts @@ -14,12 +14,15 @@ import { join } from "node:path"; import { resolveCodemapConfig } from "../config"; import { closeDb, createTables, openDb, upsertQueryBaseline } from "../db"; import { initCodemap } from "../runtime"; +import { installCodemapTestTeardown } from "../test-helpers/runtime-reset"; import { handleRequest } from "./http-server"; import { MCP_TOOL_NAMES } from "./mcp-tool-allowlist"; import { MCP_TOOL_ANNOTATIONS } from "./mcp-tool-annotations"; import { createManagedWatchSession } from "./session-lifecycle"; import { _resetWatchStateForTests } from "./watcher"; +installCodemapTestTeardown(); + let benchDir: string; let serverHandle: { close: () => Promise; port: number } | undefined; diff --git a/src/application/index-engine.test.ts b/src/application/index-engine.test.ts index 447c2a6e..074356e0 100644 --- a/src/application/index-engine.test.ts +++ b/src/application/index-engine.test.ts @@ -3,6 +3,10 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { installCodemapTestTeardown } from "../test-helpers/runtime-reset"; + +installCodemapTestTeardown(); + import { resolveCodemapConfig } from "../config"; import { closeDb, createTables, openDb } from "../db"; import { initCodemap } from "../runtime"; diff --git a/src/application/index-freshness.test.ts b/src/application/index-freshness.test.ts index 97653a95..080c6ebb 100644 --- a/src/application/index-freshness.test.ts +++ b/src/application/index-freshness.test.ts @@ -3,6 +3,10 @@ import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { installCodemapTestTeardown } from "../test-helpers/runtime-reset"; + +installCodemapTestTeardown(); + import { resolveCodemapConfig } from "../config"; import * as dbModule from "../db"; import { closeDb, createTables, openDb, setMeta } from "../db"; diff --git a/src/application/ingest-coverage-run.test.ts b/src/application/ingest-coverage-run.test.ts index 8af6c841..e0411e3e 100644 --- a/src/application/ingest-coverage-run.test.ts +++ b/src/application/ingest-coverage-run.test.ts @@ -4,6 +4,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; +import { installCodemapTestTeardown } from "../test-helpers/runtime-reset"; + +installCodemapTestTeardown(); + import { resolveCodemapConfig } from "../config"; import { closeDb, diff --git a/src/application/mcp-server.test.ts b/src/application/mcp-server.test.ts index c1ef01c6..71931206 100644 --- a/src/application/mcp-server.test.ts +++ b/src/application/mcp-server.test.ts @@ -21,10 +21,13 @@ import { upsertQueryBaseline, } from "../db"; import { initCodemap } from "../runtime"; +import { installCodemapTestTeardown } from "../test-helpers/runtime-reset"; import { createMcpServer } from "./mcp-server"; import { MCP_TOOL_NAMES } from "./mcp-tool-allowlist"; import { MCP_TOOL_ANNOTATIONS } from "./mcp-tool-annotations"; +installCodemapTestTeardown(); + let benchDir: string; beforeEach(() => { diff --git a/src/application/output-budget.test.ts b/src/application/output-budget.test.ts index d3ad636e..d3a97004 100644 --- a/src/application/output-budget.test.ts +++ b/src/application/output-budget.test.ts @@ -3,9 +3,13 @@ import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { installCodemapTestTeardown } from "../test-helpers/runtime-reset"; + +installCodemapTestTeardown(); + import { resolveCodemapConfig } from "../config"; import { closeDb, createTables, insertFile, openDb } from "../db"; -import { initCodemap } from "../runtime"; +import { initCodemap, resetCodemapForTest } from "../runtime"; import { applySourceCharBudget, DEFAULT_EXPLORE_ROW_LIMIT, @@ -85,11 +89,13 @@ describe("resolveEffectiveSnippetBudget", () => { const largeDir = mkdtempSync(join(tmpdir(), "output-budget-large-")); mkdirSync(join(smallDir, "src"), { recursive: true }); mkdirSync(join(largeDir, "src"), { recursive: true }); + resetCodemapForTest(); initCodemap(resolveCodemapConfig(smallDir, undefined)); const small = seedFileCount(3); const smallBudget = resolveEffectiveSnippetBudget(small); closeDb(small); + resetCodemapForTest(); initCodemap(resolveCodemapConfig(largeDir, undefined)); const large = seedFileCount(6000); try { diff --git a/src/application/query-baseline.test.ts b/src/application/query-baseline.test.ts index 0fb8e25a..8b49c576 100644 --- a/src/application/query-baseline.test.ts +++ b/src/application/query-baseline.test.ts @@ -3,6 +3,10 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { installCodemapTestTeardown } from "../test-helpers/runtime-reset"; + +installCodemapTestTeardown(); + import { resolveCodemapConfig } from "../config"; import { closeDb, createTables, openDb, upsertQueryBaseline } from "../db"; import { initCodemap } from "../runtime"; diff --git a/src/application/query-engine.test.ts b/src/application/query-engine.test.ts index 46c741f8..6dff283d 100644 --- a/src/application/query-engine.test.ts +++ b/src/application/query-engine.test.ts @@ -3,6 +3,10 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { installCodemapTestTeardown } from "../test-helpers/runtime-reset"; + +installCodemapTestTeardown(); + import { resolveCodemapConfig } from "../config"; import { closeDb, createTables, openDb } from "../db"; import { initCodemap } from "../runtime"; diff --git a/src/application/query-recipes.test.ts b/src/application/query-recipes.test.ts index 06b7d6bd..366877ae 100644 --- a/src/application/query-recipes.test.ts +++ b/src/application/query-recipes.test.ts @@ -3,6 +3,10 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { installCodemapTestTeardown } from "../test-helpers/runtime-reset"; + +installCodemapTestTeardown(); + import { resolveCodemapConfig } from "../config"; import { initCodemap } from "../runtime"; import { diff --git a/src/application/recipe-recency.test.ts b/src/application/recipe-recency.test.ts index abd8c05f..b86306b0 100644 --- a/src/application/recipe-recency.test.ts +++ b/src/application/recipe-recency.test.ts @@ -3,6 +3,10 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { installCodemapTestTeardown } from "../test-helpers/runtime-reset"; + +installCodemapTestTeardown(); + import { resolveCodemapConfig } from "../config"; import { closeDb, createTables, openDb } from "../db"; import { initCodemap } from "../runtime"; diff --git a/src/application/resource-handlers.test.ts b/src/application/resource-handlers.test.ts index 8c4466db..888bcdde 100644 --- a/src/application/resource-handlers.test.ts +++ b/src/application/resource-handlers.test.ts @@ -3,6 +3,10 @@ import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { installCodemapTestTeardown } from "../test-helpers/runtime-reset"; + +installCodemapTestTeardown(); + import { resolveCodemapConfig } from "../config"; import { closeDb, createTables, openDb } from "../db"; import { initCodemap } from "../runtime"; diff --git a/src/application/run-call-resolve-synthesis.test.ts b/src/application/run-call-resolve-synthesis.test.ts index 7f999484..4272ffbe 100644 --- a/src/application/run-call-resolve-synthesis.test.ts +++ b/src/application/run-call-resolve-synthesis.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "bun:test"; +import { installCodemapTestTeardown } from "../test-helpers/runtime-reset"; + +installCodemapTestTeardown(); + import { resolveCodemapConfig } from "../config"; import { closeDb, diff --git a/src/application/run-index.test.ts b/src/application/run-index.test.ts index 9c28e06e..b6a48c1f 100644 --- a/src/application/run-index.test.ts +++ b/src/application/run-index.test.ts @@ -4,6 +4,10 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { installCodemapTestTeardown } from "../test-helpers/runtime-reset"; + +installCodemapTestTeardown(); + import { resolveCodemapConfig } from "../config"; import { closeDb, createTables, getMeta, openDb, setMeta } from "../db"; import { hashContent } from "../hash"; diff --git a/src/application/tool-handlers.test.ts b/src/application/tool-handlers.test.ts index d14e986e..a08d41c3 100644 --- a/src/application/tool-handlers.test.ts +++ b/src/application/tool-handlers.test.ts @@ -15,6 +15,7 @@ import { resolveCodemapConfig } from "../config"; import { closeDb, createTables, insertFile, openDb } from "../db"; import { upsertQueryBaseline } from "../db"; import { initCodemap } from "../runtime"; +import { installCodemapTestTeardown } from "../test-helpers/runtime-reset"; import { handleApply, handleApplyDiffInput, @@ -27,6 +28,8 @@ import { handleSnippet, } from "./tool-handlers"; +installCodemapTestTeardown(); + let projectRoot: string; beforeEach(() => { diff --git a/src/application/trace-engine.test.ts b/src/application/trace-engine.test.ts index de83bb15..ac93fcb4 100644 --- a/src/application/trace-engine.test.ts +++ b/src/application/trace-engine.test.ts @@ -3,6 +3,10 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { installCodemapTestTeardown } from "../test-helpers/runtime-reset"; + +installCodemapTestTeardown(); + import { resolveCodemapConfig } from "../config"; import { closeDb, createTables, insertFile, openDb } from "../db"; import { initCodemap } from "../runtime"; diff --git a/src/cli/cmd-affected.test.ts b/src/cli/cmd-affected.test.ts index 24731283..48d8947c 100644 --- a/src/cli/cmd-affected.test.ts +++ b/src/cli/cmd-affected.test.ts @@ -2,6 +2,10 @@ import { beforeAll, describe, expect, it } from "bun:test"; import { existsSync } from "node:fs"; import { join } from "node:path"; +import { installCodemapTestTeardown } from "../test-helpers/runtime-reset"; + +installCodemapTestTeardown(); + import { resolveCodemapConfig } from "../config"; import { closeDb, openDb } from "../db"; import { initCodemap } from "../runtime"; diff --git a/src/cli/cmd-cli-parity-e2e.test.ts b/src/cli/cmd-cli-parity-e2e.test.ts index f2dd6708..0a5909a8 100644 --- a/src/cli/cmd-cli-parity-e2e.test.ts +++ b/src/cli/cmd-cli-parity-e2e.test.ts @@ -2,6 +2,10 @@ import { beforeAll, describe, expect, it } from "bun:test"; import { existsSync } from "node:fs"; import { join } from "node:path"; +import { installCodemapTestTeardown } from "../test-helpers/runtime-reset"; + +installCodemapTestTeardown(); + import { readResource, unknownFileResourceError, diff --git a/src/cli/cmd-validate.test.ts b/src/cli/cmd-validate.test.ts index f9275ff2..eed2e032 100644 --- a/src/cli/cmd-validate.test.ts +++ b/src/cli/cmd-validate.test.ts @@ -15,9 +15,12 @@ import { resolveCodemapConfig } from "../config"; import { closeDb, openDb } from "../db"; import { hashContent } from "../hash"; import { initCodemap } from "../runtime"; +import { installCodemapTestTeardown } from "../test-helpers/runtime-reset"; import { canCreateSymlinks } from "../test/symlink-capable"; import { parseValidateRest } from "./cmd-validate"; +installCodemapTestTeardown(); + let tmpRoot = ""; const symlinkCapable = canCreateSymlinks(); diff --git a/src/config.test.ts b/src/config.test.ts index bcc0abd1..bbff3875 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -238,14 +238,13 @@ describe("loadUserConfig", () => { expect(cfg).toBeUndefined(); }); - it("invalid JSON config throws when resolved", async () => { + it("invalid JSON config throws at load", async () => { const stateDir = join(dir, ".codemap"); mkdirSync(stateDir, { recursive: true }); writeFileSync( join(stateDir, "config.json"), JSON.stringify({ include: [1, 2] }), ); - const cfg = await loadUserConfig(dir); - expect(() => resolveCodemapConfig(dir, cfg)).toThrow(/include/); + await expect(loadUserConfig(dir)).rejects.toThrow(/include/); }); }); diff --git a/src/config.ts b/src/config.ts index fd7de83e..e08aea34 100644 --- a/src/config.ts +++ b/src/config.ts @@ -440,10 +440,10 @@ export async function loadUserConfig( const def = mod.default; if (typeof def === "function") { const out = await def(); - return out as CodemapUserConfig; + return parseCodemapUserConfig(out); } if (def && typeof def === "object") { - return def as CodemapUserConfig; + return parseCodemapUserConfig(def); } return undefined; }; @@ -452,7 +452,7 @@ export async function loadUserConfig( if (explicitPath.endsWith(".json")) { if (!existsSync(explicitPath)) return undefined; const raw = await readJsonFile(explicitPath); - return raw as CodemapUserConfig; + return parseCodemapUserConfig(raw); } return tryImport(explicitPath); } @@ -463,12 +463,12 @@ export async function loadUserConfig( if (basename.endsWith(".json")) { if (existsSync(candidate)) { const raw = await readJsonFile(candidate); - return raw as CodemapUserConfig; + return parseCodemapUserConfig(raw); } continue; } const fromImport = await tryImport(candidate); - if (fromImport) return fromImport; + if (fromImport) return parseCodemapUserConfig(fromImport); } return undefined; diff --git a/src/resolver.ts b/src/resolver.ts index a30ca8ab..2aff0aae 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -1,7 +1,10 @@ +import { resolve } from "node:path"; + import { ResolverFactory } from "oxc-resolver"; import { projectRelativePathFromResolved } from "./application/path-containment"; import type { ImportRow, DependencyRow } from "./db"; +import { isRuntimeSwapActive } from "./runtime-swap"; let _projectRoot: string | null = null; let _tsconfigPath: string | null = null; @@ -15,11 +18,27 @@ export function configureResolver( projectRoot: string, tsconfigPath: string | null, ): void { + if (_projectRoot !== null && !isRuntimeSwapActive()) { + const current = resolve(_projectRoot); + const next = resolve(projectRoot); + if (current !== next) { + throw new Error( + `Codemap: cannot switch resolver root from ${current} to ${next} in the same process`, + ); + } + } _projectRoot = projectRoot; _tsconfigPath = tsconfigPath; _resolver = null; } +/** Maintainer test helper — clears resolver singleton between test cases. */ +export function resetResolverForTest(): void { + _projectRoot = null; + _tsconfigPath = null; + _resolver = null; +} + function getResolver(): ResolverFactory { if (!_projectRoot) { throw new Error( diff --git a/src/runtime-swap.ts b/src/runtime-swap.ts new file mode 100644 index 00000000..c22d2050 --- /dev/null +++ b/src/runtime-swap.ts @@ -0,0 +1,17 @@ +/** Depth of nested audit worktree root swaps (exempt from root-switch guard). */ +let _runtimeSwapDepth = 0; + +/** Enter audit worktree bracket — `initCodemap` may switch roots while depth > 0. */ +export function enterRuntimeSwap(): void { + _runtimeSwapDepth++; +} + +/** Leave audit worktree bracket — must pair every `enterRuntimeSwap`. */ +export function exitRuntimeSwap(): void { + if (_runtimeSwapDepth > 0) _runtimeSwapDepth--; +} + +/** True while inside `makeWorktreeReindex` save/swap/restore. */ +export function isRuntimeSwapActive(): boolean { + return _runtimeSwapDepth > 0; +} diff --git a/src/runtime.test.ts b/src/runtime.test.ts index d76cbdfd..9e1cd944 100644 --- a/src/runtime.test.ts +++ b/src/runtime.test.ts @@ -1,10 +1,43 @@ -import { describe, expect, it, beforeAll } from "bun:test"; +import { beforeEach, describe, expect, it } from "bun:test"; import { resolveCodemapConfig } from "./config"; import { initCodemap, isPathExcluded } from "./runtime"; +import { enterRuntimeSwap, exitRuntimeSwap } from "./runtime-swap"; +import { installCodemapTestTeardown } from "./test-helpers/runtime-reset"; + +installCodemapTestTeardown(); + +describe("initCodemap root guard", () => { + it("throws when switching to a different root without runtime swap", () => { + initCodemap(resolveCodemapConfig("/root-a", {})); + expect(() => initCodemap(resolveCodemapConfig("/root-b", {}))).toThrow( + /cannot switch project root/, + ); + }); + + it("allows re-init on the same root", () => { + initCodemap(resolveCodemapConfig("/same-root", { excludeDirNames: ["x"] })); + expect(() => + initCodemap(resolveCodemapConfig("/same-root", {})), + ).not.toThrow(); + }); + + it("allows root switch inside audit runtime swap bracket", () => { + initCodemap(resolveCodemapConfig("/live-root", {})); + enterRuntimeSwap(); + try { + expect(() => + initCodemap(resolveCodemapConfig("/worktree-root", {})), + ).not.toThrow(); + initCodemap(resolveCodemapConfig("/live-root", {})); + } finally { + exitRuntimeSwap(); + } + }); +}); describe("isPathExcluded", () => { - beforeAll(() => { + beforeEach(() => { initCodemap( resolveCodemapConfig("/virtual-root", { excludeDirNames: ["node_modules", ".git", "dist"], diff --git a/src/runtime.ts b/src/runtime.ts index 9d5c8a62..ae37a0b7 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1,15 +1,35 @@ +import { resolve } from "node:path"; + import type { ResolvedCodemapConfig } from "./config"; +import { isRuntimeSwapActive } from "./runtime-swap"; let _config: ResolvedCodemapConfig | null = null; /** * Store resolved config for the current process (`getProjectRoot`, `openDb`, etc.). * Must run before indexing or `openDb()`; typically via `createCodemap` or the CLI. + * + * Throws when switching to a different `root` mid-process (audit worktree reindex + * is the only exempt path — bracketed via `runtime-swap.ts`). */ export function initCodemap(config: ResolvedCodemapConfig): void { + if (_config !== null && !isRuntimeSwapActive()) { + const current = resolve(_config.root); + const next = resolve(config.root); + if (current !== next) { + throw new Error( + `Codemap: cannot switch project root from ${current} to ${next} in the same process (use a fresh process or audit --base worktree reindex)`, + ); + } + } _config = config; } +/** Maintainer test helper — clears process-global config between test cases. */ +export function resetCodemapForTest(): void { + _config = null; +} + export function getCodemapConfig(): ResolvedCodemapConfig { if (!_config) { throw new Error( diff --git a/src/test-helpers/runtime-reset.ts b/src/test-helpers/runtime-reset.ts new file mode 100644 index 00000000..473b0617 --- /dev/null +++ b/src/test-helpers/runtime-reset.ts @@ -0,0 +1,26 @@ +import { afterEach, beforeEach } from "bun:test"; + +import { resetResolverForTest } from "../resolver"; +import { resetCodemapForTest } from "../runtime"; + +/** + * Clears process-global codemap + resolver state. Maintainer tests only — + * not a consumer surface. + */ +export function resetRuntimeForTest(): void { + resetCodemapForTest(); + resetResolverForTest(); +} + +/** + * Register `beforeEach` + `afterEach` reset for suites that call `initCodemap`. + * `beforeEach` clears bleed from prior files (e.g. `createCodemap` without teardown). + */ +export function installCodemapTestTeardown(): void { + beforeEach(() => { + resetRuntimeForTest(); + }); + afterEach(() => { + resetRuntimeForTest(); + }); +} diff --git a/src/worker-pool.test.ts b/src/worker-pool.test.ts index ced17199..5284d204 100644 --- a/src/worker-pool.test.ts +++ b/src/worker-pool.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test"; import { join } from "node:path"; +import { installCodemapTestTeardown } from "./test-helpers/runtime-reset"; + +installCodemapTestTeardown(); + import { resolveCodemapConfig } from "./config"; import { globSync } from "./glob-sync"; import { initCodemap } from "./runtime"; From c09173e21710f935ff10a685a8d4043b70fc6a77 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Thu, 11 Jun 2026 09:15:59 +0300 Subject: [PATCH 2/4] harden: swap finally safety, tests, retire runtime-test-isolation plan Pair exitRuntimeSwap in outer finally; reset swap depth in test teardown. Add createCodemap/configureResolver/makeWorktreeReindex coverage; consumer-clean docs/changeset; delete plan and repoint orchestrator + roadmap. --- .changeset/runtime-test-isolation.md | 2 +- docs/architecture.md | 4 +- docs/plans/runtime-test-isolation.md | 75 ------------------- docs/plans/security-hardening-orchestrator.md | 11 +-- docs/roadmap.md | 2 +- src/api.test.ts | 20 +++++ src/api.ts | 5 +- src/application/audit-engine.test.ts | 45 ++++++++++- src/application/audit-engine.ts | 19 +++-- src/runtime-swap.ts | 5 ++ src/runtime.test.ts | 13 ++++ src/test-helpers/runtime-reset.ts | 2 + 12 files changed, 109 insertions(+), 94 deletions(-) delete mode 100644 docs/plans/runtime-test-isolation.md diff --git a/.changeset/runtime-test-isolation.md b/.changeset/runtime-test-isolation.md index a2a173f0..54432b53 100644 --- a/.changeset/runtime-test-isolation.md +++ b/.changeset/runtime-test-isolation.md @@ -2,4 +2,4 @@ "@stainless-code/codemap": minor --- -`createCodemap()` and `loadUserConfig()` now fail fast: switching to a different project root in the same process throws (audit `--base` worktree reindex is exempt), and invalid config files throw at load time instead of on first resolve. +`createCodemap()` now fails fast when switching to a different project root in the same process (audit `--base` worktree reindex is exempt), and invalid config files throw at load time instead of on first use. diff --git a/docs/architecture.md b/docs/architecture.md index e5d40159..650a8435 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -234,13 +234,13 @@ The npm package exports **`createCodemap`**, **`Codemap`** (`query`, `index`), * 2. **`await cm.index({ mode, files?, quiet? })`** — same pipeline as the CLI (incremental / full / targeted). 3. **`cm.query(sql)`** — read-only SQL against `.codemap/index.db` (opens the DB per call). -**Constraint:** One project root per process — a second `initCodemap` / `createCodemap` with a different `root` throws. Audit `--base` worktree reindex brackets root swaps via `runtime-swap.ts`. Re-init on the same root is allowed. +**Constraint:** One project root per process — a second `initCodemap` / `createCodemap` with a different `root` throws. Audit `--base` worktree reindex brackets temporary root swaps (internal swap guard). Re-init on the same root is allowed. ### User config Optional **`/config.{ts,js,json}`** (default `.codemap/config.*`; default export: object or async factory). **`--config `** overrides with an explicit file (absolute or relative to cwd). Example shape: [`codemap.config.example.json`](../codemap.config.example.json). **Self-healing (D11):** `/.gitignore` is reconciled to canonical on every codemap boot via **`ensureStateGitignore`** (`src/application/state-dir.ts`); JSON config is reconciled via **`ensureStateConfig`** (`src/application/state-config.ts` — prunes unknown keys with a warning, sorts alphabetically, write-only-on-drift). TS/JS configs are validate-only at load time. Bumping the canonical `STATE_GITIGNORE_BODY` constant or the Zod schema IS the migration — every consumer's project repairs itself on next boot. Single attachment point: **`src/cli/bootstrap-codemap.ts`** runs the reconcilers before `loadUserConfig`. -**Validation:** **`codemapUserConfigSchema`** ([Zod](https://zod.dev)) — strict object (unknown keys are rejected). **`defineConfig({ ... })`**, **`parseCodemapUserConfig`**, **`loadUserConfig`**, and **`resolveCodemapConfig`** (CLI and merged `createCodemap({ config })`) all go through the same schema. Invalid config throws **`TypeError`** with a short path/message list. +**Validation:** **`codemapUserConfigSchema`** ([Zod](https://zod.dev)) — strict object (unknown keys are rejected). **`defineConfig({ ... })`**, **`parseCodemapUserConfig`**, and **`resolveCodemapConfig`** (CLI and merged `createCodemap({ config })`) all go through the same schema; **`createCodemap`** and the CLI load path validate config files at load time. Invalid config throws **`TypeError`** with a short path/message list. **Exports:** `codemapUserConfigSchema`, `parseCodemapUserConfig`, `defineConfig`, and **`CodemapUserConfig`** (inferred type) from the package entry — see **`src/config.ts`** / **`dist/index.d.mts`**. diff --git a/docs/plans/runtime-test-isolation.md b/docs/plans/runtime-test-isolation.md deleted file mode 100644 index e13fc670..00000000 --- a/docs/plans/runtime-test-isolation.md +++ /dev/null @@ -1,75 +0,0 @@ -# PR 3 — runtime guards & test isolation - -> **Status:** open (in progress) · **PR:** 3 of 3 · **Effort:** S–M -> -> **Orchestrator:** [`security-hardening-orchestrator.md`](./security-hardening-orchestrator.md) -> -> **Motivator:** Codify one-root-per-process constraint; stop silent `initCodemap` root bleed in tests; fail-fast invalid config at load. Maintainer-heavy; small user-visible API change (`createCodemap` second root throws). - ---- - -## Agent start here - -**Blocked until PR 1 merges** (PR 2 optional beforehand). - -### Key touchpoints - -| File | What | -| ----------------------------------- | --------------------------------------------------- | -| `src/runtime-swap.ts` | Audit worktree root bracket (new) | -| `src/runtime.ts` | Throw on root switch | -| `src/resolver.ts` | Resolver reset / guard | -| `src/test-helpers/runtime-reset.ts` | `resetCodemapForTest`, `installCodemapTestTeardown` | -| `src/application/audit-engine.ts` | `makeWorktreeReindex` bracket | -| `src/config.ts` / `state-config.ts` | `loadUserConfig` validation | -| `src/api.ts` | Doc: throws vs last-wins | - -### Suites needing teardown rollout (grep `initCodemap`) - -`churn-ingest.test.ts`, `context-engine.test.ts`, `trace-engine.test.ts`, `worker-pool.dist.test.ts`, `cmd-affected` tests, `recipe-recency.test.ts`, `benchmark-config.test.ts`, `agents-init.test.ts`, … — complete list in PR diff. - ---- - -## Task list - -| ID | Task | Status | Verify | -| --- | -------------------------------------------------------- | ------- | ------------------------------ | -| 5.1 | `runtime-swap.ts` + audit worktree bracket | done | `bun test src/runtime.test.ts` | -| 5.2 | `initCodemap` / `configureResolver` throw on root switch | done | runtime tests | -| 5.3 | `resetCodemapForTest` + `installCodemapTestTeardown` | done | — | -| 5.4 | Teardown rollout on `initCodemap` test suites | done | affected `*.test.ts` | -| 5.5 | `loadUserConfig` → `parseCodemapUserConfig` at load | done | `bun test src/config.test.ts` | -| 5.6 | `api.ts` + architecture: throws-on-root-switch | done | — | -| 5.s | Commit + PR + CI | pending | `bun run check` | - ---- - -## Pre-locked decisions - -| # | Decision | -| ---- | ---------------------------------------------------------------------------------- | -| P3.1 | Audit `--base` worktree reindex is the **only** exempt root switch (swap bracket). | -| P3.2 | `createCodemap({ root: B })` after root A **throws** — document breaking tighten. | -| P3.3 | Teardown helper is maintainer-only; not a consumer surface. | - ---- - -## Acceptance - -- [ ] Second `initCodemap` with different root throws (audit exempt) -- [ ] Invalid explicit config fails at `loadUserConfig` -- [ ] Teardown on all `initCodemap` suites touched in PR -- [ ] PR merged to `main` - -### Verify - -```bash -bun test src/runtime.test.ts src/config.test.ts -bun run check -``` - ---- - -## Lifecycle - -**Close when:** PR merged. Delete this file; lift to `docs/architecture.md`; update orchestrator session log. diff --git a/docs/plans/security-hardening-orchestrator.md b/docs/plans/security-hardening-orchestrator.md index 8d4c1328..bcf72950 100644 --- a/docs/plans/security-hardening-orchestrator.md +++ b/docs/plans/security-hardening-orchestrator.md @@ -21,11 +21,11 @@ ## PR schedule -| PR | Plan | Status | Blocks | -| ----- | --------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------- | -| **1** | lifted → [`architecture.md`](../architecture.md) (plan retired) | **merged** ([#180](https://github.com/stainless-code/codemap/pull/180) · `a5caca8`) | — | -| **2** | lifted → [`architecture.md`](../architecture.md) (plan retired) | **merged** ([#181](https://github.com/stainless-code/codemap/pull/181) · `aae172f`) | — | -| **3** | [`runtime-test-isolation.md`](./runtime-test-isolation.md) | **in progress** (`fix/runtime-test-isolation`) | PR **1** merged (PR **2** optional) | +| PR | Plan | Status | Blocks | +| ----- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------ | +| **1** | lifted → [`architecture.md`](../architecture.md) (plan retired) | **merged** ([#180](https://github.com/stainless-code/codemap/pull/180) · `a5caca8`) | — | +| **2** | lifted → [`architecture.md`](../architecture.md) (plan retired) | **merged** ([#181](https://github.com/stainless-code/codemap/pull/181) · `aae172f`) | — | +| **3** | lifted → [`architecture.md`](../architecture.md) (plan retired) | **PR open** ([#182](https://github.com/stainless-code/codemap/pull/182) · `fix/runtime-test-isolation`) | — | | — | — | **deferred** | golden `schema.test.ts` + path guards | | — | — | **skip** | atomic `ensureStateConfig` writes | @@ -74,6 +74,7 @@ Evaluated 2026-06 against [roadmap § Floors](../roadmap.md#floors-v1-product-sh | 2026-06-05 | PR 2 harden | `/harden-pr full` — plan retired; CLI/MCP/docs parity. | | 2026-06-05 | PR 2 merge | [#181](https://github.com/stainless-code/codemap/pull/181) → `aae172f`. | | 2026-06-05 | PR 3 start | `fix/runtime-test-isolation` — root guards + test teardown + config validate. | +| 2026-06-05 | PR 3 harden | `/harden-pr full` — plan retired; swap finally fix + API/config tests. | | — | PR 3 merge | _fill · close orchestrator_ | --- diff --git a/docs/roadmap.md b/docs/roadmap.md index ce87dc89..737a159a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -108,7 +108,7 @@ Predicate-as-API only — enrich row shape and audit deltas; no standalone pass/ - [ ] **`organize-imports` diff-shape recipe** — deterministic single-file import sort/group; `imports.line_number` + `source` substrate sufficient. Review-first (`auto_fixable: false`). Effort: S. - [ ] **`codemap-to-tsmorph` Path B adapter** — separate package experiment: `query_recipe` discovery → `ts-morph` / `jscodeshift` transforms for AST-shape edits codemap's substring executor defers (see [architecture § Rejected apply-path alternatives](./architecture.md#apply--input-modes-transport-and-policy)). Not an in-tree AST writer (Path A rejected). Effort: M. - [ ] **Apply write-safety hardening** — close apply TOCTOU: SHA-256 `hashContent` at phase-1 read, recheck disk hash immediately before phase-2 write (`file content changed` conflict); `fsync` temp file before `rename`; skip files with mixed CRLF/LF (`mixed line endings`). Preserves all-or-nothing on any conflict. Plan: [`plans/apply-write-safety.md`](./plans/apply-write-safety.md). Effort: L. -- [ ] **Read-surface hardening (3 PRs)** — query/HTTP/validate safety, `impact` `inPath` homonyms, runtime guards + test teardown. **Orchestrator:** [`plans/security-hardening-orchestrator.md`](./plans/security-hardening-orchestrator.md). PR1 ([#180](https://github.com/stainless-code/codemap/pull/180), lifted to [architecture](./architecture.md)) · PR2 (`impact` homonyms, lifted to [architecture](./architecture.md)) · Plans: [PR3](./plans/runtime-test-isolation.md). Effort: S–M. +- [ ] **Read-surface hardening (3 PRs)** — query/HTTP/validate safety, `impact` `inPath` homonyms, runtime guards + test teardown. **Orchestrator:** [`plans/security-hardening-orchestrator.md`](./plans/security-hardening-orchestrator.md). PR1 ([#180](https://github.com/stainless-code/codemap/pull/180), lifted to [architecture](./architecture.md)) · PR2 ([#181](https://github.com/stainless-code/codemap/pull/181), lifted to [architecture](./architecture.md)) · PR3 ([#182](https://github.com/stainless-code/codemap/pull/182), lifted to [architecture](./architecture.md)). Effort: S–M. - [ ] **`history` table** (deferred — revisit-triggered) — temporal queries: "when did symbol X get `@deprecated`?", "coverage trend over last 50 commits", "files that became dead this week". `audit --base ` covers the most-common temporal question (PR-scoped diff) without schema growth, so the table earns its place only when bigger questions emerge. Two shapes (per-commit snapshots ~N × DB size; append-only event log heavier CTE walks); both pay an N-reindexes backfill cost (~30s per reindex). **Revisit triggers:** two consumers ship `jq`-based "audit-runs-over-time" workflows, OR `query_baselines` evolution becomes a recurring agent need. - [ ] **`codemap audit` verdict + thresholds** (v1.x) — `verdict: "pass" | "warn" | "fail"` driven by an `audit.deltas[].{added_max, action}` field on the config object (`.codemap/config.{ts,js,json}`). Triggers: two consumers ship `jq`-based threshold scripts with similar shapes, OR one consumer asks with a concrete config sketch. Until then, raw deltas + consumer-side `jq` is the CI exit-code idiom. **Likely accelerant:** the Marketplace Action (next item) shipping is the most plausible path to firing the trigger — once `- uses: stainless-code/codemap@v1` is the dominant CI path, real `jq` threshold scripts will surface. - [ ] **GitHub Marketplace Action — publish + listing finish** — core Action implementation is in-tree: root `action.yml`, `query --ci`, `audit --format sarif` / `--ci`, package-manager detection, dogfood smoke, and opt-in `pr-comment` summary renderer have shipped. Remaining work is the release/listing slice: `MARKETPLACE.md`, `v1.0.0` / floating `v1` tags, Marketplace setup, sacrificial-repo smoke, and making `action-smoke` blocking once the Action tag exists. Action version stream is independent of CLI version (`package.json` currently drives CLI/npm version; Action publishes at its own `v1.0.0`). Plan: [`plans/github-marketplace-action.md`](./plans/github-marketplace-action.md). Effort: S. diff --git a/src/api.test.ts b/src/api.test.ts index ea9089cd..bde7ad80 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -16,4 +16,24 @@ describe("createCodemap", () => { const rows = cm.query("SELECT 1 as ok") as { ok: number }[]; expect(rows[0]?.ok).toBe(1); }); + + test("throws when switching to a different root in the same process", async () => { + const rootA = mkdtempSync(join(tmpdir(), "codemap-api-a-")); + const rootB = mkdtempSync(join(tmpdir(), "codemap-api-b-")); + writeFileSync(join(rootA, "package.json"), "{}"); + writeFileSync(join(rootB, "package.json"), "{}"); + await createCodemap({ root: rootA }); + await expect(createCodemap({ root: rootB })).rejects.toThrow( + /cannot switch project root/, + ); + }); + + test("throws when config file is invalid at load", async () => { + const root = mkdtempSync(join(tmpdir(), "codemap-api-bad-")); + const configPath = join(root, "bad.json"); + writeFileSync(configPath, JSON.stringify({ include: [1, 2] })); + await expect( + createCodemap({ root, configFile: configPath }), + ).rejects.toThrow(/include/); + }); }); diff --git a/src/api.ts b/src/api.ts index 8e0dfe8a..ace41195 100644 --- a/src/api.ts +++ b/src/api.ts @@ -47,7 +47,10 @@ export interface CodemapInitOptions { * @remarks * One project root per process: a second `createCodemap()` with a different `root` throws. * Re-initializing the same root is allowed. Audit `--base` worktree reindex is the only - * production path that may temporarily switch roots (`runtime-swap.ts`). + * production path that may temporarily switch roots. + * + * Invalid project config (unknown keys, wrong types) throws at load time via the same + * schema as {@link parseCodemapUserConfig}. */ export async function createCodemap( options: CodemapInitOptions = {}, diff --git a/src/application/audit-engine.test.ts b/src/application/audit-engine.test.ts index f1e3e5f9..9ddf6579 100644 --- a/src/application/audit-engine.test.ts +++ b/src/application/audit-engine.test.ts @@ -1,19 +1,31 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, spyOn } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveCodemapConfig } from "../config"; import { createTables, insertFile, upsertQueryBaseline } from "../db"; import type { CodemapDatabase } from "../db"; import { diffRows } from "../diff-rows"; +import { configureResolver } from "../resolver"; +import { getProjectRoot, initCodemap } from "../runtime"; import { openCodemapDatabase } from "../sqlite-db"; +import { installCodemapTestTeardown } from "../test-helpers/runtime-reset"; import { buildFindingKeySet, collapseAuditEnvelopeForSummary, computeDelta, findingKey, + makeWorktreeReindex, runAudit, tagAddedWithAttribution, V1_DELTAS, } from "./audit-engine"; import type { AuditEnvelope } from "./audit-engine"; +import * as runIndex from "./run-index"; +import type { IndexResult } from "./types"; + +installCodemapTestTeardown(); function freshDb(): CodemapDatabase { const db = openCodemapDatabase(":memory:"); @@ -488,3 +500,34 @@ describe("computeDelta — files", () => { } }); }); + +describe("makeWorktreeReindex", () => { + it("swaps roots under runtime bracket and restores live config", async () => { + const liveRoot = mkdtempSync(join(tmpdir(), "audit-reindex-live-")); + const wtRoot = mkdtempSync(join(tmpdir(), "audit-reindex-wt-")); + writeFileSync(join(liveRoot, "package.json"), "{}"); + writeFileSync(join(wtRoot, "package.json"), "{}"); + mkdirSync(join(wtRoot, ".codemap"), { recursive: true }); + + initCodemap(resolveCodemapConfig(liveRoot, {})); + configureResolver(liveRoot, null); + + const spy = spyOn(runIndex, "runCodemapIndex").mockResolvedValue({ + mode: "full", + indexed: 0, + skipped: 0, + elapsedMs: 0, + stats: { files: 0 }, + } as IndexResult); + try { + await makeWorktreeReindex()(wtRoot); + expect(getProjectRoot()).toBe(liveRoot); + expect(() => + initCodemap(resolveCodemapConfig("/other-root", {})), + ).toThrow(/cannot switch project root/); + expect(spy).toHaveBeenCalled(); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/src/application/audit-engine.ts b/src/application/audit-engine.ts index f3658e49..125b5b21 100644 --- a/src/application/audit-engine.ts +++ b/src/application/audit-engine.ts @@ -459,15 +459,18 @@ export function makeWorktreeReindex(): ReindexFn { let wtDb; enterRuntimeSwap(); try { - const wtUser = await loadUserConfig(worktreePath, undefined); - initCodemap(resolveCodemapConfig(worktreePath, wtUser)); - configureResolver(getProjectRoot(), getTsconfigPath()); - wtDb = openCodemapDatabase(); - await runCodemapIndex(wtDb, { mode: "full", quiet: true, commit }); + try { + const wtUser = await loadUserConfig(worktreePath, undefined); + initCodemap(resolveCodemapConfig(worktreePath, wtUser)); + configureResolver(getProjectRoot(), getTsconfigPath()); + wtDb = openCodemapDatabase(); + await runCodemapIndex(wtDb, { mode: "full", quiet: true, commit }); + } finally { + wtDb?.close(); + initCodemap(savedConfig); + configureResolver(getProjectRoot(), getTsconfigPath()); + } } finally { - wtDb?.close(); - initCodemap(savedConfig); - configureResolver(getProjectRoot(), getTsconfigPath()); exitRuntimeSwap(); } }); diff --git a/src/runtime-swap.ts b/src/runtime-swap.ts index c22d2050..eb35d928 100644 --- a/src/runtime-swap.ts +++ b/src/runtime-swap.ts @@ -15,3 +15,8 @@ export function exitRuntimeSwap(): void { export function isRuntimeSwapActive(): boolean { return _runtimeSwapDepth > 0; } + +/** Maintainer test helper — clears swap depth between test cases. */ +export function resetRuntimeSwapForTest(): void { + _runtimeSwapDepth = 0; +} diff --git a/src/runtime.test.ts b/src/runtime.test.ts index 9e1cd944..e6d2aa05 100644 --- a/src/runtime.test.ts +++ b/src/runtime.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it } from "bun:test"; import { resolveCodemapConfig } from "./config"; +import { configureResolver } from "./resolver"; import { initCodemap, isPathExcluded } from "./runtime"; import { enterRuntimeSwap, exitRuntimeSwap } from "./runtime-swap"; import { installCodemapTestTeardown } from "./test-helpers/runtime-reset"; @@ -33,6 +34,18 @@ describe("initCodemap root guard", () => { } finally { exitRuntimeSwap(); } + expect(() => initCodemap(resolveCodemapConfig("/other-root", {}))).toThrow( + /cannot switch project root/, + ); + }); +}); + +describe("configureResolver root guard", () => { + it("throws when switching to a different root without runtime swap", () => { + configureResolver("/resolver-a", null); + expect(() => configureResolver("/resolver-b", null)).toThrow( + /cannot switch resolver root/, + ); }); }); diff --git a/src/test-helpers/runtime-reset.ts b/src/test-helpers/runtime-reset.ts index 473b0617..ef9b43bb 100644 --- a/src/test-helpers/runtime-reset.ts +++ b/src/test-helpers/runtime-reset.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach } from "bun:test"; import { resetResolverForTest } from "../resolver"; import { resetCodemapForTest } from "../runtime"; +import { resetRuntimeSwapForTest } from "../runtime-swap"; /** * Clears process-global codemap + resolver state. Maintainer tests only — @@ -10,6 +11,7 @@ import { resetCodemapForTest } from "../runtime"; export function resetRuntimeForTest(): void { resetCodemapForTest(); resetResolverForTest(); + resetRuntimeSwapForTest(); } /** From 8314042b081769f0eacdf7a707611b6b1293898d Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Thu, 11 Jun 2026 09:18:29 +0300 Subject: [PATCH 3/4] harden: cover loadUserConfig invalid .ts paths at load --- src/config.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/config.test.ts b/src/config.test.ts index bbff3875..3ebb5655 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -247,4 +247,20 @@ describe("loadUserConfig", () => { ); await expect(loadUserConfig(dir)).rejects.toThrow(/include/); }); + + it("invalid explicit .ts config throws at load", async () => { + const p = join(dir, "bad.ts"); + writeFileSync(p, "export default { include: [1, 2] };\n"); + await expect(loadUserConfig(dir, p)).rejects.toThrow(/include/); + }); + + it("invalid state-dir config.ts throws at load", async () => { + const stateDir = join(dir, ".codemap"); + mkdirSync(stateDir, { recursive: true }); + writeFileSync( + join(stateDir, "config.ts"), + "export default { extra: 1 };\n", + ); + await expect(loadUserConfig(dir)).rejects.toThrow(/extra/); + }); }); From ea7e47729ad136be50cdfaddcf1892874b517094 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Thu, 11 Jun 2026 09:19:11 +0300 Subject: [PATCH 4/4] chore(changeset): patch bump for runtime root guard tighten --- .changeset/runtime-test-isolation.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/runtime-test-isolation.md b/.changeset/runtime-test-isolation.md index 54432b53..62f1f13a 100644 --- a/.changeset/runtime-test-isolation.md +++ b/.changeset/runtime-test-isolation.md @@ -1,5 +1,5 @@ --- -"@stainless-code/codemap": minor +"@stainless-code/codemap": patch --- -`createCodemap()` now fails fast when switching to a different project root in the same process (audit `--base` worktree reindex is exempt), and invalid config files throw at load time instead of on first use. +`createCodemap()` and the CLI now reject invalid project config at load time. A second `createCodemap()` with a different project root in the same process throws (audit `--base` worktree reindex is exempt).