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.
- adr_drift-0.1.0/LICENSE +21 -0
- adr_drift-0.1.0/PKG-INFO +105 -0
- adr_drift-0.1.0/README.md +92 -0
- adr_drift-0.1.0/adr_drift/__init__.py +0 -0
- adr_drift-0.1.0/adr_drift/__main__.py +4 -0
- adr_drift-0.1.0/adr_drift/cli.py +368 -0
- adr_drift-0.1.0/adr_drift/config.py +87 -0
- adr_drift-0.1.0/adr_drift/engine.py +145 -0
- adr_drift-0.1.0/adr_drift/githelp.py +62 -0
- adr_drift-0.1.0/adr_drift/miniyaml.py +273 -0
- adr_drift-0.1.0/adr_drift/rules.py +109 -0
- adr_drift-0.1.0/adr_drift/scanner.py +97 -0
- adr_drift-0.1.0/adr_drift.egg-info/PKG-INFO +105 -0
- adr_drift-0.1.0/adr_drift.egg-info/SOURCES.txt +22 -0
- adr_drift-0.1.0/adr_drift.egg-info/dependency_links.txt +1 -0
- adr_drift-0.1.0/adr_drift.egg-info/entry_points.txt +2 -0
- adr_drift-0.1.0/adr_drift.egg-info/top_level.txt +1 -0
- adr_drift-0.1.0/pyproject.toml +18 -0
- adr_drift-0.1.0/setup.cfg +4 -0
- adr_drift-0.1.0/tests/test_cli.py +237 -0
- adr_drift-0.1.0/tests/test_engine.py +151 -0
- adr_drift-0.1.0/tests/test_miniyaml.py +106 -0
- adr_drift-0.1.0/tests/test_rules.py +84 -0
- adr_drift-0.1.0/tests/test_scanner.py +84 -0
adr_drift-0.1.0/LICENSE
ADDED
|
@@ -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.
|
adr_drift-0.1.0/PKG-INFO
ADDED
|
@@ -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,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
|