mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
ef6ec2995b
* chore(layering): document R12/R18/R19, retire R8, make R9 shrink mandatory (#1781 A6) The A6 review kept `check:layering` in full (15/15 planted violations fired, no other enforcer exists) and left four follow-throughs. R12 bin-alias-fast-path, R18 contracts-implementation-authority and R19 selector-pipeline-ownership were live rules with no ADR or CONTEXT anchor — they now carry one each, in the same list as R7/R9/R10/R13. R8 zero-dep-job-closure is retired: no CI job sets `install-deps: false` and ci.yml records why each keeps it enabled, so the invariant has no subjects. R11's relative-into-packages exception existed only because a zero-dep closure cannot coexist with specifier loads, so it retires with R8; the route is now closed to every caller. R1 was retired the same way at #1490. R9 was growth-only and merely suggested lowering the ceiling, which is headroom the next change spends without a number moving. It is now an equality pin like R6 and the R10 R7 counts, and the committed baseline drops 47 -> 46 (daemon-server ceiling 17 -> 16) to match the measurement. ADR 0019 §6 now says each runtime-command-cutover row is deleted when that command's migration is declared closed. * chore(layering): rename R9 to type-cycle-size now that it fails both ways (#1781 A6)
320 lines
12 KiB
TypeScript
320 lines
12 KiB
TypeScript
// R11 package-boundaries: the workspace rules of #1490, as data the gate walks.
|
|
//
|
|
// Package resolution already makes a deep `@agent-device/*` specifier a runtime
|
|
// resolution error; these checks close the bypasses resolution alone cannot see:
|
|
// a package reaching back into root `src/`, a root file tunnelling into
|
|
// `packages/*/src` with a relative path, an import of a workspace package the
|
|
// manifest never declared, and a specifier subpath the owning `exports` map
|
|
// does not name.
|
|
//
|
|
// No relative route into a package is tolerated. Node's ESM loader does not
|
|
// realpath specifiers, so a module loaded BOTH relatively and via its package
|
|
// specifier instantiates twice in one process (duplicate AppError, broken
|
|
// instanceof). The one exception this rule used to grant — a file inside an R8
|
|
// zero-dep job closure, where no node_modules means specifier loads cannot
|
|
// coexist — retired with R8 itself (#1781 A6), because the repo runs no
|
|
// `install-deps: false` job for it to cover.
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { parseImports } from './model.ts';
|
|
|
|
export type PackageBoundaryViolation = {
|
|
rule: string;
|
|
file: string;
|
|
line: number;
|
|
message: string;
|
|
};
|
|
|
|
export type WorkspacePackage = {
|
|
/** Repo-relative package dir, e.g. `packages/kernel`. */
|
|
dir: string;
|
|
name: string;
|
|
/** Full import specifier -> repo-relative source target, from `exports`. */
|
|
exportTargets: ReadonlyMap<string, string>;
|
|
/** Declared `workspace:*` dependencies on sibling internal packages. */
|
|
workspaceDependencies: ReadonlySet<string>;
|
|
/** Non-workspace dependencies that the root build must externalize. */
|
|
externalDependencies: ReadonlyMap<string, string>;
|
|
};
|
|
|
|
export type SpecifierSite = {
|
|
file: string;
|
|
line: number;
|
|
specifier: string;
|
|
};
|
|
|
|
/**
|
|
* Every import specifier in `source`, with its 1-based line — through the
|
|
* layering model's own parser, so static/dynamic/side-effect/re-export sites
|
|
* and both quote styles are covered by one scanner instead of a private regex
|
|
* that silently missed double-quoted routes.
|
|
*/
|
|
export function specifierSites(file: string, source: string): SpecifierSite[] {
|
|
return parseImports(source).map((edge) => ({ file, line: edge.line, specifier: edge.spec }));
|
|
}
|
|
|
|
export function readWorkspacePackages(repoRoot: string): WorkspacePackage[] {
|
|
const packagesDir = path.join(repoRoot, 'packages');
|
|
if (!fs.existsSync(packagesDir)) return [];
|
|
const packages: WorkspacePackage[] = [];
|
|
for (const entry of fs.readdirSync(packagesDir).sort()) {
|
|
const manifestPath = path.join(packagesDir, entry, 'package.json');
|
|
if (!fs.existsSync(manifestPath)) continue;
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as {
|
|
name?: string;
|
|
private?: boolean;
|
|
exports?: Record<string, { default?: string } | string>;
|
|
dependencies?: Record<string, string>;
|
|
};
|
|
if (!manifest.name) continue;
|
|
const exportTargets = new Map<string, string>();
|
|
for (const [subpath, target] of Object.entries(manifest.exports ?? {})) {
|
|
const targetFile = typeof target === 'string' ? target : target.default;
|
|
if (!targetFile) continue;
|
|
exportTargets.set(
|
|
path.posix.join(manifest.name, subpath),
|
|
path.posix.join('packages', entry, path.posix.normalize(targetFile)),
|
|
);
|
|
}
|
|
const workspaceDependencies = new Set(
|
|
Object.entries(manifest.dependencies ?? {})
|
|
.filter(([, range]) => range.startsWith('workspace:'))
|
|
.map(([name]) => name),
|
|
);
|
|
const externalDependencies = new Map(
|
|
Object.entries(manifest.dependencies ?? {}).filter(
|
|
([, range]) => !range.startsWith('workspace:'),
|
|
),
|
|
);
|
|
packages.push({
|
|
dir: `packages/${entry}`,
|
|
name: manifest.name,
|
|
exportTargets,
|
|
workspaceDependencies,
|
|
externalDependencies,
|
|
});
|
|
}
|
|
return packages;
|
|
}
|
|
|
|
function packageByName(packages: readonly WorkspacePackage[], name: string) {
|
|
return packages.find((pkg) => pkg.name === name);
|
|
}
|
|
|
|
function specifierPackageName(specifier: string): string | undefined {
|
|
const match = /^(@[^/]+\/[^/]+)/.exec(specifier);
|
|
return match?.[1];
|
|
}
|
|
|
|
/**
|
|
* Rules for files INSIDE a package: no relative escape past the package dir;
|
|
* exported package self-references are legal; any sibling-package import must
|
|
* be declared `workspace:*`; and every package specifier must name an export.
|
|
*/
|
|
export function checkPackageInternalSites(
|
|
pkg: WorkspacePackage,
|
|
sites: readonly SpecifierSite[],
|
|
allPackages: readonly WorkspacePackage[],
|
|
): PackageBoundaryViolation[] {
|
|
const violations: PackageBoundaryViolation[] = [];
|
|
for (const site of sites) {
|
|
if (site.specifier.startsWith('.')) {
|
|
const resolved = path.posix.normalize(
|
|
path.posix.join(path.posix.dirname(site.file), site.specifier),
|
|
);
|
|
if (!resolved.startsWith(`${pkg.dir}/`)) {
|
|
violations.push({
|
|
rule: 'R11 package-boundaries',
|
|
file: site.file,
|
|
line: site.line,
|
|
message:
|
|
`'${site.specifier}' escapes ${pkg.dir}/ — a workspace package may not reach root ` +
|
|
`code. Depend on another package's specifier, or move the shared code below this package.`,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
const name = specifierPackageName(site.specifier);
|
|
if (!name || !name.startsWith('@agent-device/')) continue;
|
|
const target = packageByName(allPackages, name);
|
|
if (!target) {
|
|
violations.push({
|
|
rule: 'R11 package-boundaries',
|
|
file: site.file,
|
|
line: site.line,
|
|
message: `'${site.specifier}' names an unknown workspace package.`,
|
|
});
|
|
continue;
|
|
}
|
|
if (name !== pkg.name && !pkg.workspaceDependencies.has(name)) {
|
|
violations.push({
|
|
rule: 'R11 package-boundaries',
|
|
file: site.file,
|
|
line: site.line,
|
|
message:
|
|
`${pkg.name} imports '${site.specifier}' without declaring "${name}": "workspace:*" ` +
|
|
`in ${pkg.dir}/package.json dependencies.`,
|
|
});
|
|
}
|
|
if (!target.exportTargets.has(site.specifier)) {
|
|
violations.push({
|
|
rule: 'R11 package-boundaries',
|
|
file: site.file,
|
|
line: site.line,
|
|
message:
|
|
`'${site.specifier}' is not named by ${target.dir}/package.json#exports — import an ` +
|
|
`exported subpath or earn a new one with a real consumer.`,
|
|
});
|
|
}
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
/**
|
|
* Rules for files OUTSIDE packages/ (src, test, scripts): workspace specifiers
|
|
* must be root-declared and exports-named, and relative paths into
|
|
* `packages/·/src` are forbidden outright.
|
|
*/
|
|
export function checkRootSites(
|
|
sites: readonly SpecifierSite[],
|
|
packages: readonly WorkspacePackage[],
|
|
rootWorkspaceDependencies: ReadonlySet<string>,
|
|
): PackageBoundaryViolation[] {
|
|
const violations: PackageBoundaryViolation[] = [];
|
|
for (const site of sites) {
|
|
if (site.specifier.startsWith('.')) {
|
|
const resolved = path.posix.normalize(
|
|
path.posix.join(path.posix.dirname(site.file), site.specifier),
|
|
);
|
|
if (!/^packages\/[^/]+\//.test(resolved)) continue;
|
|
violations.push({
|
|
rule: 'R11 package-boundaries',
|
|
file: site.file,
|
|
line: site.line,
|
|
message:
|
|
`'${site.specifier}' bypasses the package boundary — import the package specifier ` +
|
|
`instead, or dual specifier/relative loads instantiate the module twice.`,
|
|
});
|
|
continue;
|
|
}
|
|
const name = specifierPackageName(site.specifier);
|
|
if (!name || !name.startsWith('@agent-device/')) continue;
|
|
const target = packageByName(packages, name);
|
|
if (!target) {
|
|
violations.push({
|
|
rule: 'R11 package-boundaries',
|
|
file: site.file,
|
|
line: site.line,
|
|
message: `'${site.specifier}' names an unknown workspace package.`,
|
|
});
|
|
continue;
|
|
}
|
|
if (!rootWorkspaceDependencies.has(name)) {
|
|
violations.push({
|
|
rule: 'R11 package-boundaries',
|
|
file: site.file,
|
|
line: site.line,
|
|
message:
|
|
`'${site.specifier}' is used but "${name}" is not a "workspace:*" entry in the root ` +
|
|
`package.json devDependencies.`,
|
|
});
|
|
}
|
|
if (!target.exportTargets.has(site.specifier)) {
|
|
violations.push({
|
|
rule: 'R11 package-boundaries',
|
|
file: site.file,
|
|
line: site.line,
|
|
message:
|
|
`'${site.specifier}' is not named by ${target.dir}/package.json#exports — deep imports ` +
|
|
`into package internals are a resolution error; import an exported subpath.`,
|
|
});
|
|
}
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
/** Root-manifest `workspace:*` names, from dependencies + devDependencies. */
|
|
export function rootWorkspaceDependencyNames(repoRoot: string): Set<string> {
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as {
|
|
dependencies?: Record<string, string>;
|
|
devDependencies?: Record<string, string>;
|
|
};
|
|
return new Set(
|
|
Object.entries({ ...manifest.dependencies, ...manifest.devDependencies })
|
|
.filter(([, range]) => range.startsWith('workspace:'))
|
|
.map(([name]) => name),
|
|
);
|
|
}
|
|
|
|
/** Root runtime dependency ranges used by the published bundle. */
|
|
export function rootExternalDependencyRanges(repoRoot: string): Map<string, string> {
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as {
|
|
dependencies?: Record<string, string>;
|
|
};
|
|
return new Map(Object.entries(manifest.dependencies ?? {}));
|
|
}
|
|
|
|
function walkTsFiles(repoRoot: string, relativeDir: string): string[] {
|
|
const absolute = path.join(repoRoot, relativeDir);
|
|
if (!fs.existsSync(absolute)) return [];
|
|
const files: string[] = [];
|
|
const queue = [absolute];
|
|
while (queue.length > 0) {
|
|
const dir = queue.pop()!;
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (entry.name !== 'node_modules' && entry.name !== 'dist-types') queue.push(full);
|
|
} else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) {
|
|
files.push(path.relative(repoRoot, full).replaceAll(path.sep, '/'));
|
|
}
|
|
}
|
|
}
|
|
return files.sort();
|
|
}
|
|
|
|
/** Flat `specifier -> repo-relative source` map across all workspace packages. */
|
|
export function workspaceSpecifierTargets(repoRoot: string): Map<string, string> {
|
|
const targets = new Map<string, string>();
|
|
for (const pkg of readWorkspacePackages(repoRoot)) {
|
|
for (const [specifier, target] of pkg.exportTargets) targets.set(specifier, target);
|
|
}
|
|
return targets;
|
|
}
|
|
|
|
/** The real-tree R11 run used by check.ts. */
|
|
export function checkPackageBoundaries(repoRoot: string): PackageBoundaryViolation[] {
|
|
const packages = readWorkspacePackages(repoRoot);
|
|
if (packages.length === 0) return [];
|
|
const rootDependencies = rootWorkspaceDependencyNames(repoRoot);
|
|
const violations: PackageBoundaryViolation[] = [];
|
|
for (const pkg of packages) {
|
|
for (const file of walkTsFiles(repoRoot, pkg.dir)) {
|
|
const source = fs.readFileSync(path.join(repoRoot, file), 'utf8');
|
|
violations.push(...checkPackageInternalSites(pkg, specifierSites(file, source), packages));
|
|
}
|
|
}
|
|
for (const root of ['src', 'test', 'scripts']) {
|
|
for (const file of walkTsFiles(repoRoot, root)) {
|
|
// Gate tests under scripts/ carry import syntax inside fixture strings
|
|
// (which is why this reads module records instead of scanning lines);
|
|
// src/ and test/ suites stay covered — they import packages for real.
|
|
if (root === 'scripts' && file.endsWith('.test.ts')) continue;
|
|
const source = fs.readFileSync(path.join(repoRoot, file), 'utf8');
|
|
violations.push(...checkRootSites(specifierSites(file, source), packages, rootDependencies));
|
|
}
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
/** Success-line fragment for check.ts's report. */
|
|
export function packageBoundariesSummary(repoRoot: string): string {
|
|
const packages = readWorkspacePackages(repoRoot);
|
|
const exported = packages.reduce((sum, pkg) => sum + pkg.exportTargets.size, 0);
|
|
return (
|
|
`R11 holds ${packages.length} workspace package(s) behind ${exported} exported subpath(s) ` +
|
|
`with zero root back-imports`
|
|
);
|
|
}
|