@blundergoat/goat-flow
Version:
AI coding agent harness and local dashboard for Claude Code, OpenAI Codex, Google Antigravity, and GitHub Copilot - setup audits, guardrails, structured skills, deny hooks, and persistent learning loops.
130 lines (116 loc) • 4.2 kB
JavaScript
/**
* Verifies that README-relative links resolve to files included by `npm pack`.
* This catches package documentation links that work in the repo but break for
* consumers after the package `files` whitelist is applied.
*/
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { posix } from "node:path";
/** Return true when a Markdown href does not need a packed-file check. */
function isIgnoredHref(href) {
return (
href.length === 0 ||
href.startsWith("#") ||
href.startsWith("//") ||
/^[a-z][a-z0-9+.-]*:/i.test(href)
);
}
/** Strip fragment/query/title syntax down to a package-relative path. */
function normalizeMarkdownHref(rawHref) {
const trimmed = rawHref.trim();
const href = trimmed.startsWith("<")
? trimmed.replace(/^<|>$/g, "")
: (trimmed.match(/^\S+/)?.[0] ?? "");
if (isIgnoredHref(href)) return null;
const withoutFragment = href.split("#", 1)[0] ?? "";
const withoutQuery = withoutFragment.split("?", 1)[0] ?? "";
if (withoutQuery.length === 0) return null;
const normalized = posix.normalize(withoutQuery).replace(/^\.\//, "");
if (normalized === "." || normalized.startsWith("../")) return null;
return normalized;
}
/**
* Extract package-local Markdown link/image targets from README content.
* Returned paths are unique and sorted so validation output is deterministic.
*
* @param readmeText - Markdown README content to scan
*/
export function extractPackageLocalReadmeLinks(readmeText) {
const links = [];
const inlineLinkPattern = /!?\[[^\]]*]\(([^)]+)\)/g;
for (const match of readmeText.matchAll(inlineLinkPattern)) {
const rawHref = match[1];
if (rawHref === undefined) continue;
const target = normalizeMarkdownHref(rawHref);
if (target !== null) links.push(target);
}
return [...new Set(links)].sort();
}
/**
* Validate README package-local links against npm pack output paths.
*
* @param readmeText - Markdown README content to scan
* @param packedPaths - package-relative paths emitted by npm pack
*/
export function validatePackageReadmeLinks(readmeText, packedPaths) {
const packed = new Set(packedPaths);
const links = extractPackageLocalReadmeLinks(readmeText);
const missing = links.filter((target) => !packed.has(target));
return { links, missing };
}
/**
* Parse the `npm pack --dry-run --json` payload into package paths.
* Throws when npm changes the JSON schema because missing `files` means the
* link check cannot prove package contents.
*
* @param packJson - raw stdout from `npm pack --dry-run --json`
*/
export function parsePackFileList(packJson) {
const parsed = JSON.parse(packJson);
const first = Array.isArray(parsed) ? parsed[0] : parsed;
if (!first || !Array.isArray(first.files)) {
throw new Error("npm pack JSON did not include a files array");
}
return first.files
.map((entry) => entry?.path)
.filter((path) => typeof path === "string")
.sort();
}
/** Spawns npm pack as a process side effect and return the dry-run package paths. */
function readPackFileList() {
const output = execFileSync(
"npm",
["pack", "--dry-run", "--json", "--ignore-scripts"],
{
encoding: "utf-8",
env: {
...process.env,
npm_config_cache:
process.env.npm_config_cache ?? "/tmp/goat-flow-npm-cache",
},
},
);
return parsePackFileList(output);
}
/** CLI entry point; exits non-zero when README links target unpacked paths. */
function main() {
const packedPaths = readPackFileList();
const readmeText = readFileSync("README.md", "utf-8");
const result = validatePackageReadmeLinks(readmeText, packedPaths);
if (result.missing.length > 0) {
console.error("Package README link check: FAIL");
for (const target of result.missing) {
console.error(
` - README.md references unpacked package path: ${target}`,
);
}
process.exit(1);
}
console.log(
`Package README link check: PASS (${result.links.length} relative links checked against ${packedPaths.length} packed files)`,
);
}
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}