import { spawn } from "node:child_process"; import { cpSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import ts from "typescript"; import type { ProbeReport } from "./conflicts.ts"; import { listFiles } from "./publish.ts"; import { isExternal, packageOf } from "./spec.ts"; const HERE = dirname(fileURLToPath(import.meta.url)); const PROBE = join(HERE, "load-probe.ts"); export interface VerifyOptions { /** Extra bare packages this package may legitimately import. */ externals?: Record; /** User settings written into the probe's isolated agent directory. */ userSettings?: Record; timeoutMs?: number; } export interface VerifyReport extends ProbeReport { package: string; /** Bare specifiers found in emitted JavaScript that violate the external policy. */ forbiddenImports: string[]; ok: boolean; } /** Static and dynamic import specifiers of one JavaScript module. */ export function moduleImports(file: string, text: string): string[] { const specifiers = new Set(); const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true); const visit = (node: ts.Node): void => { if ( (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier) ) specifiers.add(node.moduleSpecifier.text); if ( ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword && node.arguments[0] && ts.isStringLiteralLike(node.arguments[0]) ) specifiers.add(node.arguments[0].text); ts.forEachChild(node, visit); }; visit(source); return [...specifiers].sort(); } export function scanImports(packageDir: string): string[] { const specifiers = new Set(); for (const file of listFiles(packageDir)) { // Installed external dependency trees own their internal imports. if (file.startsWith("node_modules/") || !/\.[cm]?js$/u.test(file)) continue; for (const specifier of moduleImports( file, readFileSync(join(packageDir, file), "utf8"), )) specifiers.add(specifier); } return [...specifiers].sort(); } export function forbiddenImports( packageDir: string, externals: Record = {}, ): string[] { return scanImports(packageDir).filter((specifier) => { if (specifier.startsWith("./") || specifier.startsWith("../")) { return specifier.startsWith("../") || /\.[cm]?ts$/u.test(specifier); } if (specifier.startsWith("/") || specifier.startsWith("file:")) return true; return ( !isExternal(specifier, externals) && !(packageOf(specifier) in externals) ); }); } /** Run the Pi load probe over relocated package copies. */ export async function probePackages( packageDirs: string[], options: Pick = {}, ): Promise { const timeoutMs = options.timeoutMs ?? 120_000; const root = mkdtempSync(join(tmpdir(), "pi-ext-verify-")); try { const relocated = packageDirs.map((dir) => { const copy = join(root, "packages", basename(dir)); cpSync(dir, copy, { recursive: true }); return copy; }); const agentDir = join(root, "agent"); const output = await new Promise((resolveOutput, reject) => { const child = spawn( process.execPath, ["--no-warnings", PROBE, ...relocated], { cwd: root, env: { ...process.env, PI_CODING_AGENT_DIR: agentDir, PI_OFFLINE: "1", ...(options.userSettings ? { PI_EXT_PROBE_USER_SETTINGS: JSON.stringify( options.userSettings, ), } : {}), }, stdio: ["ignore", "pipe", "pipe"], }, ); let text = ""; const append = (chunk: Buffer) => { text += chunk.toString("utf8"); }; child.stdout.on("data", append); child.stderr.on("data", append); const timer = setTimeout(() => { child.kill("SIGKILL"); reject(new Error(`load probe timed out after ${timeoutMs}ms\n${text}`)); }, timeoutMs); child.once("error", (error) => { clearTimeout(timer); reject(error); }); child.once("close", () => { clearTimeout(timer); resolveOutput(text); }); }); const line = output .split("\n") .reverse() .find((candidate) => candidate.startsWith("PI_EXT_PROBE=")); if (!line) throw new Error(`load probe produced no report\n${output}`); return JSON.parse(line.slice("PI_EXT_PROBE=".length)) as ProbeReport; } finally { rmSync(root, { recursive: true, force: true }); } } /** * Verify the complete configured collection relocated together: every package * loads and no two packages claim the same identity, tool, command, flag, or skill. */ export async function verifyCollection( packageDirs: string[], options: Pick = {}, ): Promise { const report = await probePackages( packageDirs.map((dir) => resolve(dir)), options, ); return { ...report, ok: report.errors.length === 0 && report.conflicts.length === 0, }; } /** Verify one generated package relocated outside the checkout. */ export async function verifyPackage( packageDir: string, options: VerifyOptions = {}, ): Promise { const dir = resolve(packageDir); const forbidden = forbiddenImports(dir, options.externals); const report = await probePackages([dir], options); return { package: dir, ...report, forbiddenImports: forbidden, ok: report.errors.length === 0 && forbidden.length === 0, }; }