Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

scripts/build/verify.ts

Raw
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<string, string>;
	/** User settings written into the probe's isolated agent directory. */
	userSettings?: Record<string, unknown>;
	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<string>();
	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<string>();
	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, string> = {},
): 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<VerifyOptions, "userSettings" | "timeoutMs"> = {},
): Promise<ProbeReport> {
	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<string>((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<VerifyOptions, "userSettings" | "timeoutMs"> = {},
): Promise<ProbeReport & { ok: boolean }> {
	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<VerifyReport> {
	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,
	};
}