Node
A stable identity for something architecture rules can talk about. File rules primarily use source files such as src/ui/ui.ts.
How ArchUnit works, from first principles
ArchUnit does not run your application and watch requests move through it. It inspects source structure, resolves dependency evidence, builds a directed graph, and evaluates a precise architectural sentence against that graph.
The language adapter changes. Python reads Python syntax; TypeScript asks the TypeScript compiler. The reasoning after extraction stays recognizable.
The smallest useful model
These terms give us a shared vocabulary. The later chapters show the implementation details, language adaptations, projections, algorithms, and test integration behind them.
A stable identity for something architecture rules can talk about. File rules primarily use source files such as src/ui/ui.ts.
An arrow from the depender to the dependency. If A imports B, the graph recordsA → B. Direction is architectural evidence.
A focused view of the raw graph. The same edges can be viewed as files, folders, layers, slices, internal dependencies, external modules, or cycle paths.
Structured evidence that the projected graph disagrees with a rule. It remains data until a test adapter decides to raise, fail, print, or render it.
Chapter 02 / Discover and parse
Static analysis means inspecting program text and its structure without executing the application. The shared goal is the same in every language, but the project boundary, parser, and supported syntax are language-specific.
Language adapter
Select a language to change the discovery, parsing, and resolution walkthrough.
Python adapter selected.
Python adapter
Nothing is imported or executed. ArchUnitPython chooses a root, discovers supported files below it, reads each file as text, and asks Python's standard parser for a syntax tree.
project_files("src/") stores the supplied path until check() runs. Extraction turns it into an absolute path. If no path is supplied,os.getcwd() uses the test process's current working directory. This one root anchors discovery,.archignore, caching, and the decision that a resolved target belongs to this project.
os is Python's built-in operating-system module. os.walk(root) works top down and yields three values for each folder: its path, a mutable list of immediate child folder names, and a list of filenames.
Before filtering ["src", ".venv", "build"]
After dirnames[:] ["src"]
dirnames[:] = kept_names replaces the contents of that same list object. os.walk()consults the list before descending, so it enters onlysrc. This does not rename or delete folders. Assigning a new local list with dirnames = kept_nameswould not change the list held by os.walk().
A source file begins as bytes on disk. Opening it in text mode with UTF-8 decoding and calling file.read() consumes the complete file to its end and returns one Python str.errors="replace" substitutes malformed byte sequences rather than aborting the whole scan. This implementation passes that decoded source string to ast.parse().
AST means Abstract Syntax Tree. ast.parse(source)returns an ast.Module whose child nodes represent statements and expressions. Thefilename=file_path argument labels parser diagnostics; it does not read the file. ast.walk(tree)recursively yields every descendant, so the outer loop can see imports nested in functions, conditionals, and try blocks. An inner loop is needed because one statement such asimport a, b contains two aliases and must produce two import records.
Python Source-faithful file-discovery path with teaching comments
# The fluent builder stores project_path. Extraction resolves it here.
root = os.path.abspath(project_path or os.getcwd())
# os.walk yields one directory at a time: its path,
# mutable child-directory names, and the file names inside it.
for dirpath, dirnames, filenames in os.walk(root):
# Example before filtering: ["src", ".venv", "build"]
# Replace this same list's contents, so os.walk sees ["src"]
# and never descends into .venv or build.
dirnames[:] = [
name for name in dirnames
if not _should_exclude_path(
os.path.join(dirpath, name), root, exclude, is_dir=True
)
]
for filename in filenames:
full_path = os.path.join(dirpath, filename)
if filename.endswith(".py") and not _should_exclude_path(
full_path, root, exclude, is_dir=False
):
# Keep one stable absolute identity per Python source file.
py_files.append(os.path.abspath(full_path))Python Abridged source: error handling, parsing, and one import branch
def _extract_located_imports(file_path):
# Decode the complete .py file into one Python string.
try:
with open(file_path, "r", encoding="utf-8", errors="replace") as file:
source = file.read()
except (OSError, IOError):
return []
# filename labels parser diagnostics; ast.parse does not read the file.
try:
tree = ast.parse(source, filename=file_path)
except SyntaxError:
return []
imports = []
ignore_directives = _find_ignore_directives(source)
type_checking_ranges = _find_type_checking_ranges(tree)
conditional_import_ranges = _find_conditional_import_ranges(tree)
# Walk descendants because imports may be nested in functions or try blocks.
for node in ast.walk(tree):
if isinstance(node, ast.Import):
syntax_kind = ImportKind.IMPORT
kind = _classify_import(
node, syntax_kind, type_checking_ranges, conditional_import_ranges
)
for alias in node.names:
imports.append(_LocatedImport(
alias.name, kind, node.lineno, syntax_kind
))
elif isinstance(node, ast.ImportFrom):
# Relative and from-import nodes follow the same record pipeline.
...
elif isinstance(node, ast.Call):
# Only literal importlib/__import__ module names can be recorded.
...
# Apply inline and next-line # archunit: ignore directives.
return [
item for item in imports
if not _is_ignored_import(item, ignore_directives)
]source: strThe decoded characters from one complete .py file.
tree: ast.ModuleA root node whose descendants mirror the file's syntax and source locations.
_LocatedImport stores module text, syntax and resolution kinds, line number, fallbacks, and aliases. The outer file loop separately supplies file_path as the source identity.
from ..db import sessionImportFrom(module="db", level=2)_LocatedImport(module_name="..db", import_kind=RELATIVE_IMPORT, line_number=12, resolution_kind=RELATIVE_IMPORT, aliases=("session",))TypeScript adapter
ArchUnitTS does not begin with a general directory walk. It finds or receives a TypeScript config, asks the compiler to calculate its files and syntax trees, and keeps the compiler options needed to resolve each import correctly.
projectFiles('./tsconfig.app.json') uses that config. With projectFiles(), the search starts in the Node process's current working directory. Iftsconfig.json is absent, ArchUnitTS checks the parent, then the parent's parent, until the filesystem root. This recursion goes upward only; it does not search child folders. The directory containing the selected root config becomes the base for displayed relative paths.
ts.readConfigFile() reads one config.ts.parseJsonConfigFileContent() applies that config's files,include,exclude, inherited settings, and extension rules to produce fileNames and compiler options. Project references are a separate step: ArchUnitTS follows eachprojectReferences entry itself, protects the recursion with a visited-config set, and unions all resulting file lists.
ts.createCompilerHost() provides file and module-system operations. ts.createProgram()receives the union of configured root files and returns aProgram. ItsgetSourceFiles() output contains project files plus declarations and other files the compiler pulled in. That is why the real trace later reports 2 project files but 199 source files in the program.
The compiler has already converted each file into aSourceFile syntax tree. At that root level, ts.forEachChild() visits direct top-level statements. ts.isImportDeclaration()keeps static ES import declarations. Unlike Python'sast.walk(), this branch is not a recursive walk through every descendant and does not currently capture dynamicimport(),require(), or re-export declarations.
TypeScript Abridged source: explicit or upward config discovery
// projectFiles(explicitPath) uses that config. Without one, start at cwd.
const configFile = explicitPath ?? guessLocationOfTsconfig();
function guessLocationOfTsconfigRecursively(directory: string) {
const names = fs.readdirSync(directory);
if (names.includes('tsconfig.json')) {
return path.resolve(directory, 'tsconfig.json');
}
const parent = path.resolve(directory, '..');
if (parent === path.resolve(directory)) return undefined;
// Search upward through parents, not downward through child folders.
return guessLocationOfTsconfigRecursively(parent);
}
// The directory containing the selected config becomes the root.
const rootDir = path.dirname(path.resolve(configFile));TypeScript Abridged source: referenced configs, program, and syntax
function visitConfig(candidate: string) {
const config = ts.readConfigFile(candidate, ts.sys.readFile);
const parsed = ts.parseJsonConfigFileContent(
config.config, ts.sys, path.dirname(candidate), {}, candidate
);
contexts.push(parsed);
// Project references are visited explicitly and protected by a Set.
for (const reference of parsed.projectReferences ?? []) {
visitConfig(ts.resolveProjectReferencePath(reference));
}
}
const files = [...new Set(contexts.flatMap(c => c.fileNames))];
const host = ts.createCompilerHost(rootOptions);
const program = ts.createProgram({ rootNames: files, options: rootOptions, host });
for (const sourceFile of program.getSourceFiles()) {
// At SourceFile level, this visits top-level syntax children.
ts.forEachChild(sourceFile, node => {
if (ts.isImportDeclaration(node)) recordImport(node, sourceFile);
});
}configFileThe selected config path. It may still be relative here;path.resolve(configFile) makes the reporting root absolute when rootDir is derived.
ParsedCommandLine[]File lists and options for the root config and every visited reference.
SourceFile[]Compiler syntax trees ready for supported import extraction.
.NET adapter
The library follows the shared source-to-graph model, but this page does not yet document its parser and resolver at the same source-verified depth as Python and TypeScript.
Ruby adapter
The library follows the shared source-to-graph model, but this page does not yet document its parser and resolver at the same source-verified depth as Python and TypeScript.
Rust adapter
The library follows the shared source-to-graph model, but this page does not yet document its parser and resolver at the same source-verified depth as Python and TypeScript.
Zig adapter
The library follows the shared source-to-graph model, but this page does not yet document its parser and resolver at the same source-verified depth as Python and TypeScript.
Go adapter
The library follows the shared source-to-graph model, but this page does not yet document its parser and resolver at the same source-verified depth as Python and TypeScript.
Java adapter
The library follows the shared source-to-graph model, but this page does not yet document its parser and resolver at the same source-verified depth as Python and TypeScript.
PHP adapter
ArchUnitPHP is at foundation stage, so there is no released extractor to document at source-verified depth yet. This page does not infer an implementation from another language.
Chapter 03 / Resolve and build the graph
A parser returns the written module text and its syntax context. The same text can identify different files depending on the importing file, project root, package layout, aliases, and language configuration. A graph needs stable file identities, so the adapter must resolve that text before recording an edge.
Python adapter
..db; the resolver finds the concrete file._LocatedImport is deliberately intermediate evidence. It knows what was written, where it appeared, and whether the syntax was relative, conditional, type-only, or dynamic. It cannot know the final target from syntax alone.
src/ui/view.pyThe outer file loop supplies the source identity. Inside that file, the parser findsfrom ..db import database on line 12.
_LocatedImportThe record keeps module_name="..db", line 12, alias database, and two kinds. Theimport_kind describes context, whileresolution_kind preserves relative-path semantics even if the import is also conditional or type-only.
src/db.pyLeading dots move from the importing package. The resolver tests a module file and then a package __init__.py. A path inside the discovered project becomes an internal target.
Edge(view.py → db.py)Only after resolution can ArchUnitPython store the importing path, resolved target,external=False, and the import kinds as one directed evidence record.
Python resolution in source
Relative imports move from the source directory. Absolute imports are tested from the project root and its parent. One from-import can yield multiple namespace-package alias targets, so _resolve_import_targets() returns a list rather than one answer.
The excerpt is abridged only around its enclosing loops. Every shown branch and record field comes from the current extractor.
# One located import can resolve to multiple namespace-package targets.
for resolved, is_external in _resolve_import_targets(
located_import, file_path, project_path
):
# Do not duplicate the self-edge already used to preserve the node.
if resolved and resolved != _normalize(file_path):
# An internal target must belong to the discovered Python file set.
if not is_external and resolved not in normalized_py_file_set:
continue
edges.append(Edge(
source=_normalize(file_path),
target=resolved,
external=is_external,
import_kinds=_edge_import_kinds(located_import),
))
# Equal source/target pairs become one edge with combined import kinds.
return _merge_edges(edges)The raw graph is Graph = list[Edge]. Each immutable edge stores a normalized absolute source path. Internal targets are absolute file paths too. An external target may remain a module identifier such asos, while an unresolved relative import or a module found outside the project root can remain a normalized absolute candidate path. The edge also carries an external flag and import kinds such asrelative,conditional, ortype. There is no separate node table. A self-edge for every discovered file preserves files with no imports; equal source-target pairs are merged and their import kinds are combined.
TypeScript adapter
Parsing can prove that ui.ts containsimport '../db/db'. It cannot yet prove which file that string names. A path alias such as@payments/domain could refer to a local source file, a referenced project, or an installed package depending on the applicable compiler options.
src/ui/ui.tsThe outer program loop supplies the importer; the AST supplies the module text.
sourceCompilerOptionsArchUnitTS finds every parsed config whose file list contains this source. A file can belong to several referenced projects, so more than one context may be tried.
ts.resolveModuleName()TypeScript combines module text, importer filename, compiler options, and the compiler host. Results are deduplicated by target filename and external-library status.
Edge(ui.ts → db.ts)Paths are normalized relative to the root config directory, then stored with the external flag and import kinds such as default, named, namespace, type, or value. A side-effect-only import still creates an edge, but it contributes no import-kind label.
TypeScript resolution in source
Unresolved imports are skipped. ArchUnitTS also skips a resolved target when its root-relative path begins with node_modules ordist. Those prefixes are extractor constants, not settings read from the selected config. The abridged excerpt below keeps the fallback, cross-context deduplication, prefix checks, and error boundary while condensing logging and counters.
// The AST gives us module text, but not a concrete target file.
const moduleText = importNode.moduleSpecifier.text;
const normalizedSourceFileName = relativeToRoot(sourceFile.fileName);
// A source file can belong to more than one referenced config.
// The Map deduplicates contexts that resolve to the same target.
const resolvedModules = new Map<string, ts.ResolvedModuleFull>();
for (const compilerOptions of sourceCompilerOptions) {
try {
let resolved = ts.resolveModuleName(
moduleText, sourceFile.fileName, compilerOptions, compilerHost
).resolvedModule;
// If normal resolution fails, try the extractor's path suggestion.
if (!resolved) {
const suggestion = new ImportPathsResolver(compilerOptions)
.getImportSuggestions(
moduleText, path.dirname(normalizedSourceFileName)
)?.[0];
if (suggestion && suggestion !== moduleText) {
resolved = ts.resolveModuleName(
suggestion, sourceFile.fileName, compilerOptions, compilerHost
).resolvedModule;
}
}
if (!resolved) continue;
const identity = `${normalizeFileIdentity(resolved.resolvedFileName)}:`
+ `${resolved.isExternalLibraryImport ?? false}`;
resolvedModules.set(identity, resolved);
} catch {
// One failed compiler context does not prevent trying the others.
continue;
}
}
for (const resolved of resolvedModules.values()) {
const target = relativeToRoot(resolved.resolvedFileName);
const excluded =
(EXCLUDE_NODE_MODULES && target.startsWith("node_modules")) ||
(EXCLUDE_DIST && target.startsWith("dist"));
if (excluded) {
continue;
}
edges.push({
source: normalizedSourceFileName,
target,
external: resolved.isExternalLibraryImport ?? false,
importKinds: determineImportKinds(importNode),
});
}
// A self-edge is also added for every project file.The raw graph is an Edge[] with source, target, external status, and import kinds. It has no separate node table. ArchUnitTS adds a self-edge for each project source file so import-free files remain represented. Unlike the Python extractor, it does not merge duplicate endpoint pairs before projection.
.NET adapter
The shared graph, projection, fluent-rule, and evaluation chapters below still describe the architecture-testing model used across the library family.
Ruby adapter
The shared graph, projection, fluent-rule, and evaluation chapters below still describe the architecture-testing model used across the library family.
Rust adapter
The shared graph, projection, fluent-rule, and evaluation chapters below still describe the architecture-testing model used across the library family.
Zig adapter
The shared graph, projection, fluent-rule, and evaluation chapters below still describe the architecture-testing model used across the library family.
Go adapter
The shared graph, projection, fluent-rule, and evaluation chapters below still describe the architecture-testing model used across the library family.
Java adapter
The shared graph, projection, fluent-rule, and evaluation chapters below still describe the architecture-testing model used across the library family.
PHP adapter
The shared graph, projection, fluent-rule, and evaluation chapters below still describe the architecture-testing model used across the library family.
Concrete trace from ArchUnitTS
ArchUnitPython does not currently emit an equivalent edge-by-edge debug trace. The lab therefore uses ArchUnitTS to make discovery, compiler setup, import resolution, projection, and evaluation visible. The trace below was captured from the repository fixture; it is not fabricated output.
// This import is a violation!
import { startDb } from '../db/db';
export function openUi() {
console.log('Opened UI');
startDb();
}The arrow points from the file that imports to the file being imported. Reversing it would reverse the architecture rule.
[INFO] Starting architecture rule check: Dependency check: 1 filters[INFO] Using TypeScript config file: <repo>/test/layered-arch/layered-arch-1/tsconfig.json[INFO] TypeScript program created with 199 source files[DEBUG] Project source files: 2[DEBUG] Node modules files: 197[DEBUG] Processing file 1/2: src/db/db.ts[DEBUG] Processing file 2/2: src/ui/ui.ts[DEBUG] Processing import: "../db/db" in src/ui/ui.ts[DEBUG] Resolved "../db/db" to: src/db/db.ts (external: false)[INFO] Import analysis completed:[INFO] - Total edges found: 132[INFO] - Files processed: 2[INFO] Graph extraction completed with 132 edges[DEBUG] Analyzing dependencies across 124 edges[INFO] Found edge: From src/ui/ui.ts to src/db/db.ts[WARN] Violation found: Dependency violation: {"dependency":{"sourceLabel":"src/ui/ui.ts","targetLabel":"src/db/db.ts","cumulatedEdges":[{"source":"src/ui/ui.ts","target":"src/db/db.ts","external":false,"importKinds":["value","named"]}]},"isNegated":true}[WARN] Completed architecture rule check: Dependency check: 1 filters (1 violations)This is a filtered real run of the repository's layered-architecture fixture. Only the machine-specific repository prefix was replaced with <repo>; Windows separators were normalized for display. The messages, counts, ordering, paths, and violation evidence come from the run.
Complete trace shown. Replay it to follow the pipeline one step at a time.
A TypeScript Program includes project files plus declaration files required by its configured type environment. The trace identifies 2 project files and 197 files classified by the logger as coming fromnode_modules.
Processing import andResolved show syntax becoming a target. The full run recorded 132 raw edges. The file-rule projection grouped duplicate source-target labels into 124 candidates for evaluation.
Found edge logs each projected candidate before the rule's filters and mood are applied. The followingViolation found line is where this candidate is confirmed as forbidden; it keeps the source, target, accumulated raw evidence, and negated mood. The full raw run also contains the self-edges that preserve import-free files, although this excerpt focuses on the forbidden UI-to-database dependency.
Raw graph versus projected graph
The raw graph is the adapter's file-level evidence. A rule may need to ask about those exact files, or it may need a coarser view such as folders, layers, or business slices. A projection is the small mapping step between those two levels. It can discard edges that are irrelevant to the question, assign labels to each endpoint, and group edges that now have the same label pair.
orders/api.py → payments/client.pyorders/jobs.py → payments/client.pyOrders → Paymentscumulated_edges still contains both raw file edges.
In this grouping example, neither contributing import is lost. The projected edge gives the rule one Orders → Payments candidate, while its accumulated evidence still identifies the two exact imports to report when the rule fails. A different projection may deliberately skip edges outside its question. This is why the model needs both a raw graph and a projected graph.
Python file dependency rules:per_internal_edge() removes external and self-edges, keeps the full paths as labels, and groups equal pairs.
TypeScript file dependency rules:perEdge() keeps each raw edge's labels and groups duplicate pairs. It does not automatically remove external or self-edges. The real trace's 132 to 124 change is grouping, not a second scan.
A raw edge list containing resolved facts, plus a question-specific list of projected candidates whose accumulated edges preserve the underlying proof.
Chapter 04 / Turn intent into a fluent rule
Building the chain records a subject, a mood, a predicate, an object, and optionally a rationale. Expensive work begins only at the terminalcheck() or the test-framework assertion.
Chapters 02 and 03 use the language selector because discovery, parsing, and resolution are adapter-specific. From this chapter onward, the machine is shared. Python and TypeScript implementation differences are labeled explicitly; unavailable adapters are not inferred from another language.
Python The sentence and its architectural reason
# Build a lazy architecture sentence. No project scan happens yet.
rule = (
project_files("src/")
# Subject: only files under a UI folder.
.in_folder("**/ui/**")
# Mood and predicate: forbid matching dependency edges.
.should_not()
.depend_on_files()
# Object: dependency targets under a DB folder.
.in_folder("**/db/**")
.because("UI code must not own persistence")
)
# The assertion terminal extracts, evaluates, and fails on findings.
assert_passes(rule)TypeScript The real fixture rule with debug logging
// Build the same lazy sentence with TypeScript naming.
const rule = projectFiles("tsconfig.json")
.inFolder('src/ui')
.shouldNot()
.dependOnFiles()
.inFolder('src/db');
// check() triggers extraction and returns structured violations.
const violations = await rule.check({
clearCache: true,
logging: { enabled: true, level: 'debug' },
});Select source labels. Multiple scope filters combine to narrow the population.
should_not() sets negated semantics. It does not mean that violations pass.
Select dependency targets. For this rule, a matching directed edge is forbidden evidence.
because() travels to the assertion message so policy and evidence stay together.
Chapter 05 / Evaluate the rule
Language-specific parsing is finished. From here the rule terminal projects graph edges, applies selectors, and creates typed violations. The negative dependency example can be described without knowing Python or TypeScript syntax.
Obtain the cached or newly built raw graph.
Create stable source and target labels for this rule. Python file-dependency rules keep internal edges; TypeScript uses perEdge()and retains external and self-edges.
Does the source label match every UI filter?
Does the target label match every DB filter?
For should_not(), a double match is a violation.
Keep the projected edge and its underlying raw imports.
Python Extract, project internal edges, then evaluate
# DependOnFileCondition.check(): extract, project, then delegate.
graph = extract_graph(self._project_path, options=options)
edges = project_edges(graph, per_internal_edge())
return gather_depend_on_file_violations(
edges,
self._subject_filters,
self._object_filters,
self._is_negated,
)
# gather_depend_on_file_violations(): inspect each projected edge.
for edge in edges:
# All subject filters must accept the importing file.
source_matches = all(
matches_pattern(edge.source_label, f) for f in subject_filters
)
if not source_matches:
continue
# All object filters must accept the resolved target file.
target_matches = all(
matches_pattern(edge.target_label, f) for f in object_filters
)
# For should_not(), a matching source and target is forbidden.
if is_negated and target_matches:
violations.append(ViolatingFileDependency(
dependency=edge, is_negated=True
))TypeScript Extract, project per edge, guard empty selections, then evaluate
// DependOnFileCondition.check(): extract, project, then delegate.
const graph = await extractGraph(configFileName, options);
// perEdge() keeps external and self-edges; equal label pairs are grouped.
const projectedEdges = projectEdges(graph, perEdge());
// These aliases shorten the current builder fields for this excerpt.
const sourceFilters = this.dependOnFileConditionBuilder
.matchPatternFileConditionBuilder
.filesShouldCondition.filters;
const targetFilters = this.dependencyFilters;
const isNegated = this.dependOnFileConditionBuilder
.matchPatternFileConditionBuilder.isNegated;
const allowEmptyTests = options?.allowEmptyTests || false;
return gatherDependOnFileViolations(
projectedEdges,
sourceFilters,
targetFilters,
isNegated,
allowEmptyTests,
);
// gatherDependOnFileViolations(): select both ends of each edge.
const matchingSources = projectedEdges.filter((edge) =>
matchesAllPatterns(edge.sourceLabel, sourceFilters),
);
const matchingTargets = projectedEdges.filter((edge) =>
matchesAllPatterns(edge.targetLabel, targetFilters),
);
if (matchingSources.length === 0 && !allowEmptyTests) {
return [new EmptyTestViolation(sourceFilters)];
}
if (matchingTargets.length === 0 && !allowEmptyTests) {
return [new EmptyTestViolation(targetFilters)];
}
// For shouldNot(), an edge selected at both ends is forbidden.
return projectedEdges
.filter((edge) =>
isNegated
? matchingSources.includes(edge) && matchingTargets.includes(edge)
: !matchingSources.includes(edge) || !matchingTargets.includes(edge),
)
.map((edge) => new ViolatingFileDependency(edge, isNegated));all(...)This is logical AND across every configured filter. A source or target must satisfy all of them, not merely one.
EmptyTestViolationTypeScript reports an empty-test violation when no source or target candidate matches, unless allowEmptyTests is enabled. This prevents a misspelled path from passing silently.
continueA nonmatching source rejects the current edge early and advances the loop to the next candidate.
matches_pattern()The matcher compares the normalized projected path label with a fluent API filter such as **/ui/**.
Zero matches need an explicit policy
Empty-selection handling is terminal-specific. Python's cycle and some pattern terminals call _check_empty_test() and return anEmptyTestViolation unlessallow_empty_tests is enabled. The PythonDependOnFileCondition shown above does not currently add that guard. This is concrete terminal behavior, not a magical property of the graph, and callers should scope dependency rules deliberately. TypeScript's dependency condition checks both the source and target selections and returns anEmptyTestViolation by default when either is empty.
Python returns a list[Violation]; TypeScript resolves a Promise<Violation[]>. Each dependency finding keeps the projected edge, its accumulated raw edges, and whether the rule was negated. Nothing has failed the test runner yet.
Chapter 06 / Algorithms
A forbidden direct dependency is local: inspect candidate edges. A cycle is global: find a path that eventually returns to its starting node. ArchUnit chooses the operation that matches the meaning of the rule.
Project to the relevant edge type, match source labels, match target labels, and emit one finding for every forbidden relationship.
Work grows with inspected edges.A strongly connected component is a region where every node can reach every other node. Tarjan identifies those regions in linear time over nodes and edges.
Discard regions that cannot contain a cycle.Johnson's algorithm runs inside the relevant components and returns complete cycle paths without repeating vertices inside one path.
Work also depends on how many cycles must be reported.Tarjan keeps A, B, C together. Johnson reports A → B → C → A. D is eliminated from cycle enumeration.
Chapter 07 / Cross the test boundary last
Separating evaluation from presentation makes the same findings usable by pytest, Jest, Vitest, CI logs, reports, and custom tools. It also keeps the core algorithms testable without a test runner.
Python Abridged source from the test assertion adapter
def assert_passes(checkable, options=None):
# Keep evaluation as data until the test boundary.
violations = checkable.check(options)
if violations:
# Carry the architectural reason into the failure message.
because = getattr(checkable, "because_reason", None)
raise AssertionError(
format_violations(violations, because=because)
)TypeScript The shared Jest and Vitest matcher boundary
expect.extend({
async toPassAsync(checkable, options) {
if (!checkable) {
return ResultFactory.error(
'expected something checkable as an argument for expect()',
);
}
// The rule still returns data at this point.
const violations = await checkable.check(options);
const testViolations = violations.map((violation) =>
ViolationFactory.from(violation),
);
// Jest or Vitest receives the final pass flag and message function.
return ResultFactory.result(Boolean(this.isNot), testViolations);
},
});Python checkable.check(options) and TypeScript checkable.check(options) return typed violation data rather than failing a test themselves.
With no findings, Python returns normally. TypeScript's matcher produces a result withpass: true.
With findings, Python formats concrete details and the optionalbecause_reason. TypeScript maps each finding through ViolationFactory.from() and keeps every accumulated raw edge for diagnostics.
Python raises AssertionError. TypeScript returns{ pass, message }; Jest or Vitest turnspass: false into the failed test.
No findings means a normal return. Findings become one formattedAssertionError with concrete dependency evidence and the optional because_reason. TypeScript keeps the same separation and crosses the runner boundary throughtoPassAsync().
The complete machine
The parser is language-native. The graph is explicit. The rule is readable. The finding keeps its evidence. That is how an architectural intention becomes an ordinary test the team can run on every change.