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 CAPABILITY_SECTIONS = new Set([
"added",
"changed",
"new features",
"fixed",
]);
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 extractCapabilities(
markdown,
{ afterVersion, throughVersion },
) {
const capabilities = [];
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) {
if (!CAPABILITY_SECTIONS.has(section.name.toLowerCase())) continue;
for (const text of section.bullets)
capabilities.push({
version: release.version,
section: section.name,
text,
});
}
}
return capabilities;
}
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 managedPiPackage(installRoot) {
const currentFile = path.join(installRoot, "current-version");
const version = readFileSync(currentFile, "utf8").trim();
if (
!version ||
version === "." ||
version === ".." ||
!/^[0-9A-Za-z._+-]+$/.test(version)
)
throw new Error(`Managed Pi version file is invalid: ${currentFile}`);
const packageRoot = path.join(
installRoot,
"releases",
version,
"node_modules",
...PACKAGE.split("/"),
);
const manifest = packageAt(packageRoot);
if (!manifest)
throw new Error(
`installed ${PACKAGE} package not found in managed release ${packageRoot}`,
);
if (manifest.version !== version)
throw new Error(
`Managed Pi version ${version} does not match package version ${manifest.version}`,
);
return { packageRoot, manifest };
}
function findPiPackage(executable, managedInstallRoot) {
if (managedInstallRoot) return managedPiPackage(managedInstallRoot);
let directory = path.dirname(realpathSync(executable));
const launcherInstallRoot = path.resolve(directory, "..", "install");
if (existsSync(path.join(launcherInstallRoot, "current-version")))
return managedPiPackage(launcherInstallRoot);
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,
managedInstallRoot = process.env.PI_MANAGED_INSTALL_ROOT,
) {
const { packageRoot, manifest } = findPiPackage(
executable,
managedInstallRoot,
);
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 mergeEvidence(groups) {
const merged = new Map();
for (const { source: sourceName, entries } of groups) {
for (const candidate of entries) {
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,
capabilities,
}) {
return {
schemaVersion: 3,
status: "ok",
piVersion,
candidatesAfterVersion,
changelogs: {
installed: localChangelogPath,
remote: remoteChangelogUrl,
},
candidates,
capabilities,
};
}
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 sources = [
{ source: "installed", markdown: localChangelog },
{ source: "remote", markdown: remoteChangelog },
];
const candidates = mergeEvidence(
sources.map(({ source, markdown }) => ({
source,
entries: extractCandidates(markdown, interval),
})),
);
const capabilities = mergeEvidence(
sources.map(({ source, markdown }) => ({
source,
entries: extractCapabilities(markdown, interval),
})),
);
process.stdout.write(
`${JSON.stringify(
createReport({
piVersion: latestVersion,
candidatesAfterVersion: afterVersion,
localChangelogPath,
remoteChangelogUrl,
candidates,
capabilities,
}),
)}\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: 3,
status: "failed",
reason: error instanceof Error ? error.message : String(error),
})}\n`,
);
process.exitCode = 1;
});
}