Files
DongHyeonkaandClaude Opus 5 bdee07a93b chore: sync the frontend template from a0fbafb to 5434760
Carries eight template commits: the provider sandbox actually running, release
admission to a named environment, the product feature manifest with its runtime
kill switch, architecture and documentation rules that match what is enforced,
the removability fixtures, and the browser, visual and performance evidence.

Product identity is unchanged. `package.json` keeps `tech-log-frontend` and the
catalog keeps the Tech Log naming; the home page was not in the delta. The
visual baselines are this product's own — the template's were excluded from the
transplant and these were regenerated here, where the only difference is the
platform overview's new product-feature section.

What this repository gains operationally: `config/runtime/{local,development,
staging,production}.json` with `FE-GATE-027` refusing an artifact whose runtime
document does not match the environment it is being admitted to, and
`FEATURE_OVERRIDES` for taking an installed feature out of service without a
rebuild.

Verified here: eight gates green, build green, visual 5/5, and 1,858 of 1,859
tests in the suites that do not need a sandbox — the one failure passes in
isolation and is a jsdom lazy-chunk timeout under parallel load. The provider
suites cannot run on this machine at all: `kernel.apparmor_restrict_unprivileged
_userns=1` makes `bwrap --unshare-net` fail, reproducible without any code from
either repository.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 21:34:19 +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 };
}
}