1060 lines
31 KiB
TypeScript
1060 lines
31 KiB
TypeScript
import { parseAsync } from "@babel/core";
|
|
import { spawnSync } from "node:child_process";
|
|
import { mkdir, readFile, readdir } from "node:fs/promises";
|
|
import { dirname, extname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { createRequire } from "node:module";
|
|
|
|
import { architectureDependencyReportArtifactSchema } from "./contracts/release-artifacts.ts";
|
|
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
|
|
|
type PathRule = Readonly<{ path?: string; pathNot?: string }>;
|
|
type ArchitectureRule = Readonly<{
|
|
name: string;
|
|
severity?: string;
|
|
from?: PathRule;
|
|
to?: PathRule & Readonly<{ circular?: boolean }>;
|
|
}>;
|
|
type ArchitectureConfig = Readonly<{
|
|
forbidden: readonly ArchitectureRule[];
|
|
allowed: readonly unknown[];
|
|
required: readonly unknown[];
|
|
}>;
|
|
type DependencyEdge = Readonly<{
|
|
source: string;
|
|
target: string;
|
|
specifier: string;
|
|
kind: "local" | "external";
|
|
}>;
|
|
type UnresolvedDependency = Readonly<{
|
|
source: string;
|
|
specifier: string;
|
|
reason: string;
|
|
}>;
|
|
type ParseFailure = Readonly<{ source: string; reason: string }>;
|
|
type ArchitectureViolation = Readonly<{
|
|
rule: string;
|
|
severity: string;
|
|
source: string;
|
|
target: string;
|
|
cycle?: readonly string[];
|
|
}>;
|
|
type SourceGraph = Readonly<{
|
|
modules: string[];
|
|
dependencies: DependencyEdge[];
|
|
unresolved: UnresolvedDependency[];
|
|
parseFailures: ParseFailure[];
|
|
cycles: string[][];
|
|
violations: ArchitectureViolation[];
|
|
}>;
|
|
type GraphFixtureResult = Readonly<{
|
|
passed: boolean;
|
|
checks: string[];
|
|
failures: string[];
|
|
}>;
|
|
type DependencyResolution = Readonly<{ path?: string; reason: string }>;
|
|
type DependencyCruiserReport = Record<string, unknown>;
|
|
type TypeScriptOnlyPolicy = Readonly<{
|
|
checkedRoots: readonly string[];
|
|
exceptionsAllowed: false;
|
|
violations: readonly string[];
|
|
passed: boolean;
|
|
}>;
|
|
|
|
type BabelOptions = NonNullable<Parameters<typeof parseAsync>[1]>;
|
|
type BabelParserOptions = NonNullable<BabelOptions["parserOpts"]>;
|
|
type BabelParserPlugin = NonNullable<BabelParserOptions["plugins"]>[number];
|
|
|
|
const projectRoot = process.cwd();
|
|
const sourceRoot = resolve(projectRoot, "src");
|
|
const qualityArtifact = resolve(
|
|
projectRoot,
|
|
"artifacts/quality/dependency-report.json",
|
|
);
|
|
const require = createRequire(import.meta.url);
|
|
const architectureConfig = parseArchitectureConfig(
|
|
require(resolve(projectRoot, ".dependency-cruiser.json")),
|
|
);
|
|
const architectureRules = architectureConfig.forbidden;
|
|
const sourceExtensionPattern = /\.(?:ts|tsx|mts|cts)$/u;
|
|
const declarationExtensionPattern = /\.d\.(?:ts|mts|cts)$/u;
|
|
const lintFixtureExtensionPattern = sourceExtensionPattern;
|
|
const forbiddenJavaScriptExtensionPattern = /\.(?:js|jsx|mjs|cjs)$/u;
|
|
const forbiddenLocalJavaScriptSpecifierPattern =
|
|
/\.(?:js|jsx|mjs|cjs)(?:[?#]|$)/u;
|
|
const forbiddenLocalJavaScriptSpecifierReason =
|
|
"local JavaScript-family specifiers are forbidden; use the actual TypeScript extension";
|
|
const relativeSpecifierPattern = /^\.{1,2}(?:\/|$)/u;
|
|
|
|
await mkdir(dirname(qualityArtifact), { recursive: true });
|
|
|
|
const configuredPnpmCli = process.env.npm_execpath;
|
|
|
|
if (!configuredPnpmCli) {
|
|
throw new Error("check:architecture must run through the pnpm script");
|
|
}
|
|
const pnpmCli = configuredPnpmCli;
|
|
|
|
if (
|
|
(architectureConfig.allowed?.length ?? 0) > 0 ||
|
|
(architectureConfig.required?.length ?? 0) > 0
|
|
) {
|
|
throw new Error(
|
|
"Static architecture graph must be extended before allowed/required rules are configured",
|
|
);
|
|
}
|
|
validateArchitectureRules(architectureRules);
|
|
|
|
function runPnpm(arguments_: string[]) {
|
|
return spawnSync(process.execPath, [pnpmCli, ...arguments_], {
|
|
encoding: "utf8",
|
|
});
|
|
}
|
|
|
|
// Keep dependency-cruiser's report and checks. The second graph is authoritative
|
|
// for TypeScript 7 coverage because dependency-cruiser 18 cannot parse TS 7 yet.
|
|
const dependencyCruiser = runPnpm([
|
|
"exec",
|
|
"depcruise",
|
|
"src",
|
|
"--config",
|
|
".dependency-cruiser.json",
|
|
"--output-type",
|
|
"json",
|
|
]);
|
|
const sourceGraph = await analyzeSourceGraph(sourceRoot, "src");
|
|
const typeScriptOnlyPolicy = await inspectTypeScriptOnlyPolicy();
|
|
const graphFixtureResult = await runGraphFixtureChecks();
|
|
const dependencyReport = parseDependencyCruiserReport(dependencyCruiser.stdout);
|
|
const graphErrors = blockingViolations(sourceGraph);
|
|
const dependencyCruiserReportValid = !(
|
|
"dependencyCruiserOutput" in dependencyReport
|
|
);
|
|
|
|
dependencyReport.staticImportGraph = {
|
|
analyzer: "babel-parser-node-resolver",
|
|
modules: sourceGraph.modules,
|
|
dependencies: sourceGraph.dependencies,
|
|
unresolved: sourceGraph.unresolved,
|
|
parseFailures: sourceGraph.parseFailures,
|
|
cycles: sourceGraph.cycles,
|
|
violations: sourceGraph.violations,
|
|
summary: {
|
|
modules: sourceGraph.modules.length,
|
|
typescriptModules: sourceGraph.modules.filter((module) =>
|
|
sourceExtensionPattern.test(module),
|
|
).length,
|
|
dependencies: sourceGraph.dependencies.length,
|
|
localDependencies: sourceGraph.dependencies.filter(
|
|
({ kind }) => kind === "local",
|
|
).length,
|
|
unresolved: sourceGraph.unresolved.length,
|
|
parseFailures: sourceGraph.parseFailures.length,
|
|
cycles: sourceGraph.cycles.length,
|
|
errors: graphErrors.length,
|
|
typeScriptOnlyPolicyPassed: typeScriptOnlyPolicy.passed,
|
|
nonTypeScriptExecutableSources: typeScriptOnlyPolicy.violations.length,
|
|
},
|
|
fixtureChecks: graphFixtureResult,
|
|
typeScriptOnlySourcePolicy: typeScriptOnlyPolicy,
|
|
};
|
|
|
|
await writeValidatedJsonArtifact({
|
|
path: qualityArtifact,
|
|
schema: architectureDependencyReportArtifactSchema,
|
|
value: dependencyReport,
|
|
});
|
|
|
|
let architectureFailed = false;
|
|
|
|
if (!dependencyCruiserReportValid) {
|
|
architectureFailed = true;
|
|
process.stderr.write("dependency-cruiser returned an invalid JSON report\n");
|
|
}
|
|
|
|
if (dependencyCruiser.status !== 0) {
|
|
architectureFailed = true;
|
|
process.stderr.write(
|
|
(dependencyCruiser.error?.message ?? dependencyCruiser.stderr) ||
|
|
dependencyCruiser.stdout ||
|
|
"dependency-cruiser failed\n",
|
|
);
|
|
}
|
|
|
|
for (const failure of sourceGraph.parseFailures) {
|
|
architectureFailed = true;
|
|
process.stderr.write(
|
|
`Architecture graph parse failure: ${failure.source}: ${failure.reason}\n`,
|
|
);
|
|
}
|
|
|
|
for (const dependency of sourceGraph.unresolved) {
|
|
architectureFailed = true;
|
|
process.stderr.write(
|
|
`Unresolved architecture dependency: ${dependency.source} -> ${dependency.specifier} (${dependency.reason})\n`,
|
|
);
|
|
}
|
|
|
|
for (const violation of graphErrors) {
|
|
architectureFailed = true;
|
|
const cycle = violation.cycle
|
|
? ` (cycle group: ${violation.cycle.join(", ")})`
|
|
: "";
|
|
process.stderr.write(
|
|
`Architecture violation [${violation.rule}]: ${violation.source} -> ${violation.target}${cycle}\n`,
|
|
);
|
|
}
|
|
|
|
for (const file of typeScriptOnlyPolicy.violations) {
|
|
architectureFailed = true;
|
|
process.stderr.write(`Non-TypeScript executable source: ${file}\n`);
|
|
}
|
|
|
|
if (!graphFixtureResult.passed) {
|
|
architectureFailed = true;
|
|
for (const failure of graphFixtureResult.failures) {
|
|
process.stderr.write(`Architecture graph fixture failed: ${failure}\n`);
|
|
}
|
|
}
|
|
|
|
if (architectureFailed) {
|
|
process.exit(1);
|
|
}
|
|
|
|
process.stdout.write(
|
|
`Static import graph: ${sourceGraph.modules.length} modules, ${sourceGraph.dependencies.length} dependencies, all imports resolved\n`,
|
|
);
|
|
process.stdout.write(
|
|
`Architecture graph fixtures: ${graphFixtureResult.checks.length} regression checks PASS\n`,
|
|
);
|
|
process.stdout.write(
|
|
`TypeScript-only source policy: PASS (${typeScriptOnlyPolicy.checkedRoots.join(", ")}; no fixture exceptions)\n`,
|
|
);
|
|
|
|
const allowed = runPnpm([
|
|
"exec",
|
|
"eslint",
|
|
"tests/fixtures/architecture/allowed",
|
|
"--no-ignore",
|
|
"--max-warnings=0",
|
|
]);
|
|
|
|
const forbidden = runPnpm([
|
|
"exec",
|
|
"eslint",
|
|
"tests/fixtures/architecture/forbidden",
|
|
"--no-ignore",
|
|
"--max-warnings=0",
|
|
]);
|
|
|
|
async function fixtureFiles(directory: string): Promise<string[]> {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
const files = await Promise.all(
|
|
entries.map((entry) => {
|
|
const target = `${directory}/${entry.name}`;
|
|
return entry.isDirectory()
|
|
? fixtureFiles(target)
|
|
: lintFixtureExtensionPattern.test(entry.name)
|
|
? [target]
|
|
: [];
|
|
}),
|
|
);
|
|
return files.flat();
|
|
}
|
|
|
|
const forbiddenResults = await Promise.all(
|
|
(await fixtureFiles("tests/fixtures/architecture/forbidden")).map((file) => ({
|
|
file,
|
|
result: runPnpm([
|
|
"exec",
|
|
"eslint",
|
|
file,
|
|
"--no-ignore",
|
|
"--max-warnings=0",
|
|
]),
|
|
})),
|
|
);
|
|
const acceptedForbidden = forbiddenResults.filter(
|
|
({ result }) => result.status === 0,
|
|
);
|
|
|
|
if (
|
|
allowed.status !== 0 ||
|
|
forbidden.status === 0 ||
|
|
acceptedForbidden.length > 0
|
|
) {
|
|
process.stderr.write(allowed.stderr || allowed.stdout);
|
|
process.stderr.write(forbidden.stderr || forbidden.stdout);
|
|
for (const { file } of acceptedForbidden) {
|
|
process.stderr.write(`Forbidden fixture was accepted: ${file}\n`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
process.stdout.write(
|
|
`Architecture fixtures: allowed PASS, ${forbiddenResults.length} forbidden rejected\n`,
|
|
);
|
|
|
|
async function analyzeSourceGraph(
|
|
rootDirectory: string,
|
|
reportPrefix: string,
|
|
): Promise<SourceGraph> {
|
|
const allFiles = await listFiles(rootDirectory);
|
|
const allFileSet = new Set(allFiles);
|
|
const sourceFiles = allFiles.filter((file) => sourceExtensionPattern.test(file));
|
|
const modules = sourceFiles
|
|
.map((file) => reportPath(file, rootDirectory, reportPrefix))
|
|
.sort();
|
|
const moduleNames = new Set(modules);
|
|
const dependencies: DependencyEdge[] = [];
|
|
const unresolved: UnresolvedDependency[] = [];
|
|
const parseFailures: ParseFailure[] = [];
|
|
|
|
for (const sourceFile of sourceFiles) {
|
|
const source = reportPath(sourceFile, rootDirectory, reportPrefix);
|
|
let specifiers: string[];
|
|
try {
|
|
specifiers = await importSpecifiers(sourceFile);
|
|
} catch (error) {
|
|
parseFailures.push({
|
|
source,
|
|
reason: error instanceof Error ? error.message : String(error),
|
|
});
|
|
continue;
|
|
}
|
|
|
|
for (const specifier of specifiers) {
|
|
if (isExplicitLocalJavaScriptSpecifier(specifier)) {
|
|
unresolved.push({
|
|
source,
|
|
specifier,
|
|
reason: forbiddenLocalJavaScriptSpecifierReason,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (relativeSpecifierPattern.test(specifier)) {
|
|
const resolution = resolveRelativeDependency(
|
|
sourceFile,
|
|
specifier,
|
|
rootDirectory,
|
|
allFileSet,
|
|
);
|
|
if (!resolution.path) {
|
|
unresolved.push({ source, specifier, reason: resolution.reason });
|
|
continue;
|
|
}
|
|
dependencies.push({
|
|
source,
|
|
target: reportPath(resolution.path, rootDirectory, reportPrefix),
|
|
specifier,
|
|
kind: "local",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const resolvedSpecifier = import.meta.resolve(
|
|
specifier,
|
|
pathToFileURL(sourceFile).href,
|
|
);
|
|
if (resolvedSpecifier.startsWith("file:")) {
|
|
const resolvedFile = fileURLToPath(resolvedSpecifier);
|
|
const resolvedSource = resolutionCandidates(resolvedFile).find(
|
|
(candidate) => allFileSet.has(candidate),
|
|
);
|
|
if (resolvedSource) {
|
|
dependencies.push({
|
|
source,
|
|
target: reportPath(resolvedSource, rootDirectory, reportPrefix),
|
|
specifier,
|
|
kind: "local",
|
|
});
|
|
continue;
|
|
}
|
|
if (specifier.startsWith("/") || specifier.startsWith("file:")) {
|
|
throw new Error(
|
|
"absolute file imports must resolve within the analyzed source root",
|
|
);
|
|
}
|
|
}
|
|
dependencies.push({
|
|
source,
|
|
target: specifier,
|
|
specifier,
|
|
kind: "external",
|
|
});
|
|
} catch (error) {
|
|
unresolved.push({
|
|
source,
|
|
specifier,
|
|
reason: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
dependencies.sort(compareDependencies);
|
|
unresolved.sort(compareSourceAndSpecifier);
|
|
parseFailures.sort((left, right) => left.source.localeCompare(right.source));
|
|
const cycles = findCycles(modules, dependencies, moduleNames);
|
|
|
|
return {
|
|
modules,
|
|
dependencies,
|
|
unresolved,
|
|
parseFailures,
|
|
cycles,
|
|
violations: findArchitectureViolations(dependencies, cycles),
|
|
};
|
|
}
|
|
|
|
async function importSpecifiers(sourceFile: string): Promise<string[]> {
|
|
const sourceText = await readFile(sourceFile, "utf8");
|
|
const isTypeScript = sourceExtensionPattern.test(sourceFile);
|
|
const isJsx = /\.tsx$/u.test(sourceFile);
|
|
const plugins: BabelParserPlugin[] = [];
|
|
|
|
if (isTypeScript) {
|
|
plugins.push([
|
|
"typescript",
|
|
{
|
|
dts: declarationExtensionPattern.test(sourceFile),
|
|
},
|
|
]);
|
|
}
|
|
if (isJsx) {
|
|
plugins.push("jsx");
|
|
}
|
|
|
|
const syntaxTree = await parseAsync(sourceText, {
|
|
filename: sourceFile,
|
|
babelrc: false,
|
|
configFile: false,
|
|
sourceType: "unambiguous",
|
|
parserOpts: { plugins },
|
|
});
|
|
if (!syntaxTree) {
|
|
throw new Error("Babel returned no syntax tree");
|
|
}
|
|
|
|
const specifiers = new Set<string>();
|
|
const nonLiteralModuleLoads = new Set<string>();
|
|
visitSyntaxNode(syntaxTree, specifiers, nonLiteralModuleLoads);
|
|
if (nonLiteralModuleLoads.size > 0) {
|
|
throw new Error(
|
|
`module loading must use string literals: ${[...nonLiteralModuleLoads].join(", ")}`,
|
|
);
|
|
}
|
|
for (const comment of syntaxTree.comments ?? []) {
|
|
const importTypePattern =
|
|
/\bimport\s*\(\s*["']([^"'\\\r\n]+)["']\s*\)/gu;
|
|
let match = importTypePattern.exec(comment.value);
|
|
while (match) {
|
|
const specifier = match[1];
|
|
if (specifier) specifiers.add(specifier);
|
|
match = importTypePattern.exec(comment.value);
|
|
}
|
|
}
|
|
return [...specifiers].sort();
|
|
}
|
|
|
|
function visitSyntaxNode(
|
|
value: unknown,
|
|
specifiers: Set<string>,
|
|
nonLiteralModuleLoads: Set<string>,
|
|
): void {
|
|
if (Array.isArray(value)) {
|
|
for (const child of value) {
|
|
visitSyntaxNode(child, specifiers, nonLiteralModuleLoads);
|
|
}
|
|
return;
|
|
}
|
|
if (!value || typeof value !== "object") {
|
|
return;
|
|
}
|
|
|
|
const node = value as Record<string, unknown>;
|
|
const nodeType = typeof node.type === "string" ? node.type : undefined;
|
|
if (
|
|
nodeType === "ImportDeclaration" ||
|
|
nodeType === "ExportNamedDeclaration" ||
|
|
nodeType === "ExportAllDeclaration"
|
|
) {
|
|
addStringLiteral(node.source, specifiers);
|
|
} else if (nodeType === "ImportExpression") {
|
|
if (!addStringLiteral(node.source, specifiers)) {
|
|
nonLiteralModuleLoads.add("import()");
|
|
}
|
|
} else if (
|
|
nodeType === "CallExpression" &&
|
|
syntaxNodeType(node.callee) === "Import"
|
|
) {
|
|
const arguments_ = Array.isArray(node.arguments) ? node.arguments : [];
|
|
if (!addStringLiteral(arguments_[0], specifiers)) {
|
|
nonLiteralModuleLoads.add("import()");
|
|
}
|
|
} else if (
|
|
nodeType === "CallExpression" &&
|
|
syntaxNodeType(node.callee) === "Identifier" &&
|
|
syntaxNodeProperty(node.callee, "name") === "require"
|
|
) {
|
|
const arguments_ = Array.isArray(node.arguments) ? node.arguments : [];
|
|
if (!addStringLiteral(arguments_[0], specifiers)) {
|
|
nonLiteralModuleLoads.add("require()");
|
|
}
|
|
} else if (nodeType === "TSExternalModuleReference") {
|
|
addStringLiteral(node.expression, specifiers);
|
|
} else if (nodeType === "TSImportType") {
|
|
addStringLiteral(
|
|
syntaxNodeType(node.argument) === "TSLiteralType"
|
|
? syntaxNodeProperty(node.argument, "literal")
|
|
: node.argument,
|
|
specifiers,
|
|
);
|
|
}
|
|
|
|
for (const [key, child] of Object.entries(node)) {
|
|
if (
|
|
key === "comments" ||
|
|
key === "leadingComments" ||
|
|
key === "trailingComments" ||
|
|
key === "innerComments" ||
|
|
key === "loc"
|
|
) {
|
|
continue;
|
|
}
|
|
visitSyntaxNode(child, specifiers, nonLiteralModuleLoads);
|
|
}
|
|
}
|
|
|
|
function syntaxNodeType(value: unknown): string | undefined {
|
|
const type = syntaxNodeProperty(value, "type");
|
|
return typeof type === "string" ? type : undefined;
|
|
}
|
|
|
|
function syntaxNodeProperty(value: unknown, key: string): unknown {
|
|
return value && typeof value === "object"
|
|
? (value as Record<string, unknown>)[key]
|
|
: undefined;
|
|
}
|
|
|
|
function addStringLiteral(value: unknown, specifiers: Set<string>): boolean {
|
|
if (!value || typeof value !== "object") {
|
|
return false;
|
|
}
|
|
const literal = value as Record<string, unknown>;
|
|
if (
|
|
(literal.type === "StringLiteral" || literal.type === "Literal") &&
|
|
typeof literal.value === "string"
|
|
) {
|
|
specifiers.add(literal.value);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function resolveRelativeDependency(
|
|
sourceFile: string,
|
|
specifier: string,
|
|
rootDirectory: string,
|
|
allFiles: Set<string>,
|
|
): DependencyResolution {
|
|
if (isExplicitLocalJavaScriptSpecifier(specifier)) {
|
|
return { reason: forbiddenLocalJavaScriptSpecifierReason };
|
|
}
|
|
|
|
const pathPart = specifier.split(/[?#]/u, 1)[0];
|
|
const requestedPath = resolve(dirname(sourceFile), pathPart);
|
|
const relativeToRoot = relative(rootDirectory, requestedPath);
|
|
if (
|
|
relativeToRoot === ".." ||
|
|
relativeToRoot.startsWith(`..${sep}`) ||
|
|
isAbsolute(relativeToRoot)
|
|
) {
|
|
return { reason: "relative import resolves outside the analyzed source root" };
|
|
}
|
|
|
|
for (const candidate of resolutionCandidates(requestedPath)) {
|
|
if (allFiles.has(candidate)) {
|
|
return { path: candidate, reason: "" };
|
|
}
|
|
}
|
|
return { reason: "no matching source or asset exists within the analyzed root" };
|
|
}
|
|
|
|
function isExplicitLocalJavaScriptSpecifier(specifier: string): boolean {
|
|
return (
|
|
(relativeSpecifierPattern.test(specifier) ||
|
|
specifier.startsWith("/") ||
|
|
specifier.startsWith("file:")) &&
|
|
forbiddenLocalJavaScriptSpecifierPattern.test(specifier)
|
|
);
|
|
}
|
|
|
|
function resolutionCandidates(requestedPath: string): string[] {
|
|
const extension = extname(requestedPath);
|
|
const sourceExtensions = [
|
|
".ts",
|
|
".tsx",
|
|
".mts",
|
|
".cts",
|
|
".d.ts",
|
|
".d.mts",
|
|
".d.cts",
|
|
".json",
|
|
];
|
|
|
|
if (extension) {
|
|
return [requestedPath];
|
|
}
|
|
return [
|
|
requestedPath,
|
|
...sourceExtensions.map((candidate) => `${requestedPath}${candidate}`),
|
|
...sourceExtensions.map((candidate) =>
|
|
resolve(requestedPath, `index${candidate}`),
|
|
),
|
|
];
|
|
}
|
|
|
|
function findCycles(
|
|
modules: string[],
|
|
dependencies: DependencyEdge[],
|
|
moduleNames: Set<string>,
|
|
): string[][] {
|
|
const adjacency = new Map<string, string[]>(
|
|
modules.map((module) => [module, []]),
|
|
);
|
|
for (const dependency of dependencies) {
|
|
if (dependency.kind === "local" && moduleNames.has(dependency.target)) {
|
|
adjacency.get(dependency.source)?.push(dependency.target);
|
|
}
|
|
}
|
|
|
|
let nextIndex = 0;
|
|
const indexes = new Map<string, number>();
|
|
const lowLinks = new Map<string, number>();
|
|
const stack: string[] = [];
|
|
const onStack = new Set<string>();
|
|
const cycles: string[][] = [];
|
|
|
|
function connect(module: string): void {
|
|
indexes.set(module, nextIndex);
|
|
lowLinks.set(module, nextIndex);
|
|
nextIndex += 1;
|
|
stack.push(module);
|
|
onStack.add(module);
|
|
|
|
for (const target of adjacency.get(module) ?? []) {
|
|
if (!indexes.has(target)) {
|
|
connect(target);
|
|
lowLinks.set(
|
|
module,
|
|
Math.min(
|
|
requireMapValue(lowLinks, module),
|
|
requireMapValue(lowLinks, target),
|
|
),
|
|
);
|
|
} else if (onStack.has(target)) {
|
|
lowLinks.set(
|
|
module,
|
|
Math.min(
|
|
requireMapValue(lowLinks, module),
|
|
requireMapValue(indexes, target),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (lowLinks.get(module) !== indexes.get(module)) {
|
|
return;
|
|
}
|
|
const component: string[] = [];
|
|
let member: string | undefined;
|
|
do {
|
|
member = stack.pop();
|
|
if (!member) break;
|
|
onStack.delete(member);
|
|
component.push(member);
|
|
} while (member !== module);
|
|
|
|
const firstComponent = component[0];
|
|
const selfCycle =
|
|
component.length === 1 &&
|
|
firstComponent !== undefined &&
|
|
adjacency.get(firstComponent)?.includes(firstComponent);
|
|
if (component.length > 1 || selfCycle) {
|
|
cycles.push(component.sort());
|
|
}
|
|
}
|
|
|
|
for (const module of modules) {
|
|
if (!indexes.has(module)) connect(module);
|
|
}
|
|
return cycles.sort((left, right) =>
|
|
(left[0] ?? "").localeCompare(right[0] ?? ""),
|
|
);
|
|
}
|
|
|
|
function requireMapValue<Key, Value>(map: Map<Key, Value>, key: Key): Value {
|
|
const value = map.get(key);
|
|
if (value === undefined) {
|
|
throw new Error("Architecture graph invariant failed");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function findArchitectureViolations(
|
|
dependencies: DependencyEdge[],
|
|
cycles: string[][],
|
|
): ArchitectureViolation[] {
|
|
const violations: ArchitectureViolation[] = [];
|
|
for (const rule of architectureRules) {
|
|
if (rule.to?.circular) {
|
|
for (const cycle of cycles) {
|
|
violations.push({
|
|
rule: rule.name,
|
|
severity: rule.severity ?? "warn",
|
|
source: cycle[0] ?? "unknown-cycle-source",
|
|
target: cycle[1] ?? cycle[0] ?? "unknown-cycle-target",
|
|
cycle,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
for (const dependency of dependencies) {
|
|
if (
|
|
matchesPath(dependency.source, rule.from) &&
|
|
matchesPath(dependency.target, rule.to)
|
|
) {
|
|
violations.push({
|
|
rule: rule.name,
|
|
severity: rule.severity ?? "warn",
|
|
source: dependency.source,
|
|
target: dependency.target,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return violations.sort((left, right) =>
|
|
`${left.rule}:${left.source}:${left.target}`.localeCompare(
|
|
`${right.rule}:${right.source}:${right.target}`,
|
|
),
|
|
);
|
|
}
|
|
|
|
function matchesPath(
|
|
modulePath: string,
|
|
criterion: PathRule | undefined,
|
|
): boolean {
|
|
if (!criterion) return true;
|
|
if (criterion.path && !new RegExp(criterion.path, "u").test(modulePath)) {
|
|
return false;
|
|
}
|
|
return !(
|
|
criterion.pathNot && new RegExp(criterion.pathNot, "u").test(modulePath)
|
|
);
|
|
}
|
|
|
|
function validateArchitectureRules(rules: readonly ArchitectureRule[]): void {
|
|
if (!rules.some((rule) => rule.to?.circular === true)) {
|
|
throw new Error("Architecture configuration must contain a circular rule");
|
|
}
|
|
for (const rule of rules) {
|
|
for (const key of Object.keys(rule.from ?? {})) {
|
|
if (key !== "path" && key !== "pathNot") {
|
|
throw new Error(`Unsupported architecture matcher from.${key} in ${rule.name}`);
|
|
}
|
|
}
|
|
for (const key of Object.keys(rule.to ?? {})) {
|
|
if (key !== "path" && key !== "pathNot" && key !== "circular") {
|
|
throw new Error(`Unsupported architecture matcher to.${key} in ${rule.name}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function blockingViolations(graph: SourceGraph): ArchitectureViolation[] {
|
|
return graph.violations.filter(({ severity }) => severity === "error");
|
|
}
|
|
|
|
async function runGraphFixtureChecks(): Promise<GraphFixtureResult> {
|
|
const fixtureRoot = resolve(
|
|
projectRoot,
|
|
"tests/fixtures/architecture/dependency-graph",
|
|
);
|
|
const allowedRoot = resolve(fixtureRoot, "allowed");
|
|
const [allowedGraph, unresolvedGraph, layerGraph, cycleGraph] =
|
|
await Promise.all([
|
|
analyzeSourceGraph(allowedRoot, "src"),
|
|
analyzeSourceGraph(resolve(fixtureRoot, "unresolved"), "src"),
|
|
analyzeSourceGraph(resolve(fixtureRoot, "layer"), "src"),
|
|
analyzeSourceGraph(resolve(fixtureRoot, "cycle"), "src"),
|
|
]);
|
|
const allowedFiles = new Set(await listFiles(allowedRoot));
|
|
const allowedSourceFile = resolve(
|
|
allowedRoot,
|
|
"application/read-value.ts",
|
|
);
|
|
const legacySpecifierRejections = [
|
|
"../domain/value.js",
|
|
"../domain/value.jsx",
|
|
"../domain/value.mjs",
|
|
"../domain/value.cjs",
|
|
].map((specifier) =>
|
|
resolveRelativeDependency(
|
|
allowedSourceFile,
|
|
specifier,
|
|
allowedRoot,
|
|
allowedFiles,
|
|
),
|
|
);
|
|
const assertions = [
|
|
{
|
|
name: "explicit TS specifier resolves to a TS module",
|
|
passed: allowedGraph.dependencies.some(
|
|
({ source, target, specifier }) =>
|
|
source === "src/application/read-value.ts" &&
|
|
target === "src/domain/value.ts" &&
|
|
specifier === "../domain/value.ts",
|
|
),
|
|
},
|
|
{
|
|
name: "local JavaScript-family specifiers are rejected",
|
|
passed: legacySpecifierRejections.every(
|
|
({ path, reason }) =>
|
|
path === undefined &&
|
|
reason === forbiddenLocalJavaScriptSpecifierReason,
|
|
),
|
|
},
|
|
{
|
|
name: "TSX modules are included",
|
|
passed: allowedGraph.modules.includes("src/presentation/value-view.tsx"),
|
|
},
|
|
{
|
|
name: "allowed graph has no blocking findings",
|
|
passed:
|
|
allowedGraph.unresolved.length === 0 &&
|
|
allowedGraph.parseFailures.length === 0 &&
|
|
blockingViolations(allowedGraph).length === 0,
|
|
},
|
|
{
|
|
name: "unresolved relative imports are rejected",
|
|
passed: unresolvedGraph.unresolved.some(
|
|
({ specifier }) => specifier === "../domain/missing-value.ts",
|
|
),
|
|
},
|
|
{
|
|
name: "unresolved package imports are rejected",
|
|
passed: unresolvedGraph.unresolved.some(
|
|
({ specifier }) =>
|
|
specifier === "architecture-fixture-package-that-does-not-exist",
|
|
),
|
|
},
|
|
{
|
|
name: "unresolved absolute imports are rejected",
|
|
passed: unresolvedGraph.unresolved.some(
|
|
({ specifier }) =>
|
|
specifier === "/definitely-missing-architecture-fixture.ts",
|
|
),
|
|
},
|
|
{
|
|
name: "unresolved file URL imports are rejected",
|
|
passed: unresolvedGraph.unresolved.some(
|
|
({ specifier }) =>
|
|
specifier === "file:///definitely-missing-architecture-fixture.ts",
|
|
),
|
|
},
|
|
{
|
|
name: "non-literal dynamic imports are rejected",
|
|
passed: unresolvedGraph.parseFailures.some(
|
|
({ source, reason }) =>
|
|
source === "src/application/load-value.ts" &&
|
|
reason.includes("import()"),
|
|
),
|
|
},
|
|
{
|
|
name: "non-literal CommonJS imports are rejected",
|
|
passed: unresolvedGraph.parseFailures.some(
|
|
({ source, reason }) =>
|
|
source === "src/application/require-value.ts" &&
|
|
reason.includes("require()"),
|
|
),
|
|
},
|
|
{
|
|
name: "TypeScript layer violations are rejected",
|
|
passed: blockingViolations(layerGraph).some(
|
|
({ rule }) => rule === "application-does-not-know-concrete-runtime",
|
|
),
|
|
},
|
|
{
|
|
name: "TypeScript cycles are rejected",
|
|
passed: blockingViolations(cycleGraph).some(
|
|
({ rule }) => rule === "no-circular-dependencies",
|
|
),
|
|
},
|
|
];
|
|
return {
|
|
passed: assertions.every(({ passed }) => passed),
|
|
checks: assertions.map(({ name }) => name),
|
|
failures: assertions.filter(({ passed }) => !passed).map(({ name }) => name),
|
|
};
|
|
}
|
|
|
|
async function inspectTypeScriptOnlyPolicy(): Promise<TypeScriptOnlyPolicy> {
|
|
const requiredRoots = ["src", "scripts", "tests", ".storybook"] as const;
|
|
const optionalRoots = ["recipes"] as const;
|
|
const checkedRoots = [...requiredRoots, ...optionalRoots];
|
|
const files = (
|
|
await Promise.all(
|
|
[
|
|
...requiredRoots.map((root) =>
|
|
listFiles(resolve(projectRoot, root)),
|
|
),
|
|
...optionalRoots.map((root) =>
|
|
listOptionalFiles(resolve(projectRoot, root)),
|
|
),
|
|
],
|
|
)
|
|
).flat();
|
|
const rootEntries = await readdir(projectRoot, { withFileTypes: true });
|
|
const rootConfigFiles = rootEntries
|
|
.filter(
|
|
(entry) =>
|
|
entry.isFile() &&
|
|
/\.config\.(?:js|jsx|mjs|cjs)$/u.test(entry.name),
|
|
)
|
|
.map((entry) => resolve(projectRoot, entry.name));
|
|
const candidates = [...files, ...rootConfigFiles]
|
|
.filter((file) => forbiddenJavaScriptExtensionPattern.test(file))
|
|
.map((file) => reportPath(file, projectRoot, ""))
|
|
.sort();
|
|
const violations = candidates;
|
|
return {
|
|
checkedRoots,
|
|
exceptionsAllowed: false,
|
|
violations,
|
|
passed: violations.length === 0,
|
|
};
|
|
}
|
|
|
|
async function listOptionalFiles(directory: string): Promise<string[]> {
|
|
try {
|
|
return await listFiles(directory);
|
|
} catch (error) {
|
|
if (hasErrorCode(error, "ENOENT")) {
|
|
return [];
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function hasErrorCode(error: unknown, code: string): boolean {
|
|
return (
|
|
typeof error === "object" &&
|
|
error !== null &&
|
|
"code" in error &&
|
|
error.code === code
|
|
);
|
|
}
|
|
|
|
async function listFiles(directory: string): Promise<string[]> {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
const files = await Promise.all(
|
|
entries.map((entry) => {
|
|
const target = resolve(directory, entry.name);
|
|
return entry.isDirectory() ? listFiles(target) : [target];
|
|
}),
|
|
);
|
|
return files.flat().sort();
|
|
}
|
|
|
|
function reportPath(
|
|
file: string,
|
|
rootDirectory: string,
|
|
prefix: string,
|
|
): string {
|
|
const childPath = relative(rootDirectory, file).split(sep).join("/");
|
|
return prefix ? `${prefix}/${childPath}` : childPath;
|
|
}
|
|
|
|
function compareDependencies(
|
|
left: DependencyEdge,
|
|
right: DependencyEdge,
|
|
): number {
|
|
return `${left.source}:${left.target}:${left.specifier}`.localeCompare(
|
|
`${right.source}:${right.target}:${right.specifier}`,
|
|
);
|
|
}
|
|
|
|
function compareSourceAndSpecifier(
|
|
left: Pick<UnresolvedDependency, "source" | "specifier">,
|
|
right: Pick<UnresolvedDependency, "source" | "specifier">,
|
|
): number {
|
|
return `${left.source}:${left.specifier}`.localeCompare(
|
|
`${right.source}:${right.specifier}`,
|
|
);
|
|
}
|
|
|
|
function parseArchitectureConfig(value: unknown): ArchitectureConfig {
|
|
if (!isRecord(value)) {
|
|
throw new TypeError(".dependency-cruiser.json must contain an object");
|
|
}
|
|
const forbidden = value.forbidden ?? [];
|
|
const allowed = value.allowed ?? [];
|
|
const required = value.required ?? [];
|
|
if (!Array.isArray(forbidden) || !forbidden.every(isArchitectureRule)) {
|
|
throw new TypeError(
|
|
".dependency-cruiser.json forbidden rules have an invalid shape",
|
|
);
|
|
}
|
|
if (!Array.isArray(allowed) || !Array.isArray(required)) {
|
|
throw new TypeError(
|
|
".dependency-cruiser.json allowed/required must be arrays",
|
|
);
|
|
}
|
|
return { forbidden, allowed, required };
|
|
}
|
|
|
|
function isArchitectureRule(value: unknown): value is ArchitectureRule {
|
|
if (!isRecord(value) || typeof value.name !== "string") return false;
|
|
if (value.severity !== undefined && typeof value.severity !== "string") {
|
|
return false;
|
|
}
|
|
return (
|
|
(value.from === undefined || isPathRule(value.from, false)) &&
|
|
(value.to === undefined || isPathRule(value.to, true))
|
|
);
|
|
}
|
|
|
|
function isPathRule(value: unknown, allowCircular: boolean): value is PathRule {
|
|
if (!isRecord(value)) return false;
|
|
if (value.path !== undefined && typeof value.path !== "string") return false;
|
|
if (value.pathNot !== undefined && typeof value.pathNot !== "string") {
|
|
return false;
|
|
}
|
|
return (
|
|
!allowCircular ||
|
|
value.circular === undefined ||
|
|
typeof value.circular === "boolean"
|
|
);
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
}
|
|
|
|
function parseDependencyCruiserReport(
|
|
output: string,
|
|
): DependencyCruiserReport {
|
|
try {
|
|
const report: unknown = JSON.parse(output);
|
|
if (!report || typeof report !== "object" || Array.isArray(report)) {
|
|
throw new Error("dependency-cruiser report must be a JSON object");
|
|
}
|
|
return report as DependencyCruiserReport;
|
|
} catch {
|
|
return { summary: { errors: 1 }, dependencyCruiserOutput: output };
|
|
}
|
|
}
|