mirror of
https://github.com/giancarloerra/socraticode.git
synced 2026-07-03 14:05:21 +02:00
4e41b4604e
Closes the four reviewer-flagged gaps from the previous round:
1. **Phase F wired into the watcher / `codebase_update`.**
`rebuildGraph(path, { skipSymbolGraph: true })` now exposes a
file-import-only build mode. `services/indexer.ts` calls it +
`updateChangedFilesSymbolGraph(...)` when meta exists AND ≤ 50 files
changed (`INCREMENTAL_SYMBOL_THRESHOLD`); falls back to full rebuild
above that. Measured speedup on a 1000-file synthetic repo: full
rebuild 6.55 s → Phase F single-file update 197 ms (~33×).
2. **Real end-to-end scale test.**
New `tests/integration/symbol-graph-scale.test.ts` generates 1000
synthetic Python files × 20 symbols/file (20k symbols) against a real
Qdrant, asserts (a) full rebuild within budget, (b) cold listSymbols /
getImpactRadius queries within budget, (c) Phase F update ≥ 4× faster
than full rebuild. `SCALE_LARGE=1` pushes to 10k files / 200k symbols.
3. **Smoke benchmark numbers captured.**
New `scripts/benchmark-graph.ts` runs `rebuildGraph` against any
target dir and emits JSON + a Markdown row. Numbers for SocratiCode
itself (82 files / 571 symbols / 9914 call edges / 0.90 s / 167 MB
RSS) and the synthetic 1000-file repo are now in DEVELOPER.md
§ "Real-world benchmark numbers".
4. **Logger test flake fixed.**
`services/logger.ts` exposes `setLogLevel` / `getLogLevel`;
`tests/unit/logger.test.ts` pins the level in beforeEach and restores
in afterEach. Verified deterministic with `SOCRATICODE_LOG_LEVEL=debug`
set in the shell environment.
### Bug discovered + fixed by the new benchmark
Running `scripts/benchmark-graph.ts` against SocratiCode itself crashed
the symbol graph build with `TypeError: existing.push is not a function`.
Root cause: shard maps used `shard[name]` bracket access on a plain
`{}`, which returned `Object.prototype.constructor` (a function) for
common method names like `constructor`, `toString`, `hasOwnProperty`.
Fixed by guarding all reads with `Object.hasOwn` in
`services/code-graph.ts` and `services/symbol-graph-incremental.ts`.
Added a regression test in
`tests/integration/symbol-graph-incremental.test.ts`.
### QA
- Biome lint: clean (auto-fixed 1 file).
- VS Code Problems panel: clean.
- Unit tests: 676/676 pass (29 files); reproducible.
- Integration tests touched: 45/45 pass (incremental, scale,
indexer, code-graph).
- CodeRabbit review: no findings.
- Snyk Code: 0 issues.
### Doc updates
- DEVELOPER.md: removed "watcher still triggers full rebuild" wording,
added "Real-world benchmark numbers" subsection with measured table.
- CHANGELOG.md: removed "Known Limitations" block; added new
Bug Fixes entries (prototype keys, logger flake) and a Performance
entry for the wired Phase F path with measured numbers.
189 lines
6.5 KiB
TypeScript
189 lines
6.5 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-only
|
|
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { projectIdFromPath } from "../../src/config.js";
|
|
import {
|
|
invalidateGraphCache,
|
|
rebuildGraph,
|
|
} from "../../src/services/code-graph.js";
|
|
import { updateChangedFilesSymbolGraph } from "../../src/services/symbol-graph-incremental.js";
|
|
import {
|
|
loadFilePayload,
|
|
loadSymbolGraphMeta,
|
|
} from "../../src/services/symbol-graph-store.js";
|
|
import {
|
|
createFixtureProject,
|
|
type FixtureProject,
|
|
isDockerAvailable,
|
|
} from "../helpers/fixtures.js";
|
|
import { waitForQdrant } from "../helpers/setup.js";
|
|
|
|
const dockerAvailable = isDockerAvailable();
|
|
|
|
describe.skipIf(!dockerAvailable)(
|
|
"symbol-graph-incremental",
|
|
{ timeout: 120_000 },
|
|
() => {
|
|
let fixture: FixtureProject;
|
|
let projectId: string;
|
|
|
|
beforeAll(async () => {
|
|
await waitForQdrant();
|
|
fixture = createFixtureProject("symbol-graph-incremental-test");
|
|
projectId = projectIdFromPath(fixture.root);
|
|
// Establish baseline meta + payloads from a real full rebuild.
|
|
await rebuildGraph(fixture.root);
|
|
}, 60_000);
|
|
|
|
afterAll(() => {
|
|
invalidateGraphCache(fixture.root);
|
|
fixture.cleanup();
|
|
});
|
|
|
|
it("returns fullRebuildRequired=false when meta exists (no-op call)", async () => {
|
|
const graph = await rebuildGraph(fixture.root);
|
|
const result = await updateChangedFilesSymbolGraph(
|
|
projectId,
|
|
fixture.root,
|
|
graph,
|
|
[],
|
|
[],
|
|
);
|
|
expect(result.fullRebuildRequired).toBe(false);
|
|
expect(result.filesChanged).toBe(0);
|
|
expect(result.filesRemoved).toBe(0);
|
|
});
|
|
|
|
it("re-extracts and persists a changed file's payload", async () => {
|
|
const graph = await rebuildGraph(fixture.root);
|
|
const rel = "src/index.ts";
|
|
|
|
// Mutate the file: add a new exported function.
|
|
const abs = path.join(fixture.root, rel);
|
|
const original = fs.readFileSync(abs, "utf-8");
|
|
try {
|
|
fs.writeFileSync(
|
|
abs,
|
|
`${original}\nexport function brandNewIncrementalSymbol(): number { return 42; }\n`,
|
|
"utf-8",
|
|
);
|
|
|
|
const result = await updateChangedFilesSymbolGraph(
|
|
projectId,
|
|
fixture.root,
|
|
graph,
|
|
[rel],
|
|
[],
|
|
);
|
|
expect(result.fullRebuildRequired).toBe(false);
|
|
expect(result.filesChanged).toBe(1);
|
|
|
|
// The new symbol should appear in the persisted payload.
|
|
const payload = await loadFilePayload(projectId, rel);
|
|
expect(payload).toBeTruthy();
|
|
const names = payload?.symbols.map((s) => s.name) ?? [];
|
|
expect(names).toContain("brandNewIncrementalSymbol");
|
|
} finally {
|
|
fs.writeFileSync(abs, original, "utf-8");
|
|
}
|
|
});
|
|
|
|
it("is a no-op when content hash is unchanged", async () => {
|
|
const graph = await rebuildGraph(fixture.root);
|
|
const rel = "src/index.ts";
|
|
const before = await loadSymbolGraphMeta(projectId);
|
|
const result = await updateChangedFilesSymbolGraph(
|
|
projectId,
|
|
fixture.root,
|
|
graph,
|
|
[rel],
|
|
[],
|
|
);
|
|
// The diff path detects identical hash and skips writes.
|
|
expect(result.fullRebuildRequired).toBe(false);
|
|
expect(result.symbolsDelta).toBe(0);
|
|
expect(result.edgesDelta).toBe(0);
|
|
const after = await loadSymbolGraphMeta(projectId);
|
|
expect(after?.symbolCount).toBe(before?.symbolCount);
|
|
});
|
|
|
|
it("removes a deleted file's payload from the store", async () => {
|
|
const graph = await rebuildGraph(fixture.root);
|
|
const rel = "src/utils/helpers.ts";
|
|
// Confirm baseline.
|
|
const before = await loadFilePayload(projectId, rel);
|
|
expect(before).toBeTruthy();
|
|
|
|
const result = await updateChangedFilesSymbolGraph(
|
|
projectId,
|
|
fixture.root,
|
|
graph,
|
|
[],
|
|
[rel],
|
|
);
|
|
expect(result.fullRebuildRequired).toBe(false);
|
|
expect(result.filesRemoved).toBe(1);
|
|
|
|
const after = await loadFilePayload(projectId, rel);
|
|
expect(after).toBeNull();
|
|
});
|
|
|
|
it("handles symbols whose names collide with Object.prototype keys (regression)", async () => {
|
|
// Regression for the "existing.push is not a function" crash hit on
|
|
// SocratiCode itself: symbols named `constructor` / `toString` /
|
|
// `hasOwnProperty` previously short-circuited bracket lookup on a
|
|
// plain `{}` shard to the prototype value (a function), then
|
|
// `existing.push(...)` blew up.
|
|
const rel = "src/proto-keys.ts";
|
|
const filePath = path.join(fixture.root, rel);
|
|
fs.writeFileSync(
|
|
filePath,
|
|
[
|
|
"export class A {",
|
|
" constructor() {}",
|
|
" toString() { return \"a\"; }",
|
|
" hasOwnProperty() { return true; }",
|
|
"}",
|
|
"",
|
|
"export function constructor() { return 1; }",
|
|
"export function toString() { return \"x\"; }",
|
|
"export function hasOwnProperty() { return false; }",
|
|
"",
|
|
].join("\n"),
|
|
"utf-8",
|
|
);
|
|
try {
|
|
// The original crash happened during the *full* persistSymbolGraph
|
|
// path, so exercise that as well.
|
|
await rebuildGraph(fixture.root);
|
|
const meta = await loadSymbolGraphMeta(projectId);
|
|
expect(meta).not.toBeNull();
|
|
const payload = await loadFilePayload(projectId, rel);
|
|
expect(payload).not.toBeNull();
|
|
const names = payload?.symbols.map((s) => s.name) ?? [];
|
|
// All three prototype-collision names must be present.
|
|
expect(names).toEqual(expect.arrayContaining(["constructor", "toString", "hasOwnProperty"]));
|
|
|
|
// And the incremental path must also accept them without throwing.
|
|
// Mutate the file so the incremental layer doesn't skip it as
|
|
// unchanged (its hash already matches after the full rebuild above).
|
|
fs.appendFileSync(filePath, "\nexport const PROTO_KEYS_REV = 2;\n", "utf-8");
|
|
const graph = await rebuildGraph(fixture.root, { skipSymbolGraph: true });
|
|
const result = await updateChangedFilesSymbolGraph(
|
|
projectId,
|
|
fixture.root,
|
|
graph,
|
|
[rel],
|
|
[],
|
|
);
|
|
expect(result.fullRebuildRequired).toBe(false);
|
|
expect(result.filesChanged).toBe(1);
|
|
} finally {
|
|
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
|
|
}
|
|
});
|
|
},
|
|
);
|