Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

.pi/skills/modernize/scripts/audit.mjs

Raw
#!/usr/bin/env node

import {
	accessSync,
	constants,
	existsSync,
	readFileSync,
	realpathSync,
} from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";

const PACKAGE = "@earendil-works/pi-coding-agent";
const REGISTRY_URL =
	"https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest";
const MIGRATION_SECTIONS = new Set([
	"breaking changes",
	"changed",
	"deprecated",
	"removed",
]);
const MIGRATION_WORDS =
	/\b(breaking|deprecat\w*|migrat\w*|remov\w*|renam\w*|replac\w*|no longer|instead of|must now)\b/i;

function versionParts(value) {
	const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(value.trim());
	if (!match) throw new Error(`invalid semantic version: ${value}`);
	return match.slice(1, 4).map(Number);
}

export function compareVersions(left, right) {
	const a = versionParts(left);
	const b = versionParts(right);
	for (let index = 0; index < 3; index++) {
		if (a[index] !== b[index]) return a[index] > b[index] ? 1 : -1;
	}
	return 0;
}

export function assertLatest(localVersion, latestVersion) {
	if (compareVersions(localVersion, latestVersion) !== 0) {
		throw new Error(
			`installed Pi ${localVersion} is not latest ${latestVersion}; update Pi before modernizing`,
		);
	}
}

export function parseChangelog(markdown) {
	const releases = [];
	let release;
	let section;
	let bullet;

	const flushBullet = () => {
		if (bullet && section) section.bullets.push(bullet.trim());
		bullet = undefined;
	};

	for (const line of markdown.split(/\r?\n/)) {
		const releaseMatch = /^## \[(\d+\.\d+\.\d+(?:[-+][^\]]+)?)\]/.exec(line);
		if (releaseMatch) {
			flushBullet();
			release = { version: releaseMatch[1], sections: [] };
			releases.push(release);
			section = undefined;
			continue;
		}
		if (/^## /.test(line)) {
			flushBullet();
			release = undefined;
			section = undefined;
			continue;
		}
		const sectionMatch = /^### (.+?)\s*$/.exec(line);
		if (sectionMatch && release) {
			flushBullet();
			section = { name: sectionMatch[1], bullets: [] };
			release.sections.push(section);
			continue;
		}
		const bulletMatch = /^- (.+)$/.exec(line);
		if (bulletMatch && section) {
			flushBullet();
			bullet = bulletMatch[1];
			continue;
		}
		if (bullet && line.trim() && !/^#{1,6} /.test(line)) {
			bullet += ` ${line.trim()}`;
		}
	}
	flushBullet();
	if (releases.length === 0)
		throw new Error("changelog contains no release sections");
	return releases;
}

export function extractCandidates(markdown, { afterVersion, throughVersion }) {
	const candidates = [];
	for (const release of parseChangelog(markdown)) {
		if (compareVersions(release.version, afterVersion) <= 0) continue;
		if (compareVersions(release.version, throughVersion) > 0) continue;
		for (const section of release.sections) {
			const migrationSection = MIGRATION_SECTIONS.has(
				section.name.toLowerCase(),
			);
			for (const text of section.bullets) {
				if (!migrationSection && !MIGRATION_WORDS.test(text)) continue;
				candidates.push({
					version: release.version,
					section: section.name,
					text,
				});
			}
		}
	}
	return candidates;
}

export function collectPackageVersions(lock) {
	const packages =
		lock && typeof lock === "object" && lock.packages ? lock.packages : {};
	const found = new Map();
	for (const [location, value] of Object.entries(packages)) {
		const match = /(?:^|\/)node_modules\/(@earendil-works\/pi-[^/]+)$/.exec(
			location,
		);
		if (!match || !value || typeof value !== "object") continue;
		if (typeof value.version !== "string") continue;
		const item = { name: match[1], version: value.version };
		found.set(`${item.name}@${item.version}`, item);
	}
	return [...found.values()].sort(
		(a, b) =>
			a.name.localeCompare(b.name) || compareVersions(a.version, b.version),
	);
}

export function executableOnPath(name) {
	const extensions =
		process.platform === "win32"
			? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")
			: [""];
	for (const directory of (process.env.PATH ?? "").split(path.delimiter)) {
		if (!directory) continue;
		for (const extension of extensions) {
			const candidate = path.join(directory, `${name}${extension}`);
			try {
				accessSync(candidate, constants.X_OK);
				return realpathSync(candidate);
			} catch {
				// Try the next PATH candidate.
			}
		}
	}
	throw new Error(`${name} executable not found on PATH`);
}

function packageAt(directory) {
	const manifestPath = path.join(directory, "package.json");
	if (!existsSync(manifestPath)) return undefined;
	const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
	return manifest?.name === PACKAGE ? manifest : undefined;
}

