215 lines
6.8 KiB
JavaScript
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}`;
|
|
}
|