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; type TypeScriptOnlyPolicy = Readonly<{ checkedRoots: readonly string[]; exceptionsAllowed: false; violations: readonly string[]; passed: boolean; }>; type BabelOptions = NonNullable[1]>; type BabelParserOptions = NonNullable; type BabelParserPlugin = NonNullable[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 { 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 { 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 { 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(); const nonLiteralModuleLoads = new Set(); 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, nonLiteralModuleLoads: Set, ): 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; 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)[key] : undefined; } function addStringLiteral(value: unknown, specifiers: Set): boolean { if (!value || typeof value !== "object") { return false; } const literal = value as Record; 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, ): 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[][] { const adjacency = new Map( 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(); const lowLinks = new Map(); const stack: string[] = []; const onStack = new Set(); 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(map: Map, 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 { 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 { 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 { 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 { 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, right: Pick, ): 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 { 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 }; } }