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.
- diff_contract/__init__.py +7 -0
- diff_contract/cli.py +436 -0
- diff_contract/contract.py +95 -0
- diff_contract/engine.py +199 -0
- diff_contract/sarif.py +92 -0
- diff_contract-0.1.1.dist-info/METADATA +225 -0
- diff_contract-0.1.1.dist-info/RECORD +11 -0
- diff_contract-0.1.1.dist-info/WHEEL +5 -0
- diff_contract-0.1.1.dist-info/entry_points.txt +2 -0
- diff_contract-0.1.1.dist-info/licenses/LICENSE +21 -0
- diff_contract-0.1.1.dist-info/top_level.txt +1 -0
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)
|
diff_contract/engine.py
ADDED
|
@@ -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
|
+

|
|
@@ -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,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
|