diff-contract 0.1.1__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.
@@ -0,0 +1,7 @@
1
+ """diff-contract — deterministic guardrails for ai-generated diffs."""
2
+
3
+ from diff_contract.engine import GitDiffError
4
+
5
+ __version__ = "0.1.1"
6
+
7
+ __all__ = ["__version__", "GitDiffError"]
diff_contract/cli.py ADDED
@@ -0,0 +1,436 @@
1
+ """CLI entry point for diff-contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from diff_contract import __version__
11
+ from diff_contract.contract import load_contract
12
+ from diff_contract.engine import (
13
+ DiffCalculator,
14
+ GitDiffError,
15
+ RulesEngine,
16
+ ViolationSeverity,
17
+ )
18
+ from diff_contract.sarif import violations_to_sarif
19
+
20
+
21
+ def main(argv: list[str] | None = None) -> int:
22
+ parser = argparse.ArgumentParser(
23
+ prog="diff-contract",
24
+ description="Deterministic guardrails for AI-generated diffs",
25
+ )
26
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
27
+
28
+ subparsers = parser.add_subparsers(dest="command", required=True)
29
+
30
+ # `check` command
31
+ check_parser = subparsers.add_parser("check", help="Check diff against contract")
32
+ check_parser.add_argument(
33
+ "--contract",
34
+ type=Path,
35
+ default=Path(".diffcontract.yml"),
36
+ help="Path to .diffcontract.yml (default: .diffcontract.yml)",
37
+ )
38
+ check_parser.add_argument(
39
+ "--base",
40
+ default="main",
41
+ help="Base branch to diff against (default: main)",
42
+ )
43
+ check_parser.add_argument(
44
+ "--output",
45
+ choices=["json", "text"],
46
+ default="text",
47
+ help="Output format (default: text)",
48
+ )
49
+ check_parser.add_argument(
50
+ "--sarif",
51
+ action="store_true",
52
+ help="Output SARIF 2.1.0 format for GitHub Code Scanning",
53
+ )
54
+ check_parser.add_argument(
55
+ "--files",
56
+ nargs="*",
57
+ help="Specific files to check (instead of git diff)",
58
+ )
59
+
60
+ # `validate` command (no git dependency)
61
+ validate_parser = subparsers.add_parser(
62
+ "validate", help="Validate files against contract (no git required)"
63
+ )
64
+ validate_parser.add_argument(
65
+ "--contract",
66
+ type=Path,
67
+ default=Path(".diffcontract.yml"),
68
+ help="Path to .diffcontract.yml (default: .diffcontract.yml)",
69
+ )
70
+ validate_parser.add_argument(
71
+ "--files",
72
+ nargs="*",
73
+ help="Files to validate",
74
+ )
75
+ validate_parser.add_argument(
76
+ "--from-stdin",
77
+ action="store_true",
78
+ help="Read file list from stdin (one per line)",
79
+ )
80
+ validate_parser.add_argument(
81
+ "--output",
82
+ choices=["json", "text"],
83
+ default="text",
84
+ help="Output format (default: text)",
85
+ )
86
+ validate_parser.add_argument(
87
+ "--sarif",
88
+ action="store_true",
89
+ help="Output SARIF 2.1.0 format for GitHub Code Scanning",
90
+ )
91
+
92
+ # `init` command
93
+ init_parser = subparsers.add_parser("init", help="Create a sample .diffcontract.yml")
94
+ init_parser.add_argument(
95
+ "--template",
96
+ choices=["python", "react", "django", "rust", "docs"],
97
+ default="python",
98
+ help="Template type (default: python)",
99
+ )
100
+
101
+ args = parser.parse_args(argv)
102
+
103
+ if args.command == "check":
104
+ return _cmd_check(args)
105
+ elif args.command == "validate":
106
+ return _cmd_validate(args)
107
+ elif args.command == "init":
108
+ return _cmd_init(args.template)
109
+ else:
110
+ parser.print_help()
111
+ return 1
112
+
113
+
114
+ def _cmd_check(args: argparse.Namespace) -> int:
115
+ """Execute the check command."""
116
+ # Load contract
117
+ contract_path: Path = args.contract
118
+ if not contract_path.exists():
119
+ print(f"ERROR: Contract not found: {contract_path}", file=sys.stderr)
120
+ return 1
121
+
122
+ contract = load_contract(contract_path)
123
+
124
+ # Get changed files
125
+ if args.files:
126
+ changed_files: list = list(args.files)
127
+ else:
128
+ calc = DiffCalculator(base_branch=args.base)
129
+ try:
130
+ changed_files = calc.get_changed_files()
131
+ except GitDiffError as e:
132
+ print(f"ERROR: {e}", file=sys.stderr)
133
+ return 1
134
+
135
+ # Validate
136
+ engine = RulesEngine(contract.rules)
137
+ violations = engine.check(changed_files)
138
+
139
+ # Output
140
+ if args.sarif:
141
+ sarif_doc = violations_to_sarif(violations)
142
+ print(json.dumps(sarif_doc, indent=2))
143
+ elif args.output == "json":
144
+ result = {
145
+ "clean": len(violations) == 0,
146
+ "block_count": sum(1 for v in violations if v.severity == ViolationSeverity.BLOCK),
147
+ "warn_count": sum(1 for v in violations if v.severity == ViolationSeverity.WARN),
148
+ "violations": [
149
+ {
150
+ "file": v.file,
151
+ "severity": v.severity.value,
152
+ "rule": v.rule,
153
+ "message": v.message,
154
+ }
155
+ for v in violations
156
+ ],
157
+ }
158
+ print(json.dumps(result, indent=2))
159
+ else:
160
+ if not violations:
161
+ print("✓ Diff is clean — no contract violations")
162
+ else:
163
+ print(f"✗ {len(violations)} violation(s):")
164
+ for v in violations:
165
+ icon = "🔴" if v.severity == ViolationSeverity.BLOCK else "🟡"
166
+ print(f" {icon} [{v.severity.value.upper()}] {v.message}")
167
+
168
+ # Exit code
169
+ has_block = any(v.severity == ViolationSeverity.BLOCK for v in violations)
170
+ has_warn = any(v.severity == ViolationSeverity.WARN for v in violations)
171
+ if has_block:
172
+ return 1
173
+ elif has_warn:
174
+ return 2
175
+ return 0
176
+
177
+
178
+ def _cmd_validate(args: argparse.Namespace) -> int:
179
+ """Execute the validate command (no git dependency)."""
180
+ contract_path: Path = args.contract
181
+ if not contract_path.exists():
182
+ print(f"ERROR: Contract not found: {contract_path}", file=sys.stderr)
183
+ return 1
184
+
185
+ contract = load_contract(contract_path)
186
+
187
+ # Get changed files
188
+ if args.from_stdin:
189
+ changed_files = [line.strip() for line in sys.stdin if line.strip()]
190
+ elif args.files:
191
+ changed_files = list(args.files)
192
+ else:
193
+ print("ERROR: Provide --files or --from-stdin", file=sys.stderr)
194
+ return 1
195
+
196
+ # Validate
197
+ engine = RulesEngine(contract.rules)
198
+ violations = engine.check(changed_files)
199
+
200
+ # Output
201
+ if args.sarif:
202
+ sarif_doc = violations_to_sarif(violations)
203
+ print(json.dumps(sarif_doc, indent=2))
204
+ elif args.output == "json":
205
+ result = {
206
+ "clean": len(violations) == 0,
207
+ "block_count": sum(1 for v in violations if v.severity == ViolationSeverity.BLOCK),
208
+ "warn_count": sum(1 for v in violations if v.severity == ViolationSeverity.WARN),
209
+ "violations": [
210
+ {
211
+ "file": v.file,
212
+ "severity": v.severity.value,
213
+ "rule": v.rule,
214
+ "message": v.message,
215
+ }
216
+ for v in violations
217
+ ],
218
+ }
219
+ print(json.dumps(result, indent=2))
220
+ else:
221
+ if not violations:
222
+ print("✓ Diff is clean — no contract violations")
223
+ else:
224
+ print(f"✗ {len(violations)} violation(s):")
225
+ for v in violations:
226
+ icon = "🔴" if v.severity == ViolationSeverity.BLOCK else "🟡"
227
+ print(f" {icon} [{v.severity.value.upper()}] {v.message}")
228
+
229
+ # Exit codes: 0 clean, 1 block, 2 warn
230
+ has_block = any(v.severity == ViolationSeverity.BLOCK for v in violations)
231
+ has_warn = any(v.severity == ViolationSeverity.WARN for v in violations)
232
+ if has_block:
233
+ return 1
234
+ elif has_warn:
235
+ return 2
236
+ return 0
237
+
238
+
239
+ def _cmd_init(template_type: str = "python") -> int:
240
+ """Create a sample .diffcontract.yml from a template."""
241
+ template = _TEMPLATES.get(template_type, _TEMPLATES["python"])
242
+ path = Path(".diffcontract.yml")
243
+ if path.exists():
244
+ print(f"WARNING: {path} already exists — not overwriting", file=sys.stderr)
245
+ return 1
246
+ path.write_text(template)
247
+ print(f"✓ Created {path} (template: {template_type})")
248
+ return 0
249
+
250
+
251
+ _TEMPLATES = {
252
+ "python": """# .diffcontract.yml — Python project contract
253
+ version: 1
254
+
255
+ rules:
256
+ # Block changes to critical infrastructure
257
+ - name: "Protect CI/CD configs"
258
+ deny:
259
+ - ".github/workflows/*.yml"
260
+ - "Dockerfile"
261
+ - "docker-compose.yml"
262
+ on_violation: block
263
+
264
+ # Allow feature work with limits
265
+ - name: "Feature development"
266
+ allow:
267
+ - "src/features/**"
268
+ - "tests/features/**"
269
+ max_files: 15
270
+ max_lines: 400
271
+ on_violation: block
272
+
273
+ # Warn on large diffs
274
+ - name: "Large diff warning"
275
+ max_files: 20
276
+ max_lines: 500
277
+ on_violation: warn
278
+ """,
279
+ "react": """# React/Next.js project contract
280
+ version: 1
281
+
282
+ rules:
283
+ - name: "Protect CI/CD configs"
284
+ deny:
285
+ - ".github/workflows/*.yml"
286
+ - "Dockerfile"
287
+ - "docker-compose.yml"
288
+ on_violation: block
289
+
290
+ - name: "Protect root config"
291
+ deny:
292
+ - "package.json"
293
+ - "package-lock.json"
294
+ - "tsconfig.json"
295
+ - "next.config.*"
296
+ - ".env*"
297
+ on_violation: block
298
+
299
+ - name: "Feature development"
300
+ allow:
301
+ - "app/**"
302
+ - "components/**"
303
+ - "lib/**"
304
+ - "pages/**"
305
+ - "src/**"
306
+ - "styles/**"
307
+ - "public/**"
308
+ max_files: 20
309
+ max_lines: 600
310
+ on_violation: block
311
+
312
+ - name: "Test updates"
313
+ allow:
314
+ - "tests/**"
315
+ - "__tests__/**"
316
+ - "*.test.*"
317
+ - "*.spec.*"
318
+ max_files: 10
319
+ max_lines: 300
320
+ on_violation: warn
321
+ """,
322
+ "django": """# Django project contract
323
+ version: 1
324
+
325
+ rules:
326
+ - name: "Protect deployment configs"
327
+ deny:
328
+ - "Dockerfile"
329
+ - "docker-compose.yml"
330
+ - ".github/workflows/*.yml"
331
+ - "requirements/base.txt"
332
+ on_violation: block
333
+
334
+ - name: "Protect root config"
335
+ deny:
336
+ - "manage.py"
337
+ - "project/settings/*.py"
338
+ - "pyproject.toml"
339
+ - "requirements/*.txt"
340
+ on_violation: block
341
+
342
+ - name: "Feature development"
343
+ allow:
344
+ - "apps/**"
345
+ - "templates/**"
346
+ - "static/**"
347
+ - "media/**"
348
+ max_files: 15
349
+ max_lines: 500
350
+ on_violation: block
351
+
352
+ - name: "Test updates"
353
+ allow:
354
+ - "tests/**"
355
+ - "**/tests.py"
356
+ - "**/test_*.py"
357
+ max_files: 10
358
+ max_lines: 200
359
+ on_violation: warn
360
+ """,
361
+ "rust": """# Rust workspace contract
362
+ version: 1
363
+
364
+ rules:
365
+ - name: "Protect CI/CD configs"
366
+ deny:
367
+ - ".github/workflows/*.yml"
368
+ - "Dockerfile"
369
+ - "docker-compose.yml"
370
+ - "rust-toolchain.toml"
371
+ on_violation: block
372
+
373
+ - name: "Protect workspace config"
374
+ deny:
375
+ - "Cargo.toml"
376
+ - "Cargo.lock"
377
+ - "deny.toml"
378
+ - "clippy.toml"
379
+ - "rustfmt.toml"
380
+ on_violation: block
381
+
382
+ - name: "Feature development"
383
+ allow:
384
+ - "crates/**"
385
+ - "src/**"
386
+ - "tests/**"
387
+ - "benches/**"
388
+ - "examples/**"
389
+ max_files: 15
390
+ max_lines: 500
391
+ on_violation: block
392
+
393
+ - name: "Test updates"
394
+ allow:
395
+ - "tests/**"
396
+ - "**/tests/*.rs"
397
+ - "**/tests/**/*.rs"
398
+ - "benches/**"
399
+ max_files: 10
400
+ max_lines: 200
401
+ on_violation: warn
402
+ """,
403
+ "docs": """# Documentation-only project contract
404
+ version: 1
405
+
406
+ rules:
407
+ - name: "Block source code changes"
408
+ deny:
409
+ - "src/**"
410
+ - "lib/**"
411
+ - "app/**"
412
+ - "packages/**"
413
+ - "*.py"
414
+ - "*.js"
415
+ - "*.ts"
416
+ - "*.rs"
417
+ - "*.go"
418
+ on_violation: block
419
+
420
+ - name: "Documentation updates"
421
+ allow:
422
+ - "docs/**"
423
+ - "*.md"
424
+ - "*.rst"
425
+ - "CHANGELOG*"
426
+ - "LICENSE*"
427
+ - "README*"
428
+ max_files: 30
429
+ max_lines: 1000
430
+ on_violation: block
431
+ """,
432
+ }
433
+
434
+
435
+ if __name__ == "__main__":
436
+ sys.exit(main())
@@ -0,0 +1,95 @@
1
+ """Contract parser — reads and validates .diffcontract.yml files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+
13
+ class ViolationSeverity(enum.Enum):
14
+ BLOCK = "block"
15
+ WARN = "warn"
16
+ INFO = "info"
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ContractRule:
21
+ name: str
22
+ allow: tuple[str, ...] = ()
23
+ deny: tuple[str, ...] = ()
24
+ on_violation: ViolationSeverity = ViolationSeverity.BLOCK
25
+ max_files: int | None = None
26
+ max_lines: int | None = None
27
+
28
+ def matches_allow(self, file_path: str) -> bool:
29
+ """Return True if file matches any allow glob."""
30
+ import fnmatch
31
+
32
+ return any(fnmatch.fnmatch(file_path, pattern) for pattern in self.allow)
33
+
34
+ def matches_deny(self, file_path: str) -> bool:
35
+ """Return True if file matches any deny glob."""
36
+ import fnmatch
37
+
38
+ return any(fnmatch.fnmatch(file_path, pattern) for pattern in self.deny)
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Contract:
43
+ version: int
44
+ rules: tuple[ContractRule, ...] = ()
45
+
46
+
47
+ class ContractParser:
48
+ """Parse contract from YAML dict."""
49
+
50
+ def parse(self, data: dict[str, Any]) -> Contract:
51
+ version = data.get("version", 1)
52
+ raw_rules = data.get("rules", [])
53
+ rules: list[ContractRule] = []
54
+ for i, raw_rule in enumerate(raw_rules):
55
+ rule = self._parse_rule(raw_rule, index=i)
56
+ rules.append(rule)
57
+ return Contract(version=version, rules=tuple(rules))
58
+
59
+ def _parse_rule(self, raw: dict[str, Any], index: int) -> ContractRule:
60
+ name = raw.get("name", f"rule-{index}")
61
+ severity_str = raw.get("on_violation", "block")
62
+ try:
63
+ severity = ViolationSeverity(severity_str)
64
+ except ValueError:
65
+ valid = [s.value for s in ViolationSeverity]
66
+ raise ValueError(
67
+ f"Invalid on_violation value '{severity_str}' at rule {index}. "
68
+ f"Valid values: {valid}"
69
+ )
70
+
71
+ allow = tuple(raw.get("allow", []))
72
+ deny = tuple(raw.get("deny", []))
73
+ max_files = raw.get("max_files")
74
+ max_lines = raw.get("max_lines")
75
+
76
+ return ContractRule(
77
+ name=name,
78
+ allow=allow,
79
+ deny=deny,
80
+ on_violation=severity,
81
+ max_files=max_files,
82
+ max_lines=max_lines,
83
+ )
84
+
85
+ def parse_file(self, path: Path) -> Contract:
86
+ """Parse contract from YAML file."""
87
+ text = path.read_text()
88
+ data = yaml.safe_load(text) or {}
89
+ return self.parse(data)
90
+
91
+
92
+ def load_contract(path: Path) -> Contract:
93
+ """Convenience: load contract from path."""
94
+ parser = ContractParser()
95
+ return parser.parse_file(path)
@@ -0,0 +1,199 @@
1
+ """Rules engine — validates diffs against contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Sequence, Union
9
+
10
+ from diff_contract.contract import Contract, ContractRule, ViolationSeverity
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Violation:
15
+ file: str
16
+ severity: ViolationSeverity
17
+ rule: str
18
+ message: str
19
+
20
+
21
+ # A file entry can be a raw path (from --files CLI arg) or a change dict
22
+ FileEntry = Union[str, dict]
23
+
24
+
25
+ def _extract_path(entry: FileEntry) -> str:
26
+ """Extract file path from a FileEntry (str or dict)."""
27
+ if isinstance(entry, dict):
28
+ return entry.get("path", "")
29
+ return entry
30
+
31
+
32
+ def _extract_lines(entry: FileEntry) -> int:
33
+ """Extract changed line count from a FileEntry."""
34
+ if isinstance(entry, dict):
35
+ return entry.get("lines", 0)
36
+ return 0
37
+
38
+
39
+ class RulesEngine:
40
+ """Validate a list of changed files against contract rules."""
41
+
42
+ def __init__(self, rules: Sequence[ContractRule]) -> None:
43
+ self.rules = list(rules)
44
+
45
+ def check(self, changed_files: Sequence[FileEntry]) -> list[Violation]:
46
+ """Check changed files against all rules.
47
+
48
+ All rules are evaluated for each file. Deny rules take precedence
49
+ over allow rules — if any deny rule matches, the file is blocked.
50
+ """
51
+ violations: list[Violation] = []
52
+ for file in changed_files:
53
+ path = _extract_path(file)
54
+ file_violations: list[Violation] = []
55
+ has_deny = False
56
+ for rule in self.rules:
57
+ v = self._check_file(path, rule)
58
+ if v is not None:
59
+ file_violations.append(v)
60
+ if v.severity == ViolationSeverity.BLOCK:
61
+ has_deny = True
62
+ # Deny rules take precedence — if any deny matched, report only denies
63
+ if has_deny:
64
+ violations.extend(v for v in file_violations if v.severity == ViolationSeverity.BLOCK)
65
+ else:
66
+ violations.extend(file_violations)
67
+ # Check aggregate rules (max_files, max_lines)
68
+ violations.extend(self._check_aggregate_rules(changed_files))
69
+ return violations
70
+
71
+ def _check_aggregate_rules(self, changed_files: Sequence[FileEntry]) -> list[Violation]:
72
+ """Check aggregate rules like max_files and max_lines."""
73
+ violations: list[Violation] = []
74
+ total_files = len(changed_files)
75
+ total_lines = sum(_extract_lines(f) for f in changed_files)
76
+ for rule in self.rules:
77
+ if rule.max_files is not None and total_files > rule.max_files:
78
+ violations.append(
79
+ Violation(
80
+ file="<aggregate>",
81
+ severity=rule.on_violation,
82
+ rule=rule.name,
83
+ message=f"Too many files changed ({total_files} > {rule.max_files})",
84
+ )
85
+ )
86
+ if rule.max_lines is not None and total_lines > rule.max_lines:
87
+ violations.append(
88
+ Violation(
89
+ file="<aggregate>",
90
+ severity=rule.on_violation,
91
+ rule=rule.name,
92
+ message=f"Too many lines changed ({total_lines} > {rule.max_lines})",
93
+ )
94
+ )
95
+ return violations
96
+
97
+ def _check_file(self, file: str, rule: ContractRule) -> Violation | None:
98
+ """Check a single file against a single rule."""
99
+ # Deny takes priority — if file matches deny, it's a violation
100
+ if rule.matches_deny(file):
101
+ return Violation(
102
+ file=file,
103
+ severity=rule.on_violation,
104
+ rule=rule.name,
105
+ message=f"File '{file}' is denied by rule '{rule.name}'",
106
+ )
107
+
108
+ # If allow patterns are defined and file doesn't match, it's a violation
109
+ if rule.allow and not rule.matches_allow(file):
110
+ return Violation(
111
+ file=file,
112
+ severity=rule.on_violation,
113
+ rule=rule.name,
114
+ message=f"File '{file}' not allowed by rule '{rule.name}'",
115
+ )
116
+
117
+ return None
118
+
119
+
120
+ class GitDiffError(Exception):
121
+ """Raised when git diff command fails."""
122
+
123
+ def __init__(self, message: str, returncode: int = 1, stderr: str = "") -> None:
124
+ super().__init__(message)
125
+ self.returncode = returncode
126
+ self.stderr = stderr
127
+
128
+
129
+ class DiffCalculator:
130
+ """Calculate diff between current branch and base."""
131
+
132
+ def __init__(self, base_branch: str = "main", cwd: Path | None = None) -> None:
133
+ self.base_branch = base_branch
134
+ self.cwd = cwd
135
+
136
+ def get_changed_files(self) -> list[dict]:
137
+ """Run git diff and return list of file change dicts with line counts.
138
+
139
+ Raises:
140
+ GitDiffError: If git diff fails or git executable is not found.
141
+ """
142
+ try:
143
+ result = subprocess.run(
144
+ ["git", "--no-pager", "diff", "--numstat", f"{self.base_branch}...HEAD"],
145
+ capture_output=True,
146
+ text=True,
147
+ check=True,
148
+ cwd=self.cwd,
149
+ )
150
+ except subprocess.CalledProcessError as e:
151
+ err_msg = (
152
+ e.stderr.strip()
153
+ if e.stderr
154
+ else f"git command failed with exit code {e.returncode}"
155
+ )
156
+ raise GitDiffError(
157
+ f"git diff failed: {err_msg}",
158
+ returncode=e.returncode,
159
+ stderr=e.stderr or "",
160
+ ) from e
161
+ except FileNotFoundError as e:
162
+ raise GitDiffError("git executable not found", returncode=127) from e
163
+ return self._parse_numstat(result.stdout)
164
+
165
+ def _parse_numstat(self, raw: str) -> list[dict]:
166
+ """Parse git diff --numstat output into list of change dicts."""
167
+ if not raw.strip():
168
+ return []
169
+ changes = []
170
+ for line in raw.splitlines():
171
+ parts = line.split("\t")
172
+ if len(parts) >= 3:
173
+ try:
174
+ added = int(parts[0])
175
+ except ValueError:
176
+ added = 0 # binary files show "-"
177
+ try:
178
+ deleted = int(parts[1])
179
+ except ValueError:
180
+ deleted = 0
181
+ path = parts[2]
182
+ changes.append(
183
+ {
184
+ "path": path,
185
+ "added": added,
186
+ "deleted": deleted,
187
+ "lines": added + deleted,
188
+ }
189
+ )
190
+ return changes
191
+
192
+
193
+ def validate_diff(
194
+ contract: Contract,
195
+ changed_files: Sequence[FileEntry],
196
+ ) -> list[Violation]:
197
+ """Convenience: validate changed files against a contract."""
198
+ engine = RulesEngine(contract.rules)
199
+ return engine.check(changed_files)
diff_contract/sarif.py ADDED
@@ -0,0 +1,92 @@
1
+ """SARIF 2.1.0 output for diff-contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from diff_contract.engine import Violation
9
+
10
+
11
+ def violations_to_sarif(
12
+ violations: list[Violation],
13
+ tool_name: str = "diff-contract",
14
+ tool_version: str = "0.1.0",
15
+ ) -> dict[str, Any]:
16
+ """Convert violations to SARIF 2.1.0 format.
17
+
18
+ Args:
19
+ violations: List of Violation objects from RulesEngine
20
+ tool_name: Tool name for SARIF runner
21
+ tool_version: Tool version for SARIF runner
22
+
23
+ Returns:
24
+ SARIF 2.1.0 document as dict
25
+ """
26
+ rules = []
27
+ results = []
28
+
29
+ # Build rules from unique violation types
30
+ rule_ids = set()
31
+ for v in violations:
32
+ rule_id = f"diff-contract/{v.rule}"
33
+ if rule_id not in rule_ids:
34
+ rule_ids.add(rule_id)
35
+ rules.append(
36
+ {
37
+ "id": rule_id,
38
+ "name": v.rule,
39
+ "shortDescription": {
40
+ "text": f"Contract rule: {v.rule}",
41
+ },
42
+ "fullDescription": {
43
+ "text": f"Violates diff-contract rule '{v.rule}'. File: {v.file}",
44
+ },
45
+ "defaultConfiguration": {
46
+ "level": "error" if v.severity.value == "block" else "warning",
47
+ },
48
+ }
49
+ )
50
+
51
+ # Build results
52
+ for v in violations:
53
+ level = "error" if v.severity.value == "block" else "warning"
54
+ results.append(
55
+ {
56
+ "ruleId": f"diff-contract/{v.rule}",
57
+ "level": level,
58
+ "message": {"text": v.message},
59
+ "locations": [
60
+ {
61
+ "physicalLocation": {
62
+ "artifactLocation": {
63
+ "uri": v.file,
64
+ },
65
+ },
66
+ }
67
+ ],
68
+ }
69
+ )
70
+
71
+ return {
72
+ "version": "2.1.0",
73
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
74
+ "runs": [
75
+ {
76
+ "tool": {
77
+ "driver": {
78
+ "name": tool_name,
79
+ "version": tool_version,
80
+ "informationUri": "https://github.com/yunaremaia/diff-contract",
81
+ "rules": rules,
82
+ },
83
+ },
84
+ "results": results,
85
+ }
86
+ ],
87
+ }
88
+
89
+
90
+ def sarif_to_string(sarif_doc: dict[str, Any]) -> str:
91
+ """Serialize SARIF document to JSON string."""
92
+ return json.dumps(sarif_doc, indent=2)
@@ -0,0 +1,225 @@
1
+ Metadata-Version: 2.4
2
+ Name: diff-contract
3
+ Version: 0.1.1
4
+ Summary: Deterministic guardrails for AI-generated diffs — define what files can change, block violations.
5
+ Author-email: Yunare Maia <yunare@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/yunaremaia/diff-contract
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: pyyaml>=6.0
15
+ Provides-Extra: dev
16
+ Requires-Dist: mypy>=1.10; extra == "dev"
17
+ Requires-Dist: pytest>=8.0; extra == "dev"
18
+ Requires-Dist: ruff>=0.5; extra == "dev"
19
+ Requires-Dist: types-PyYAML; extra == "dev"
20
+ Dynamic: license-file
21
+
22
+ # diff-contract
23
+
24
+ **Deterministic guardrails for AI-generated diffs — define what files can change, block violations.**
25
+
26
+ ```bash
27
+ pip install diff-contract
28
+ diff-contract check --contract .diffcontract.yml
29
+ ```
30
+
31
+ ## The Problem
32
+
33
+ AI coding tools (Cursor, Claude Code, Codex) sometimes modify unrelated files, introduce changes outside the intended scope, or drift from the original structure. `diff-contract` sits between AI-generated code and your repo, enforcing **deterministic** constraints — not relying on another AI pass to review.
34
+
35
+ > "Most tools either help generate code or review it after the fact, but there's no real control layer in between." — HN discussion, 2026
36
+
37
+ ## Quick Start
38
+
39
+ ### 1. Install
40
+ ```bash
41
+ pip install diff-contract
42
+ ```
43
+
44
+ ### 2. Define your contract
45
+ ```yaml
46
+ # .diffcontract.yml
47
+ version: 1
48
+ rules:
49
+ - name: "Block core changes"
50
+ deny:
51
+ - "src/core/**"
52
+ - "*.env"
53
+ on_violation: block
54
+
55
+ - name: "Allow feature X"
56
+ allow:
57
+ - "src/features/X/**"
58
+ - "tests/features/X/**"
59
+ on_violation: block
60
+ ```
61
+
62
+ ### 3. Check your diff
63
+ ```bash
64
+ # Check current branch vs main
65
+ diff-contract check
66
+
67
+ # Check specific files
68
+ diff-contract check --files src/app.py src/utils.py
69
+
70
+ # JSON output (for CI)
71
+ diff-contract check --output json
72
+ ```
73
+
74
+ ### 4. GitHub Action
75
+ ```yaml
76
+ # .github/workflows/diff-contract.yml
77
+ name: diff-contract
78
+ on: pull_request
79
+
80
+ jobs:
81
+ check:
82
+ runs-on: ubuntu-latest
83
+ steps:
84
+ - uses: actions/checkout@v4
85
+ - uses: yunaremaia/diff-contract@main
86
+ ```
87
+
88
+ ## Validate files (no git required)
89
+
90
+ Validate specific files against your contract without a git diff — ideal for pre-commit hooks:
91
+
92
+ ```bash
93
+ diff-contract validate --files src/app.py tests/test_app.py
94
+ echo "src/foo.py" | diff-contract validate --from-stdin
95
+ ```
96
+
97
+ ## Initialize a contract
98
+
99
+ Create a starter `.diffcontract.yml`:
100
+
101
+ ```bash
102
+ diff-contract init # default Python project contract
103
+ diff-contract init --template react # React/Next.js
104
+ diff-contract init --template django # Django
105
+ diff-contract init --template rust # Rust workspace
106
+ diff-contract init --template docs # documentation-only
107
+ ```
108
+
109
+ Ready-to-use templates for React, Django, Rust, and documentation-only projects are available in the [`examples/`](examples/) directory.
110
+
111
+ ## Pre-commit hook
112
+
113
+ diff-contract ships a pre-commit hook. Add to your `.pre-commit-config.yaml`:
114
+
115
+ ```yaml
116
+ repos:
117
+ - repo: https://github.com/yunaremaia/diff-contract
118
+ rev: v0.1.0
119
+ hooks:
120
+ - id: diff-contract
121
+ args: ["--contract", ".diffcontract.yml"]
122
+ ```
123
+
124
+ ## SARIF Output (GitHub Code Scanning)
125
+
126
+ Generate SARIF 2.1.0 output for GitHub Code Scanning integration:
127
+
128
+ ```bash
129
+ diff-contract check --sarif > diff-contract.sarif
130
+ diff-contract validate --files src/foo.py --sarif
131
+ ```
132
+
133
+ GitHub Actions workflow:
134
+
135
+ ```yaml
136
+ - uses: yunaremaia/diff-contract@main
137
+ with:
138
+ format: sarif
139
+ sarif-output: diff-contract.sarif
140
+
141
+ - uses: github/codeql-action/upload-sarif@v3
142
+ with:
143
+ sarif_file: diff-contract.sarif
144
+ ```
145
+
146
+ SARIF output includes one rule per violation type. Block violations emit at `error` level, warnings at `warning`.
147
+
148
+ ## Exit Codes
149
+
150
+ | Code | Meaning |
151
+ |------|---------|
152
+ | 0 | Clean — no violations |
153
+ | 1 | Block violation — file denied, outside allowed scope, or an aggregate limit exceeded with `on_violation: block` |
154
+ | 2 | Warning — non-blocking violation (e.g., large diff with `on_violation: warn`) |
155
+
156
+ ## Rules
157
+
158
+ - **allow**: File globs that are permitted (all others blocked)
159
+ - **deny**: File globs that are denied (takes priority)
160
+ - **on_violation**: `block` (exit 1) or `warn` (exit 2)
161
+ - **max_files** / **max_lines**: optional aggregate size limits (see below)
162
+
163
+ ## Aggregate Limits
164
+
165
+ Path allow/deny rules constrain *which* files may change. `max_files` and `max_lines` constrain *how large* a change set may be. They are evaluated against the whole diff after per-file rules run.
166
+
167
+ | Field | Meaning |
168
+ |-------|---------|
169
+ | `max_files` | Maximum number of changed files in the diff |
170
+ | `max_lines` | Maximum total changed lines (`added + deleted` from `git diff --numstat`) |
171
+
172
+ A rule may set either field, both, or neither. Limits are omitted by default (no size budget). When a limit is exceeded, the engine records an aggregate violation on the synthetic path `<aggregate>` and applies that rule's `on_violation`:
173
+
174
+ - `on_violation: block` — fail the check (exit code **1**). Use this to stop AI agents or CI from landing oversized diffs.
175
+ - `on_violation: warn` — report the overshoot but continue (exit code **2** if there are no block violations). Use this as a PR-size nudge.
176
+
177
+ Typical uses:
178
+
179
+ - Cap feature work so an agent cannot rewrite half the tree while implementing one ticket.
180
+ - Keep documentation or bugfix rules tight even when the path globs are broad.
181
+ - Warn on large diffs without blocking hotfixes.
182
+
183
+ Limits are compared against the **entire** change list, not only files that match that rule's `allow`/`deny` globs. A rule that only sets `max_files` / `max_lines` (no path patterns) is a global size guard.
184
+
185
+ ### Example
186
+
187
+ ```yaml
188
+ # .diffcontract.yml
189
+ version: 1
190
+ rules:
191
+ - name: "Block core changes"
192
+ deny:
193
+ - "src/core/**"
194
+ - "*.env"
195
+ on_violation: block
196
+
197
+ - name: "Feature development"
198
+ allow:
199
+ - "src/features/**"
200
+ - "tests/features/**"
201
+ max_files: 15
202
+ max_lines: 400
203
+ on_violation: block
204
+
205
+ - name: "Large diff warning"
206
+ max_files: 20
207
+ max_lines: 600
208
+ on_violation: warn
209
+ ```
210
+
211
+ In this contract:
212
+
213
+ - Changes under `src/core/**` or `*.env` are blocked.
214
+ - Feature-area diffs may proceed only if they stay within 15 files and 400 lines; exceeding either budget is a **block** (exit 1).
215
+ - Any diff larger than 20 files or 600 lines also produces a **warning** (exit 2 when nothing is blocked).
216
+
217
+ See [`examples/strict.yml`](examples/strict.yml) for a fuller contract that combines deny rules with per-rule size budgets.
218
+
219
+ ## License
220
+
221
+ MIT
222
+
223
+ # diff-contract
224
+
225
+ ![CI](https://github.com/yunaremaia/diff-contract/actions/workflows/ci.yml/badge.svg)
@@ -0,0 +1,11 @@
1
+ diff_contract/__init__.py,sha256=qvLKBGfNLz4dZEpO6Os_4Y73IigYeAe5eSKZ2MBdaMc,186
2
+ diff_contract/cli.py,sha256=qUKRBxzxMTPVNeZS7EKtnjIwqJyNj6hDhRW9ct4kTNQ,11485
3
+ diff_contract/contract.py,sha256=lX2S2QBQeW-wN4fAU11gfSZF8kb591CO2RBc_Rh0nKo,2772
4
+ diff_contract/engine.py,sha256=MjHsSgkN3caFiX92SBJuPG8DETVWL3UygfBl9FB1rIo,7035
5
+ diff_contract/sarif.py,sha256=hsJSf9f75hzV2YKgEbXLiComOdLHViPUCm2g9dxmi64,2702
6
+ diff_contract-0.1.1.dist-info/licenses/LICENSE,sha256=jaJ4N5zOiHxuIAaPgohynRXOjhcZsCwBBU6lpDNG-IE,1068
7
+ diff_contract-0.1.1.dist-info/METADATA,sha256=caDuy8PdoXq4KsPWgWQ6mPxohhp0rDs1j06s5iu7grs,6782
8
+ diff_contract-0.1.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
9
+ diff_contract-0.1.1.dist-info/entry_points.txt,sha256=3scYACMiJxCLhgtlKNLzsUOz3jRiKP2SjSRQUDR32-g,57
10
+ diff_contract-0.1.1.dist-info/top_level.txt,sha256=jpfrzUOpWQPxkMXo0tMp6CBqYGJw85m4ZdXPxQzAWzo,14
11
+ diff_contract-0.1.1.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
+ diff-contract = diff_contract.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yunare Maia
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
+ diff_contract