adr-drift 0.1.0__tar.gz

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.
@@ -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,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,92 @@
1
+ # ADR Drift
2
+
3
+ Deterministic architecture tests for any language. ADRs rot the moment they
4
+ merge — this keeps them mechanically true. Define layers and constraints once,
5
+ then `adr-drift check` fails CI when code drifts from the decisions.
6
+
7
+ ## The problem
8
+
9
+ > "The moment an ADR gets merged, it decays into a passive historical record
10
+ > unless something mechanical forces it to stay alive."
11
+
12
+ Developers — and now AI coding agents — merge boundary violations because
13
+ nothing checks decisions against code. ArchUnit is Java-only, deptrac is
14
+ PHP-only. ADR Drift is language-agnostic, repo-local, and CI-friendly.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ git clone <repo> && cd adr-drift
20
+ pip install . # provides the `adr-drift` console script
21
+ ```
22
+
23
+ Or run without installing: `python3 -m adr_drift` from the repo root.
24
+
25
+ ## Quick start
26
+
27
+ ```bash
28
+ cd your-repo
29
+ python3 -m adr_drift init # scaffold arch.yaml + sample ADR from folder layout
30
+ # tighten the rules in arch.yaml, add ## Constraints to your ADRs
31
+ python3 -m adr_drift check # verify; exit 1 on violations (CI-ready)
32
+ # repo already has legacy violations? grandfather them, then enforce:
33
+ python3 -m adr_drift baseline # record current violations
34
+ python3 -m adr_drift check --baseline .adr-baseline.json
35
+ ```
36
+
37
+ ## Commands
38
+
39
+ | Command | What it does |
40
+ |---|---|
41
+ | `init` | Infer layers from folder layout, write `arch.yaml` + sample ADR + index |
42
+ | `check` | Verify the repo against `arch.yaml` + every ADR's `## Constraints` (`--baseline FILE` grandfathers recorded violations) |
43
+ | `baseline` | Write current violations to `.adr-baseline.json` (default) for `check --baseline` |
44
+ | `drift --since HEAD~5` | Report violations *introduced* since a git ref, plus violations *fixed* since then (pre-existing ones ignored) |
45
+ | `new "title"` | Create the next-numbered ADR from a template |
46
+ | `index` | Regenerate the one-sentence-per-ADR index (`docs/adr/README.md`) |
47
+
48
+ ## The constraint DSL
49
+
50
+ Rules live in `arch.yaml` and in any ADR's `## Constraints` section:
51
+
52
+ ```
53
+ forbid-import: handlers -> db.drivers # layering: handlers may not import drivers
54
+ forbid-pattern: src/**.py !~ \beval\( # no eval() anywhere in src/
55
+ require-pattern: src/handlers/**.py =~ authenticate\( # handlers must authenticate
56
+ ```
57
+
58
+ `from`/`to` match layer names or module prefixes (`db.drivers` matches
59
+ `db.drivers.pg`); `*` wildcards supported. Comments start with `#`.
60
+ To-side patterns resolve against layer paths too: in a `src/`-layout repo
61
+ both `db.drivers` and `src.db.drivers` spellings match a `db` layer mapped
62
+ to `src/db/**`.
63
+
64
+ ## How it checks
65
+
66
+ - **Python** is parsed with the real `ast` — no regex false positives from
67
+ comments or strings.
68
+ - **JS/TS/Go/Rust/Java/Ruby/PHP** use a heuristic regex import extractor
69
+ (documented as such; `--strict-py-only` disables it).
70
+ - `drift` compares violations at HEAD against the base ref via `git show` —
71
+ it never touches your working tree, and `git blame` tags each new
72
+ violation with its introducing commit.
73
+
74
+ ## CI
75
+
76
+ ```yaml
77
+ - run: python3 -m adr_drift check --format json --fail-on violations
78
+ ```
79
+
80
+ `--fail-on none` reports without failing. JSON output is stable for tooling.
81
+
82
+ ## Honest limits
83
+
84
+ - The regex fallback for non-Python languages is heuristic — it can miss
85
+ exotic import forms. Python is precise; everything else is best-effort.
86
+ - `drift` needs a git repo; `check` works anywhere.
87
+ - Rules are only as good as the constraints you write — `init` scaffolds,
88
+ you tighten.
89
+
90
+ ## Tests
91
+
92
+ 55/55 pass: `python3 -m unittest discover -s tests`
File without changes
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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())
@@ -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