White Paper static-analysisarchitectureci-cdtypescriptgosarif

drift-scan: Zone-Based Architectural Drift Detection Across TypeScript, Python, and Go

How drift-scan turns declared layer rules into a parsed dependency graph, a blast-radius severity score, and a CI-enforceable gate.

September 22, 2026

The model: zones and permitted imports

drift-scan’s unit of analysis is the zone. A .driftrc.json config declares a layers list and a zones map, where each zone specifies its file paths (as globs) and a canImport list naming which other zones it’s allowed to depend on:

{
  "layers": ["domain", "application", "infrastructure"],
  "zones": {
    "domain": { "paths": ["src/domain/**"], "canImport": [] },
    "application": { "paths": ["src/application/**"], "canImport": ["domain"] },
    "infrastructure": { "paths": ["src/infrastructure/**"], "canImport": ["domain", "application"] }
  },
  "ignore": ["**/*.test.ts", "**/*.spec.ts"]
}

This is the whole rule set. domain can’t import from anything. application can import from domain. infrastructure can import from either. Any file matching a zone’s paths glob inherits its canImport restrictions; anything outside those globs that still imports from a declared zone is an undeclared violation, separate from a layer violation between two declared zones.

drift init [dir] bootstraps this file by inspecting existing directory names, giving you a starting point instead of a blank config.

Parsing and graph construction

drift scan [dir] walks every .ts, .py, and .go file in the target tree and extracts imports per language: import, require(), dynamic import(), export … from, and import type for TypeScript/JavaScript; import x and from x import y (including relative imports) for Python; module-relative import "github.com/org/repo/internal/pkg" for Go. These form a dependency graph, and every edge that crosses a zone boundary in a direction not permitted by canImport becomes a violation. A circular violation is a specific case: a dependency cycle that spans zone boundaries.

For TypeScript, tsconfig.json’s compilerOptions.paths and baseUrl are read automatically, so aliased imports like @domain/User resolve to their real filesystem path before zone matching runs.

Scoring: blast radius, not violation count

A flat violation count treats a broken import in a dead file the same as one in a file half the codebase depends on. drift-scan scores instead by blast radius: fanout (how many files in the same zone import the offending file) multiplied into the score, combined with how central the zone itself is in the graph. The formula is fanout × 2 + zone_centrality for layer and undeclared violations, and cycle_length + zone_centrality for circular ones. Scores map to severity bands: ≥12 is critical, ≥8 high, ≥4 medium, below 4 low. import type violations are scored lower and marked [type-only], reflecting that a type-only reference has no runtime coupling even though it crosses the same boundary.

Suppression: inline and config-level

Two escape hatches exist for legitimate exceptions. Inline, a drift-ignore comment on the import line or the line directly above it suppresses that single import, using the native comment syntax of each language (// for TypeScript and Go, # for Python). At the config level, an exceptions array whitelists specific path patterns without touching source files:

{
  "exceptions": [
    { "from": "src/domain/legacy/**", "to": "src/infra/**", "reason": "migration in progress" }
  ]
}

from is required, to is optional, and both accept the same glob syntax as zone paths.

Baseline mode and CI enforcement

Adopting drift-scan on a codebase that already has violations doesn’t require fixing them all upfront. drift scan --save-baseline baseline.drift.json snapshots the current violation set as stable fingerprints, built from violation type plus relative file paths plus line number. In CI, drift scan --from-baseline baseline.drift.json --fail-on high reports only violations not present in that baseline, so a violation that shifts to a different line due to unrelated edits still matches against the baseline rather than showing up as new.

Output modes support both machine and human consumption: --json for programmatic use, --sarif for GitHub code scanning (paired with github/codeql-action/upload-sarif@v3), --output <file> to write anywhere other than stdout. --since <ref> restricts a scan to files changed against a git ref, narrowing a check to the diff under review rather than the whole tree. --fail-on <severity> sets the exit-1 threshold; --min-severity filters the report display independent of the exit code. --watch re-scans on file changes for local iteration.