Files
socraticode/tests/unit/logger.test.ts
Giancarlo Erra 4e41b4604e feat(impact): wire Phase F into watcher; fix prototype-key crash; add real scale test
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.
2026-04-21 15:59:55 +01:00

194 lines
6.2 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getLogLevel, logger, setLogLevel, setMcpLogSender } from "../../src/services/logger.js";
type SenderFn = Parameters<typeof setMcpLogSender>[0];
describe("logger", () => {
let stderrSpy: ReturnType<typeof vi.spyOn>;
let savedLevel: ReturnType<typeof getLogLevel>;
beforeEach(() => {
// Pin the level so tests don't depend on SOCRATICODE_LOG_LEVEL in the
// host shell — that env was the suspected cause of the reviewer's
// "does not emit debug at the default info level" flake.
savedLevel = getLogLevel();
setLogLevel("info");
stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
});
afterEach(() => {
stderrSpy.mockRestore();
setLogLevel(savedLevel);
});
describe("log methods exist", () => {
it("has debug method", () => {
expect(typeof logger.debug).toBe("function");
});
it("has info method", () => {
expect(typeof logger.info).toBe("function");
});
it("has warn method", () => {
expect(typeof logger.warn).toBe("function");
});
it("has error method", () => {
expect(typeof logger.error).toBe("function");
});
});
describe("info logging", () => {
it("writes to stderr", () => {
logger.info("test message");
expect(stderrSpy).toHaveBeenCalled();
});
it("outputs valid JSON", () => {
logger.info("test message");
const output = stderrSpy.mock.calls[0]?.[0] as string;
expect(() => JSON.parse(output)).not.toThrow();
});
it("includes timestamp, level, and data", () => {
logger.info("hello world");
const output = stderrSpy.mock.calls[0]?.[0] as string;
const parsed = JSON.parse(output);
expect(parsed.timestamp).toBeDefined();
expect(parsed.level).toBe("info");
expect(parsed.data).toBe("hello world");
});
it("includes context fields when provided", () => {
logger.info("test", { projectPath: "/test", count: 42 });
const output = stderrSpy.mock.calls[0]?.[0] as string;
const parsed = JSON.parse(output);
expect(parsed.projectPath).toBe("/test");
expect(parsed.count).toBe(42);
});
it("outputs with trailing newline", () => {
logger.info("test");
const output = stderrSpy.mock.calls[0]?.[0] as string;
expect(output.endsWith("\n")).toBe(true);
});
});
describe("warn logging", () => {
it("writes with warn level", () => {
logger.warn("warning message");
const output = stderrSpy.mock.calls[0]?.[0] as string;
const parsed = JSON.parse(output);
expect(parsed.level).toBe("warn");
expect(parsed.data).toBe("warning message");
});
});
describe("error logging", () => {
it("writes with error level", () => {
logger.error("error message");
const output = stderrSpy.mock.calls[0]?.[0] as string;
const parsed = JSON.parse(output);
expect(parsed.level).toBe("error");
expect(parsed.data).toBe("error message");
});
it("includes error context", () => {
logger.error("failed", { error: "something broke", code: 500 });
const output = stderrSpy.mock.calls[0]?.[0] as string;
const parsed = JSON.parse(output);
expect(parsed.error).toBe("something broke");
expect(parsed.code).toBe(500);
});
});
describe("timestamp format", () => {
it("uses ISO 8601 format", () => {
logger.info("timestamp test");
const output = stderrSpy.mock.calls[0]?.[0] as string;
const parsed = JSON.parse(output);
// ISO 8601: 2024-01-15T10:30:00.000Z
expect(parsed.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
});
});
describe("log level filtering", () => {
it("does not emit debug messages at the default info level", () => {
logger.debug("should be filtered");
expect(stderrSpy).not.toHaveBeenCalled();
});
it("emits info, warn, and error at the default info level", () => {
logger.info("info msg");
logger.warn("warn msg");
logger.error("error msg");
expect(stderrSpy).toHaveBeenCalledTimes(3);
});
});
describe("setMcpLogSender", () => {
let senderMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
senderMock = vi.fn();
setMcpLogSender(senderMock as SenderFn);
});
afterEach(() => {
// Reset so subsequent tests continue using the stderr path
setMcpLogSender(null as unknown as SenderFn);
});
it("routes logs through the MCP sender instead of stderr", () => {
logger.info("mcp test");
expect(senderMock).toHaveBeenCalledOnce();
expect(stderrSpy).not.toHaveBeenCalled();
});
it("passes correct logger name, level, and data to the sender", () => {
logger.info("hello");
expect(senderMock).toHaveBeenCalledWith({
level: "info",
logger: "socraticode",
data: "hello",
});
});
it("maps warn level to 'warning' to comply with the MCP spec", () => {
logger.warn("something off");
expect(senderMock).toHaveBeenCalledWith(expect.objectContaining({
level: "warning",
}));
});
it("keeps debug, info, error level names unchanged for MCP", () => {
logger.error("boom");
expect(senderMock).toHaveBeenCalledWith(expect.objectContaining({
level: "error",
}));
});
it("embeds context fields in the data string when context is provided", () => {
logger.info("msg", { foo: "bar", count: 3 });
const call = senderMock.mock.calls[0]?.[0] as { data: string };
expect(call.data).toContain("msg");
expect(call.data).toContain("foo");
expect(call.data).toContain("bar");
});
it("sends plain message string when no context is provided", () => {
logger.info("plain message");
const call = senderMock.mock.calls[0]?.[0] as { data: string };
expect(call.data).toBe("plain message");
});
it("swallows exceptions thrown by the sender", () => {
senderMock.mockImplementation(() => { throw new Error("transport closed"); });
expect(() => logger.info("test")).not.toThrow();
});
});
});