function findPiPackage(executable) {
	let directory = path.dirname(realpathSync(executable));
	while (true) {
		for (const packageRoot of [
			directory,
			path.join(directory, "node_modules", ...PACKAGE.split("/")),
		]) {
			const manifest = packageAt(packageRoot);
			if (manifest) return { packageRoot, manifest };
		}
		const parent = path.dirname(directory);
		if (parent === directory)
			throw new Error(
				`installed ${PACKAGE} package not found near ${executable}`,
			);
		directory = parent;
	}
}

export function resolvePiInstallation(executable) {
	const { packageRoot, manifest } = findPiPackage(executable);
	const bin =
		typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.pi;
	if (typeof bin !== "string")
		throw new Error(`installed ${PACKAGE} package has no pi bin entry`);
	const cli = path.resolve(packageRoot, bin);
	const relativeCli = path.relative(packageRoot, cli);
	if (relativeCli.startsWith("..") || path.isAbsolute(relativeCli))
		throw new Error(`installed ${PACKAGE} pi bin points outside its package`);
	accessSync(cli, constants.R_OK);
	const changelog = path.join(packageRoot, "CHANGELOG.md");
	accessSync(changelog, constants.R_OK);
	if (typeof manifest.version !== "string")
		throw new Error(`installed ${PACKAGE} package has no version`);
	return { cli, changelog, version: manifest.version };
}

async function fetchJson(url) {
	const response = await fetch(url, {
		headers: { "user-agent": "pi-ext-modernize" },
		signal: AbortSignal.timeout(30_000),
	});
	if (!response.ok)
		throw new Error(`fetch ${url} failed: HTTP ${response.status}`);
	return response.json();
}

async function fetchText(url) {
	const response = await fetch(url, {
		headers: { "user-agent": "pi-ext-modernize" },
		signal: AbortSignal.timeout(30_000),
	});
	if (!response.ok)
		throw new Error(`fetch ${url} failed: HTTP ${response.status}`);
	return response.text();
}

function oldestVersion(packages, fallback) {
	return packages.reduce(
		(oldest, item) =>
			compareVersions(item.version, oldest) < 0 ? item.version : oldest,
		fallback,
	);
}

function mergeCandidates(groups) {
	const merged = new Map();
	for (const { source: sourceName, candidates } of groups) {
		for (const candidate of candidates) {
			const key = `${candidate.version}\0${candidate.section}\0${candidate.text}`;
			const current = merged.get(key);
			if (current) current.sources.push(sourceName);
			else merged.set(key, { ...candidate, sources: [sourceName] });
		}
	}
	return [...merged.values()].sort((a, b) =>
		compareVersions(a.version, b.version),
	);
}

export function createReport({
	piVersion,
	candidatesAfterVersion,
	localChangelogPath,
	remoteChangelogUrl,
	candidates,
}) {
	return {
		schemaVersion: 2,
		status: "ok",
		piVersion,
		candidatesAfterVersion,
		changelogs: {
			installed: localChangelogPath,
			remote: remoteChangelogUrl,
		},
		candidates,
	};
}

async function main() {
	const repo = process.cwd();
	const installation = resolvePiInstallation(executableOnPath("pi"));
	// Package resolution already reads the authoritative installed manifest;
	// spawning another Node process only to print the same version costs ~770 ms.
	const localVersion = installation.version;
	const metadata = await fetchJson(REGISTRY_URL);
	if (!metadata || typeof metadata.version !== "string")
		throw new Error(`registry response for ${PACKAGE} has no version`);
	const latestVersion = metadata.version;
	assertLatest(localVersion, latestVersion);

	const localChangelogPath = installation.changelog;
	const localChangelog = readFileSync(localChangelogPath, "utf8");
	const remoteChangelogUrl = `https://raw.githubusercontent.com/earendil-works/pi/v${latestVersion}/packages/coding-agent/CHANGELOG.md`;
	const remoteChangelog = await fetchText(remoteChangelogUrl);
	parseChangelog(localChangelog);
	parseChangelog(remoteChangelog);

	const lockPath = path.join(repo, "package-lock.json");
	const packages = existsSync(lockPath)
		? collectPackageVersions(JSON.parse(readFileSync(lockPath, "utf8")))
		: [];
	const afterVersion = oldestVersion(packages, localVersion);
	const interval = { afterVersion, throughVersion: latestVersion };
	const candidates = mergeCandidates([
		{
			source: "installed",
			candidates: extractCandidates(localChangelog, interval),
		},
		{
			source: "remote",
			candidates: extractCandidates(remoteChangelog, interval),
		},
	]);
	process.stdout.write(
		`${JSON.stringify(
			createReport({
				piVersion: latestVersion,
				candidatesAfterVersion: afterVersion,
				localChangelogPath,
				remoteChangelogUrl,
				candidates,
			}),
		)}\n`,
	);
}

const invoked = process.argv[1]
	? import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
	: false;
if (invoked) {
	main().catch((error) => {
		process.stderr.write(
			`${JSON.stringify({
				schemaVersion: 2,
				status: "failed",
				reason: error instanceof Error ? error.message : String(error),
			})}\n`,
		);
		process.exitCode = 1;
	});
}