Files
DongHyeonkaandClaude Opus 5 3ea3397691 fix: make the architecture and documentation rules say what is actually true
Three boundaries the layer contract declares had no executable rule behind
them, so the code drifted across all three while every gate stayed green.

`src/contracts` reached back up into `src/application` for the shared `Result`
carrier and the compatibility predicate. Neither package owned the shared
vocabulary and the dependency pointed both ways. Both now live in contracts —
the lower package — and application re-exports them, so no caller moves.

A concrete adapter was not supposed to depend on another concrete adapter, but
only adapter-to-presentation was enforced, and `diagnostics` imported a guard
out of `telemetry`. The guard belongs to neither, so it moved to the adapter
kernel. Stating the rule needed the checker to resolve `$1` in a `to` pattern
against the importing module's own directory; the alternative is one rule per
adapter group, which silently stops covering a group the moment one is added.

Product assembly leaks out of bootstrap: generic presentation reads the
installed-feature registries. That is a real refactor, so the rule freezes the
exact set of modules doing it today rather than pretending it is fixed — a new
edge fails. The two remaining open edges are named in the config, not silent.

Each rule was verified by introducing the violation it forbids and confirming
the gate rejects it.

The documentation drifted the same way. README and the manual accessibility
checklist both said six routes while ten were registered, which left the
platform overview and three reference-resource screens outside the declared
manual review scope without anyone deciding they should be. The scope is now
derived from the route registry by `verify:documentation`, so the sentence
cannot outlive the registry again. The review ledger also named a canonical
path that does not exist in this tree; it is upstream provenance, and it now
says so instead of looking like a broken repository reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:23:10 +09:00

1100 lines
32 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) {
const sourceGroups = rule.from?.path
? (new RegExp(rule.from.path, "u").exec(dependency.source)?.slice(1) ??
[])
: [];
if (
matchesPath(dependency.source, rule.from) &&
matchesPath(dependency.target, rule.to, sourceGroups)
) {
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,
sourceGroups: readonly string[] = [],
): boolean {
if (!criterion) return true;
if (
criterion.path &&
!new RegExp(expandSourceGroups(criterion.path, sourceGroups), "u").test(
modulePath,
)
) {
return false;
}
return !(
criterion.pathNot &&
new RegExp(expandSourceGroups(criterion.pathNot, sourceGroups), "u").test(
modulePath,
)
);
}
/**
* Substitutes `$1`..`$9` in a `to` pattern with the capture groups the `from`
* pattern matched on the importing module.
*
* Without it, "an adapter may not import a *different* adapter" cannot be
* written as one rule: the target pattern has to name the importer's own
* directory to exempt it. The alternative is one rule per adapter group, which
* silently stops covering a group the moment somebody adds one — exactly the
* gap that let `diagnostics` import `telemetry` while the documented rule said
* it could not.
*/
function expandSourceGroups(
pattern: string,
sourceGroups: readonly string[],
): string {
return pattern.replaceAll(/\$([1-9])/gu, (whole, index: string) => {
const captured = sourceGroups[Number(index) - 1];
// A `from` pattern that did not capture leaves the token literal rather
// than quietly matching everything.
return captured === undefined ? whole : escapeRegExp(captured);
});
}
function escapeRegExp(value: string): string {
return value.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
}
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 };
}
}