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:
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+263
-25
@@ -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,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
@@ -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
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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