feat: avoid duplicate project-map hint injection by checking context

The before_agent_start handler now scans the active session context via
ctx.sessionManager.buildSessionContext() for an existing pi-project-map-hint
custom message and skips injection when one is already present in the
current branch. This prevents duplicate visible hints in advisory/pre-init
modes and duplicate hidden hints in strong/strict modes. The hint is
automatically re-injected after compaction or /tree navigation removes it
from the active path.

Also removes the unused hooks/on-prompt.ts prompt-text injector.
This commit is contained in:
Developer
2026-06-14 08:57:23 +00:00
78 changed files with 11212 additions and 2466 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { extractFileAST } from "../src/ast-extract.js";
import { extractFileAST } from "../src/ast/ast-extract.js";
import { mkdtempSync, writeFileSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
+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",
]);
});
});
+263 -25
View File
@@ -11,6 +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 } from "./mock-llm.js";
describe("integration", () => {
let dir: string;
@@ -23,61 +24,157 @@ 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`);
await initProject(dir);
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(map).toContain("foo");
expect(index).toContain("# src (index)");
expect(index).toContain("dir: src");
});
it("root artifacts contain Project Map Protocol", async () => {
writeFileSync(join(dir, "package.json"), `{}\n`);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
const rootMap = readFileSync(join(dir, ".pi-map.md"), "utf8");
const rootIndex = readFileSync(join(dir, ".pi-map.index.md"), "utf8");
expect(rootMap).toContain("## Project Map Protocol");
expect(rootMap).toContain("index: ./.pi-map.index.md");
expect(rootIndex).toContain("## Project Map Protocol");
expect(rootIndex).toContain("map: ./.pi-map.md");
});
it("non-root map contains index link", async () => {
mkdirSync(join(dir, "src"));
writeFileSync(join(dir, "src", "index.ts"), `export function foo() {}\n`);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
const map = readFileSync(join(dir, "src", ".pi-map.md"), "utf8");
expect(map).toContain("index: src/.pi-map.index.md");
expect(map).toContain("dir: src");
});
it("index contains parent and children when applicable", async () => {
mkdirSync(join(dir, "src"));
mkdirSync(join(dir, "src", "utils"));
writeFileSync(join(dir, "src", "index.ts"), `export function foo() {}\n`);
writeFileSync(
join(dir, "src", "utils", "helpers.ts"),
`export const h = 1;\n`,
);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
const srcIndex = readFileSync(join(dir, "src", ".pi-map.index.md"), "utf8");
expect(srcIndex).toContain("## parent");
expect(srcIndex).toContain("index: ./.pi-map.index.md");
expect(srcIndex).toContain("## children");
expect(srcIndex).toContain("- src/utils");
const utilsIndex = readFileSync(
join(dir, "src", "utils", ".pi-map.index.md"),
"utf8",
);
expect(utilsIndex).toContain("## parent");
expect(utilsIndex).toContain("index: src/.pi-map.index.md");
});
it("patch updates a file entry", async () => {
mkdirSync(join(dir, "src"));
writeFileSync(join(dir, "src", "index.ts"), `export function foo() {}\n`);
writeFileSync(join(dir, "src", "utils.ts"), `export const bar = 1;\n`);
await initProject(dir);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
// Modify a file
writeFileSync(
join(dir, "src", "index.ts"),
`export function foo() {}\nexport function baz() {}\n`,
);
await patchFile(join(dir, "src", "index.ts"));
await patchFile(join(dir, "src", "index.ts"), client, dir);
const map = readFileSync(join(dir, "src", ".pi-map.md"), "utf8");
expect(map).toContain("baz");
});
it("patch 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`,
);
}
await initProject(dir);
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"));
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 () => {
mkdirSync(join(dir, "src"));
writeFileSync(join(dir, "src", "a.ts"), `export const a = 1;\n`);
await initProject(dir);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
// Add new file
writeFileSync(join(dir, "src", "b.ts"), `export const b = 2;\n`);
@@ -91,7 +188,8 @@ describe("integration", () => {
mkdirSync(join(dir, "src"));
writeFileSync(join(dir, "src", "a.ts"), `export const a = 1;\n`);
writeFileSync(join(dir, "src", "b.ts"), `export const b = 2;\n`);
await initProject(dir);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
// Delete a file
rmSync(join(dir, "src", "b.ts"));
@@ -104,7 +202,8 @@ describe("integration", () => {
it("validate detects changed signatures", async () => {
mkdirSync(join(dir, "src"));
writeFileSync(join(dir, "src", "a.ts"), `export const a = 1;\n`);
await initProject(dir);
const client = createMockFileClient();
await initProject(dir, { llmClient: client, verbose: false });
// Change exports
writeFileSync(
@@ -118,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)");
});
});
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { withRetry, processFiles } from "../src/llm-batch.js";
import { withRetry, processFiles } from "../src/llm/llm-batch.js";
import { LLMError } from "../src/llm-error.js";
describe("withRetry", () => {
+14 -10
View File
@@ -1,10 +1,10 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { getCached, setCached } from "../src/llm-cache.js";
import { existsSync, unlinkSync, rmdirSync } from "fs";
import { getCached, setCached } from "../src/llm/llm-cache.js";
import { existsSync, unlinkSync } from "fs";
import { join } from "path";
import { homedir } from "os";
import { tmpdir } from "os";
const TEST_CACHE_DIR = join(homedir(), ".cache", "pi-project-map");
const TEST_CACHE_DIR = join(tmpdir(), "pi-project-map-test-cache");
const TEST_CACHE_FILE = join(TEST_CACHE_DIR, "llm-cache.json");
describe("llm-cache", () => {
@@ -21,20 +21,24 @@ describe("llm-cache", () => {
});
it("returns undefined for missing entries", () => {
const result = getCached("nonexistent-hash");
const result = getCached("nonexistent-hash", TEST_CACHE_DIR);
expect(result).toBeUndefined();
});
it("stores and retrieves cached results", () => {
setCached("abc123", "PURPOSE: test\nDEPS: none\nCONCEPTS: none");
const result = getCached("abc123");
setCached(
"abc123",
"PURPOSE: test\nDEPS: none\nCONCEPTS: none",
TEST_CACHE_DIR,
);
const result = getCached("abc123", TEST_CACHE_DIR);
expect(result).toBe("PURPOSE: test\nDEPS: none\nCONCEPTS: none");
});
it("overwrites existing entries", () => {
setCached("abc123", "old");
setCached("abc123", "new");
const result = getCached("abc123");
setCached("abc123", "old", TEST_CACHE_DIR);
setCached("abc123", "new", TEST_CACHE_DIR);
const result = getCached("abc123", TEST_CACHE_DIR);
expect(result).toBe("new");
});
});
+47 -87
View File
@@ -1,67 +1,17 @@
import { describe, it, expect } from "vitest";
import { extractFileLLM, extractFileHeuristic } from "../src/llm-extract.js";
import { extractFileLLM } from "../src/llm/llm-extract.js";
import { writeFileSync, mkdtempSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import type { LLMClient } from "../src/llm-client.js";
import type { LLMClient } from "../src/llm/llm-client.js";
describe("llm-extract heuristics", () => {
it("extracts TypeScript exports", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-map-"));
const file = join(dir, "test.ts");
writeFileSync(
file,
`export function foo() {}
export class Bar {}
export const baz = 1;
export type Qux = string;
export { a, b as c };
`,
);
const result = await extractFileLLM(file);
expect(result.exports).toContain("foo");
expect(result.exports).toContain("Bar");
expect(result.exports).toContain("baz");
expect(result.exports).toContain("Qux");
expect(result.exports).toContain("a");
expect(result.exports).toContain("b");
});
it("extracts TypeScript imports", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-map-"));
const file = join(dir, "test.ts");
writeFileSync(
file,
`import { foo } from "./bar";
import * as baz from "baz-lib";
import type { Qux } from "qux";
const x = require("legacy");
`,
);
const result = await extractFileLLM(file);
expect(result.deps).toContain("./bar");
expect(result.deps).toContain("baz-lib");
expect(result.deps).toContain("qux");
expect(result.deps).toContain("legacy");
});
it("infers purpose from filename patterns", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-map-"));
const file = join(dir, "userController.ts");
writeFileSync(file, `export class UserController {}`);
const result = await extractFileLLM(file);
expect(result.purpose).toMatch(/Controller|Exports/);
});
it("handles non-code files", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-map-"));
const file = join(dir, "Dockerfile");
writeFileSync(file, `FROM node:20\nWORKDIR /app`);
const result = await extractFileLLM(file);
expect(result.purpose).toBe("Container definition");
expect(result.exports).toEqual([]);
});
});
function createMockClient(response: string): LLMClient {
return {
async complete() {
return response;
},
};
}
describe("llm-extract with mock client", () => {
it("uses LLM client when provided", async () => {
@@ -69,54 +19,64 @@ describe("llm-extract with mock client", () => {
const file = join(dir, "test.ts");
writeFileSync(file, `export const foo = 1;`);
const mockClient: LLMClient = {
async complete() {
return "PURPOSE: Test file\nDEPS: none\nCONCEPTS: testing";
},
};
const mockClient = createMockClient(
"PURPOSE: Test file\nDEPS: none\nCONCEPTS: testing",
);
const result = await extractFileLLM(file, mockClient);
const result = await extractFileLLM(file, mockClient, tmpdir());
expect(result.purpose).toBe("Test file");
expect(result.deps).toEqual([]);
expect(result.concepts).toContain("testing");
});
it("throws without LLM client", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-map-"));
const file = join(dir, "test.ts");
writeFileSync(file, `export const foo = 1;`);
await expect(extractFileLLM(file)).rejects.toThrow("No LLM client configured");
});
it("skips large files", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-map-"));
const file = join(dir, "big.ts");
writeFileSync(file, "x".repeat(60 * 1024));
writeFileSync(file, "x".repeat(600 * 1024));
const mockClient: LLMClient = {
async complete() {
return "PURPOSE: Should not call\nDEPS: none\nCONCEPTS: none";
},
};
const mockClient = createMockClient(
"PURPOSE: Should not call\nDEPS: none\nCONCEPTS: none",
);
const result = await extractFileLLM(file, mockClient);
expect(result.purpose).toBe("Large/generated file");
expect(result.purpose).toBe("Large file");
});
it("falls back to heuristics without client", async () => {
it("skips binary files", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-map-"));
const file = join(dir, "utils.ts");
writeFileSync(file, `export function helper() {}`);
const file = join(dir, "image.png");
// Write some binary-looking content with null bytes
const buf = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
writeFileSync(file, buf);
const result = await extractFileLLM(file);
expect(result.purpose).toBe("Utility functions");
expect(result.exports).toContain("helper");
const mockClient = createMockClient(
"PURPOSE: Should not call\nDEPS: none\nCONCEPTS: none",
);
const result = await extractFileLLM(file, mockClient);
expect(result.purpose).toBe("Binary file");
});
});
describe("extractFileHeuristic", () => {
it("returns structured data", async () => {
it("parses response with deps and concepts", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-map-"));
const file = join(dir, "test.ts");
writeFileSync(file, `export const x = 1;`);
writeFileSync(file, `import { foo } from "bar";\nexport const x = 1;`);
const result = await extractFileHeuristic(file);
expect(result.purpose).toBeDefined();
expect(Array.isArray(result.exports)).toBe(true);
expect(Array.isArray(result.deps)).toBe(true);
expect(Array.isArray(result.concepts)).toBe(true);
const mockClient = createMockClient(
"PURPOSE: Config module\nDEPS: bar, baz\nCONCEPTS: constants, config",
);
const result = await extractFileLLM(file, mockClient, tmpdir());
expect(result.purpose).toBe("Config module");
expect(result.deps).toEqual(["bar", "baz"]);
expect(result.concepts).toEqual(["constants", "config"]);
});
});
+10 -10
View File
@@ -2,9 +2,9 @@ import { describe, it, expect } from "vitest";
import { writeFileSync, mkdtempSync, readFileSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import { createLLMClient } from "../src/llm-client.js";
import { extractFileLLM, extractPackageLLM } from "../src/llm-extract.js";
import { processFiles } from "../src/llm-batch.js";
import { createLLMClient } from "../src/llm/llm-client.js";
import { extractFileLLM, extractPackageLLM } from "../src/llm/llm-extract.js";
import { processFiles } from "../src/llm/llm-batch.js";
// Load .env file manually (no dotenv dependency needed)
function loadEnv(): Record<string, string> {
@@ -55,7 +55,7 @@ describe.skipIf(!hasKimiKey)("LLM integration with Kimi", () => {
);
const client = createLLMClient("kimi", { model: kimiModel });
const result = await extractFileLLM(file, client);
const result = await extractFileLLM(file, client, dir);
expect(result.purpose).toBeTruthy();
expect(result.purpose.length).toBeGreaterThan(5);
@@ -90,12 +90,12 @@ describe.skipIf(!hasKimiKey)("LLM integration with Kimi", () => {
writeFileSync(file, `export const version = "1.0.0";`);
const client = createLLMClient("kimi", { model: kimiModel });
const result1 = await extractFileLLM(file, client);
const result1 = await extractFileLLM(file, client, dir);
expect(result1.purpose).toBeTruthy();
// Second call should hit cache — much faster
const start = Date.now();
const result2 = await extractFileLLM(file, client);
const result2 = await extractFileLLM(file, client, dir);
const elapsed = Date.now() - start;
expect(result2.purpose).toBe(result1.purpose);
@@ -119,7 +119,7 @@ describe.skipIf(!hasKimiKey)("LLM integration with Kimi", () => {
const start = Date.now();
const results = await processFiles(
files,
async (f) => extractFileLLM(f, client),
async (f) => extractFileLLM(f, client, dir),
{ concurrency: 3, maxRetries: 1, retryDelaysMs: [2000] },
);
const elapsed = Date.now() - start;
@@ -135,7 +135,7 @@ describe.skipIf(!hasKimiKey)("LLM integration with Kimi", () => {
it("skips large files without calling LLM", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-map-llm-"));
const file = join(dir, "big.ts");
writeFileSync(file, "x".repeat(60 * 1024));
writeFileSync(file, "x".repeat(600 * 1024));
let calls = 0;
const trackingClient = createLLMClient("kimi", { model: kimiModel });
@@ -145,8 +145,8 @@ describe.skipIf(!hasKimiKey)("LLM integration with Kimi", () => {
return originalComplete(...args);
};
const result = await extractFileLLM(file, trackingClient);
expect(result.purpose).toBe("Large/generated file");
const result = await extractFileLLM(file, trackingClient, dir);
expect(result.purpose).toBe("Large file");
expect(calls).toBe(0); // Should never call LLM for large files
});
});
+103
View File
@@ -0,0 +1,103 @@
import { describe, it, expect } from "vitest";
import { mergeFileData } from "../src/merge.js";
describe("mergeFileData", () => {
it("collapses multi-line parameters into single-line func signatures", () => {
const result = mergeFileData(
"api.ts",
{ purpose: "API helpers", exports: [], deps: [] },
{
exports: ["shouldReinjectForEvent"],
deps: [],
classes: [],
functions: [
{
name: "shouldReinjectForEvent",
params: [
"event: {\n\t\tmessages?: Array<{ content?: unknown; text?: string }>;\n\t\ttype?: string;\n\t\tpayload?: unknown;\n\t}",
"mode: PromptInjectionMode",
],
returns: "ReinjectDecision",
calls: ["outgoingMessagesHaveMarker"],
raises: [],
},
],
},
);
const funcExport = result.exports.find((e) =>
e.startsWith("func:shouldReinjectForEvent"),
);
expect(funcExport).toBeDefined();
expect(funcExport).not.toContain("\n");
expect(funcExport).not.toContain("\t");
expect(funcExport).toBe(
"func:shouldReinjectForEvent(event: { messages?: Array<{ content?: unknown; text?: string }>; type?: string; payload?: unknown; }, mode: PromptInjectionMode) → ReinjectDecision",
);
});
it("collapses multi-line return types into single-line func signatures", () => {
const result = mergeFileData(
"types.ts",
{ purpose: "Types", exports: [], deps: [] },
{
exports: ["complexReturn"],
deps: [],
classes: [],
functions: [
{
name: "complexReturn",
params: ["x: number"],
returns: "{\n a: string;\n b: number;\n}",
calls: [],
raises: [],
},
],
},
);
const funcExport = result.exports.find((e) =>
e.startsWith("func:complexReturn"),
);
expect(funcExport).toBeDefined();
expect(funcExport).not.toContain("\n");
expect(funcExport).toBe(
"func:complexReturn(x: number) → { a: string; b: number; }",
);
});
it("collapses multi-line method parameters into single-line method signatures", () => {
const result = mergeFileData(
"class.ts",
{ purpose: "Class file", exports: [], deps: [] },
{
exports: ["MyClass"],
deps: [],
classes: [
{
name: "MyClass",
methods: [
{
name: "doThing",
params: ["opts: {\n a: string;\n b: number;\n}"],
returns: "void",
calls: [],
raises: [],
},
],
},
],
functions: [],
},
);
const methodExport = result.exports.find((e) =>
e.startsWith("method:doThing"),
);
expect(methodExport).toBeDefined();
expect(methodExport).not.toContain("\n");
expect(methodExport).toBe(
"method:doThing(opts: { a: string; b: number; }) → void",
);
});
});
+17
View File
@@ -0,0 +1,17 @@
import type { LLMClient } from "../src/llm/llm-client.js";
export function createMockFileClient(purpose = "Test file"): LLMClient {
return {
async complete() {
return `PURPOSE: ${purpose}\nDEPS: none\nCONCEPTS: testing`;
},
};
}
export function createMockPackageClient(): LLMClient {
return {
async complete() {
return "ROLE: Test package\nARCH: Test architecture";
},
};
}
+1020 -12
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+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);
});
});