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, existsSync, 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 {
	type EsNode,
	literalString,
	parseModule,
	walkEs,
} from "../lib/esast.ts";
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> | undefined;
	/** User settings written into the probe's isolated agent directory. */
	userSettings?: Record<string, unknown>;
	timeoutMs?: number;
	/** Isolated SDK probe entrypoint for compatibility verification. */
	probePath?: string;
}

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>();
	walkEs(parseModule(file, text), (node) => {
		const source =
			node.type === "ImportDeclaration" ||
			node.type === "ExportNamedDeclaration" ||
			node.type === "ExportAllDeclaration" ||
			node.type === "ImportExpression"
				? literalString(node["source"] as EsNode | null)
				: undefined;
		if (source !== undefined) specifiers.add(source);
	});
	return [...specifiers].sort();
}

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/") ||
			/\.d\.[cm]?ts$/u.test(file) ||
			!/\.[cm]?[jt]s$/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) &&
					!existsSync(join(packageDir, 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. */
async function probePackages(
	packageDirs: string[],
	options: Pick<VerifyOptions, "userSettings" | "timeoutMs" | "probePath"> = {},
): 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", options.probePath ?? 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" | "probePath"> = {},
): 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,
	};
}

/** Declared dependencies a generated package ships, so importing them is allowed. */
function manifestDependencies(dir: string): Record<string, string> {
	try {
		const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as {
			dependencies?: Record<string, string>;
		};
		return pkg.dependencies ?? {};
	} catch {
		return {};
	}
}

/** Verify one generated package relocated outside the checkout. */
export async function verifyPackage(
	packageDir: string,
	options: VerifyOptions = {},
): Promise<VerifyReport> {
	const dir = resolve(packageDir);
	const externals = { ...manifestDependencies(dir), ...options.externals };
	const forbidden = forbiddenImports(dir, externals);
	const report = await probePackages([dir], options);
	return {
		package: dir,
		...report,
		forbiddenImports: forbidden,
		ok: report.errors.length === 0 && forbidden.length === 0,
	};
}