#!/usr/bin/env node
* Every consumer package names one import root, never both.
*
* A published shell (`@prisma/orm-*`) bundles a copy of each internal package
* it carries, so a module reached through a shell and the same module reached
* through its `@internal/*` workspace name are two objects. A package that
* names both roots therefore runs two copies of everything they share: two
* registries, two sets of classes, two of every value compared by reference.
* See `packages/9-public/@prisma/orm-framework/test/module-identity.test.ts`
* for the demonstration.
*
* The failure is silent. Both copies type-check, both behave identically in
* isolation, and the divergence shows up only as an `instanceof` that stops
* holding or a lookup that stops finding — which is why this is a lint and
* not something left to review.
*
* Scope is the consumer trees: `examples/`, `apps/` and `test/`. Packages
* under `packages/` are the substrate the shells are built from and name
* workspace packages by construction; the shells' own tests legitimately name
* both, and are excluded with them. The packaging suites under
* `test/integration/test/packaging/` are exempt for the same reason: their
* published-root specifiers are strings handed to scratch projects that
* install packed tarballs and run in a child process, so both roots never
* load into one module graph.
*
* Exits 1 listing every mixed package; exits 0 otherwise.
*/
import { readdirSync, readFileSync } from 'node:fs';
import { extname, join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const CONSUMER_ROOTS = ['examples', 'apps', 'test'];
const EXEMPT_SUBTREES = ['test/integration/test/packaging'];
const INTERNAL_SCOPE = '@internal/';
const PUBLISHED_SCOPE = '@prisma/orm-';
const INCLUDED_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']);
const EXCLUDED_DIRECTORIES = new Set([
'node_modules',
'dist',
'dist-tsc',
'dist-tsc-prod',
'coverage',
'.tmp-output',
'.next',
'.turbo',
'build',
]);
const MODULE_SPECIFIER = /\b(?:from|import)\s*\(?\s*(['"])([^'"\n]+)\1/g;
const repoRoot = join(fileURLToPath(new URL('.', import.meta.url)), '..');
function* walkFiles(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (EXCLUDED_DIRECTORIES.has(entry.name)) continue;
const full = join(dir, entry.name);
if (entry.isDirectory()) yield* walkFiles(full);
else if (entry.isFile() && INCLUDED_EXTENSIONS.has(extname(full))) yield full;
}
}
* Consumer package directories: any directory under a consumer root that has
* a `package.json`. Nested ones count separately — the CLI test-app fixtures
* install their own dependencies and resolve independently of their parent.
*/
function* walkPackages(dir) {
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
if (entries.some((entry) => entry.name === 'package.json')) yield dir;
for (const entry of entries) {
if (EXCLUDED_DIRECTORIES.has(entry.name)) continue;
if (entry.isDirectory()) yield* walkPackages(join(dir, entry.name));
}
}
function ownFiles(pkg, allPackages) {
const nested = allPackages.filter((other) => other !== pkg && other.startsWith(pkg + sep));
return [...walkFiles(pkg)].filter((file) => !nested.some((dir) => file.startsWith(dir + sep)));
}
* Consumer packages naming both roots, each with one example specifier per
* root and how many more there are. Takes the roots as an argument so the
* check is reachable from a test with a fixture tree of its own.
*/
export function findMixedPackages(baseDir, roots = CONSUMER_ROOTS, exempt = EXEMPT_SUBTREES) {
const packages = roots.flatMap((root) => [...walkPackages(join(baseDir, root))]);
const exemptPrefixes = exempt.map((subtree) => join(baseDir, subtree) + sep);
const mixed = [];
for (const pkg of packages) {
const internal = new Map();
const published = new Map();
const files = ownFiles(pkg, packages).filter(
(file) => !exemptPrefixes.some((prefix) => file.startsWith(prefix)),
);
for (const file of files) {
for (const [, , specifier] of readFileSync(file, 'utf8').matchAll(MODULE_SPECIFIER)) {
const seen = specifier.startsWith(INTERNAL_SCOPE)
? internal
: specifier.startsWith(PUBLISHED_SCOPE)
? published
: undefined;
if (seen !== undefined && !seen.has(specifier))
seen.set(specifier, relative(baseDir, file));
}
}
if (internal.size > 0 && published.size > 0) {
mixed.push({ pkg: relative(baseDir, pkg), internal, published });
}
}
return mixed;
}
* Checks the tree at `baseDir`, defaulting to this repository. The argument
* exists for the same reason `findMixedPackages` takes its roots: so a test
* can run the check the way CI runs it — through this entry point, exit code
* and message included — against a tree it built itself.
*/
export function main(baseDir = repoRoot) {
const mixed = findMixedPackages(baseDir);
if (mixed.length === 0) {
console.log(`No consumer package under ${CONSUMER_ROOTS.join(', ')} names both import roots.`);
return 0;
}
console.error(`${mixed.length} consumer package(s) name both import roots:`);
for (const entry of mixed) {
console.error(`\n ${entry.pkg}`);
for (const [scope, seen] of [
['internal', entry.internal],
['published', entry.published],
]) {
const [specifier, file] = [...seen][0];
console.error(
` ${scope}: ${specifier} (${file})` +
(seen.size > 1 ? ` and ${seen.size - 1} more` : ''),
);
}
}
console.error(
'\nA published shell bundles its own copy of each internal package, so naming\n' +
'both roots loads two copies of every module they share. Move the package to\n' +
'one root; if the published surface has no name for something it needs, that\n' +
'is a gap in the surface (ADR 242), not a reason to keep the workspace name.',
);
return 1;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
process.exit(main(process.argv[2]));
}