Skip to content

Commit c09173e

Browse files
committed
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.
1 parent 7b3c939 commit c09173e

12 files changed

Lines changed: 109 additions & 94 deletions

.changeset/runtime-test-isolation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"@stainless-code/codemap": minor
33
---
44

5-
`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.
5+
`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.

docs/architecture.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,13 +234,13 @@ The npm package exports **`createCodemap`**, **`Codemap`** (`query`, `index`), *
234234
2. **`await cm.index({ mode, files?, quiet? })`** — same pipeline as the CLI (incremental / full / targeted).
235235
3. **`cm.query(sql)`** — read-only SQL against `.codemap/index.db` (opens the DB per call).
236236

237-
**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.
237+
**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.
238238

239239
### User config
240240

241241
Optional **`<state-dir>/config.{ts,js,json}`** (default `.codemap/config.*`; default export: object or async factory). **`--config <path>`** overrides with an explicit file (absolute or relative to cwd). Example shape: [`codemap.config.example.json`](../codemap.config.example.json). **Self-healing (D11):** `<state-dir>/.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`.
242242

243-
**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.
243+
**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.
244244

245245
**Exports:** `codemapUserConfigSchema`, `parseCodemapUserConfig`, `defineConfig`, and **`CodemapUserConfig`** (inferred type) from the package entry — see **`src/config.ts`** / **`dist/index.d.mts`**.
246246

docs/plans/runtime-test-isolation.md

Lines changed: 0 additions & 75 deletions
This file was deleted.

docs/plans/security-hardening-orchestrator.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@
2121

2222
## PR schedule
2323

