Files
tech-log-frontend/scripts/check-architecture.ts
T

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 };
}
}