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;
});
}