24-
| PR | Plan | Status | Blocks |
25-
| ----- | --------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------- |
26-
| **1** | lifted → [`architecture.md`](../architecture.md) (plan retired) | **merged** ([#180](https://github.com/stainless-code/codemap/pull/180) · `a5caca8`) | |
27-
| **2** | lifted → [`architecture.md`](../architecture.md) (plan retired) | **merged** ([#181](https://github.com/stainless-code/codemap/pull/181) · `aae172f`) | |
28-
| **3** | [`runtime-test-isolation.md`](./runtime-test-isolation.md) | **in progress** (`fix/runtime-test-isolation`) | PR **1** merged (PR **2** optional) |
24+
| PR | Plan | Status | Blocks |
25+
| ----- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------ |
26+
| **1** | lifted → [`architecture.md`](../architecture.md) (plan retired) | **merged** ([#180](https://github.com/stainless-code/codemap/pull/180) · `a5caca8`) | |
27+
| **2** | lifted → [`architecture.md`](../architecture.md) (plan retired) | **merged** ([#181](https://github.com/stainless-code/codemap/pull/181) · `aae172f`) | |
28+
| **3** | lifted → [`architecture.md`](../architecture.md) (plan retired) | **PR open** ([#182](https://github.com/stainless-code/codemap/pull/182) · `fix/runtime-test-isolation`) | |
2929

3030
||| **deferred** | golden `schema.test.ts` + path guards |
3131
||| **skip** | atomic `ensureStateConfig` writes |
@@ -74,6 +74,7 @@ Evaluated 2026-06 against [roadmap § Floors](../roadmap.md#floors-v1-product-sh
7474
| 2026-06-05 | PR 2 harden | `/harden-pr full` — plan retired; CLI/MCP/docs parity. |
7575
| 2026-06-05 | PR 2 merge | [#181](https://github.com/stainless-code/codemap/pull/181)`aae172f`. |
7676
| 2026-06-05 | PR 3 start | `fix/runtime-test-isolation` — root guards + test teardown + config validate. |
77+
| 2026-06-05 | PR 3 harden | `/harden-pr full` — plan retired; swap finally fix + API/config tests. |
7778
|| PR 3 merge | _fill · close orchestrator_ |
7879

7980
---

docs/roadmap.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ Predicate-as-API only — enrich row shape and audit deltas; no standalone pass/
108108
- [ ] **`organize-imports` diff-shape recipe** — deterministic single-file import sort/group; `imports.line_number` + `source` substrate sufficient. Review-first (`auto_fixable: false`). Effort: S.
109109
- [ ] **`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.
110110
- [ ] **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.
111-
- [ ] **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.
111+
- [ ] **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.
112112
- [ ] **`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 <ref>` 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.
113113
- [ ] **`codemap audit` verdict + thresholds** (v1.x) — `verdict: "pass" | "warn" | "fail"` driven by an `audit.deltas[<key>].{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.
114114
- [ ] **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.

src/api.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,24 @@ describe("createCodemap", () => {
1616
const rows = cm.query("SELECT 1 as ok") as { ok: number }[];
1717
expect(rows[0]?.ok).toBe(1);
1818
});
19+
20+
test("throws when switching to a different root in the same process", async () => {
21+
const rootA = mkdtempSync(join(tmpdir(), "codemap-api-a-"));
22+
const rootB = mkdtempSync(join(tmpdir(), "codemap-api-b-"));
23+
writeFileSync(join(rootA, "package.json"), "{}");
24+
writeFileSync(join(rootB, "package.json"), "{}");
25+
await createCodemap({ root: rootA });
26+
await expect(createCodemap({ root: rootB })).rejects.toThrow(
27+
/cannot switch project root/,
28+
);
29+
});
30+
31+
test("throws when config file is invalid at load", async () => {
32+
const root = mkdtempSync(join(tmpdir(), "codemap-api-bad-"));
33+
const configPath = join(root, "bad.json");
34+
writeFileSync(configPath, JSON.stringify({ include: [1, 2] }));
35+
await expect(
36+
createCodemap({ root, configFile: configPath }),
37+
).rejects.toThrow(/include/);
38+
});
1939
});

src/api.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,10 @@ export interface CodemapInitOptions {
4747
* @remarks
4848
* One project root per process: a second `createCodemap()` with a different `root` throws.
4949
* Re-initializing the same root is allowed. Audit `--base` worktree reindex is the only
50-
* production path that may temporarily switch roots (`runtime-swap.ts`).
50+
* production path that may temporarily switch roots.
51+
*
52+
* Invalid project config (unknown keys, wrong types) throws at load time via the same
53+
* schema as {@link parseCodemapUserConfig}.
5154
*/
5255
export async function createCodemap(
5356
options: CodemapInitOptions = {},

src/application/audit-engine.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,31 @@
1-
import { describe, expect, it } from "bun:test";
1+
import { describe, expect, it, spyOn } from "bun:test";
2+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
25

6+
import { resolveCodemapConfig } from "../config";
37
import { createTables, insertFile, upsertQueryBaseline } from "../db";
48
import type { CodemapDatabase } from "../db";
59
import { diffRows } from "../diff-rows";
10+
import { configureResolver } from "../resolver";
11+
import { getProjectRoot, initCodemap } from "../runtime";
612
import { openCodemapDatabase } from "../sqlite-db";
13+
import { installCodemapTestTeardown } from "../test-helpers/runtime-reset";
714
import {
815
buildFindingKeySet,
916
collapseAuditEnvelopeForSummary,
1017
computeDelta,
1118
findingKey,
19+
makeWorktreeReindex,
1220
runAudit,
1321
tagAddedWithAttribution,
1422
V1_DELTAS,
1523
} from "./audit-engine";
1624
import type { AuditEnvelope } from "./audit-engine";
25+
import * as runIndex from "./run-index";
26+
import type { IndexResult } from "./types";
27+
28+
installCodemapTestTeardown();
1729

1830
function freshDb(): CodemapDatabase {
1931
const db = openCodemapDatabase(":memory:");
@@ -488,3 +500,34 @@ describe("computeDelta — files", () => {
488500
}
489501
});
490502
});
503+
504+
describe("makeWorktreeReindex", () => {
505+
it("swaps roots under runtime bracket and restores live config", async () => {
506+
const liveRoot = mkdtempSync(join(tmpdir(), "audit-reindex-live-"));
507+
const wtRoot = mkdtempSync(join(tmpdir(), "audit-reindex-wt-"));
508+
writeFileSync(join(liveRoot, "package.json"), "{}");
509+
writeFileSync(join(wtRoot, "package.json"), "{}");
510+
mkdirSync(join(wtRoot, ".codemap"), { recursive: true });
511+
512+
initCodemap(resolveCodemapConfig(liveRoot, {}));
513+
configureResolver(liveRoot, null);
514+
515+
const spy = spyOn(runIndex, "runCodemapIndex").mockResolvedValue({
516+
mode: "full",
517+
indexed: 0,
518+
skipped: 0,
519+
elapsedMs: 0,
520+
stats: { files: 0 },
521+
} as IndexResult);
522+
try {
523+
await makeWorktreeReindex()(wtRoot);
524+
expect(getProjectRoot()).toBe(liveRoot);
525+
expect(() =>
526+
initCodemap(resolveCodemapConfig("/other-root", {})),
527+
).toThrow(/cannot switch project root/);
528+
expect(spy).toHaveBeenCalled();
529+
} finally {
530+
spy.mockRestore();
531+
}
532+
});
533+
});

src/application/audit-engine.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -459,15 +459,18 @@ export function makeWorktreeReindex(): ReindexFn {
459459
let wtDb;
460460
enterRuntimeSwap();
461461
try {
462-
const wtUser = await loadUserConfig(worktreePath, undefined);
463-
initCodemap(resolveCodemapConfig(worktreePath, wtUser));
464-
configureResolver(getProjectRoot(), getTsconfigPath());
465-
wtDb = openCodemapDatabase();
466-
await runCodemapIndex(wtDb, { mode: "full", quiet: true, commit });
462+
try {
463+
const wtUser = await loadUserConfig(worktreePath, undefined);
464+
initCodemap(resolveCodemapConfig(worktreePath, wtUser));
465+
configureResolver(getProjectRoot(), getTsconfigPath());
466+
wtDb = openCodemapDatabase();
467+
await runCodemapIndex(wtDb, { mode: "full", quiet: true, commit });
468+
} finally {
469+
wtDb?.close();
470+
initCodemap(savedConfig);
471+
configureResolver(getProjectRoot(), getTsconfigPath());
472+
}
467473
} finally {
468-
wtDb?.close();
469-
initCodemap(savedConfig);
470-
configureResolver(getProjectRoot(), getTsconfigPath());
471474
exitRuntimeSwap();
472475
}
473476
});

src/runtime-swap.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,8 @@ export function exitRuntimeSwap(): void {
1515
export function isRuntimeSwapActive(): boolean {
1616
return _runtimeSwapDepth > 0;
1717
}
18+
19+
/** Maintainer test helper — clears swap depth between test cases. */
20+
export function resetRuntimeSwapForTest(): void {
21+
_runtimeSwapDepth = 0;
22+
}

0 commit comments

Comments
 (0)