Files
nocobase/scripts/check-peer-deps.mjs
T
chenos 71dbc34542 feat(lifecycle): add record lifecycles with ticket, expense and office flow examples (#519)
Add record lifecycle definitions, transactional transitions, durable effects, recovery, jobs integration, and React bindings. Include ticket, expense, and office flow examples in the Examples application, with concurrency and database compatibility fixes.
2026-10-08 20:55:03 -04:00

229 lines
9.6 KiB
JavaScript

// Verifies that packages depend on identity-sensitive workspace packages through peerDependencies rather than
// dependencies.
//
// An identity-sensitive package carries runtime state whose behavior depends on the module instance being unique in a
// process: a `ServiceToken` is compared by object identity in `ServiceContainer`'s `Map`, a React context object only
// matches the provider created from the same module, and a plugin typed against a host-owned service such as
// `@nocobase/queue`'s `QueueService` needs the host's version of that contract. Two copies of such a package silently
// split that state or that contract.
//
// The monorepo hides the problem because `workspace:` links every consumer to one directory. It surfaces only after
// publishing, when a package manager is free to install a second copy to satisfy a `dependencies` range — no warning
// at install time, a missing service or an undefined context at runtime in a customer's application.
import { readFile, readdir } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
// Packages whose runtime identity must be unique in a process.
//
// This list records decisions already made; it is not the rule and it is expected to be incomplete. A package belongs
// here when it exports something that only works while one copy of the module exists: a value used as a key by
// identity (a ServiceToken in the container's Map), a React context, a module-level singleton, or a registration into
// a process-wide registry. A package exporting only classes, functions, and types holds nothing a second copy could
// split, and stays an ordinary dependency until it gains one of those exports.
//
// The reason is recorded per package because it is what lets someone apply the rule to a package not listed here. A
// bare list gets copied without being understood. See AGENTS.md, "Depending on Identity-Sensitive Packages".
export const IDENTITY_SENSITIVE_PACKAGES = new Map([
[
'@nocobase/service-provider',
'ServiceToken objects are compared by identity in the service container',
],
['@nocobase/db', 'exports databaseManagerToken and migration identity'],
[
'@nocobase/app-server',
'exports the service tokens every server plugin resolves against',
],
[
'@nocobase/app-client',
'exports React contexts plus identity-keyed API and realtime client tokens',
],
[
'@nocobase/app-cli',
'AppCommand reads the application the runner located and the runtimes it tracks; a plugin command built on a second copy runs under another version of that contract',
],
['@nocobase/i18n', 'exports React contexts for the i18n runtime'],
[
'@nocobase/queue',
'types the host-owned QueueService that plugins resolve from queueServiceToken',
],
[
'@nocobase/lifecycle',
'a LifecycleRuntime is shared between plugins through addGuard(), and EffectFailure and LifecycleError are recognised with instanceof, so a second copy neither registers into the runtime it is handed nor recognises the errors thrown by the other',
],
]);
// Packages a guest reaches only through the host that wraps them, so it declares them in no dependency field at all.
//
// A peer would not be enough here: the host is the only consumer whose copy counts, and a plugin naming the package
// itself, at any range, invites a second copy that the host never sees. Each entry says which host export to use.
export const HOST_ONLY_PACKAGES = new Map([
[
'hono-openapi',
"route metadata is attached to middleware under a symbol hono-openapi owns, and the document is generated by @nocobase/app-server's copy, which cannot see metadata attached by another; import describeRoute, apiValidator and the response helpers from @nocobase/app-server/router",
],
]);
/** Plugins export service tokens for one another, so a plugin-to-plugin dependency carries the same risk. */
export function isIdentitySensitive(packageName) {
return (
IDENTITY_SENSITIVE_PACKAGES.has(packageName) ||
packageName.startsWith('@nocobase/app-plugin-')
);
}
export function reasonFor(packageName) {
return (
IDENTITY_SENSITIVE_PACKAGES.get(packageName) ??
'plugins export service tokens that consumers resolve by object identity'
);
}
/**
* Violations for a single manifest.
*
* One rule: an identity-sensitive package is a peer, never a dependency, so an application provides exactly one copy.
*
* A matching devDependency used to be required alongside each workspace peer, on the grounds that the peer range is
* wide enough for development to drift off this repository's copy. It does not: pnpm resolves a `workspace:^` peer to
* the package in this repository, the same as `workspace:*` would, and a plugin with the devDependency removed still
* links, typechecks, builds, and tests against it. The rule asked for a second declaration that changed nothing, so
* every peer — workspace or third-party — is now declared once.
*/
export function findViolations(manifest) {
const dependencies = Object.keys(manifest.dependencies ?? {});
const violations = [];
for (const dependency of dependencies) {
if (!isIdentitySensitive(dependency)) continue;
if (dependency === manifest.name) continue;
violations.push({
kind: 'should-be-peer',
dependency,
message: `"${dependency}" must be a peerDependency, not a dependency — ${reasonFor(dependency)}`,
});
}
for (const field of [
'dependencies',
'peerDependencies',
'devDependencies',
'optionalDependencies',
]) {
for (const dependency of Object.keys(manifest[field] ?? {})) {
if (!HOST_ONLY_PACKAGES.has(dependency)) continue;
violations.push({
kind: 'host-only',
dependency,
message: `"${dependency}" must not be declared (found in ${field}) — ${HOST_ONLY_PACKAGES.get(dependency)}`,
});
}
}
return violations;
}
// Guests are checked: a plugin, or a tool loaded into an application, arrives in an application that already provides
// the runtime, so it must never install its own copy.
//
// The other groups are hosts rather than guests. `packages/app` and `packages/libs` compose the runtime — `app-server`
// depending on `@nocobase/db` is what puts the single copy in place for everyone else — and `packages/templates` are
// applications, which is the side that satisfies a peer range. Requiring peers there would leave the ranges with
// nothing to resolve against.
//
// A new group under `packages/` needs a deliberate decision about which side of that line it sits on before it is
// added here.
const CHECKED_GROUPS = ['plugins', 'examples', 'tools'];
// Guests that live in a host group. `@nocobase/app-cli` sits in `packages/app` but is installed into an application
// and runs against the `@nocobase/app-server` and `@nocobase/db` the application provides, so it is held to the same
// rule as a plugin. It came from `packages/tools` (as `nb3-cli` and `app-tools`), where the group rule covered it.
const CHECKED_PACKAGES = ['app/app-cli'];
export async function collectPackages(repositoryRoot) {
const packages = [];
for (const relative of CHECKED_PACKAGES) {
const manifestPath = path.join(
repositoryRoot,
'packages',
relative,
'package.json',
);
try {
packages.push({
manifest: JSON.parse(await readFile(manifestPath, 'utf8')),
manifestPath,
});
} catch (error) {
// Absent in a fixture repository; the group scan below still runs. Anything else — a manifest that does not
// parse, or cannot be read — fails the check rather than dropping the package from it.
if (error.code !== 'ENOENT') throw error;
}
}
for (const group of CHECKED_GROUPS) {
const groupDirectory = path.join(repositoryRoot, 'packages', group);
let entries;
try {
entries = await readdir(groupDirectory, { withFileTypes: true });
} catch (error) {
if (error.code !== 'ENOENT') throw error;
continue;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const manifestPath = path.join(
groupDirectory,
entry.name,
'package.json',
);
let manifest;
try {
manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
} catch (error) {
// A directory without a manifest is not a package. One whose manifest does not parse is a package the check
// would otherwise pass over in silence.
if (error.code !== 'ENOENT') throw error;
continue;
}
packages.push({ manifest, manifestPath });
}
}
return packages;
}
async function main() {
const repositoryRoot = path.resolve(import.meta.dirname, '..');
const packages = await collectPackages(repositoryRoot);
let failed = false;
for (const { manifest, manifestPath } of packages) {
const violations = findViolations(manifest);
if (violations.length === 0) continue;
failed = true;
const relativePath = path.relative(repositoryRoot, manifestPath);
console.error(`\n${relativePath} (${manifest.name})`);
for (const violation of violations) {
console.error(` - ${violation.message}`);
if (process.env.GITHUB_ACTIONS) {
console.error(`::error file=${relativePath}::${violation.message}`);
}
}
}
if (failed) {
console.error(
'\nMove each identity-sensitive entry to peerDependencies, and remove each host-only entry in favor of the host export named above.',
);
console.error('See AGENTS.md, "Depending on Identity-Sensitive Packages".');
process.exit(1);
}
console.log(
`Checked ${packages.length} packages — no peer dependency violations.`,
);
}
if (process.argv[1] === import.meta.filename) {
await main();
}