Implement layered maps and context retrieval

This commit is contained in:
2026-06-11 12:56:18 +02:00
parent 010e4b83eb
commit c6064f8d94
35 changed files with 4410 additions and 383 deletions
+215
View File
@@ -0,0 +1,215 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import { execSync } from "child_process";
import { fileURLToPath } from "url";
const projectRoot = fileURLToPath(new URL("..", import.meta.url));
function runCli(
args: string,
cwd: string,
): { stdout: string; stderr: string; exitCode: number } {
try {
const stdout = execSync(
`npx tsx ${join(projectRoot, "src/cli/cli.ts")} ${args}`,
{
cwd,
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
},
);
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
} catch (err: any) {
return {
stdout: err.stdout?.toString().trim() || "",
stderr: err.stderr?.toString().trim() || "",
exitCode: err.status || 1,
};
}
}
describe("cli context", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "pi-map-cli-"));
});
afterEach(() => {
rmSync(dir, { recursive: true });
});
it("context command returns a bundle for a matching query", () => {
mkdirSync(join(dir, "src"));
mkdirSync(join(dir, "src", "auth"));
writeFileSync(
join(dir, "src", "auth", "tokens.ts"),
`export function validateToken() {}\n`,
);
writeFileSync(
join(dir, "src", "auth", ".pi-map.md"),
`# src/auth
dir: src/auth
index: src/auth/.pi-map.index.md
## role
Authentication and token validation.
## files
- tokens.ts | Token validation | exp: validateToken | dep: -
## arch
Guard pattern.
## tags
auth, token, validate
## symbols
validateToken
## workflows
- validate token
files: tokens.ts
## dirty
-
`,
);
writeFileSync(
join(dir, "src", "auth", ".pi-map.index.md"),
`# src/auth (index)
dir: src/auth
## role
Auth layer.
## parent
index: ./.pi-map.index.md
map: ./.pi-map.md
## children
-
## files
- tokens.ts
## links
index: src/auth/.pi-map.index.md
map: src/auth/.pi-map.md
## workflows
- validate token
## dirty
-
`,
);
const { stdout, exitCode } = runCli('context "token validation"', dir);
expect(exitCode).toBe(0);
expect(stdout).toContain("# Context bundle: token validation");
expect(stdout).toContain("## relevant indexes");
expect(stdout).toContain("src/auth/.pi-map.index.md");
expect(stdout).toContain("## relevant maps");
expect(stdout).toContain("src/auth/.pi-map.md");
expect(stdout).toContain("## likely files");
expect(stdout).toContain("src/auth/tokens.ts");
expect(stdout).toContain("## instructions");
});
it("context command omits symbols section when no symbols exist", () => {
mkdirSync(join(dir, "src"));
writeFileSync(
join(dir, "src", ".pi-map.md"),
`# src
dir: src
index: src/.pi-map.index.md
## role
Core source.
## files
- index.ts | Entry | exp: main | dep: -
## arch
Entrypoint.
## tags
-
## symbols
-
## workflows
-
## dirty
-
`,
);
writeFileSync(
join(dir, "src", ".pi-map.index.md"),
`# src (index)
dir: src
## role
Core source.
## parent
-
## children
-
## files
- index.ts
## links
index: src/.pi-map.index.md
map: src/.pi-map.md
## workflows
-
## dirty
-
`,
);
const { stdout, exitCode } = runCli('context "core source"', dir);
expect(exitCode).toBe(0);
expect(stdout).toContain("# Context bundle: core source");
expect(stdout).not.toContain("## relevant symbols");
});
it("context command returns no-results bundle when nothing matches", () => {
mkdirSync(join(dir, "src"));
writeFileSync(
join(dir, "src", ".pi-map.md"),
`# src
dir: src
index: src/.pi-map.index.md
## role
Core source.
## files
- index.ts | Entry | exp: main | dep: -
## arch
Entrypoint.
## tags
-
## symbols
-
## workflows
-
## dirty
-
`,
);
writeFileSync(
join(dir, "src", ".pi-map.index.md"),
`# src (index)
dir: src
## role
Core source.
## parent
-
## children
-
## files
- index.ts
## links
index: src/.pi-map.index.md
map: src/.pi-map.md
## workflows
-
## dirty
-
`,
);
const { stdout, exitCode } = runCli('context "zzzzzzzz"', dir);
expect(exitCode).toBe(0);
expect(stdout).toContain("# Context bundle: zzzzzzzz");
expect(stdout).toContain("No relevant directories found");
});
it("context command errors when query is missing", () => {
const { stderr, exitCode } = runCli("context", dir);
expect(exitCode).toBe(1);
expect(stderr).toContain("Missing query");
});
});
+289
View File
@@ -2,8 +2,13 @@ import { describe, it, expect } from "vitest";
import {
renderPackageMap,
parsePackageMap,
renderDirectoryMap,
parseDirectoryMap,
renderDirectoryIndex,
parseDirectoryIndex,
type PackageMapData,
} from "../src/format.js";
import { createDirectoryModel } from "../src/directory-model.js";
const sampleData: PackageMapData = {
path: "pkg/auth",
@@ -42,6 +47,9 @@ describe("format", () => {
"- tokens.ts | JWT gen/val | exp: issueToken, verifyToken, refreshToken | dep: crypto/hmac, db/sessions",
);
expect(output).toContain("## arch");
expect(output).toContain("## tags");
expect(output).toContain("## symbols");
expect(output).toContain("## workflows");
expect(output).toContain("## dirty");
expect(output).toContain("-");
});
@@ -94,6 +102,12 @@ Test package
Line one.
Line two.
Line three.
## tags
-
## symbols
-
## workflows
-
## dirty
-
`;
@@ -101,3 +115,278 @@ Line three.
expect(parsed.arch).toBe("Line one.\nLine two.\nLine three.");
});
});
describe("paired artifacts", () => {
it("renders and parses a directory map with tags/symbols/workflows", () => {
const model = createDirectoryModel({
dir: "src/cli",
role: "CLI entrypoint and command parsing",
files: [
{
name: "cli.ts",
purpose: "CLI entrypoint",
exports: ["main"],
deps: ["commander"],
},
],
arch: "Command-line interface built on commander.js",
});
model.tags = ["cli", "entrypoint"];
model.symbols = ["main"];
model.workflows = [{ task: "add command", read: ["cli.ts"] }];
const rendered = renderDirectoryMap(model);
expect(rendered).toContain("# src/cli");
expect(rendered).toContain("## tags");
expect(rendered).toContain("cli, entrypoint");
expect(rendered).toContain("## symbols");
expect(rendered).toContain("- main");
expect(rendered).toContain("## workflows");
expect(rendered).toContain("- add command");
const parsed = parseDirectoryMap(rendered);
expect(parsed.dir).toBe("src/cli");
expect(parsed.tags).toEqual(["cli", "entrypoint"]);
expect(parsed.symbols).toEqual(["main"]);
expect(parsed.workflows).toHaveLength(1);
expect(parsed.workflows[0].task).toBe("add command");
});
it("renders and parses a directory index with parent/children/links", () => {
const model = createDirectoryModel({
dir: "src",
role: "Core source code",
files: [
{ name: "index.ts", purpose: "Main exports", exports: [], deps: [] },
],
arch: "Source code root",
parent: ".",
children: ["src/cli", "src/lib"],
});
const rendered = renderDirectoryIndex(model);
expect(rendered).toContain("# src (index)");
expect(rendered).toContain("dir: src");
expect(rendered).toContain("## parent");
expect(rendered).toContain("index: ./.pi-map.index.md");
expect(rendered).toContain("map: ./.pi-map.md");
expect(rendered).toContain("## children");
expect(rendered).toContain("- src/cli");
expect(rendered).toContain("index: src/cli/.pi-map.index.md");
expect(rendered).toContain("map: src/cli/.pi-map.md");
expect(rendered).toContain("## links");
expect(rendered).toContain("index: src/.pi-map.index.md");
expect(rendered).toContain("map: src/.pi-map.md");
const parsed = parseDirectoryIndex(rendered);
expect(parsed.dir).toBe("src");
expect(parsed.parent).toBe(".");
expect(parsed.children).toEqual(["src/cli", "src/lib"]);
});
it("renders a root index with Project Map Protocol", () => {
const model = createDirectoryModel({
dir: ".",
role: "Project root",
files: [],
arch: "Root architecture",
isRoot: true,
});
const rendered = renderDirectoryIndex(model);
expect(rendered).toContain("# . (index)");
expect(rendered).toContain("dir: .");
});
it("round-trips an empty leaf index", () => {
const model = createDirectoryModel({
dir: "src/utils",
role: "Utilities",
files: [
{ name: "helpers.ts", purpose: "Helpers", exports: [], deps: [] },
],
arch: "Shared helpers",
});
const rendered = renderDirectoryIndex(model);
expect(rendered).toContain("# src/utils (index)");
expect(rendered).toContain("## children");
expect(rendered).toContain("-");
const parsed = parseDirectoryIndex(rendered);
expect(parsed.dir).toBe("src/utils");
expect(parsed.children).toEqual([]);
expect(parsed.files).toHaveLength(1);
expect(parsed.files[0].name).toBe("helpers.ts");
});
it("round-trips workflows with read continuations in map", () => {
const model = createDirectoryModel({
dir: "src/cli",
role: "CLI entrypoint",
files: [{ name: "cli.ts", purpose: "CLI", exports: ["main"], deps: [] }],
arch: "CLI",
});
model.workflows = [
{ task: "add command", read: ["cli.ts", "commands.ts"] },
{ task: "update flags", read: ["cli.ts"] },
];
const rendered = renderDirectoryMap(model);
expect(rendered).toContain("- add command");
expect(rendered).toContain(" read: cli.ts, commands.ts");
expect(rendered).toContain("- update flags");
expect(rendered).toContain(" read: cli.ts");
const parsed = parseDirectoryMap(rendered);
expect(parsed.workflows).toHaveLength(2);
expect(parsed.workflows[0].task).toBe("add command");
expect(parsed.workflows[0].read).toEqual(["cli.ts", "commands.ts"]);
expect(parsed.workflows[1].task).toBe("update flags");
expect(parsed.workflows[1].read).toEqual(["cli.ts"]);
});
it("round-trips workflows with read continuations in index", () => {
const model = createDirectoryModel({
dir: "src/cli",
role: "CLI entrypoint",
files: [{ name: "cli.ts", purpose: "CLI", exports: ["main"], deps: [] }],
arch: "CLI",
});
model.workflows = [
{ task: "add command", read: ["cli.ts", "commands.ts"] },
];
const rendered = renderDirectoryIndex(model);
expect(rendered).toContain("- add command");
expect(rendered).toContain(" read: cli.ts, commands.ts");
const parsed = parseDirectoryIndex(rendered);
expect(parsed.workflows).toHaveLength(1);
expect(parsed.workflows[0].task).toBe("add command");
expect(parsed.workflows[0].read).toEqual(["cli.ts", "commands.ts"]);
});
it("round-trips workflows with index, map, and files continuations", () => {
const model = createDirectoryModel({
dir: "src",
role: "Source root",
files: [{ name: "index.ts", purpose: "Exports", exports: [], deps: [] }],
arch: "Source",
children: ["src/auth", "src/cli"],
});
model.workflows = [
{
task: "explore subdirectories",
index: ["src/auth/.pi-map.index.md", "src/cli/.pi-map.index.md"],
map: ["src/auth/.pi-map.md"],
files: ["src/index.ts"],
},
];
const mapRendered = renderDirectoryMap(model);
expect(mapRendered).toContain("- explore subdirectories");
expect(mapRendered).toContain(
" index: src/auth/.pi-map.index.md, src/cli/.pi-map.index.md",
);
expect(mapRendered).toContain(" map: src/auth/.pi-map.md");
expect(mapRendered).toContain(" files: src/index.ts");
const mapParsed = parseDirectoryMap(mapRendered);
expect(mapParsed.workflows).toHaveLength(1);
expect(mapParsed.workflows[0].index).toEqual([
"src/auth/.pi-map.index.md",
"src/cli/.pi-map.index.md",
]);
expect(mapParsed.workflows[0].map).toEqual(["src/auth/.pi-map.md"]);
expect(mapParsed.workflows[0].files).toEqual(["src/index.ts"]);
const indexRendered = renderDirectoryIndex(model);
expect(indexRendered).toContain("- explore subdirectories");
expect(indexRendered).toContain(
" index: src/auth/.pi-map.index.md, src/cli/.pi-map.index.md",
);
const indexParsed = parseDirectoryIndex(indexRendered);
expect(indexParsed.workflows).toHaveLength(1);
expect(indexParsed.workflows[0].index).toEqual([
"src/auth/.pi-map.index.md",
"src/cli/.pi-map.index.md",
]);
});
it("renderer includes dir and index preamble directly", () => {
const model = createDirectoryModel({
dir: "src/core",
role: "Core logic",
files: [],
arch: "Core",
});
const rendered = renderDirectoryMap(model);
expect(rendered).toContain("dir: src/core");
expect(rendered).toContain("index: src/core/.pi-map.index.md");
});
it("root renderer includes Project Map Protocol directly", () => {
const model = createDirectoryModel({
dir: ".",
role: "Project root",
files: [],
arch: "Root",
isRoot: true,
});
const mapRendered = renderDirectoryMap(model);
expect(mapRendered).toContain("## Project Map Protocol");
expect(mapRendered).toContain(
"Trust boundary: index routes, map orients, source decides.",
);
const indexRendered = renderDirectoryIndex(model);
expect(indexRendered).toContain("## Project Map Protocol");
});
it("parses file lines with commas inside signatures", () => {
const mapText = `# src
## files
- format.ts | Renders maps | exp: PackageMapData, func:renderPackageMap(data: PackageMapData) → string, call:convertPackageMapToModel, func:renderDirectoryIndex(model: DirectoryArtifactModel) → string | dep: ./model.js
## arch
Test
## dirty
-
`;
const parsed = parseDirectoryMap(mapText);
expect(parsed.files).toHaveLength(1);
expect(parsed.files[0].exports).toEqual([
"PackageMapData",
"func:renderPackageMap(data: PackageMapData) → string",
"call:convertPackageMapToModel",
"func:renderDirectoryIndex(model: DirectoryArtifactModel) → string",
]);
expect(parsed.files[0].deps).toEqual(["./model.js"]);
});
it("parses file lines with pipes and commas inside type signatures", () => {
const mapText = `# src
## files
- llm-cache.ts | Cache helpers | exp: func:getCached(hash: string, cacheDir: string) → string | undefined, func:setCached(hash: string, result: string, cacheDir: string) → void | dep: fs, path
- user.ts | User helpers | exp: User, func:createUser(data: Omit<User, "id" | "createdAt">) → User, func:serializeUser(user: User) → string | dep: ../utils/validation.js
## arch
Test
## dirty
-
`;
const parsed = parseDirectoryMap(mapText);
expect(parsed.files).toHaveLength(2);
expect(parsed.files[0].exports).toEqual([
"func:getCached(hash: string, cacheDir: string) → string | undefined",
"func:setCached(hash: string, result: string, cacheDir: string) → void",
]);
expect(parsed.files[1].exports).toEqual([
"User",
'func:createUser(data: Omit<User, "id" | "createdAt">) → User',
"func:serializeUser(user: User) → string",
]);
});
});
+250 -18
View File
@@ -11,7 +11,7 @@ import { tmpdir } from "os";
import { initProject } from "../src/init.js";
import { patchFile } from "../src/patch.js";
import { validateMaps } from "../src/validate.js";
import { createMockFileClient, createMockPackageClient } from "./mock-llm.js";
import { createMockFileClient } from "./mock-llm.js";
describe("integration", () => {
let dir: string;
@@ -24,14 +24,68 @@ describe("integration", () => {
rmSync(dir, { recursive: true });
});
it("init creates .pi-map.md files", async () => {
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 () => {
@@ -52,29 +106,68 @@ describe("integration", () => {
expect(map).toContain("baz");
});
it("patch adds dirty marker for large packages", async () => {
it("patch with small mode refreshes ancestor indexes but preserves ancestor maps", async () => {
mkdirSync(join(dir, "src"));
// Create 11 files so it's a "large" package
for (let i = 0; i < 11; i++) {
writeFileSync(
join(dir, "src", `file${i}.ts`),
`export const x${i} = ${i};\n`,
);
}
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 });
// Modify a file
writeFileSync(
join(dir, "src", "file0.ts"),
`export const x0 = 0;\nexport const y = 99;\n`,
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`,
);
await patchFile(join(dir, "src", "file0.ts"), client, dir);
const map = readFileSync(join(dir, "src", ".pi-map.md"), "utf8");
expect(map).toContain("y");
expect(map).toContain("dirty");
expect(map).toContain("patched");
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 () => {
@@ -124,4 +217,143 @@ describe("integration", () => {
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)");
});
});
+57 -5
View File
@@ -81,12 +81,13 @@ describe("pi-extension", () => {
});
describe("tool registration", () => {
it("registers 4 tools", () => {
expect(Object.keys(registeredTools)).toHaveLength(4);
it("registers 5 tools", () => {
expect(Object.keys(registeredTools)).toHaveLength(5);
expect(registeredTools).toHaveProperty("project_map_init");
expect(registeredTools).toHaveProperty("project_map_patch");
expect(registeredTools).toHaveProperty("project_map_validate");
expect(registeredTools).toHaveProperty("project_map_reinit");
expect(registeredTools).toHaveProperty("project_map_context");
});
it("registers session_start and before_agent_start events", () => {
@@ -141,11 +142,15 @@ describe("pi-extension", () => {
});
describe("project_map_validate tool", () => {
it("reports clean when map exists and is up to date", async () => {
it("reports clean when map and index exist and are up to date", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-ext-test-"));
writeFileSync(
join(dir, ".pi-map.md"),
"# .\n## role\nTest\n## files\n## arch\n## dirty\n-\n",
"# .\ndir: .\n\nindex: ./.pi-map.index.md\n\n## Project Map Protocol\n\n1. Read this protocol and the root `.pi-map.index.md` first.\n\nTrust boundary: index routes, map orients, source decides.\n\n## role\nTest\n## files\n-\n## arch\nTest arch\n## tags\n-\n## symbols\n-\n## workflows\n-\n## dirty\n-\n",
);
writeFileSync(
join(dir, ".pi-map.index.md"),
"# . (index)\ndir: .\n\n## Project Map Protocol\n\n1. Read this protocol and the root `.pi-map.index.md` first.\n\nTrust boundary: index routes, map orients, source decides.\n\n## role\nTest\n## parent\n-\n## children\n-\n## files\n-\n## links\nindex: ./.pi-map.index.md\nmap: ./.pi-map.md\n## workflows\n-\n## dirty\n-\n",
);
mockCtx.cwd = dir;
@@ -180,6 +185,51 @@ describe("pi-extension", () => {
});
});
describe("project_map_context tool", () => {
it("returns a context bundle for a query", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-ext-test-"));
// Create a minimal map + index
writeFileSync(
join(dir, ".pi-map.md"),
"# .\ndir: .\n\nindex: ./.pi-map.index.md\n\n## Project Map Protocol\n\nTrust boundary: index routes, map orients, source decides.\n\n## role\nTest project\n## files\n- test.ts | Test file\n## arch\nTest\n## tags\ntest\n## symbols\n- main\n## workflows\n-\n## dirty\n-\n",
);
writeFileSync(
join(dir, ".pi-map.index.md"),
"# . (index)\ndir: .\n\n## role\nTest project\n## parent\n-\n## children\n-\n## files\n- test.ts\n## links\nindex: ./.pi-map.index.md\nmap: ./.pi-map.md\n## workflows\n-\n## dirty\n-\n",
);
mockCtx.cwd = dir;
const tool = registeredTools.project_map_context;
const result = await tool.execute(
"tool-1",
{ query: "test" },
null,
null,
mockCtx,
);
expect(result.details.success).toBe(true);
expect(result.content[0].text).toContain("# Context bundle: test");
expect(result.content[0].text).toContain("## relevant indexes");
expect(result.content[0].text).toContain("## instructions");
});
it("returns no-results bundle when no maps match", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-ext-test-"));
mockCtx.cwd = dir;
const tool = registeredTools.project_map_context;
const result = await tool.execute(
"tool-1",
{ query: "nonexistent" },
null,
null,
mockCtx,
);
expect(result.details.success).toBe(true);
expect(result.content[0].text).toContain("No relevant directories found");
});
});
describe("session_start event", () => {
it("notifies when dirty .pi-map.md files exist", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-ext-test-"));
@@ -221,7 +271,7 @@ describe("pi-extension", () => {
});
describe("before_agent_start event", () => {
it("injects hint when .pi-map.md files exist", async () => {
it("injects layered protocol hint when project map files exist", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-ext-test-"));
writeFileSync(join(dir, ".pi-map.md"), "# .\n## role\nTest\n");
mockCtx.cwd = dir;
@@ -230,7 +280,9 @@ describe("pi-extension", () => {
const result = await handler(null, mockCtx);
expect(result).toHaveProperty("message");
expect(result.message.content).toContain("root `.pi-map.index.md`");
expect(result.message.content).toContain("project_map_patch");
expect(result.message.content).toContain("project_map_validate");
expect(result.message.display).toBe(false);
});
+244
View File
@@ -0,0 +1,244 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import { retrieveContext } from "../src/retrieve.js";
describe("retrieve", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "pi-ret-"));
});
afterEach(() => {
rmSync(dir, { recursive: true });
});
function writeMap(
relDir: string,
role: string,
files: { name: string; purpose: string; exports?: string[] }[],
tags?: string[],
symbols?: string[],
) {
const d = join(dir, relDir);
if (!d.startsWith(dir)) throw new Error("invalid path");
mkdirSync(d, { recursive: true });
const fileLines = files
.map((f) => {
const exp = f.exports?.length ? ` | exp: ${f.exports.join(", ")}` : "";
return `- ${f.name} | ${f.purpose}${exp}`;
})
.join("\n");
const tagLine = tags?.length ? tags.join(", ") : "-";
const symLines = symbols?.length
? symbols.map((s) => `- ${s}`).join("\n")
: "-";
writeFileSync(
join(d, ".pi-map.md"),
`# ${relDir}
dir: ${relDir}
index: ${relDir}/.pi-map.index.md
## role
${role}
## files
${fileLines}
## arch
Test arch
## tags
${tagLine}
## symbols
${symLines}
## workflows
-
## dirty
-
`,
);
writeFileSync(
join(d, ".pi-map.index.md"),
`# ${relDir} (index)
dir: ${relDir}
## role
${role}
## parent
-
## children
-
## files
${files.map((f) => `- ${f.name}`).join("\n")}
## links
index: ${relDir}/.pi-map.index.md
map: ${relDir}/.pi-map.md
## workflows
-
## dirty
-
`,
);
}
it("returns empty bundle when no maps exist", () => {
const bundle = retrieveContext("auth", dir);
expect(bundle).toContain("# Context bundle: auth");
expect(bundle).toContain("No relevant directories found");
});
it("ranks directories by query relevance", () => {
writeMap(
"src/auth",
"Auth layer: JWT issuance, validation, refresh.",
[{ name: "tokens.ts", purpose: "JWT gen/val", exports: ["issueToken"] }],
["auth", "jwt"],
["issueToken"],
);
writeMap(
"src/utils",
"Shared utilities and helpers.",
[{ name: "helpers.ts", purpose: "Helpers" }],
["utils"],
);
const bundle = retrieveContext("jwt validation", dir);
expect(bundle).toContain("src/auth/.pi-map.index.md");
expect(bundle).toContain("src/auth/.pi-map.md");
});
it("limits results to top 3 by default", () => {
for (let i = 0; i < 5; i++) {
writeMap(
`src/pkg${i}`,
`Package ${i} logic`,
[{ name: `file${i}.ts`, purpose: `Feature ${i}` }],
[`pkg${i}`],
);
}
// Query matching all
const bundle = retrieveContext("pkg", dir);
const indexMatches = bundle.match(/\.pi-map\.index\.md/g) || [];
expect(indexMatches.length).toBeLessThanOrEqual(3);
});
it("includes likely files from matched directories", () => {
writeMap(
"src/auth",
"Auth layer",
[
{ name: "tokens.ts", purpose: "JWT tokens", exports: ["issueToken"] },
{
name: "middleware.ts",
purpose: "Auth middleware",
exports: ["requireAuth"],
},
],
["auth"],
["issueToken", "requireAuth"],
);
const bundle = retrieveContext("auth middleware", dir);
expect(bundle).toContain("src/auth/tokens.ts");
expect(bundle).toContain("src/auth/middleware.ts");
});
it("includes relevant symbols when present", () => {
writeMap(
"src/auth",
"Auth layer",
[{ name: "tokens.ts", purpose: "JWT tokens" }],
["auth"],
["issueToken", "verifyToken"],
);
const bundle = retrieveContext("auth", dir);
expect(bundle).toContain("## relevant symbols");
expect(bundle).toContain("issueToken");
expect(bundle).toContain("verifyToken");
});
it("omits symbol section when no symbols exist", () => {
writeMap(
"src/utils",
"Utilities",
[{ name: "helpers.ts", purpose: "Helpers" }],
["utils"],
[],
);
const bundle = retrieveContext("utils", dir);
expect(bundle).not.toContain("## relevant symbols");
});
it("includes instructions in every bundle", () => {
writeMap(
"src/cli",
"CLI entrypoint",
[{ name: "cli.ts", purpose: "CLI" }],
["cli"],
);
const bundle = retrieveContext("cli", dir);
expect(bundle).toContain("## instructions");
expect(bundle).toContain("Read the indexes first");
});
it("uses stable section order", () => {
writeMap(
"src/auth",
"Auth layer",
[{ name: "tokens.ts", purpose: "JWT tokens" }],
["auth"],
["issueToken"],
);
const bundle = retrieveContext("auth", dir);
const queryIdx = bundle.indexOf("## query");
const indexIdx = bundle.indexOf("## relevant indexes");
const mapIdx = bundle.indexOf("## relevant maps");
const fileIdx = bundle.indexOf("## likely files");
const symIdx = bundle.indexOf("## relevant symbols");
const instIdx = bundle.indexOf("## instructions");
expect(queryIdx).toBeGreaterThan(-1);
expect(indexIdx).toBeGreaterThan(queryIdx);
expect(mapIdx).toBeGreaterThan(indexIdx);
expect(fileIdx).toBeGreaterThan(mapIdx);
expect(symIdx).toBeGreaterThan(fileIdx);
expect(instIdx).toBeGreaterThan(symIdx);
});
it("scores file names and purposes", () => {
writeMap(
"src/auth",
"Auth layer",
[
{
name: "validateToken.ts",
purpose: "Token validation logic",
exports: ["validateToken"],
},
],
[],
[],
);
const bundle = retrieveContext("validate token", dir);
expect(bundle).toContain("src/auth/.pi-map.index.md");
});
});
+219
View File
@@ -0,0 +1,219 @@
import { describe, it, expect } from "vitest";
import { createDirectoryModel } from "../src/directory-model.js";
import { populateRoutingMetadata } from "../src/routing-metadata.js";
describe("routing metadata", () => {
it("generates tags from file purposes and exports", () => {
const model = createDirectoryModel({
dir: "src/auth",
role: "Auth layer",
files: [
{
name: "tokens.ts",
purpose: "JWT generation and validation",
exports: ["issueToken", "verifyToken"],
deps: ["crypto"],
},
],
arch: "Auth",
});
populateRoutingMetadata(model);
expect(model.tags.length).toBeGreaterThan(0);
expect(model.tags).toContain("jwt");
expect(model.tags).toContain("token");
});
it("generates symbols from exports", () => {
const model = createDirectoryModel({
dir: "src/utils",
role: "Utilities",
files: [
{
name: "helpers.ts",
purpose: "Helpers",
exports: ["formatDate", "parseUrl", "formatDate"],
deps: [],
},
],
arch: "Utils",
});
populateRoutingMetadata(model);
expect(model.symbols.length).toBeGreaterThan(0);
expect(model.symbols).toContain("formatDate");
expect(model.symbols).toContain("parseUrl");
});
it("caps tags at default limit", () => {
const model = createDirectoryModel({
dir: "src/big",
role: "Big module",
files: Array.from({ length: 20 }, (_, i) => ({
name: `file${i}.ts`,
purpose: `Purpose ${i} with many unique words ${i}`,
exports: [`export${i}A`, `export${i}B`],
deps: [`dep${i}`],
})),
arch: "Big",
});
populateRoutingMetadata(model);
expect(model.tags.length).toBeLessThanOrEqual(8);
});
it("caps symbols at default limit", () => {
const model = createDirectoryModel({
dir: "src/big",
role: "Big module",
files: Array.from({ length: 20 }, (_, i) => ({
name: `file${i}.ts`,
purpose: `Purpose ${i}`,
exports: [`export${i}A`, `export${i}B`],
deps: [],
})),
arch: "Big",
});
populateRoutingMetadata(model);
expect(model.symbols.length).toBeLessThanOrEqual(8);
});
it("generates workflow hints for source directories", () => {
const model = createDirectoryModel({
dir: "src/cli",
role: "CLI",
files: [
{
name: "cli.ts",
purpose: "CLI entrypoint",
exports: ["main"],
deps: [],
},
{
name: "cli.test.ts",
purpose: "CLI tests",
exports: [],
deps: [],
},
],
arch: "CLI",
});
populateRoutingMetadata(model);
expect(model.workflows.length).toBeGreaterThan(0);
const changeBehavior = model.workflows.find((w) =>
w.task.includes("change"),
);
expect(changeBehavior).toBeDefined();
});
it("omits workflows for non-source directories", () => {
const model = createDirectoryModel({
dir: "docs",
role: "Documentation",
files: [
{
name: "README.md",
purpose: "Readme",
exports: [],
deps: [],
},
],
arch: "Docs",
});
populateRoutingMetadata(model);
expect(model.workflows).toEqual([]);
});
it("caps workflow hints at default limit", () => {
const model = createDirectoryModel({
dir: "src/cli",
role: "CLI",
files: [
{ name: "cli.ts", purpose: "CLI", exports: ["main"], deps: [] },
{ name: "config.ts", purpose: "Config", exports: [], deps: [] },
{ name: "cli.test.ts", purpose: "Tests", exports: [], deps: [] },
{ name: "commands.ts", purpose: "Commands", exports: [], deps: [] },
],
arch: "CLI",
});
model.children = ["src/cli/sub"];
populateRoutingMetadata(model);
expect(model.workflows.length).toBeLessThanOrEqual(5);
});
it("workflow hints include read targets for relevant files", () => {
const model = createDirectoryModel({
dir: "src/auth",
role: "Auth",
files: [
{
name: "tokens.ts",
purpose: "Tokens",
exports: ["issueToken"],
deps: [],
},
{
name: "tokens.test.ts",
purpose: "Token tests",
exports: [],
deps: [],
},
],
arch: "Auth",
});
populateRoutingMetadata(model);
const testWorkflow = model.workflows.find((w) => w.task.includes("test"));
expect(testWorkflow).toBeDefined();
expect(testWorkflow!.read).toContain("tokens.test.ts");
});
it("workflow hints include index targets for directories with children", () => {
const model = createDirectoryModel({
dir: "src",
role: "Source",
files: [
{
name: "index.ts",
purpose: "Exports",
exports: [],
deps: [],
},
],
arch: "Source",
children: ["src/auth", "src/cli"],
});
populateRoutingMetadata(model);
const exploreWorkflow = model.workflows.find((w) =>
w.task.includes("explore"),
);
expect(exploreWorkflow).toBeDefined();
expect(exploreWorkflow!.index).toContain("src/auth/.pi-map.index.md");
expect(exploreWorkflow!.index).toContain("src/cli/.pi-map.index.md");
});
it("respects explicit tagCap and workflowHintCap options", () => {
const model = createDirectoryModel({
dir: "src/big",
role: "Big module",
files: Array.from({ length: 20 }, (_, i) => ({
name: `file${i}.ts`,
purpose: `Purpose ${i} with many unique words ${i}`,
exports: [`export${i}A`, `export${i}B`],
deps: [`dep${i}`],
})),
arch: "Big",
children: ["src/big/sub"],
});
populateRoutingMetadata(model, { tagCap: 3, workflowHintCap: 2 });
expect(model.tags.length).toBeLessThanOrEqual(3);
expect(model.symbols.length).toBeLessThanOrEqual(3);
expect(model.workflows.length).toBeLessThanOrEqual(2);
});
});