adr-drift 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
adr_drift/__init__.py ADDED
File without changes
adr_drift/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
adr_drift/cli.py ADDED
@@ -0,0 +1,368 @@
1
+ """ADR Drift: deterministic architecture tests for any language.
2
+
3
+ Commands:
4
+ init scaffold arch.yaml + a sample ADR from the repo's folder layout
5
+ check verify the repo against arch.yaml + ADR Constraints (CI-friendly)
6
+ baseline record current violations so check --baseline can grandfather them
7
+ drift report NEW violations introduced since a git ref
8
+ new create the next-numbered ADR from a template
9
+ index regenerate the one-sentence-per-ADR index (docs/adr/README.md)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ import re
18
+ import sys
19
+
20
+ from . import config as config_mod
21
+ from . import engine as engine_mod
22
+ from . import githelp
23
+ from . import rules as rules_mod
24
+ from . import scanner as scanner_mod
25
+
26
+ VERSION = "0.1.0"
27
+
28
+ SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv",
29
+ "dist", "build", ".tox", "vendor", "docs"}
30
+
31
+
32
+ def _infer_layers(root: str) -> list[dict]:
33
+ base = os.path.join(root, "src") if os.path.isdir(
34
+ os.path.join(root, "src")) else root
35
+ names = sorted(d for d in os.listdir(base)
36
+ if os.path.isdir(os.path.join(base, d))
37
+ and d not in SKIP_DIRS and not d.startswith(".")
38
+ and d != "tests")
39
+ prefix = "src/" if base != root else ""
40
+ return [{"name": n, "paths": [f"{prefix}{n}/**"]} for n in names]
41
+
42
+
43
+ ARCH_TEMPLATE = """# ADR Drift architecture config.
44
+ # Layers are matched top-down; the first matching layer wins.
45
+ # Rules use the constraint DSL (see README):
46
+ # forbid-import: <from> -> <to> layer/module <from> may not import <to>
47
+ # forbid-pattern: <glob> !~ <regex> files matching <glob> must not contain <regex>
48
+ # require-pattern: <glob> =~ <regex> files matching <glob> must contain <regex>
49
+ layers:
50
+ {layers}
51
+ rules:
52
+ # Tighten me: uncomment and adapt.
53
+ # - "forbid-import: handlers -> db.drivers"
54
+ """
55
+
56
+ ADR_TEMPLATE = """# {num}: {title}
57
+
58
+ ## Status
59
+
60
+ Proposed
61
+
62
+ ## Context
63
+
64
+ Why this decision is being made.
65
+
66
+ ## Decision
67
+
68
+ What we decided.
69
+
70
+ ## Constraints
71
+
72
+ Machine-checkable rules ADR Drift enforces in CI. Examples:
73
+
74
+ - forbid-import: handlers -> db.drivers
75
+ - forbid-pattern: src/**.py !~ \\beval\\(
76
+ - require-pattern: src/handlers/**.py =~ authenticate\\(
77
+
78
+ ## Consequences
79
+
80
+ What follows from this decision.
81
+ """
82
+
83
+
84
+ def cmd_init(args) -> int:
85
+ root = args.root
86
+ arch_path = os.path.join(root, config_mod.ARCH_FILE)
87
+ if os.path.exists(arch_path) and not args.force:
88
+ print(f"{config_mod.ARCH_FILE} already exists (use --force to overwrite).")
89
+ return 2
90
+ layers = _infer_layers(root)
91
+ layer_yaml = "".join(
92
+ f" - name: {l['name']}\n paths: {[p for p in l['paths']]}".replace(
93
+ "'", '"') + "\n" for l in layers)
94
+ with open(arch_path, "w") as f:
95
+ f.write(ARCH_TEMPLATE.format(layers=layer_yaml or " []\n"))
96
+ adr_dir = os.path.join(root, args.adr_dir)
97
+ os.makedirs(adr_dir, exist_ok=True)
98
+ sample = os.path.join(adr_dir, "0001-record-architecture-decisions.md")
99
+ if not os.path.exists(sample):
100
+ with open(sample, "w") as f:
101
+ f.write(ADR_TEMPLATE.format(num=1,
102
+ title="Record architecture decisions"))
103
+ cmd_index(args)
104
+ print(f"wrote {config_mod.ARCH_FILE} "
105
+ f"({len(layers)} layers: {', '.join(l['name'] for l in layers)})")
106
+ print(f"sample ADR: {os.path.relpath(sample, root)}")
107
+ print("Next: tighten the rules in arch.yaml, then run `adr-drift check`.")
108
+ return 0
109
+
110
+
111
+ def _load(root, adr_dir):
112
+ return config_mod.load_all_rules(root, adr_dir)
113
+
114
+
115
+ def _print_violations(violations, fmt: str) -> None:
116
+ if fmt == "json":
117
+ print(json.dumps([v.as_dict() for v in violations], indent=2))
118
+ return
119
+ if not violations:
120
+ print("✓ no architecture drift — all ADRs hold")
121
+ return
122
+ print(f"✗ {len(violations)} architecture violation(s):\n")
123
+ by_file: dict[str, list] = {}
124
+ for v in violations:
125
+ by_file.setdefault(v.file, []).append(v)
126
+ for path in sorted(by_file):
127
+ print(f" {path}")
128
+ for v in by_file[path]:
129
+ where = f":{v.line}" if v.line else ""
130
+ print(f" {path}{where}: {v.detail}")
131
+ print(f" rule: {v.rule.describe()} ({v.rule.source})")
132
+
133
+
134
+ def cmd_check(args) -> int:
135
+ root = args.root
136
+ try:
137
+ layers, all_rules, adrs = _load(root, args.adr_dir)
138
+ except ValueError as e:
139
+ print(f"error: {e}", file=sys.stderr)
140
+ return 2
141
+ if not all_rules:
142
+ print("no rules found — run `adr-drift init` or add a ## Constraints "
143
+ "section to an ADR.")
144
+ return 0 if args.fail_on == "none" else 2
145
+ violations = engine_mod.check(root, layers, all_rules,
146
+ strict_py_only=args.strict_py_only)
147
+ if args.baseline:
148
+ keys = _load_baseline(args.baseline, root)
149
+ if keys is None:
150
+ return 2
151
+ violations = [v for v in violations
152
+ if (v.file, v.line, v.rule.describe()) not in keys]
153
+ _print_violations(violations, args.format)
154
+ if violations and args.fail_on != "none":
155
+ return 1
156
+ return 0
157
+
158
+
159
+ def _load_baseline(baseline: str, root: str) -> set | None:
160
+ """Violation identity triples from a baseline file.
161
+
162
+ Returns None (after printing an error) when the file can't be read:
163
+ failing closed beats silently dropping the grandfather list.
164
+ """
165
+ path = baseline if os.path.isabs(baseline) else os.path.join(root, baseline)
166
+ try:
167
+ with open(path, encoding="utf-8") as f:
168
+ data = json.load(f)
169
+ except (OSError, ValueError) as e:
170
+ print(f"error: cannot read baseline {path}: {e}", file=sys.stderr)
171
+ return None
172
+ keys = set()
173
+ for item in data:
174
+ keys.add((item.get("file"), item.get("line"), item.get("rule")))
175
+ return keys
176
+
177
+
178
+ def cmd_baseline(args) -> int:
179
+ root = args.root
180
+ try:
181
+ layers, all_rules, _adrs = _load(root, args.adr_dir)
182
+ except ValueError as e:
183
+ print(f"error: {e}", file=sys.stderr)
184
+ return 2
185
+ violations = engine_mod.check(root, layers, all_rules,
186
+ strict_py_only=args.strict_py_only)
187
+ out = (args.output if os.path.isabs(args.output)
188
+ else os.path.join(root, args.output))
189
+ with open(out, "w", encoding="utf-8") as f:
190
+ json.dump([v.as_dict() for v in violations], f, indent=2)
191
+ f.write("\n")
192
+ rel = os.path.relpath(out, root)
193
+ print(f"wrote {len(violations)} baselined violation(s) -> {rel}")
194
+ print(f"run `adr-drift check --baseline {rel}` to grandfather them.")
195
+ return 0
196
+
197
+
198
+ def cmd_drift(args) -> int:
199
+ root = args.root
200
+ if not githelp.is_repo(root):
201
+ print("error: not a git repo", file=sys.stderr)
202
+ return 2
203
+ try:
204
+ layers, all_rules, _adrs = _load(root, args.adr_dir)
205
+ except ValueError as e:
206
+ print(f"error: {e}", file=sys.stderr)
207
+ return 2
208
+ if not all_rules:
209
+ print("no rules found — nothing to drift from.")
210
+ return 0
211
+
212
+ changed = [p for p in githelp.changed_files(root, args.since)
213
+ if os.path.splitext(p)[1].lower()
214
+ in (scanner_mod.PY_EXTS | scanner_mod.GENERIC_EXTS)]
215
+ if not changed:
216
+ print(f"no source files changed since {args.since} — no drift")
217
+ return 0
218
+
219
+ def entries_for(get_content) -> list:
220
+ entries = []
221
+ for rel in changed:
222
+ content = get_content(rel)
223
+ if content is None:
224
+ continue
225
+ imports, _eng = scanner_mod.extract_imports(rel, content)
226
+ layer = engine_mod._file_layer(layers, rel)
227
+ entries.append((rel, layer, imports, content))
228
+ return entries
229
+
230
+ now_entries = entries_for(
231
+ lambda rel: open(os.path.join(root, rel), encoding="utf-8",
232
+ errors="replace").read()
233
+ if os.path.isfile(os.path.join(root, rel)) else None)
234
+ base_entries = entries_for(
235
+ lambda rel: githelp.file_at_ref(root, args.since, rel))
236
+
237
+ now_v = {(v.file, v.line, v.rule.describe())
238
+ for v in engine_mod.evaluate(now_entries, layers, all_rules)}
239
+ base_v = {(v.file, v.line, v.rule.describe())
240
+ for v in engine_mod.evaluate(base_entries, layers, all_rules)}
241
+ new_keys = now_v - base_v
242
+ fixed_keys = base_v - now_v
243
+ new_violations = [v for v in engine_mod.evaluate(
244
+ now_entries, layers, all_rules)
245
+ if (v.file, v.line, v.rule.describe()) in new_keys]
246
+ fixed_violations = [v for v in engine_mod.evaluate(
247
+ base_entries, layers, all_rules)
248
+ if (v.file, v.line, v.rule.describe()) in fixed_keys]
249
+
250
+ if args.format == "json":
251
+ # Stable contract: the JSON list is the NEW violations, as before.
252
+ # Fixed violations are reported in the human output.
253
+ print(json.dumps([v.as_dict() for v in new_violations], indent=2))
254
+ else:
255
+ if not new_violations:
256
+ print(f"✓ no new drift since {args.since} "
257
+ f"({len(changed)} file(s) changed)")
258
+ else:
259
+ print(f"✗ {len(new_violations)} NEW violation(s) "
260
+ f"since {args.since}:\n")
261
+ for v in new_violations:
262
+ commit = githelp.blame_commit_for_line(
263
+ root, v.file, v.line or 1)
264
+ tag = f" [{commit}]" if commit else ""
265
+ print(f" {v.file}:{v.line or '—'}{tag}: {v.detail}")
266
+ print(f" rule: {v.rule.describe()} ({v.rule.source})")
267
+ if fixed_violations:
268
+ print(f"\n✓ {len(fixed_violations)} fixed since "
269
+ f"{args.since}:\n")
270
+ for v in fixed_violations:
271
+ print(f" {v.file}:{v.line or '—'}: {v.detail} (fixed)")
272
+ print(f" rule: {v.rule.describe()} ({v.rule.source})")
273
+ if new_violations and args.fail_on != "none":
274
+ return 1
275
+ return 0
276
+
277
+
278
+ def cmd_new(args) -> int:
279
+ adr_dir = os.path.join(args.root, args.adr_dir)
280
+ os.makedirs(adr_dir, exist_ok=True)
281
+ nums = []
282
+ for f in os.listdir(adr_dir):
283
+ m = re.match(r"(\d+)[-_]", f)
284
+ if m:
285
+ nums.append(int(m.group(1)))
286
+ n = max(nums, default=0) + 1
287
+ slug = re.sub(r"[^a-z0-9]+", "-", args.title.lower()).strip("-")
288
+ path = os.path.join(adr_dir, f"{n:04d}-{slug}.md")
289
+ with open(path, "w") as f:
290
+ f.write(ADR_TEMPLATE.format(num=n, title=args.title))
291
+ print(f"created {os.path.relpath(path, args.root)}")
292
+ return 0
293
+
294
+
295
+ def cmd_index(args) -> int:
296
+ adr_dir = os.path.join(args.root, args.adr_dir)
297
+ adrs = [config_mod.parse_adr(p)
298
+ for p in config_mod.adr_files(args.root, args.adr_dir)]
299
+ lines = ["# Architecture Decision Records\n",
300
+ "| # | Title | Status |",
301
+ "|---|-------|--------|"]
302
+ for a in adrs:
303
+ rel = os.path.relpath(a["path"], adr_dir).replace(os.sep, "/")
304
+ lines.append(f"| {a['number']} | [{a['title']}]({rel}) "
305
+ f"| {a['status'] or '—'} |")
306
+ lines.append("")
307
+ with open(os.path.join(adr_dir, "README.md"), "w") as f:
308
+ f.write("\n".join(lines))
309
+ print(f"indexed {len(adrs)} ADR(s) -> "
310
+ f"{os.path.join(args.adr_dir, 'README.md')}")
311
+ return 0
312
+
313
+
314
+ def build_parser() -> argparse.ArgumentParser:
315
+ p = argparse.ArgumentParser(
316
+ prog="adr-drift",
317
+ description="Deterministic architecture tests: keep ADRs mechanically true.")
318
+ p.add_argument("--version", action="version", version=f"%(prog)s {VERSION}")
319
+ p.add_argument("--root", default=os.getcwd(),
320
+ help="Repo root (default: cwd).")
321
+ p.add_argument("--adr-dir", default=config_mod.DEFAULT_ADR_DIR,
322
+ help="ADR directory (default: docs/adr).")
323
+ sub = p.add_subparsers(dest="cmd", required=True)
324
+
325
+ i = sub.add_parser("init", help="Scaffold arch.yaml + sample ADR.")
326
+ i.add_argument("--force", action="store_true")
327
+
328
+ c = sub.add_parser("check", help="Verify the repo against all rules.")
329
+ c.add_argument("--format", choices=["human", "json"], default="human")
330
+ c.add_argument("--fail-on", choices=["violations", "none"],
331
+ default="violations")
332
+ c.add_argument("--strict-py-only", action="store_true",
333
+ help="Skip the regex fallback for non-Python files.")
334
+ c.add_argument("--baseline", default=None, metavar="FILE",
335
+ help="Grandfather violations listed in FILE "
336
+ "(written by `adr-drift baseline`).")
337
+
338
+ b = sub.add_parser("baseline",
339
+ help="Record current violations as a grandfather file.")
340
+ b.add_argument("--output", default=".adr-baseline.json", metavar="FILE",
341
+ help="Where to write the baseline "
342
+ "(default: .adr-baseline.json under --root).")
343
+ b.add_argument("--strict-py-only", action="store_true",
344
+ help="Skip the regex fallback for non-Python files.")
345
+
346
+ d = sub.add_parser("drift", help="New violations since a git ref.")
347
+ d.add_argument("--since", default="HEAD~5",
348
+ help="Git ref to compare against (default: HEAD~5).")
349
+ d.add_argument("--format", choices=["human", "json"], default="human")
350
+ d.add_argument("--fail-on", choices=["violations", "none"],
351
+ default="violations")
352
+
353
+ n = sub.add_parser("new", help="Create the next-numbered ADR.")
354
+ n.add_argument("title")
355
+
356
+ sub.add_parser("index", help="Regenerate the ADR index README.")
357
+ return p
358
+
359
+
360
+ def main(argv=None) -> int:
361
+ args = build_parser().parse_args(argv)
362
+ return {"init": cmd_init, "check": cmd_check, "baseline": cmd_baseline,
363
+ "drift": cmd_drift, "new": cmd_new,
364
+ "index": cmd_index}[args.cmd](args)
365
+
366
+
367
+ if __name__ == "__main__":
368
+ sys.exit(main())
adr_drift/config.py ADDED
@@ -0,0 +1,87 @@
1
+ """Config + ADR loading: arch.yaml and docs/adr/*.md Constraints sections."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+
8
+ from . import miniyaml
9
+ from . import rules as rules_mod
10
+
11
+ DEFAULT_ADR_DIR = "docs/adr"
12
+ ARCH_FILE = "arch.yaml"
13
+
14
+ CONSTRAINTS_RE = re.compile(
15
+ r"^##\s+Constraints\s*$", re.IGNORECASE | re.MULTILINE)
16
+
17
+
18
+ def load_arch_yaml(root: str) -> tuple[list[dict], list[rules_mod.Rule]]:
19
+ """Returns (layers, rules). Missing arch.yaml -> ([], [])."""
20
+ path = os.path.join(root, ARCH_FILE)
21
+ if not os.path.isfile(path):
22
+ return [], []
23
+ with open(path, encoding="utf-8") as f:
24
+ data = miniyaml.safe_load(f.read()) or {}
25
+ layers = data.get("layers", []) or []
26
+ out: list[rules_mod.Rule] = []
27
+ for i, raw in enumerate(data.get("rules", []) or []):
28
+ rule = rules_mod.parse_rule_line(str(raw), source=ARCH_FILE,
29
+ line_no=i + 1)
30
+ if rule:
31
+ out.append(rule)
32
+ return layers, out
33
+
34
+
35
+ def adr_files(root: str, adr_dir: str = DEFAULT_ADR_DIR) -> list[str]:
36
+ d = os.path.join(root, adr_dir)
37
+ if not os.path.isdir(d):
38
+ return []
39
+ return sorted(
40
+ os.path.join(d, f) for f in os.listdir(d)
41
+ if f.endswith(".md") and f != "README.md")
42
+
43
+
44
+ def parse_adr(path: str) -> dict:
45
+ """Extract number, title, status, and constraint rules from an ADR."""
46
+ with open(path, encoding="utf-8") as f:
47
+ text = f.read()
48
+ info: dict = {"path": path, "number": "", "title": "",
49
+ "status": "", "rules": []}
50
+ m = re.match(r"(\d+)[-_](.+)\.md$", os.path.basename(path))
51
+ if m:
52
+ info["number"] = m.group(1)
53
+ info["title"] = m.group(2).replace("-", " ").replace("_", " ")
54
+ m = re.search(r"^#\s+(.+)$", text, re.MULTILINE)
55
+ if m:
56
+ info["title"] = m.group(1).strip()
57
+ m = re.search(r"^##\s+Status\s*$\n(.+?)(?=^##\s|\Z)",
58
+ text, re.MULTILINE | re.DOTALL | re.IGNORECASE)
59
+ if m:
60
+ info["status"] = m.group(1).strip().splitlines()[0].strip()
61
+ m = CONSTRAINTS_RE.search(text)
62
+ if m:
63
+ section = text[m.end():]
64
+ section = re.split(r"^##\s", section, maxsplit=1,
65
+ flags=re.MULTILINE)[0]
66
+ for i, line in enumerate(section.splitlines(), 1):
67
+ line = line.strip()
68
+ if line.startswith(("-", "*")):
69
+ line = line[1:].strip()
70
+ try:
71
+ rule = rules_mod.parse_rule_line(line, source=path,
72
+ line_no=i)
73
+ except ValueError as e:
74
+ raise ValueError(f"{path}: {e}")
75
+ if rule:
76
+ info["rules"].append(rule)
77
+ return info
78
+
79
+
80
+ def load_all_rules(root: str, adr_dir: str = DEFAULT_ADR_DIR
81
+ ) -> tuple[list[dict], list[rules_mod.Rule], list[dict]]:
82
+ layers, arch_rules = load_arch_yaml(root)
83
+ adrs = [parse_adr(p) for p in adr_files(root, adr_dir)]
84
+ all_rules = list(arch_rules)
85
+ for a in adrs:
86
+ all_rules.extend(a["rules"])
87
+ return layers, all_rules, adrs
adr_drift/engine.py ADDED
@@ -0,0 +1,145 @@
1
+ """Engine: evaluate rules against a scanned repo -> violations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import os
7
+ import re
8
+
9
+ from . import rules as rules_mod
10
+ from . import scanner as scanner_mod
11
+
12
+
13
+ @dataclasses.dataclass
14
+ class Violation:
15
+ rule: rules_mod.Rule
16
+ file: str # repo-relative path
17
+ line: int # 0 when the whole file is the subject
18
+ detail: str
19
+
20
+ def as_dict(self) -> dict:
21
+ return {
22
+ "rule": self.rule.describe(),
23
+ "rule_source": (f"{self.rule.source}:{self.rule.line_no}"
24
+ if self.rule.line_no else self.rule.source),
25
+ "file": self.file,
26
+ "line": self.line,
27
+ "detail": self.detail,
28
+ }
29
+
30
+
31
+ def _rel(root: str, path: str) -> str:
32
+ return os.path.relpath(path, root).replace(os.sep, "/")
33
+
34
+
35
+ def _file_layer(layers: list[dict], rel_path: str) -> str:
36
+ for layer in layers:
37
+ for pat in layer.get("paths", []):
38
+ if rules_mod.match_glob(pat, rel_path):
39
+ return layer.get("name", "")
40
+ return ""
41
+
42
+
43
+ def _layer_path_prefixes(layer: dict) -> list[str]:
44
+ """Dotted module prefixes implied by a layer's path globs.
45
+
46
+ Only the literal leading components count (up to the first
47
+ wildcard): "src/db/**" -> ["src.db"], "handlers/**" -> ["handlers"].
48
+ """
49
+ prefixes: list[str] = []
50
+ for pat in layer.get("paths", []):
51
+ lit: list[str] = []
52
+ for part in pat.replace("\\", "/").strip("/").split("/"):
53
+ if any(ch in part for ch in "*?["):
54
+ break
55
+ lit.append(part)
56
+ if lit:
57
+ prefixes.append(".".join(lit))
58
+ return prefixes
59
+
60
+
61
+ def _import_identities(mod: str, layers: list[dict]) -> list[str]:
62
+ """Every name an imported module can be known by.
63
+
64
+ Besides the literal module ("src.db.drivers") this includes the
65
+ layer name(s) whose paths contain it ("db") and the module with the
66
+ layer's top-level source dir stripped ("db.drivers"). Without this,
67
+ src/-layout repos where code is imported as "src.db.x" silently
68
+ dodge every to-side pattern that names the layer.
69
+ """
70
+ ids = [mod]
71
+ for layer in layers:
72
+ name = layer.get("name", "")
73
+ for prefix in _layer_path_prefixes(layer):
74
+ if not rules_mod.match_pat(prefix, mod):
75
+ continue
76
+ if name and name not in ids:
77
+ ids.append(name)
78
+ parts = prefix.split(".")
79
+ if len(parts) >= 2 and mod.startswith(parts[0] + "."):
80
+ stripped = mod[len(parts[0]) + 1:]
81
+ if stripped and stripped not in ids:
82
+ ids.append(stripped)
83
+ return ids
84
+
85
+
86
+ def check(root: str, layers: list[dict], all_rules: list[rules_mod.Rule],
87
+ strict_py_only: bool = False) -> list[Violation]:
88
+ entries: list[tuple[str, str, list[tuple[str, int]], str]] = []
89
+ for path in scanner_mod.iter_source_files(root):
90
+ rel = _rel(root, path)
91
+ try:
92
+ with open(path, encoding="utf-8", errors="replace") as f:
93
+ content = f.read()
94
+ except OSError:
95
+ continue
96
+ imports, _engine = scanner_mod.extract_imports(
97
+ rel, content, strict_py_only=strict_py_only)
98
+ entries.append((rel, _file_layer(layers, rel), imports, content))
99
+ return evaluate(entries, layers, all_rules)
100
+
101
+
102
+ def evaluate(entries: list[tuple[str, str, list[tuple[str, int]], str]],
103
+ layers: list[dict],
104
+ all_rules: list[rules_mod.Rule]) -> list[Violation]:
105
+ """Evaluate rules against pre-scanned entries.
106
+
107
+ Each entry: (rel_path, layer, imports[(module, line)], content).
108
+ Lets `drift` check historical git content through the same engine.
109
+ """
110
+ violations: list[Violation] = []
111
+ for rule in all_rules:
112
+ if rule.kind == "forbid-import":
113
+ for rel, layer, imports, _content in entries:
114
+ # The "from" side matches the file's layer name OR a module
115
+ # prefix of the file path (so rules work without layers).
116
+ from_hit = (layer and rules_mod.match_pat(rule.from_pat, layer))
117
+ mod_prefix = rel.replace("/", ".").rsplit(".", 1)[0]
118
+ if not from_hit:
119
+ from_hit = rules_mod.match_pat(rule.from_pat, mod_prefix)
120
+ if not from_hit:
121
+ continue
122
+ for mod, lineno in imports:
123
+ if any(rules_mod.match_pat(rule.to_pat, ident)
124
+ for ident in _import_identities(mod, layers)):
125
+ violations.append(Violation(
126
+ rule=rule, file=rel, line=lineno,
127
+ detail=f"imports {mod}"))
128
+ else:
129
+ rx = re.compile(rule.regex)
130
+ for rel, _layer, _imports, content in entries:
131
+ if not rules_mod.match_glob(rule.glob, rel):
132
+ continue
133
+ if rule.kind == "forbid-pattern":
134
+ for i, line in enumerate(content.splitlines(), 1):
135
+ if rx.search(line):
136
+ violations.append(Violation(
137
+ rule=rule, file=rel, line=i,
138
+ detail=f"matches /{rule.regex}/"))
139
+ else: # require-pattern
140
+ if not rx.search(content):
141
+ violations.append(Violation(
142
+ rule=rule, file=rel, line=0,
143
+ detail=f"missing /{rule.regex}/"))
144
+ violations.sort(key=lambda v: (v.file, v.line))
145
+ return violations
adr_drift/githelp.py ADDED
@@ -0,0 +1,62 @@
1
+ """Git helpers for `adr-drift drift`: what did recent commits introduce?"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+
8
+
9
+ def _git(root: str, *args: str) -> str:
10
+ p = subprocess.run(["git", "-C", root, *args],
11
+ capture_output=True, text=True, timeout=30)
12
+ if p.returncode != 0:
13
+ raise RuntimeError(f"git {' '.join(args)} failed: {p.stderr.strip()}")
14
+ return p.stdout
15
+
16
+
17
+ def is_repo(root: str) -> bool:
18
+ try:
19
+ _git(root, "rev-parse", "--git-dir")
20
+ return True
21
+ except Exception:
22
+ return False
23
+
24
+
25
+ def changed_files(root: str, since: str) -> list[str]:
26
+ """Repo-relative paths changed between <since> and HEAD."""
27
+ out = _git(root, "diff", "--name-only", f"{since}..HEAD")
28
+ return [l.strip() for l in out.splitlines() if l.strip()]
29
+
30
+
31
+ def file_at_ref(root: str, ref: str, rel_path: str) -> str | None:
32
+ """File content at a git ref, or None if the file didn't exist."""
33
+ p = subprocess.run(["git", "-C", root, "show", f"{ref}:{rel_path}"],
34
+ capture_output=True, text=True, timeout=30)
35
+ if p.returncode != 0:
36
+ return None
37
+ return p.stdout
38
+
39
+
40
+ def recent_commits(root: str, n: int) -> list[dict]:
41
+ out = _git(root, "log", f"-{n}", "--format=%H%x01%h%x01%s")
42
+ commits = []
43
+ for line in out.splitlines():
44
+ parts = line.split("\x01")
45
+ if len(parts) == 3:
46
+ commits.append({"sha": parts[0], "short": parts[1],
47
+ "subject": parts[2]})
48
+ return commits
49
+
50
+
51
+ def blame_commit_for_line(root: str, rel_path: str, lineno: int) -> str:
52
+ """Short sha of the commit that introduced a given line (best effort)."""
53
+ try:
54
+ out = _git(root, "blame", "--porcelain", "-L", f"{lineno},{lineno}",
55
+ "HEAD", "--", rel_path)
56
+ for line in out.splitlines():
57
+ if len(line) >= 40 and all(
58
+ c in "0123456789abcdef" for c in line[:40]):
59
+ return line[:7]
60
+ except Exception:
61
+ pass
62
+ return ""
adr_drift/miniyaml.py ADDED
@@ -0,0 +1,273 @@
1
+ """Minimal YAML subset parser (stdlib only).
2
+
3
+ Handles the subset these tools need:
4
+ - block mappings and lists, nested by indentation
5
+ - inline-map list items ("- key: value")
6
+ - flow-style lists of scalars ("[a, b]")
7
+ - scalars: quoted strings, ints, floats, bools, null
8
+ - "#" comments (outside quotes)
9
+
10
+ SHARED COPY: the canonical version lives here in adr_drift/miniyaml.py.
11
+ pr_verify and promptregress carry verbatim copies (see their headers).
12
+ Do not fork — fix the canonical copy and re-copy.
13
+
14
+ NOT supported (raises ValueError): anchors, aliases, tags, multi-line
15
+ (|, >) scalars, flow maps, duplicate keys. Configs using them get a
16
+ clear error naming the feature and the line number.
17
+
18
+ Strictness notes:
19
+ - Every ValueError carries a line number: "miniyaml: <what> (line N)".
20
+ - Unclosed flow lists ("a: [x"), embedded mapping indicators
21
+ ("a: b: c"), unterminated quotes, and nested flow collections are
22
+ rejected instead of silently misparsed.
23
+ - Nesting deeper than MAX_DEPTH (50) raises ValueError, never
24
+ RecursionError.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import re
30
+
31
+ _KEY_RE = re.compile(r"^([A-Za-z0-9_.\-/ ]+):(?:\s|$)")
32
+
33
+ #: Deepest block-nesting level accepted. Deeper input raises ValueError
34
+ #: (the parser's error contract) instead of RecursionError.
35
+ MAX_DEPTH = 50
36
+
37
+
38
+ def _err(msg: str, lineno: int) -> ValueError:
39
+ return ValueError(f"miniyaml: {msg} (line {lineno})")
40
+
41
+
42
+ def _strip_comment(line: str) -> str:
43
+ in_s = in_d = False
44
+ for i, ch in enumerate(line):
45
+ if ch == "'" and not in_d:
46
+ in_s = not in_s
47
+ elif ch == '"' and not in_s:
48
+ in_d = not in_d
49
+ elif ch == "#" and not in_s and not in_d:
50
+ if i == 0 or line[i - 1] in " \t":
51
+ return line[:i]
52
+ return line
53
+
54
+
55
+ def _scalar(s: str):
56
+ s = s.strip()
57
+ if len(s) >= 2 and s[0] == s[-1] and s[0] in "\"'":
58
+ inner = s[1:-1]
59
+ if s[0] == '"':
60
+ inner = inner.replace('\\"', '"').replace("\\\\", "\\")
61
+ return inner
62
+ low = s.lower()
63
+ if low in ("null", "~", "none", ""):
64
+ return None
65
+ if low in ("true", "yes", "on"):
66
+ return True
67
+ if low in ("false", "no", "off"):
68
+ return False
69
+ try:
70
+ return int(s)
71
+ except ValueError:
72
+ pass
73
+ try:
74
+ return float(s)
75
+ except ValueError:
76
+ pass
77
+ return s
78
+
79
+
80
+ def _check_no_mapping_indicator(v: str, lineno: int):
81
+ """Reject ':' inside a plain scalar ("a: b: c") — PyYAML rejects it too.
82
+
83
+ v is an unquoted scalar here; stray quote chars are tracked so a
84
+ colon inside e.g. "it's: fine" does not false-positive.
85
+ """
86
+ in_s = in_d = False
87
+ for i, ch in enumerate(v):
88
+ if ch == "'" and not in_d:
89
+ in_s = not in_s
90
+ elif ch == '"' and not in_s:
91
+ in_d = not in_d
92
+ elif ch == ":" and not in_s and not in_d:
93
+ nxt = v[i + 1] if i + 1 < len(v) else ""
94
+ if nxt in ("", " ", "\t"):
95
+ raise _err(
96
+ "unexpected ':' in scalar "
97
+ f"(nested mappings need their own lines): {v!r}",
98
+ lineno,
99
+ )
100
+
101
+
102
+ def _check_value(val: str, lineno: int, in_flow: bool = False):
103
+ """Reject unsupported/malformed value shapes before _scalar() sees them.
104
+
105
+ Anchors (&), aliases (*), and tags (!) are named explicitly so a
106
+ config using them fails loudly instead of silently dropping content.
107
+ """
108
+ v = val.strip()
109
+ if not v:
110
+ return
111
+ c0 = v[0]
112
+ if c0 == "&":
113
+ raise _err("YAML anchors (&) are not supported", lineno)
114
+ if c0 == "*":
115
+ raise _err("YAML aliases (*) are not supported", lineno)
116
+ if c0 == "!":
117
+ raise _err("YAML tags (!) are not supported", lineno)
118
+ if c0 in ("|", ">"):
119
+ raise _err("multi-line (|, >) scalars are not supported", lineno)
120
+ if c0 in "\"'":
121
+ # Quoted scalars are opaque — nothing else to check.
122
+ if len(v) < 2 or v[-1] != c0:
123
+ raise _err(f"unterminated quoted string: {v!r}", lineno)
124
+ return
125
+ if c0 == "{":
126
+ raise _err("flow-style maps ({...}) are not supported", lineno)
127
+ if c0 == "[":
128
+ if in_flow:
129
+ raise _err("nested flow collections are not supported", lineno)
130
+ if not v.endswith("]"):
131
+ raise _err(f"unclosed flow list: {v!r}", lineno)
132
+ return # balanced "[...]"; items are checked per-item in _flow_list
133
+ if in_flow and any(ch in v for ch in "[]{}"):
134
+ raise _err("nested flow collections are not supported", lineno)
135
+ _check_no_mapping_indicator(v, lineno)
136
+
137
+
138
+ def _flow_list(s: str, lineno: int) -> list:
139
+ """Parse [a, b, "c"] — scalars only."""
140
+ inner = s.strip()[1:-1].strip()
141
+ if not inner:
142
+ return []
143
+ raw_items, cur, in_s, in_d = [], "", False, False
144
+ for ch in inner:
145
+ if ch == "'" and not in_d:
146
+ in_s = not in_s
147
+ cur += ch
148
+ elif ch == '"' and not in_s:
149
+ in_d = not in_d
150
+ cur += ch
151
+ elif ch == "," and not in_s and not in_d:
152
+ raw_items.append(cur)
153
+ cur = ""
154
+ else:
155
+ cur += ch
156
+ if in_s or in_d:
157
+ raise _err(f"unterminated quote in flow list: {s}", lineno)
158
+ raw_items.append(cur)
159
+ out = []
160
+ for item in raw_items:
161
+ _check_value(item, lineno, in_flow=True)
162
+ out.append(_scalar(item))
163
+ return out
164
+
165
+
166
+ def safe_load(text: str):
167
+ # items: (line number, indent, content)
168
+ items = []
169
+ for lineno, raw in enumerate(text.splitlines(), 1):
170
+ line = _strip_comment(raw).rstrip()
171
+ if not line.strip():
172
+ continue
173
+ if "\t" in line[: len(line) - len(line.lstrip())]:
174
+ raise _err("tabs not allowed for indentation", lineno)
175
+ indent = len(line) - len(line.lstrip(" "))
176
+ content = line.strip()
177
+ if content in ("---", "..."):
178
+ continue
179
+ items.append((lineno, indent, content))
180
+ if not items:
181
+ return None
182
+ val, pos = _parse_block(items, 0, items[0][1], 0)
183
+ if pos != len(items):
184
+ lineno, _, content = items[pos]
185
+ raise _err(f"unexpected content: {content!r}", lineno)
186
+ return val
187
+
188
+
189
+ def _parse_block(items, pos, indent, depth):
190
+ if depth > MAX_DEPTH:
191
+ raise _err(
192
+ f"maximum nesting depth ({MAX_DEPTH}) exceeded", items[pos][0]
193
+ )
194
+ first = items[pos][2]
195
+ if first == "-" or first.startswith("- "):
196
+ return _parse_list(items, pos, indent, depth)
197
+ return _parse_map(items, pos, indent, depth)
198
+
199
+
200
+ def _parse_map(items, pos, indent, depth):
201
+ d: dict = {}
202
+ while pos < len(items):
203
+ lineno, ind, line = items[pos]
204
+ if ind < indent:
205
+ break
206
+ if ind > indent:
207
+ raise _err(f"bad indentation: {line!r}", lineno)
208
+ if line == "-" or line.startswith("- "):
209
+ raise _err(f"list item inside mapping: {line!r}", lineno)
210
+ if line[0] in "&*!":
211
+ # Anchor/alias/tag where a key was expected — name it.
212
+ _check_value(line, lineno)
213
+ m = _KEY_RE.match(line)
214
+ if not m:
215
+ raise _err(f"expected 'key: value', got: {line!r}", lineno)
216
+ key = m.group(1).strip()
217
+ rest = line[m.end():].strip()
218
+ pos += 1
219
+ if key in d:
220
+ raise _err(f"duplicate key: {key!r}", lineno)
221
+ if rest:
222
+ _check_value(rest, lineno)
223
+ if rest.startswith("["):
224
+ d[key] = _flow_list(rest, lineno)
225
+ else:
226
+ d[key] = _scalar(rest)
227
+ else:
228
+ if pos < len(items) and items[pos][1] > indent:
229
+ val, pos = _parse_block(items, pos, items[pos][1], depth + 1)
230
+ d[key] = val
231
+ else:
232
+ d[key] = None
233
+ return d, pos
234
+
235
+
236
+ def _parse_list(items, pos, indent, depth):
237
+ lst: list = []
238
+ while pos < len(items):
239
+ lineno, ind, line = items[pos]
240
+ if ind < indent:
241
+ break
242
+ if ind > indent:
243
+ raise _err(f"bad indentation: {line!r}", lineno)
244
+ if not (line == "-" or line.startswith("- ")):
245
+ break # end of list; parent map continues
246
+ content = line[1:].strip()
247
+ pos += 1
248
+ if not content:
249
+ if pos < len(items) and items[pos][1] > indent:
250
+ val, pos = _parse_block(items, pos, items[pos][1], depth + 1)
251
+ lst.append(val)
252
+ else:
253
+ lst.append(None)
254
+ continue
255
+ if _KEY_RE.match(content):
256
+ sub = [(lineno, indent + 2, content)]
257
+ while pos < len(items) and items[pos][1] > indent:
258
+ sub.append(items[pos])
259
+ pos += 1
260
+ val, _ = _parse_map(sub, 0, indent + 2, depth + 1)
261
+ lst.append(val)
262
+ elif content.startswith("[") and content.endswith("]"):
263
+ _check_value(content, lineno)
264
+ lst.append(_flow_list(content, lineno))
265
+ else:
266
+ _check_value(content, lineno)
267
+ lst.append(_scalar(content))
268
+ return lst, pos
269
+
270
+
271
+ # alias mirroring the PyYAML call sites being replaced
272
+ def load(text: str):
273
+ return safe_load(text)
adr_drift/rules.py ADDED
@@ -0,0 +1,109 @@
1
+ """Rules: the constraint DSL shared by arch.yaml and ADR files.
2
+
3
+ A rule is one line:
4
+
5
+ forbid-import: <from> -> <to> # layer/module <from> may not import <to>
6
+ forbid-pattern: <glob> !~ <regex> # files matching <glob> must not contain <regex>
7
+ require-pattern: <glob> =~ <regex> # files matching <glob> must contain <regex>
8
+
9
+ <from>/<to> match against layer names or module prefixes ("db.drivers",
10
+ "src/handlers"). "*" is a wildcard. Comments start with "#".
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import dataclasses
16
+ import fnmatch
17
+ import re
18
+
19
+
20
+ @dataclasses.dataclass
21
+ class Rule:
22
+ kind: str # forbid-import | forbid-pattern | require-pattern
23
+ source: str # "arch.yaml" or "docs/adr/0004-....md"
24
+ line_no: int = 0
25
+ raw: str = ""
26
+ # forbid-import
27
+ from_pat: str = ""
28
+ to_pat: str = ""
29
+ # pattern rules
30
+ glob: str = ""
31
+ regex: str = ""
32
+
33
+ def describe(self) -> str:
34
+ if self.kind == "forbid-import":
35
+ return f"{self.from_pat} must not import {self.to_pat}"
36
+ if self.kind == "forbid-pattern":
37
+ return f"{self.glob} must not contain /{self.regex}/"
38
+ return f"{self.glob} must contain /{self.regex}/"
39
+
40
+
41
+ _RULE_RE = re.compile(
42
+ r"^(forbid-import|forbid-pattern|require-pattern)\s*:\s*(.+?)\s*$")
43
+
44
+
45
+ def parse_rule_line(line: str, source: str = "",
46
+ line_no: int = 0) -> "Rule | None":
47
+ """Parse one DSL line. Returns None for blanks/comments/non-rules."""
48
+ text = line.split("#", 1)[0].strip()
49
+ if not text:
50
+ return None
51
+ m = _RULE_RE.match(text)
52
+ if not m:
53
+ return None
54
+ kind, rest = m.group(1), m.group(2)
55
+ rule = Rule(kind=kind, source=source, line_no=line_no, raw=line.strip())
56
+ if kind == "forbid-import":
57
+ parts = rest.split("->")
58
+ if len(parts) != 2:
59
+ raise ValueError(f"bad forbid-import rule: {line.strip()!r} "
60
+ f"(want '<from> -> <to>')")
61
+ rule.from_pat = parts[0].strip()
62
+ rule.to_pat = parts[1].strip()
63
+ if not rule.from_pat or not rule.to_pat:
64
+ raise ValueError(f"bad forbid-import rule: {line.strip()!r}")
65
+ else:
66
+ op = "!~" if kind == "forbid-pattern" else "=~"
67
+ if op not in rest:
68
+ raise ValueError(f"bad {kind} rule: {line.strip()!r} "
69
+ f"(want '<glob> {op} <regex>')")
70
+ glob, regex = rest.split(op, 1)
71
+ rule.glob = glob.strip()
72
+ rule.regex = regex.strip()
73
+ if not rule.glob or not rule.regex:
74
+ raise ValueError(f"bad {kind} rule: {line.strip()!r}")
75
+ try:
76
+ re.compile(rule.regex) # validate early
77
+ except re.error as e:
78
+ raise ValueError(f"bad {kind} regex in {line.strip()!r}: {e}")
79
+ return rule
80
+
81
+
82
+ def _norm(pat: str) -> str:
83
+ return pat.replace("/", ".").strip(".")
84
+
85
+
86
+ def match_pat(pat: str, value: str) -> bool:
87
+ """Match a from/to pattern against a layer name or dotted module.
88
+
89
+ "db.drivers" matches "db.drivers" and "db.drivers.pg" (prefix with
90
+ boundary). "*" wildcards supported via fnmatch on both dotted and
91
+ slash forms.
92
+ """
93
+ p, v = _norm(pat), _norm(value)
94
+ if "*" in p:
95
+ return (fnmatch.fnmatchcase(v, p)
96
+ or fnmatch.fnmatchcase(value, pat)
97
+ or fnmatch.fnmatchcase(v, p.rstrip("*").rstrip(".")))
98
+ return v == p or v.startswith(p + ".")
99
+
100
+
101
+ def match_glob(glob: str, path: str) -> bool:
102
+ path = path.replace("\\", "/")
103
+ g = glob.replace("\\", "/")
104
+ if fnmatch.fnmatchcase(path, g):
105
+ return True
106
+ # "**" should also match zero directories: "src/**.py" vs "src/a.py"
107
+ if "**" in g:
108
+ return fnmatch.fnmatchcase(path, g.replace("**/", ""))
109
+ return False
adr_drift/scanner.py ADDED
@@ -0,0 +1,97 @@
1
+ """Import extraction: precise for Python (AST), heuristic for the rest.
2
+
3
+ Python files get real AST parsing — no regex false positives from comments
4
+ or strings. Other languages (JS/TS/Go/Rust/Java/…) use a regex fallback
5
+ that is documented as heuristic: it can miss exotic forms and may catch
6
+ an import inside a comment. check --strict-py-only disables the fallback.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ast
12
+ import os
13
+ import re
14
+ import sys
15
+
16
+ PY_EXTS = {".py"}
17
+ GENERIC_EXTS = {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
18
+ ".go", ".rs", ".java", ".rb", ".php"}
19
+
20
+ # import x.y / from x.y import z / require("x") / import "x" / use x::y …
21
+ _GENERIC_IMPORT_RES = [
22
+ re.compile(r'^\s*import\s+(?:type\s+)?[{*]?[^"\']*?from\s+["\']([^"\']+)["\']'),
23
+ re.compile(r'^\s*import\s+["\']([^"\']+)["\']'),
24
+ re.compile(r'require\(\s*["\']([^"\']+)["\']\s*\)'),
25
+ re.compile(r'^\s*use\s+([a-zA-Z0-9_:]+)(?:::|\s*[{;])'),
26
+ re.compile(r'^\s*import\s+\(\s*["\']([^"\']+)["\']'),
27
+ re.compile(r'^\s*import\s+([a-zA-Z0-9_./@-]+)\s*;?\s*$'),
28
+ ]
29
+
30
+
31
+ def python_imports(source: str) -> list[tuple[str, int]]:
32
+ """(module, line_no) via AST. Relative imports resolve to "<local>".
33
+
34
+ Raises SyntaxError on unparseable source — callers that know the file
35
+ path (extract_imports) turn that into a CI-visible warning instead of
36
+ silently passing the file on every import rule.
37
+ """
38
+ found: list[tuple[str, int]] = []
39
+ tree = ast.parse(source)
40
+ for node in ast.walk(tree):
41
+ if isinstance(node, ast.Import):
42
+ for a in node.names:
43
+ found.append((a.name, node.lineno))
44
+ elif isinstance(node, ast.ImportFrom):
45
+ if node.level:
46
+ found.append(("<local>", node.lineno))
47
+ elif node.module:
48
+ found.append((node.module, node.lineno))
49
+ return found
50
+
51
+
52
+ def generic_imports(source: str) -> list[tuple[str, int]]:
53
+ found: list[tuple[str, int]] = []
54
+ for i, line in enumerate(source.splitlines(), 1):
55
+ for rx in _GENERIC_IMPORT_RES:
56
+ m = rx.search(line)
57
+ if m:
58
+ found.append((m.group(1), i))
59
+ break
60
+ return found
61
+
62
+
63
+ def extract_imports(path: str, source: str | None = None,
64
+ strict_py_only: bool = False) -> tuple[list[tuple[str, int]], str]:
65
+ """Returns (imports, engine) where engine is "ast", "regex", or "none"."""
66
+ ext = os.path.splitext(path)[1].lower()
67
+ if source is None:
68
+ try:
69
+ with open(path, encoding="utf-8", errors="replace") as f:
70
+ source = f.read()
71
+ except OSError:
72
+ return [], "none"
73
+ if ext in PY_EXTS:
74
+ try:
75
+ return python_imports(source), "ast"
76
+ except SyntaxError as e:
77
+ # CI-visible: an unparseable file must not silently pass
78
+ # every import rule.
79
+ print(f"warning: {path}: Python syntax error ({e}); "
80
+ f"skipping import checks for this file", file=sys.stderr)
81
+ return [], "ast"
82
+ if ext in GENERIC_EXTS and not strict_py_only:
83
+ return generic_imports(source), "regex"
84
+ return [], "none"
85
+
86
+
87
+ def iter_source_files(root: str) -> list[str]:
88
+ skip_dirs = {".git", "node_modules", "__pycache__", ".venv", "venv",
89
+ "dist", "build", ".tox", "vendor"}
90
+ out: list[str] = []
91
+ for dirpath, dirnames, filenames in os.walk(root):
92
+ dirnames[:] = [d for d in dirnames if d not in skip_dirs]
93
+ for fn in filenames:
94
+ ext = os.path.splitext(fn)[1].lower()
95
+ if ext in PY_EXTS or ext in GENERIC_EXTS:
96
+ out.append(os.path.join(dirpath, fn))
97
+ return sorted(out)
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: adr-drift
3
+ Version: 0.1.0
4
+ Summary: Deterministic architecture tests: keep the codebase honest about its own ADRs.
5
+ Author: Calas Automations Inc.
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/calasdan-sketch/adr-drift
8
+ Project-URL: Issues, https://github.com/calasdan-sketch/adr-drift/issues
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Dynamic: license-file
13
+
14
+ # ADR Drift
15
+
16
+ Deterministic architecture tests for any language. ADRs rot the moment they
17
+ merge — this keeps them mechanically true. Define layers and constraints once,
18
+ then `adr-drift check` fails CI when code drifts from the decisions.
19
+
20
+ ## The problem
21
+
22
+ > "The moment an ADR gets merged, it decays into a passive historical record
23
+ > unless something mechanical forces it to stay alive."
24
+
25
+ Developers — and now AI coding agents — merge boundary violations because
26
+ nothing checks decisions against code. ArchUnit is Java-only, deptrac is
27
+ PHP-only. ADR Drift is language-agnostic, repo-local, and CI-friendly.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ git clone <repo> && cd adr-drift
33
+ pip install . # provides the `adr-drift` console script
34
+ ```
35
+
36
+ Or run without installing: `python3 -m adr_drift` from the repo root.
37
+
38
+ ## Quick start
39
+
40
+ ```bash
41
+ cd your-repo
42
+ python3 -m adr_drift init # scaffold arch.yaml + sample ADR from folder layout
43
+ # tighten the rules in arch.yaml, add ## Constraints to your ADRs
44
+ python3 -m adr_drift check # verify; exit 1 on violations (CI-ready)
45
+ # repo already has legacy violations? grandfather them, then enforce:
46
+ python3 -m adr_drift baseline # record current violations
47
+ python3 -m adr_drift check --baseline .adr-baseline.json
48
+ ```
49
+
50
+ ## Commands
51
+
52
+ | Command | What it does |
53
+ |---|---|
54
+ | `init` | Infer layers from folder layout, write `arch.yaml` + sample ADR + index |
55
+ | `check` | Verify the repo against `arch.yaml` + every ADR's `## Constraints` (`--baseline FILE` grandfathers recorded violations) |
56
+ | `baseline` | Write current violations to `.adr-baseline.json` (default) for `check --baseline` |
57
+ | `drift --since HEAD~5` | Report violations *introduced* since a git ref, plus violations *fixed* since then (pre-existing ones ignored) |
58
+ | `new "title"` | Create the next-numbered ADR from a template |
59
+ | `index` | Regenerate the one-sentence-per-ADR index (`docs/adr/README.md`) |
60
+
61
+ ## The constraint DSL
62
+
63
+ Rules live in `arch.yaml` and in any ADR's `## Constraints` section:
64
+
65
+ ```
66
+ forbid-import: handlers -> db.drivers # layering: handlers may not import drivers
67
+ forbid-pattern: src/**.py !~ \beval\( # no eval() anywhere in src/
68
+ require-pattern: src/handlers/**.py =~ authenticate\( # handlers must authenticate
69
+ ```
70
+
71
+ `from`/`to` match layer names or module prefixes (`db.drivers` matches
72
+ `db.drivers.pg`); `*` wildcards supported. Comments start with `#`.
73
+ To-side patterns resolve against layer paths too: in a `src/`-layout repo
74
+ both `db.drivers` and `src.db.drivers` spellings match a `db` layer mapped
75
+ to `src/db/**`.
76
+
77
+ ## How it checks
78
+
79
+ - **Python** is parsed with the real `ast` — no regex false positives from
80
+ comments or strings.
81
+ - **JS/TS/Go/Rust/Java/Ruby/PHP** use a heuristic regex import extractor
82
+ (documented as such; `--strict-py-only` disables it).
83
+ - `drift` compares violations at HEAD against the base ref via `git show` —
84
+ it never touches your working tree, and `git blame` tags each new
85
+ violation with its introducing commit.
86
+
87
+ ## CI
88
+
89
+ ```yaml
90
+ - run: python3 -m adr_drift check --format json --fail-on violations
91
+ ```
92
+
93
+ `--fail-on none` reports without failing. JSON output is stable for tooling.
94
+
95
+ ## Honest limits
96
+
97
+ - The regex fallback for non-Python languages is heuristic — it can miss
98
+ exotic import forms. Python is precise; everything else is best-effort.
99
+ - `drift` needs a git repo; `check` works anywhere.
100
+ - Rules are only as good as the constraints you write — `init` scaffolds,
101
+ you tighten.
102
+
103
+ ## Tests
104
+
105
+ 55/55 pass: `python3 -m unittest discover -s tests`
@@ -0,0 +1,15 @@
1
+ adr_drift/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ adr_drift/__main__.py,sha256=MHKZ_ae3fSLGTLUUMOx15fWdeOnJSHhq-zslRP5F5Lc,79
3
+ adr_drift/cli.py,sha256=8OkgGEzxlpksdLU8UIsc1gpXvBwGk2SUKzab4lNf-S4,13855
4
+ adr_drift/config.py,sha256=MUQR2bGWMqvUuAt6KT-KeUttvN9Dcx3kX8a3gRUU8wk,3087
5
+ adr_drift/engine.py,sha256=Bzyfeaxrf8FzvLOWNyUwl0sXV2PPGMJehkJYasv8qDw,5637
6
+ adr_drift/githelp.py,sha256=Z5qCV74dv11qAvl-FwdBKJkjKVT_RgDPj21_GWgbx4I,2040
7
+ adr_drift/miniyaml.py,sha256=bDpXNkei59wFIOd3Jf7Z_gqVXHI1RpqtUxpRT3a1yCU,9170
8
+ adr_drift/rules.py,sha256=bXjGrir5YyXw-ssnpv299gghgZR7QytLRsU8H67uZmM,3762
9
+ adr_drift/scanner.py,sha256=2VIrjSO9UDeFAiIoGmfgorRKEjwIyfCFyNHhhPPmYBA,3703
10
+ adr_drift-0.1.0.dist-info/licenses/LICENSE,sha256=f-qFLkEfvZidBgKv2YINOOKS7Trk_gj1c6Cy718D8F0,1079
11
+ adr_drift-0.1.0.dist-info/METADATA,sha256=KdZ2ZLbnPUo_RfkfwL4dcqBaIMbjocq2oQCxUBxAz3k,3981
12
+ adr_drift-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
13
+ adr_drift-0.1.0.dist-info/entry_points.txt,sha256=94VmW9mcrKT61dzxWxHtsT4VLAJ45QXnNsPb1GjvHFw,49
14
+ adr_drift-0.1.0.dist-info/top_level.txt,sha256=gS9Ec1NqWgjm8y8Ecw4wCCoJCtF2kvBjfM5Sgrv5xf4,10
15
+ adr_drift-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ adr-drift = adr_drift.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Calas Automations Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ adr_drift