Implement layered maps and context retrieval
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user