paperclip/packages/paperclip-runner/scripts/lib/tracked-imports.mjs

215 lines
6.8 KiB
JavaScript

import { execFile, spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { dirname, relative, resolve } from "node:path";
import { promisify } from "node:util";
import { defaultPackageRoot, findSpecifiers } from "./forbidden-imports.mjs";
export { defaultPackageRoot };
const execFileAsync = promisify(execFile);
const SCANNED_EXTENSIONS = new Set([".cjs", ".js", ".jsx", ".mjs", ".ts", ".tsx"]);
// TypeScript resolves an ESM ".js" specifier against its ".ts" source sibling,
// so a tracked import may legitimately name a file that never exists on disk.
const JS_TO_SOURCE_EXTENSIONS = new Map([
[".js", [".ts", ".tsx", ".d.ts"]],
[".jsx", [".tsx", ".d.ts"]],
[".mjs", [".mts", ".d.mts"]],
[".cjs", [".cts", ".d.cts"]],
]);
const EXTENSIONLESS_CANDIDATES = [
".ts",
".tsx",
".mts",
".cts",
".d.ts",
".js",
".jsx",
".mjs",
".cjs",
"/index.ts",
"/index.tsx",
"/index.js",
"/index.mjs",
];
function extension(path) {
const match = path.match(/\.[^./\\]+$/);
return match?.[0] ?? "";
}
function isScannable(path) {
return SCANNED_EXTENSIONS.has(extension(path));
}
function isRelative(specifier) {
return specifier.startsWith("./") || specifier.startsWith("../");
}
/**
* Every on-disk path a bundler or `tsc` could pick for this specifier. The
* check only needs one of them to be tracked.
*/
function resolutionCandidates(file, specifier) {
const base = resolve(dirname(file), specifier);
const specifierExtension = extension(specifier);
const sourceExtensions = JS_TO_SOURCE_EXTENSIONS.get(specifierExtension);
if (sourceExtensions !== undefined) {
const withoutExtension = base.slice(0, base.length - specifierExtension.length);
return [base, ...sourceExtensions.map((suffix) => `${withoutExtension}${suffix}`)];
}
if (specifierExtension !== "") {
return [base];
}
return [base, ...EXTENSIONLESS_CANDIDATES.map((suffix) => `${base}${suffix}`)];
}
/**
* Build output is untracked on purpose, so an import that points into an
* ignored path is not evidence of a missing source file.
*/
async function listIgnoredPaths(packageRoot, paths) {
if (paths.length === 0) {
return new Set();
}
const result = spawnSync("git", ["check-ignore", "-z", "--stdin"], {
cwd: packageRoot,
input: paths.join("\0"),
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
if (result.error !== undefined) {
throw result.error;
}
// `git check-ignore` exits 1 when nothing matched, which is not a failure.
if (result.status !== 0 && result.status !== 1) {
throw new Error(`git check-ignore failed: ${result.stderr}`);
}
return new Set(
result.stdout
.split("\0")
.filter((entry) => entry !== "")
.map((entry) => resolve(packageRoot, entry)),
);
}
export async function listTrackedFiles(packageRoot = defaultPackageRoot) {
const { stdout } = await execFileAsync("git", ["ls-files", "-z", "--", "."], {
cwd: packageRoot,
maxBuffer: 64 * 1024 * 1024,
});
return stdout
.split("\0")
.filter((entry) => entry !== "")
.map((entry) => resolve(packageRoot, entry));
}
function isInside(parent, candidate) {
const pathFromParent = relative(parent, candidate);
return pathFromParent !== "" && !pathFromParent.startsWith("..");
}
/**
* Reject tracked sources whose relative imports only resolve against files git
* does not carry. A shared worktree happily builds such a tree while a clean
* clone of the same commit cannot, so this guard runs on the tracked set only.
*/
export async function checkTrackedImports({
packageRoot = defaultPackageRoot,
scanRoots = ["src", "scripts", "devtools", "examples"],
trackedFiles,
} = {}) {
const tracked = trackedFiles ?? (await listTrackedFiles(packageRoot));
const trackedSet = new Set(tracked);
const roots = scanRoots.map((root) => resolve(packageRoot, root));
const files = tracked
.filter((file) => isScannable(file) && roots.some((root) => isInside(root, file)))
.sort();
const suspects = [];
for (const file of files) {
const source = await readFile(file, "utf8");
for (const { specifier, offset } of findSpecifiers(source)) {
if (!isRelative(specifier) || /[?*]|\$\{/.test(specifier)) {
continue;
}
const candidates = resolutionCandidates(file, specifier);
if (candidates.some((candidate) => trackedSet.has(candidate))) {
continue;
}
suspects.push({
file,
line: source.slice(0, offset).split("\n").length,
specifier,
candidates,
});
}
}
const ignored = await listIgnoredPaths(
packageRoot,
[...new Set(suspects.flatMap((suspect) => suspect.candidates))],
);
return suspects
.filter((suspect) => !suspect.candidates.some((candidate) => ignored.has(candidate)))
.map(({ file, line, specifier, candidates }) => ({
file,
line,
specifier,
reason: candidates.some((candidate) => existsSync(candidate))
? "resolves only to an untracked file, so a clean checkout cannot build it"
: "does not resolve to any tracked file",
}));
}
const ENTRY_POINT_TOKEN = /^[A-Za-z0-9._@/-]+\.(?:mjs|cjs|js|jsx|ts|tsx|sh|toml)$/;
/**
* `package.json` scripts are the other way a commit can depend on a file git
* does not carry: an ignored or never-added entry point runs fine locally and
* fails immediately in a clean checkout.
*/
export async function checkTrackedScriptEntryPoints({
packageRoot = defaultPackageRoot,
trackedFiles,
} = {}) {
const manifestPath = resolve(packageRoot, "package.json");
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
const trackedSet = new Set(trackedFiles ?? (await listTrackedFiles(packageRoot)));
const suspects = [];
for (const [name, body] of Object.entries(manifest.scripts ?? {})) {
for (const token of new Set(body.split(/\s+/))) {
if (!ENTRY_POINT_TOKEN.test(token) || token.startsWith("@")) {
continue;
}
const path = resolve(packageRoot, token);
if (trackedSet.has(path) || !isInside(packageRoot, path)) {
continue;
}
suspects.push({ file: manifestPath, script: name, specifier: token, candidates: [path] });
}
}
const ignored = await listIgnoredPaths(
packageRoot,
suspects.map((suspect) => suspect.candidates[0]),
);
return suspects
.filter((suspect) => !ignored.has(suspect.candidates[0]))
.map(({ file, script, specifier }) => ({
file,
line: 1,
specifier,
reason: `the "${script}" script runs a file that is not tracked, so a clean checkout cannot run it`,
}));
}
export function formatTrackedImportViolation(violation, packageRoot = defaultPackageRoot) {
return `${relative(packageRoot, violation.file)}:${violation.line} imports ${JSON.stringify(
violation.specifier,
)}: ${violation.reason}`;
}