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.
This commit is contained in:
Developer
2026-06-16 11:46:48 +00:00
parent 5f1c107667
commit cb581f44b9
11 changed files with 384 additions and 54 deletions
+186 -1
View File
@@ -8,7 +8,7 @@ import {
} from "fs";
import { join } from "path";
import { tmpdir } from "os";
import { initProject } from "../src/init.js";
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";
@@ -356,4 +356,189 @@ describe("integration", () => {
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",
);
});
});
+2 -1
View File
@@ -16,6 +16,7 @@ vi.mock("typebox", () => ({
Object: (props: unknown) => props,
Optional: (prop: unknown) => prop,
String: (opts: unknown) => ({ type: "string", ...(opts as object) }),
Boolean: (opts: unknown) => ({ type: "boolean", ...(opts as object) }),
},
}));
@@ -251,7 +252,7 @@ describe("pi-extension", () => {
await handler(null, mockCtx);
expect(mockNotify).toHaveBeenCalledWith(
expect.stringContaining("1 dirty packages detected"),
expect.stringContaining("1 dirty package(s) detected"),
"warning",
);
});