Files
pi-map/tests/integration.test.ts
T
Developer cb581f44b9 feat: smart subtree-aware reinit and validate --fix
- project_map_reinit now regenerates only the target subtree + ancestors
  by default, falling back to full reinit when subtree file count exceeds
  reinitFullThresholdPercent (default 10%).
- Add reinitFullThresholdPercent config option.
- Expose fix=true on project_map_validate Pi tool for localized repair.
- Update docs and runtime guidance to prefer patch / validate --fix
  before full reinit.
- Add integration tests for smart reinit and update typebox mock.
2026-06-16 11:46:48 +00:00

545 lines
17 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
mkdtempSync,
writeFileSync,
mkdirSync,
rmSync,
readFileSync,
} from "fs";
import { join } from "path";
import { tmpdir } from "os";
import { initProject, reinitPath } from "../src/init.js";
import { patchFile } from "../src/patch.js";
import { validateMaps } from "../src/validate.js";
import { createMockFileClient } from "./mock-llm.js";
describe("integration", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "pi-map-int-"));
});
afterEach(() => {
rmSync(dir, { recursive: true });
});
it("init creates both .pi-map.md and .pi-map.index.md files", async () => {
mkdirSync(join(dir, "src"));
writeFileSync(join(dir, "src", "index.ts"), `export function foo() {}\n`);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
const map = readFileSync(join(dir, "src", ".pi-map.md"), "utf8");
const index = readFileSync(join(dir, "src", ".pi-map.index.md"), "utf8");
expect(map).toContain("# src");
expect(index).toContain("# src (index)");
expect(index).toContain("dir: src");
});
it("root artifacts contain Project Map Protocol", async () => {
writeFileSync(join(dir, "package.json"), `{}\n`);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
const rootMap = readFileSync(join(dir, ".pi-map.md"), "utf8");
const rootIndex = readFileSync(join(dir, ".pi-map.index.md"), "utf8");
expect(rootMap).toContain("## Project Map Protocol");
expect(rootMap).toContain("index: ./.pi-map.index.md");
expect(rootIndex).toContain("## Project Map Protocol");
expect(rootIndex).toContain("map: ./.pi-map.md");
});
it("non-root map contains index link", async () => {
mkdirSync(join(dir, "src"));
writeFileSync(join(dir, "src", "index.ts"), `export function foo() {}\n`);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
const map = readFileSync(join(dir, "src", ".pi-map.md"), "utf8");
expect(map).toContain("index: src/.pi-map.index.md");
expect(map).toContain("dir: src");
});
it("index contains parent and children when applicable", async () => {
mkdirSync(join(dir, "src"));
mkdirSync(join(dir, "src", "utils"));
writeFileSync(join(dir, "src", "index.ts"), `export function foo() {}\n`);
writeFileSync(
join(dir, "src", "utils", "helpers.ts"),
`export const h = 1;\n`,
);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
const srcIndex = readFileSync(join(dir, "src", ".pi-map.index.md"), "utf8");
expect(srcIndex).toContain("## parent");
expect(srcIndex).toContain("index: ./.pi-map.index.md");
expect(srcIndex).toContain("## children");
expect(srcIndex).toContain("- src/utils");
const utilsIndex = readFileSync(
join(dir, "src", "utils", ".pi-map.index.md"),
"utf8",
);
expect(utilsIndex).toContain("## parent");
expect(utilsIndex).toContain("index: src/.pi-map.index.md");
});
it("patch updates a file entry", async () => {
mkdirSync(join(dir, "src"));
writeFileSync(join(dir, "src", "index.ts"), `export function foo() {}\n`);
writeFileSync(join(dir, "src", "utils.ts"), `export const bar = 1;\n`);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
// Modify a file
writeFileSync(
join(dir, "src", "index.ts"),
`export function foo() {}\nexport function baz() {}\n`,
);
await patchFile(join(dir, "src", "index.ts"), client, dir);
const map = readFileSync(join(dir, "src", ".pi-map.md"), "utf8");
expect(map).toContain("baz");
});
it("patch with small mode refreshes ancestor indexes but preserves ancestor maps", async () => {
mkdirSync(join(dir, "src"));
writeFileSync(join(dir, "package.json"), `{}\n`);
writeFileSync(join(dir, "src", "index.ts"), `export const a = 1;\n`);
writeFileSync(join(dir, "src", "helper.ts"), `export const b = 2;\n`);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
writeFileSync(
join(dir, ".pi-map.md"),
`${readFileSync(join(dir, ".pi-map.md"), "utf8")}\nSENTINEL_ROOT_MAP\n`,
);
writeFileSync(
join(dir, ".pi-map.index.md"),
`${readFileSync(join(dir, ".pi-map.index.md"), "utf8")}\nSENTINEL_ROOT_INDEX\n`,
);
writeFileSync(
join(dir, "src", "index.ts"),
`export const a = 1;\nexport const c = 3;\n`,
);
await patchFile(join(dir, "src", "index.ts"), client, dir, {
rootPath: dir,
patchMode: "small",
});
const rootMap = readFileSync(join(dir, ".pi-map.md"), "utf8");
const rootIndex = readFileSync(join(dir, ".pi-map.index.md"), "utf8");
expect(rootMap).toContain("SENTINEL_ROOT_MAP");
expect(rootIndex).not.toContain("SENTINEL_ROOT_INDEX");
});
it("patch with structural mode refreshes ancestor maps and indexes", async () => {
mkdirSync(join(dir, "src"));
writeFileSync(join(dir, "package.json"), `{}\n`);
writeFileSync(join(dir, "src", "index.ts"), `export const a = 1;\n`);
writeFileSync(join(dir, "src", "helper.ts"), `export const b = 2;\n`);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
writeFileSync(
join(dir, ".pi-map.md"),
`${readFileSync(join(dir, ".pi-map.md"), "utf8")}\nSENTINEL_ROOT_MAP\n`,
);
writeFileSync(
join(dir, ".pi-map.index.md"),
`${readFileSync(join(dir, ".pi-map.index.md"), "utf8")}\nSENTINEL_ROOT_INDEX\n`,
);
writeFileSync(
join(dir, "src", "index.ts"),
`export const a = 1;\nexport const c = 3;\n`,
);
await patchFile(join(dir, "src", "index.ts"), client, dir, {
rootPath: dir,
patchMode: "structural",
});
const rootMap = readFileSync(join(dir, ".pi-map.md"), "utf8");
const rootIndex = readFileSync(join(dir, ".pi-map.index.md"), "utf8");
expect(rootMap).not.toContain("SENTINEL_ROOT_MAP");
expect(rootIndex).not.toContain("SENTINEL_ROOT_INDEX");
});
it("validate detects new files", async () => {
mkdirSync(join(dir, "src"));
writeFileSync(join(dir, "src", "a.ts"), `export const a = 1;\n`);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
// Add new file
writeFileSync(join(dir, "src", "b.ts"), `export const b = 2;\n`);
const result = await validateMaps(dir);
expect(result.clean).toBe(false);
expect(result.discrepancies.some((d) => d.type === "missing")).toBe(true);
});
it("validate detects deleted files", async () => {
mkdirSync(join(dir, "src"));
writeFileSync(join(dir, "src", "a.ts"), `export const a = 1;\n`);
writeFileSync(join(dir, "src", "b.ts"), `export const b = 2;\n`);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
// Delete a file
rmSync(join(dir, "src", "b.ts"));
const result = await validateMaps(dir);
expect(result.clean).toBe(false);
expect(result.discrepancies.some((d) => d.type === "orphaned")).toBe(true);
});
it("validate detects changed signatures", async () => {
mkdirSync(join(dir, "src"));
writeFileSync(join(dir, "src", "a.ts"), `export const a = 1;\n`);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
// Change exports
writeFileSync(
join(dir, "src", "a.ts"),
`export const a = 1;\nexport const c = 3;\n`,
);
const result = await validateMaps(dir);
expect(result.clean).toBe(false);
expect(result.discrepancies.some((d) => d.type === "stale-signature")).toBe(
true,
);
});
it("init populates routing metadata in rich maps", async () => {
mkdirSync(join(dir, "src"));
writeFileSync(
join(dir, "src", "index.ts"),
`export function init() {}\nexport function run() {}\n`,
);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
const map = readFileSync(join(dir, "src", ".pi-map.md"), "utf8");
// Tags and symbols should be scaffolded or populated
expect(map).toContain("## tags");
expect(map).toContain("## symbols");
expect(map).toContain("## workflows");
});
it("leaf directory index is tiny but present", async () => {
mkdirSync(join(dir, "src"));
mkdirSync(join(dir, "src", "utils"));
writeFileSync(
join(dir, "src", "utils", "helpers.ts"),
`export const h = 1;\n`,
);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
const index = readFileSync(
join(dir, "src", "utils", ".pi-map.index.md"),
"utf8",
);
// Leaf index should have empty children but still follow contract
expect(index).toContain("## children");
expect(index).toContain("-");
expect(index).toContain("## parent");
expect(index).toContain("index: src/.pi-map.index.md");
});
it("non-source directories omit uncertain workflows", async () => {
mkdirSync(join(dir, "docs"));
writeFileSync(join(dir, "docs", "README.md"), `# README\n`);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
const map = readFileSync(join(dir, "docs", ".pi-map.md"), "utf8");
// No source files means workflows section should be empty/omitted
const workflowsMatch = map.match(/## workflows\n([^#]*)/);
if (workflowsMatch) {
expect(workflowsMatch[1].trim()).toBe("-");
}
});
it("init loads config caps and applies them to routing metadata", async () => {
mkdirSync(join(dir, "src"));
for (let i = 0; i < 20; i++) {
writeFileSync(
join(dir, "src", `file${i}.ts`),
`export function fn${i}() {}\n`,
);
}
writeFileSync(
join(dir, ".pi-project-map.json"),
JSON.stringify({ tagCap: 3, workflowHintCap: 2 }),
);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
const map = readFileSync(join(dir, "src", ".pi-map.md"), "utf8");
const parsedMap = (await import("../src/format.js")).parseDirectoryMap(map);
expect(parsedMap.tags.length).toBeLessThanOrEqual(3);
expect(parsedMap.symbols.length).toBeLessThanOrEqual(3);
const index = readFileSync(join(dir, "src", ".pi-map.index.md"), "utf8");
const parsedIndex = (await import("../src/format.js")).parseDirectoryIndex(
index,
);
expect(parsedIndex.workflows.length).toBeLessThanOrEqual(2);
});
it("validate hard-fails when an index is missing", async () => {
mkdirSync(join(dir, "src"));
writeFileSync(join(dir, "src", "a.ts"), `export const a = 1;\n`);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
rmSync(join(dir, "src", ".pi-map.index.md"));
const result = await validateMaps(dir, { verbose: false });
expect(result.clean).toBe(false);
expect(result.discrepancies.some((d) => d.type === "stale-index")).toBe(
true,
);
});
it("validate detects broken sibling links", async () => {
mkdirSync(join(dir, "src"));
writeFileSync(join(dir, "src", "a.ts"), `export const a = 1;\n`);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
const mapPath = join(dir, "src", ".pi-map.md");
writeFileSync(
mapPath,
readFileSync(mapPath, "utf8").replace(
"index: src/.pi-map.index.md",
"index: src/bad-index.md",
),
);
const result = await validateMaps(dir, { verbose: false });
expect(result.clean).toBe(false);
expect(result.discrepancies.some((d) => d.type === "broken-link")).toBe(
true,
);
});
it("validate --fix repairs the affected chain", async () => {
mkdirSync(join(dir, "src"));
writeFileSync(join(dir, "package.json"), `{}\n`);
writeFileSync(join(dir, "src", "a.ts"), `export const a = 1;\n`);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
rmSync(join(dir, "src", ".pi-map.index.md"));
const result = await validateMaps(dir, {
fix: true,
verbose: false,
llmClient: client,
cacheDir: dir,
patchMode: "small",
});
expect(result.clean).toBe(false);
expect(result.fixed).toBeGreaterThan(0);
expect(
readFileSync(join(dir, "src", ".pi-map.index.md"), "utf8"),
).toContain("# src (index)");
});
it("reinit on root regenerates all artifacts", async () => {
mkdirSync(join(dir, "src"));
writeFileSync(join(dir, "src", "a.ts"), `export const a = 1;\n`);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
writeFileSync(
join(dir, ".pi-map.md"),
readFileSync(join(dir, ".pi-map.md"), "utf8") + "\nCORRUPTED",
);
await reinitPath(dir, { llmClient: client, verbose: false });
expect(readFileSync(join(dir, ".pi-map.md"), "utf8")).not.toContain(
"CORRUPTED",
);
});
it("reinit on a small subtree regenerates subtree and ancestors but not siblings", async () => {
mkdirSync(join(dir, "src"));
mkdirSync(join(dir, "lib"));
writeFileSync(join(dir, "src", "a.ts"), `export const a = 1;\n`);
for (let i = 0; i < 10; i++) {
writeFileSync(
join(dir, "lib", `b${i}.ts`),
`export const b${i} = ${i};\n`,
);
}
writeFileSync(
join(dir, ".pi-project-map.json"),
JSON.stringify({ reinitFullThresholdPercent: 10 }),
);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
writeFileSync(
join(dir, "src", ".pi-map.md"),
readFileSync(join(dir, "src", ".pi-map.md"), "utf8") + "\nSUBTREE_MARKER",
);
writeFileSync(
join(dir, "lib", ".pi-map.md"),
readFileSync(join(dir, "lib", ".pi-map.md"), "utf8") + "\nSIBLING_MARKER",
);
await reinitPath(join(dir, "src"), {
llmClient: client,
verbose: false,
});
expect(readFileSync(join(dir, "src", ".pi-map.md"), "utf8")).not.toContain(
"SUBTREE_MARKER",
);
expect(readFileSync(join(dir, "lib", ".pi-map.md"), "utf8")).toContain(
"SIBLING_MARKER",
);
});
it("reinit falls back to full regeneration when subtree exceeds threshold", async () => {
mkdirSync(join(dir, "src"));
mkdirSync(join(dir, "lib"));
for (let i = 0; i < 11; i++) {
writeFileSync(
join(dir, "src", `a${i}.ts`),
`export const a${i} = ${i};\n`,
);
}
writeFileSync(join(dir, "lib", "b.ts"), `export const b = 1;\n`);
writeFileSync(
join(dir, ".pi-project-map.json"),
JSON.stringify({ reinitFullThresholdPercent: 10 }),
);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
writeFileSync(
join(dir, "lib", ".pi-map.md"),
readFileSync(join(dir, "lib", ".pi-map.md"), "utf8") + "\nSIBLING_MARKER",
);
await reinitPath(join(dir, "src"), {
llmClient: client,
verbose: false,
});
expect(readFileSync(join(dir, "lib", ".pi-map.md"), "utf8")).not.toContain(
"SIBLING_MARKER",
);
});
it("reinit respects a custom reinitFullThresholdPercent", async () => {
mkdirSync(join(dir, "src"));
mkdirSync(join(dir, "lib"));
writeFileSync(join(dir, "src", "a.ts"), `export const a = 1;\n`);
writeFileSync(join(dir, "lib", "b.ts"), `export const b = 1;\n`);
writeFileSync(
join(dir, ".pi-project-map.json"),
JSON.stringify({ reinitFullThresholdPercent: 50 }),
);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
writeFileSync(
join(dir, "lib", ".pi-map.md"),
readFileSync(join(dir, "lib", ".pi-map.md"), "utf8") + "\nSIBLING_MARKER",
);
await reinitPath(join(dir, "src"), {
llmClient: client,
verbose: false,
});
expect(readFileSync(join(dir, "lib", ".pi-map.md"), "utf8")).toContain(
"SIBLING_MARKER",
);
});
it("reinit on a small subtree regenerates ancestors up to the root", async () => {
mkdirSync(join(dir, "src"));
mkdirSync(join(dir, "lib"));
writeFileSync(join(dir, "src", "a.ts"), `export const a = 1;\n`);
for (let i = 0; i < 10; i++) {
writeFileSync(
join(dir, "lib", `b${i}.ts`),
`export const b${i} = ${i};\n`,
);
}
writeFileSync(
join(dir, ".pi-project-map.json"),
JSON.stringify({ reinitFullThresholdPercent: 10 }),
);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
writeFileSync(
join(dir, ".pi-map.md"),
readFileSync(join(dir, ".pi-map.md"), "utf8") + "\nROOT_MARKER",
);
writeFileSync(
join(dir, "lib", ".pi-map.md"),
readFileSync(join(dir, "lib", ".pi-map.md"), "utf8") + "\nSIBLING_MARKER",
);
await reinitPath(join(dir, "src"), {
llmClient: client,
verbose: false,
});
expect(readFileSync(join(dir, ".pi-map.md"), "utf8")).not.toContain(
"ROOT_MARKER",
);
expect(readFileSync(join(dir, "lib", ".pi-map.md"), "utf8")).toContain(
"SIBLING_MARKER",
);
});
it("reinit on a non-existent path falls back to full regeneration", async () => {
mkdirSync(join(dir, "src"));
mkdirSync(join(dir, "lib"));
writeFileSync(join(dir, "src", "a.ts"), `export const a = 1;\n`);
writeFileSync(join(dir, "lib", "b.ts"), `export const b = 1;\n`);
writeFileSync(
join(dir, ".pi-project-map.json"),
JSON.stringify({ reinitFullThresholdPercent: 10 }),
);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
writeFileSync(
join(dir, "lib", ".pi-map.md"),
readFileSync(join(dir, "lib", ".pi-map.md"), "utf8") + "\nSIBLING_MARKER",
);
await reinitPath(join(dir, "does-not-exist"), {
llmClient: client,
verbose: false,
});
expect(readFileSync(join(dir, "lib", ".pi-map.md"), "utf8")).not.toContain(
"SIBLING_MARKER",
);
});
});