mirror of
https://github.com/nocobase/nocobase.git
synced 2026-10-13 16:17:11 +08:00
* docs(lifecycle): add examples and docs * refactor(workflow): prune workflow * docs(workflow): prune workflow
412 lines
14 KiB
JavaScript
412 lines
14 KiB
JavaScript
// Verifies that every package the server half of a shipped module imports is declared as a runtime dependency.
|
|
//
|
|
// What makes this a real failure is that server code is deployed unbundled. `pnpm build` emits `dist/server` with its
|
|
// bare imports intact and installs a `node_modules` beside it from the generated `dist/package.json`, which is built
|
|
// by following `dependencies` alone. A server module importing something declared only as a devDependency therefore
|
|
// resolves in every development checkout and is simply absent on the deployed server.
|
|
// A plugin shipped exactly that: a server module imported `typescript`, which
|
|
// was a devDependency, so a built application crashed on start with `Cannot find package 'typescript'`.
|
|
//
|
|
// Client code fails the same way, one step later. A plugin's `client/` is not bundled by the plugin — `build` is
|
|
// `tsc`, so `dist/client/*.js` keeps its bare imports and the consuming application's Vite build resolves them.
|
|
// That application installed the plugin from a registry and has only what the published manifest declares, so a
|
|
// client import left in `devDependencies` fails there with `Could not resolve "…"`. `sonner` and `@xyflow/react`
|
|
// both shipped that way.
|
|
//
|
|
// Neither failure reproduces here: a workspace install links every devDependency into the plugin's own
|
|
// `node_modules`, so both resolve in development and fail only once installed from a registry.
|
|
//
|
|
// So the rule is one question: does someone outside this repository resolve this import? See AGENTS.md,
|
|
// "Declaring Dependencies by How They Are Used".
|
|
//
|
|
// Only value imports count. `import type` and type-only named bindings are erased before anything runs.
|
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import process from 'node:process';
|
|
import ts from 'typescript';
|
|
|
|
/** Groups whose packages ship code to a consumer. Templates are applications; they are the end of the line. */
|
|
const CHECKED_GROUPS = ['plugins', 'examples', 'libs', 'app', 'tools'];
|
|
|
|
const SOURCE_EXTENSIONS = new Set([
|
|
'.ts',
|
|
'.tsx',
|
|
'.mts',
|
|
'.cts',
|
|
'.js',
|
|
'.mjs',
|
|
]);
|
|
|
|
/**
|
|
* Directories never scanned, at any depth, whatever a manifest says about them.
|
|
*
|
|
* They hold tests and fixtures, which are excluded from every package here by its `files` field — their imports of
|
|
* `vitest` and of fixture-only packages are correctly devDependencies.
|
|
*/
|
|
const SKIPPED_DIRECTORIES = new Set([
|
|
'node_modules',
|
|
'tests',
|
|
'e2e',
|
|
'fixtures',
|
|
'__tests__',
|
|
'__fixtures__',
|
|
// `registry` is shadcn-style source copied into an application and compiled there against that application's own
|
|
// `react` and `@/` alias, which the plugin cannot resolve at all. `client` is checked — see below.
|
|
'registry',
|
|
// `template` is the same shape one level up: `@nocobase/create-plugin` ships it as the source of a generated
|
|
// plugin, which compiles it against the manifest `src/lib/template.ts` writes for that plugin. The generator
|
|
// resolves none of those imports itself, so declaring them here would claim dependencies it does not have.
|
|
'template',
|
|
]);
|
|
|
|
/**
|
|
* Top-level directories a package publishes, derived from `files`.
|
|
*
|
|
* `files` is the authoritative answer to whether code ships, so the scan follows it rather than a list maintained
|
|
* here — tooling that runs from a checkout, such as a plugin's `skill-evals`, is excluded because the
|
|
* manifest already excludes it, not because this script knows its name.
|
|
*
|
|
* A package that ships `dist` is checked through the source files in its emitting TypeScript projects instead; see
|
|
* `compiledSourceFiles`. Reading TypeScript keeps `import type` distinguishable from a value import.
|
|
*/
|
|
function publishedDirectories(manifest, entries) {
|
|
const files = manifest.files ?? [];
|
|
const named = new Set(
|
|
files
|
|
.map((entry) => entry.replace(/^\.\//u, '').split('/')[0])
|
|
.filter((entry) => entry && entry !== 'dist'),
|
|
);
|
|
|
|
return entries.filter(
|
|
(entry) => named.has(entry) && !SKIPPED_DIRECTORIES.has(entry),
|
|
);
|
|
}
|
|
|
|
/** Source files emitted under `dist` by a package's root TypeScript projects. */
|
|
async function compiledSourceFiles(packageDirectory) {
|
|
const entries = await readdir(packageDirectory);
|
|
const files = new Set();
|
|
for (const entry of entries) {
|
|
if (!/^tsconfig(?:\.[^/]+)?\.json$/u.test(entry)) continue;
|
|
const configPath = path.join(packageDirectory, entry);
|
|
const config = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
if (config.error) {
|
|
throw new Error(
|
|
ts.flattenDiagnosticMessageText(config.error.messageText, '\n'),
|
|
);
|
|
}
|
|
const parsed = ts.parseJsonConfigFileContent(
|
|
config.config,
|
|
ts.sys,
|
|
packageDirectory,
|
|
undefined,
|
|
configPath,
|
|
);
|
|
if (parsed.errors.length > 0) {
|
|
throw new Error(
|
|
`Cannot parse ${configPath}: ${ts.flattenDiagnosticMessageText(parsed.errors[0].messageText, '\n')}`,
|
|
);
|
|
}
|
|
const outDir = parsed.options.outDir;
|
|
if (parsed.options.noEmit || !outDir) continue;
|
|
const relativeOutDir = path.relative(packageDirectory, outDir);
|
|
if (
|
|
relativeOutDir !== 'dist' &&
|
|
!relativeOutDir.startsWith(`dist${path.sep}`)
|
|
)
|
|
continue;
|
|
for (const file of parsed.fileNames) {
|
|
const relative = path.relative(packageDirectory, file);
|
|
if (
|
|
relative === '..' ||
|
|
relative.startsWith(`..${path.sep}`) ||
|
|
path.isAbsolute(relative)
|
|
)
|
|
continue;
|
|
if (
|
|
relative.split(path.sep).some((part) => SKIPPED_DIRECTORIES.has(part))
|
|
)
|
|
continue;
|
|
if (!SOURCE_EXTENSIONS.has(path.extname(file)) || isExcludedFile(file))
|
|
continue;
|
|
files.add(file);
|
|
}
|
|
}
|
|
return [...files];
|
|
}
|
|
|
|
/** A file whose name marks it as tooling or a test rather than shipped code. */
|
|
function isExcludedFile(fileName) {
|
|
return (
|
|
/\.(test|spec)\.[cm]?[jt]sx?$/u.test(fileName) ||
|
|
/\.config\.[cm]?[jt]s$/u.test(fileName) ||
|
|
/\.d\.[cm]?ts$/u.test(fileName)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The package a specifier resolves to, or `undefined` when nothing has to be declared for it.
|
|
*
|
|
* Relative and absolute paths stay inside the package. `node:`-prefixed specifiers are builtins. Unprefixed builtins
|
|
* such as `fs` and `path` are builtins too — they read like packages but resolve without one, and reporting them
|
|
* would be pure noise. Subpath imports (`#internal`) and the `@/` alias are resolved by the consumer, not here.
|
|
*
|
|
* `virtual:` is the Rollup and Vite convention for a module a build plugin supplies. No package publishes one, so
|
|
* there is nothing to declare; the plugin that serves it is what has to be registered in the application's build.
|
|
*/
|
|
function packageNameOf(specifier, builtinModules) {
|
|
if (
|
|
specifier.startsWith('.') ||
|
|
specifier.startsWith('/') ||
|
|
specifier.startsWith('#') ||
|
|
specifier.startsWith('@/') ||
|
|
specifier.startsWith('node:') ||
|
|
specifier.startsWith('virtual:')
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
const segments = specifier.split('/');
|
|
const name = specifier.startsWith('@')
|
|
? segments.slice(0, 2).join('/')
|
|
: segments[0];
|
|
|
|
return builtinModules.has(name) ? undefined : name;
|
|
}
|
|
|
|
/**
|
|
* Collect actual runtime module references, not import-like text inside prose.
|
|
* Parse using the source filename so TS assertions and TSX elements are distinct.
|
|
* Computed module names remain outside the scope of this static check.
|
|
*/
|
|
export function collectRuntimeSpecifiers(source, fileName = 'source.tsx') {
|
|
const file = ts.createSourceFile(
|
|
fileName,
|
|
source,
|
|
ts.ScriptTarget.Latest,
|
|
true,
|
|
);
|
|
if (file.parseDiagnostics.length > 0) {
|
|
throw new Error(
|
|
`Cannot scan ${fileName}: ${ts.flattenDiagnosticMessageText(file.parseDiagnostics[0].messageText, '\n')}`,
|
|
);
|
|
}
|
|
const specifiers = new Set();
|
|
const add = (node) => {
|
|
if (
|
|
node &&
|
|
(ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
|
|
) {
|
|
specifiers.add(node.text);
|
|
}
|
|
};
|
|
const visit = (node) => {
|
|
if (ts.isImportDeclaration(node)) {
|
|
const clause = node.importClause;
|
|
const bindings = clause?.namedBindings;
|
|
const typeOnly =
|
|
clause?.isTypeOnly ||
|
|
(!clause?.name &&
|
|
bindings &&
|
|
ts.isNamedImports(bindings) &&
|
|
bindings.elements.length > 0 &&
|
|
bindings.elements.every((item) => item.isTypeOnly));
|
|
if (!typeOnly) add(node.moduleSpecifier);
|
|
} else if (ts.isExportDeclaration(node)) {
|
|
const clause = node.exportClause;
|
|
const typeOnly =
|
|
node.isTypeOnly ||
|
|
(clause &&
|
|
ts.isNamedExports(clause) &&
|
|
clause.elements.length > 0 &&
|
|
clause.elements.every((item) => item.isTypeOnly));
|
|
if (!typeOnly) add(node.moduleSpecifier);
|
|
} else if (ts.isImportEqualsDeclaration(node)) {
|
|
if (
|
|
!node.isTypeOnly &&
|
|
ts.isExternalModuleReference(node.moduleReference)
|
|
) {
|
|
add(node.moduleReference.expression);
|
|
}
|
|
} else if (ts.isCallExpression(node)) {
|
|
const callee = node.expression;
|
|
if (
|
|
callee.kind === ts.SyntaxKind.ImportKeyword ||
|
|
(ts.isIdentifier(callee) && callee.text === 'require')
|
|
) {
|
|
add(node.arguments[0]);
|
|
}
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
};
|
|
visit(file);
|
|
return specifiers;
|
|
}
|
|
|
|
async function collectSourceFiles(directory) {
|
|
let entries;
|
|
try {
|
|
entries = await readdir(directory, { withFileTypes: true });
|
|
} catch {
|
|
return [];
|
|
}
|
|
|
|
const files = [];
|
|
for (const entry of entries) {
|
|
if (entry.isDirectory()) {
|
|
if (SKIPPED_DIRECTORIES.has(entry.name)) continue;
|
|
files.push(
|
|
...(await collectSourceFiles(path.join(directory, entry.name))),
|
|
);
|
|
continue;
|
|
}
|
|
if (!SOURCE_EXTENSIONS.has(path.extname(entry.name))) continue;
|
|
if (isExcludedFile(entry.name)) continue;
|
|
files.push(path.join(directory, entry.name));
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
/**
|
|
* Undeclared runtime imports for one package.
|
|
*
|
|
* `peerDependencies` and `optionalDependencies` count as declared: a peer is provided by the consumer on purpose,
|
|
* which is how every plugin depends on the runtime it plugs into.
|
|
*/
|
|
export async function findViolations(
|
|
packageDirectory,
|
|
manifest,
|
|
builtinModules,
|
|
) {
|
|
const declared = new Set([
|
|
...Object.keys(manifest.dependencies ?? {}),
|
|
...Object.keys(manifest.peerDependencies ?? {}),
|
|
...Object.keys(manifest.optionalDependencies ?? {}),
|
|
]);
|
|
const devDependencies = new Set(Object.keys(manifest.devDependencies ?? {}));
|
|
|
|
const entries = (
|
|
await readdir(packageDirectory, { withFileTypes: true })
|
|
).flatMap((entry) => (entry.isDirectory() ? [entry.name] : []));
|
|
|
|
const namedFiles = (
|
|
await Promise.all(
|
|
publishedDirectories(manifest, entries).map((directory) =>
|
|
collectSourceFiles(path.join(packageDirectory, directory)),
|
|
),
|
|
)
|
|
).flat();
|
|
const shipsDist = (manifest.files ?? []).some(
|
|
(entry) => entry.replace(/^\.\//u, '').split('/')[0] === 'dist',
|
|
);
|
|
const files = new Set([
|
|
...namedFiles,
|
|
...(shipsDist ? await compiledSourceFiles(packageDirectory) : []),
|
|
]);
|
|
|
|
const offenders = new Map();
|
|
for (const file of files) {
|
|
const source = await readFile(file, 'utf8');
|
|
for (const specifier of collectRuntimeSpecifiers(source, file)) {
|
|
const name = packageNameOf(specifier, builtinModules);
|
|
if (!name || name === manifest.name || declared.has(name)) continue;
|
|
if (!offenders.has(name)) {
|
|
offenders.set(name, path.relative(packageDirectory, file));
|
|
}
|
|
}
|
|
}
|
|
|
|
return [...offenders]
|
|
.map(([dependency, file]) => ({
|
|
dependency,
|
|
file,
|
|
kind: devDependencies.has(dependency) ? 'dev-only' : 'undeclared',
|
|
}))
|
|
.sort((left, right) => left.dependency.localeCompare(right.dependency));
|
|
}
|
|
|
|
export async function collectPackages(repositoryRoot) {
|
|
const packages = [];
|
|
|
|
for (const group of CHECKED_GROUPS) {
|
|
const groupDirectory = path.join(repositoryRoot, 'packages', group);
|
|
let entries;
|
|
try {
|
|
entries = await readdir(groupDirectory, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const packageDirectory = path.join(groupDirectory, entry.name);
|
|
const manifestPath = path.join(packageDirectory, 'package.json');
|
|
try {
|
|
await stat(manifestPath);
|
|
} catch {
|
|
continue;
|
|
}
|
|
packages.push({
|
|
manifest: JSON.parse(await readFile(manifestPath, 'utf8')),
|
|
manifestPath,
|
|
packageDirectory,
|
|
});
|
|
}
|
|
}
|
|
|
|
return packages;
|
|
}
|
|
|
|
function messageFor(violation) {
|
|
return violation.kind === 'dev-only'
|
|
? `"${violation.dependency}" is imported at runtime by ${violation.file} but declared only in devDependencies, so it is absent wherever this package is installed from a registry`
|
|
: `"${violation.dependency}" is imported at runtime by ${violation.file} but is not declared in dependencies, peerDependencies, or optionalDependencies`;
|
|
}
|
|
|
|
async function main() {
|
|
const repositoryRoot = path.resolve(import.meta.dirname, '..');
|
|
const { builtinModules } = await import('node:module');
|
|
const builtins = new Set(builtinModules);
|
|
const packages = await collectPackages(repositoryRoot);
|
|
let failed = false;
|
|
|
|
for (const { manifest, manifestPath, packageDirectory } of packages) {
|
|
const violations = await findViolations(
|
|
packageDirectory,
|
|
manifest,
|
|
builtins,
|
|
);
|
|
if (violations.length === 0) continue;
|
|
|
|
failed = true;
|
|
const relativePath = path.relative(repositoryRoot, manifestPath);
|
|
console.error(`\n${relativePath} (${manifest.name})`);
|
|
for (const violation of violations) {
|
|
const message = messageFor(violation);
|
|
console.error(` - ${message}`);
|
|
if (process.env.GITHUB_ACTIONS) {
|
|
console.error(`::error file=${relativePath}::${message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (failed) {
|
|
console.error(
|
|
'\nDeclare each one in dependencies, or stop importing it from shipped code.',
|
|
);
|
|
console.error(
|
|
'See AGENTS.md, "Declaring Dependencies by How They Are Used".',
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(
|
|
`Checked ${packages.length} packages — every runtime import is declared.`,
|
|
);
|
|
}
|
|
|
|
if (process.argv[1] === import.meta.filename) {
|
|
await main();
|
|
}
|