CultLib/scripts/test-declared-dependency-ranges.mjs
Meta Vens 245b0619b4 Declared-range check: find npm's package root on Unix as well as Windows
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 01:35:45 +02:00

155 lines
7 KiB
JavaScript

// Observes the dependency ranges the TypeScript workspace packages declare, not the workspace symlinks that satisfy them.
//
// Every workspace test resolves a sibling package through node_modules/<name> -> packages/<dir>, so a package that starts
// importing a new export from a sibling passes whatever range it declares. A consumer outside the workspace installs the
// newest published version the range admits. This check builds every tagged release (`<dir>-v<version>`) that a declared
// range admits and requires each runtime value the dependent imports from that package to be an export of it.
//
// node scripts/test-declared-dependency-ranges.mjs
//
// It needs the workspace installed (typescript at the repo root) and git with the release tags. A package whose range
// admits no tagged release other than the workspace's own passes, because nothing a consumer could install lacks the export.
// Namespace imports, default imports, type-only imports and subpath imports are not checked.
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const rootRequire = createRequire(join(repoRoot, "package.json"));
const ts = rootRequire("typescript");
const semver = createRequire(join(npmRoot(), "package.json"))("semver");
// npm ships semver, so the check needs no dependency of its own. npm's root is beside the node binary on Windows and under lib/ on Unix.
function npmRoot() {
const candidates = [
...(process.env.npm_execpath ? [dirname(dirname(process.env.npm_execpath))] : []),
join(dirname(process.execPath), "node_modules", "npm"),
join(dirname(process.execPath), "..", "lib", "node_modules", "npm"),
];
const root = candidates.find((candidate) => existsSync(join(candidate, "package.json")));
assert.ok(root, `npm's package root was not found in ${candidates.join(", ")}`);
return root;
}
function readJson(path) {
return JSON.parse(readFileSync(path, "utf8"));
}
function git(...args) {
return execFileSync("git", args, { cwd: repoRoot, encoding: "utf8" });
}
// The runtime names `sourceText` imports from, or re-exports from, exactly `packageName`.
function importedNames(sourceText, packageName) {
const file = ts.createSourceFile("source.ts", sourceText, ts.ScriptTarget.Latest, true);
const names = new Set();
for (const statement of file.statements) {
const from = statement.moduleSpecifier;
if (!from || !ts.isStringLiteral(from) || from.text !== packageName) {
continue;
}
let clause;
if (ts.isImportDeclaration(statement) && statement.importClause && !statement.importClause.isTypeOnly) {
clause = statement.importClause.namedBindings;
} else if (ts.isExportDeclaration(statement) && !statement.isTypeOnly) {
clause = statement.exportClause;
}
if (clause && (ts.isNamedImports(clause) || ts.isNamedExports(clause))) {
for (const element of clause.elements) {
if (!element.isTypeOnly) {
names.add((element.propertyName ?? element.name).text);
}
}
}
}
return [...names].sort();
}
function sourceFiles(directory) {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = join(directory, entry.name);
if (entry.isDirectory()) {
return sourceFiles(path);
}
return entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts") ? [path] : [];
});
}
// Builds the package as the tag released it, in a scratch directory that borrows the workspace's node_modules.
function buildRelease(directory, tag, scratch) {
const archive = join(scratch, `${tag}.tar`);
git("archive", "--format=tar", "-o", archive, tag, `packages/${directory}`);
const extracted = join(scratch, tag);
mkdirSync(extracted);
execFileSync("tar", ["-xf", archive, "-C", extracted]);
symlinkSync(join(repoRoot, "node_modules"), join(extracted, "node_modules"), "junction");
const packageRoot = join(extracted, "packages", directory);
execFileSync(process.execPath, [rootRequire.resolve("typescript/bin/tsc"), "-p", "tsconfig.json"], { cwd: packageRoot, stdio: "inherit" });
return rootRequire(join(packageRoot, "dist", "index.js"));
}
const workspaces = readJson(join(repoRoot, "package.json")).workspaces.map((directory) => {
const manifest = readJson(join(repoRoot, directory, "package.json"));
return { directory: directory.replace(/^packages\//u, ""), root: join(repoRoot, directory), manifest };
});
const byName = new Map(workspaces.map((workspace) => [workspace.manifest.name, workspace]));
const tags = git("tag", "-l").split(/\r?\n/u).filter(Boolean);
assert.ok(tags.length > 0, "this clone has no release tags; fetch them (git fetch --tags) so the admitted releases can be built");
const scratch = mkdtempSync(join(tmpdir(), "cultlib-declared-ranges-"));
const built = new Map();
let checked = 0;
try {
for (const dependent of workspaces) {
for (const [name, range] of Object.entries(dependent.manifest.dependencies ?? {})) {
const dependency = byName.get(name);
if (!dependency) {
continue;
}
assert.ok(
semver.satisfies(dependency.manifest.version, range),
`${dependent.manifest.name} declares ${name} ${range}, which excludes the workspace's own ${dependency.manifest.version}`,
);
const src = join(dependent.root, "src");
const imported = existsSync(src)
? [...new Set(sourceFiles(src).flatMap((file) => importedNames(readFileSync(file, "utf8"), name)))].sort()
: [];
const prefix = `${dependency.directory}-v`;
const admitted = tags.filter((tag) => {
const version = tag.startsWith(prefix) ? tag.slice(prefix.length) : undefined;
return version !== undefined && semver.valid(version) !== null && semver.satisfies(version, range) && version !== dependency.manifest.version;
});
for (const tag of admitted) {
if (!built.has(tag)) {
built.set(tag, buildRelease(dependency.directory, tag, scratch));
}
const exports = built.get(tag);
const missing = imported.filter((symbol) => !(symbol in exports));
assert.deepEqual(
missing,
[],
`${dependent.manifest.name} declares ${name} ${range}, which admits ${tag}, and ${tag} does not export ${missing.join(", ")}`,
);
}
checked += 1;
console.log(`${dependent.manifest.name} -> ${name} ${range}: ${imported.length} imported names, ${admitted.length} admitted tagged releases checked`);
}
}
assert.ok(checked > 0, "no workspace package declares a dependency on another workspace package");
console.log(`Declared dependency ranges: ${checked} checked`);
} finally {
rmSync(scratch, { recursive: true, force: true });
}