agent-config-score 0.18.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ __version__ = "0.18.0"
@@ -0,0 +1,457 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+ import sys
7
+
8
+ from . import __version__
9
+ from .config import ConfigError, Policy, load_policy
10
+ from .gitdiff import GitError, baseline_worktree, repository_root
11
+ from .history import load_history, summarize_history
12
+ from .initializer import InitError, initialize_repository
13
+ from .regression import compare, markdown_report
14
+ from .rules import RULES, get_rule
15
+ from .sarif import sarif_report
16
+ from .scanner import analyze, badge_svg, html_report
17
+
18
+ RESET = "\033[0m"
19
+ BOLD = "\033[1m"
20
+ COLORS = {"error": "\033[31m", "warning": "\033[33m", "info": "\033[36m"}
21
+ GRADE_COLORS = {"A": "\033[32m", "B": "\033[32m", "C": "\033[33m", "D": "\033[33m", "F": "\033[31m"}
22
+
23
+ TOP_LEVEL_HELP = """usage:
24
+ agent-config-score [PATH] [scan options]
25
+ agent-config-score init [PATH] [options]
26
+ agent-config-score rules [RULE_ID] [options]
27
+ agent-config-score history [PATH] [options]
28
+ agent-config-score diff BASE_REF [options]
29
+ agent-config-score compare BASE HEAD [options]
30
+
31
+ Score and regression-check AI coding-agent instruction files.
32
+
33
+ commands:
34
+ init Add a repository policy and GitHub Actions workflow safely.
35
+ rules List or explain the stable AgentConfigScore rule catalog.
36
+ history Show locally recorded score snapshots and overall trend.
37
+ diff Compare a Git ref with the current working tree.
38
+ compare Compare two already checked-out repository trees.
39
+
40
+ common examples:
41
+ agent-config-score init
42
+ agent-config-score rules
43
+ agent-config-score rules curl-pipe-shell
44
+ agent-config-score .
45
+ agent-config-score history
46
+ agent-config-score diff origin/main
47
+ agent-config-score compare ../repo-base .
48
+
49
+ Run `agent-config-score <command> --help` for command-specific options.
50
+ Use `agent-config-score --version` to print the version.
51
+ """
52
+
53
+
54
+ def _supports_color() -> bool:
55
+ return sys.stdout.isatty()
56
+
57
+
58
+ def _c(text: str, color: str) -> str:
59
+ return f"{color}{text}{RESET}" if _supports_color() else text
60
+
61
+
62
+ def _print_suppressed(report) -> None:
63
+ if not report.suppressed_findings:
64
+ return
65
+ print(f"\nSuppressed findings: {len(report.suppressed_findings)}")
66
+ for item in report.suppressed_findings:
67
+ finding = item.finding
68
+ suppression = item.suppression
69
+ location = finding.file + (f":{finding.line}" if finding.line else "")
70
+ print(f"~ SUPPRESSED {finding.code:20} {finding.message}")
71
+ print(f" {location}")
72
+ print(f" reason: {suppression.reason}")
73
+ print(f" expires: {suppression.expires.isoformat()}")
74
+ if suppression.paths:
75
+ print(f" paths: {', '.join(suppression.paths)}")
76
+
77
+
78
+ def print_report(report) -> None:
79
+ grade = _c(report.grade, GRADE_COLORS[report.grade])
80
+ print(f"\n{BOLD if _supports_color() else ''}AgentConfigScore{RESET if _supports_color() else ''} {grade} {report.score}/100")
81
+ print(
82
+ f"Files: {len(report.files)} Estimated tokens: {report.estimated_tokens:,} "
83
+ f"Duplication: {report.duplicate_ratio:.0%} Suppressed: {len(report.suppressed_findings)}\n"
84
+ )
85
+ if not report.findings:
86
+ print(_c("OK No active findings", "\033[32m"))
87
+ else:
88
+ for f in report.findings:
89
+ icon = {"error": "X", "warning": "!", "info": "i"}.get(f.severity, "-")
90
+ loc = f.file + (f":{f.line}" if f.line else "")
91
+ prefix = _c(f"{icon} {f.severity.upper():7}", COLORS.get(f.severity, ""))
92
+ print(f"{prefix} {f.code:20} {f.message}")
93
+ print(f" {loc}")
94
+ _print_suppressed(report)
95
+
96
+
97
+ def print_regression(report) -> None:
98
+ sign = "+" if report.delta > 0 else ""
99
+ print(
100
+ f"\n{BOLD if _supports_color() else ''}AgentConfigScore regression{RESET if _supports_color() else ''} "
101
+ f"{report.base.grade} {report.base.score} -> {report.head.grade} {report.head.score} ({sign}{report.delta})"
102
+ )
103
+ print(
104
+ f"New findings: {len(report.new_findings)} Resolved: {len(report.resolved_findings)} "
105
+ f"Suppressed: {len(report.head.suppressed_findings)}\n"
106
+ )
107
+ for finding in report.new_findings:
108
+ location = finding.file + (f":{finding.line}" if finding.line else "")
109
+ prefix = _c(f"+ {finding.severity.upper():7}", COLORS.get(finding.severity, ""))
110
+ print(f"{prefix} {finding.code:20} {finding.message}")
111
+ print(f" {location}")
112
+ for finding in report.resolved_findings:
113
+ location = finding.file + (f":{finding.line}" if finding.line else "")
114
+ print(f"- RESOLVED {finding.code:20} {finding.message}")
115
+ print(f" {location}")
116
+ if not report.new_findings and not report.resolved_findings:
117
+ print("OK No active agent-config changes detected")
118
+ _print_suppressed(report.head)
119
+
120
+
121
+ def build_scan_parser() -> argparse.ArgumentParser:
122
+ p = argparse.ArgumentParser(prog="agent-config-score", description="Score and audit coding-agent instruction files.")
123
+ p.add_argument("path", nargs="?", default=".", help="Repository path (default: current directory)")
124
+ p.add_argument("--json", action="store_true", help="Print JSON instead of text")
125
+ p.add_argument("--html", metavar="FILE", help="Write a self-contained HTML report")
126
+ p.add_argument("--badge", metavar="FILE", help="Write an SVG score badge")
127
+ p.add_argument("--sarif", metavar="FILE", help="Write a SARIF 2.1.0 report for GitHub code scanning")
128
+ p.add_argument("--fail-under", type=int, default=None, metavar="N", help="Override the repository policy score floor")
129
+ return p
130
+
131
+
132
+ def build_init_parser() -> argparse.ArgumentParser:
133
+ p = argparse.ArgumentParser(
134
+ prog="agent-config-score init",
135
+ description="Initialize AgentConfigScore policy and CI files without overwriting existing files by default.",
136
+ )
137
+ p.add_argument("path", nargs="?", default=".", help="Repository directory (default: current directory)")
138
+ p.add_argument("--no-workflow", action="store_true", help="Create only .agentconfigscore.json")
139
+ p.add_argument("--force", action="store_true", help="Overwrite conflicting generated files")
140
+ p.add_argument("--dry-run", action="store_true", help="Show the initialization plan without writing files")
141
+ return p
142
+
143
+
144
+ def build_rules_parser() -> argparse.ArgumentParser:
145
+ p = argparse.ArgumentParser(
146
+ prog="agent-config-score rules",
147
+ description="List all stable rule IDs or explain one rule in detail.",
148
+ )
149
+ p.add_argument("rule_id", nargs="?", help="Optional rule ID, for example: curl-pipe-shell")
150
+ p.add_argument("--json", action="store_true", help="Print machine-readable JSON")
151
+ return p
152
+
153
+
154
+ def build_history_parser() -> argparse.ArgumentParser:
155
+ p = argparse.ArgumentParser(
156
+ prog="agent-config-score history",
157
+ description="Show locally recorded AgentConfigScore snapshots and their overall trend.",
158
+ )
159
+ p.add_argument("path", nargs="?", default=".", help="Repository path (default: current directory)")
160
+ p.add_argument("--json", action="store_true", help="Print machine-readable JSON")
161
+ p.add_argument("--limit", type=int, default=20, metavar="N", help="Show at most the newest N snapshots (default: 20)")
162
+ return p
163
+
164
+
165
+ def _add_regression_options(p: argparse.ArgumentParser) -> None:
166
+ p.add_argument("--json", action="store_true", help="Print JSON instead of text")
167
+ p.add_argument("--markdown", metavar="FILE", help="Write a Markdown regression summary")
168
+ p.add_argument("--max-drop", type=int, default=None, metavar="N", help="Override the repository policy score-drop budget")
169
+ p.add_argument(
170
+ "--fail-on-new-errors",
171
+ action=argparse.BooleanOptionalAction,
172
+ default=None,
173
+ help="Override whether newly introduced error findings fail the check",
174
+ )
175
+
176
+
177
+ def build_compare_parser() -> argparse.ArgumentParser:
178
+ p = argparse.ArgumentParser(
179
+ prog="agent-config-score compare",
180
+ description="Compare two repository trees and fail only when agent configuration regresses.",
181
+ )
182
+ p.add_argument("base", help="Baseline repository directory")
183
+ p.add_argument("head", help="Candidate repository directory")
184
+ _add_regression_options(p)
185
+ return p
186
+
187
+
188
+ def build_diff_parser() -> argparse.ArgumentParser:
189
+ p = argparse.ArgumentParser(
190
+ prog="agent-config-score diff",
191
+ description="Compare a Git ref with the current working tree without manually creating a baseline checkout.",
192
+ )
193
+ p.add_argument("base_ref", help="Baseline Git branch, tag, or commit (for example: origin/main)")
194
+ p.add_argument("--path", default=".", metavar="DIR", help="Path inside the Git repository (default: current directory)")
195
+ _add_regression_options(p)
196
+ return p
197
+
198
+
199
+ def _existing_dir(value: str) -> Path | None:
200
+ path = Path(value)
201
+ return path if path.exists() and path.is_dir() else None
202
+
203
+
204
+ def _resolve_regression_policy(args, policy: Policy) -> None:
205
+ args.max_drop = policy.max_drop if args.max_drop is None else args.max_drop
206
+ args.fail_on_new_errors = policy.fail_on_new_errors if args.fail_on_new_errors is None else args.fail_on_new_errors
207
+ if args.max_drop < 0:
208
+ raise ConfigError("--max-drop must be >= 0")
209
+
210
+
211
+ def _finish_regression(report, args) -> int:
212
+ if args.markdown:
213
+ out = Path(args.markdown)
214
+ out.parent.mkdir(parents=True, exist_ok=True)
215
+ out.write_text(markdown_report(report), encoding="utf-8")
216
+
217
+ if args.json:
218
+ print(json.dumps(report.to_dict(), indent=2, ensure_ascii=False))
219
+ else:
220
+ print_regression(report)
221
+ if args.markdown:
222
+ print(f"\nMarkdown report: {Path(args.markdown).resolve()}")
223
+
224
+ if report.delta < -args.max_drop:
225
+ return 1
226
+ if args.fail_on_new_errors and report.new_errors:
227
+ return 1
228
+ return 0
229
+
230
+
231
+ def _main_init(argv: list[str]) -> int:
232
+ args = build_init_parser().parse_args(argv)
233
+ try:
234
+ changes = initialize_repository(
235
+ Path(args.path),
236
+ include_workflow=not args.no_workflow,
237
+ force=args.force,
238
+ dry_run=args.dry_run,
239
+ )
240
+ except InitError as exc:
241
+ print(f"error: {exc}", file=sys.stderr)
242
+ return 2
243
+
244
+ for change in changes:
245
+ if change.action == "unchanged":
246
+ label = "unchanged"
247
+ elif args.dry_run:
248
+ label = f"would {change.action}"
249
+ else:
250
+ label = f"{change.action}d" if change.action == "create" else "overwritten"
251
+ print(f"{label:12} {change.path.as_posix()}")
252
+
253
+ if args.dry_run:
254
+ print("Dry run: no files written.")
255
+ else:
256
+ print("\nAgentConfigScore initialized. Review and commit the generated files.")
257
+ return 0
258
+
259
+
260
+ def _main_rules(argv: list[str]) -> int:
261
+ args = build_rules_parser().parse_args(argv)
262
+ if args.rule_id:
263
+ rule = get_rule(args.rule_id)
264
+ if rule is None:
265
+ print(f"error: unknown rule ID: {args.rule_id}", file=sys.stderr)
266
+ return 2
267
+ if args.json:
268
+ print(json.dumps(rule.to_dict(), indent=2, ensure_ascii=False))
269
+ else:
270
+ print(rule.code)
271
+ print(f"Severity: {rule.severity}")
272
+ print(f"Category: {rule.category}")
273
+ print(f"Penalty: {rule.penalty}")
274
+ print(f"Summary: {rule.summary}")
275
+ print(f"\n{rule.description}")
276
+ return 0
277
+
278
+ if args.json:
279
+ print(json.dumps([rule.to_dict() for rule in RULES], indent=2, ensure_ascii=False))
280
+ return 0
281
+
282
+ print(f"{'RULE ID':22} {'SEVERITY':8} {'CATEGORY':8} {'PENALTY':7} SUMMARY")
283
+ for rule in RULES:
284
+ print(f"{rule.code:22} {rule.severity:8} {rule.category:8} {rule.penalty:7} {rule.summary}")
285
+ print(f"\n{len(RULES)} rules. Run `agent-config-score rules RULE_ID` for details.")
286
+ return 0
287
+
288
+
289
+ def _format_timestamp(value: object) -> str:
290
+ if not isinstance(value, str) or not value:
291
+ return "-"
292
+ return value[:19].replace("T", " ")
293
+
294
+
295
+ def _main_history(argv: list[str]) -> int:
296
+ args = build_history_parser().parse_args(argv)
297
+ root = Path(args.path)
298
+ if not root.exists() or not root.is_dir():
299
+ print(f"error: not a directory: {root}", file=sys.stderr)
300
+ return 2
301
+ if args.limit < 1:
302
+ print("error: --limit must be >= 1", file=sys.stderr)
303
+ return 2
304
+
305
+ history = load_history(root)
306
+ visible = history[-args.limit:]
307
+ summary = summarize_history(history)
308
+
309
+ if args.json:
310
+ print(json.dumps({"summary": summary, "history": visible}, indent=2, ensure_ascii=False))
311
+ return 0
312
+
313
+ print("\nAgentConfigScore history")
314
+ if not visible:
315
+ print("No local score history found.")
316
+ print("Expected: .agentconfigscore/history/index.json")
317
+ return 0
318
+
319
+ print(f"\n{'DATE':19} {'SCORE':>5} {'GRADE':>5} {'CHANGE':>7} COMMIT")
320
+ previous_score: int | float | None = None
321
+ for item in visible:
322
+ score = item.get("score")
323
+ score_text = str(score) if isinstance(score, (int, float)) else "-"
324
+ grade = str(item.get("grade", "-"))
325
+ change = "-"
326
+ if isinstance(score, (int, float)) and previous_score is not None:
327
+ delta = score - previous_score
328
+ change = f"{delta:+g}"
329
+ if isinstance(score, (int, float)):
330
+ previous_score = score
331
+ commit = item.get("commit")
332
+ commit_text = str(commit)[:12] if commit else "-"
333
+ print(f"{_format_timestamp(item.get('timestamp')):19} {score_text:>5} {grade:>5} {change:>7} {commit_text}")
334
+
335
+ delta = summary["delta"]
336
+ if delta is None:
337
+ trend_text = "unknown"
338
+ else:
339
+ arrow = {"up": "↑", "down": "↓", "flat": "→"}[summary["trend"]]
340
+ trend_text = f"{arrow} {delta:+g} overall"
341
+ print(f"\nTrend: {trend_text} across {summary['scored_count']} scored snapshots")
342
+ return 0
343
+
344
+
345
+ def _main_compare(argv: list[str]) -> int:
346
+ args = build_compare_parser().parse_args(argv)
347
+ base = _existing_dir(args.base)
348
+ head = _existing_dir(args.head)
349
+ if base is None:
350
+ print(f"error: not a directory: {args.base}", file=sys.stderr)
351
+ return 2
352
+ if head is None:
353
+ print(f"error: not a directory: {args.head}", file=sys.stderr)
354
+ return 2
355
+
356
+ try:
357
+ policy = load_policy(base)
358
+ load_policy(head)
359
+ _resolve_regression_policy(args, policy)
360
+ except ConfigError as exc:
361
+ print(f"error: {exc}", file=sys.stderr)
362
+ return 2
363
+
364
+ return _finish_regression(compare(base, head, suppressions=policy.suppressions), args)
365
+
366
+
367
+ def _main_diff(argv: list[str]) -> int:
368
+ args = build_diff_parser().parse_args(argv)
369
+
370
+ try:
371
+ repo = repository_root(Path(args.path))
372
+ with baseline_worktree(repo, args.base_ref) as (base, sha):
373
+ policy = load_policy(base)
374
+ load_policy(repo)
375
+ _resolve_regression_policy(args, policy)
376
+ report = compare(base, repo, suppressions=policy.suppressions)
377
+ report.base.root = f"git:{args.base_ref}@{sha[:12]}"
378
+ report.head.root = str(repo)
379
+ except (GitError, ConfigError) as exc:
380
+ print(f"error: {exc}", file=sys.stderr)
381
+ return 2
382
+
383
+ return _finish_regression(report, args)
384
+
385
+
386
+ def _main_scan(argv: list[str]) -> int:
387
+ args = build_scan_parser().parse_args(argv)
388
+ root = Path(args.path)
389
+ if not root.exists() or not root.is_dir():
390
+ print(f"error: not a directory: {root}", file=sys.stderr)
391
+ return 2
392
+
393
+ try:
394
+ policy = load_policy(root)
395
+ except ConfigError as exc:
396
+ print(f"error: {exc}", file=sys.stderr)
397
+ return 2
398
+
399
+ fail_under = policy.fail_under if args.fail_under is None else args.fail_under
400
+ if fail_under is not None and not 0 <= fail_under <= 100:
401
+ print("error: --fail-under must be between 0 and 100", file=sys.stderr)
402
+ return 2
403
+
404
+ report = analyze(root, suppressions=policy.suppressions)
405
+
406
+ if args.html:
407
+ out = Path(args.html)
408
+ out.parent.mkdir(parents=True, exist_ok=True)
409
+ out.write_text(html_report(report), encoding="utf-8")
410
+ if args.badge:
411
+ out = Path(args.badge)
412
+ out.parent.mkdir(parents=True, exist_ok=True)
413
+ out.write_text(badge_svg(report), encoding="utf-8")
414
+ if args.sarif:
415
+ out = Path(args.sarif)
416
+ out.parent.mkdir(parents=True, exist_ok=True)
417
+ out.write_text(json.dumps(sarif_report(report), indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
418
+
419
+ if args.json:
420
+ print(json.dumps(report.to_dict(), indent=2, ensure_ascii=False))
421
+ else:
422
+ print_report(report)
423
+ if args.html:
424
+ print(f"\nHTML report: {Path(args.html).resolve()}")
425
+ if args.badge:
426
+ print(f"Badge: {Path(args.badge).resolve()}")
427
+ if args.sarif:
428
+ print(f"SARIF report: {Path(args.sarif).resolve()}")
429
+
430
+ if fail_under is not None and report.score < fail_under:
431
+ return 1
432
+ return 0
433
+
434
+
435
+ def main(argv: list[str] | None = None) -> int:
436
+ args = list(sys.argv[1:] if argv is None else argv)
437
+ if args == ["--version"] or args == ["-V"]:
438
+ print(f"AgentConfigScore {__version__}")
439
+ return 0
440
+ if args == ["--help"] or args == ["-h"]:
441
+ print(TOP_LEVEL_HELP, end="")
442
+ return 0
443
+ if args and args[0] == "init":
444
+ return _main_init(args[1:])
445
+ if args and args[0] == "rules":
446
+ return _main_rules(args[1:])
447
+ if args and args[0] == "history":
448
+ return _main_history(args[1:])
449
+ if args and args[0] == "compare":
450
+ return _main_compare(args[1:])
451
+ if args and args[0] == "diff":
452
+ return _main_diff(args[1:])
453
+ return _main_scan(args)
454
+
455
+
456
+ if __name__ == "__main__":
457
+ raise SystemExit(main())
@@ -0,0 +1,201 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import date, datetime, timezone
5
+ from fnmatch import fnmatch
6
+ import json
7
+ from pathlib import Path
8
+
9
+ from .rules import get_rule
10
+
11
+ CONFIG_NAME = ".agentconfigscore.json"
12
+ SCHEMA_URL = "https://raw.githubusercontent.com/LE0-Lin/AgentConfigScore/v0/schema/agentconfigscore.schema.json"
13
+
14
+
15
+ class ConfigError(ValueError):
16
+ """Raised when AgentConfigScore repository configuration is invalid."""
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Suppression:
21
+ rule: str
22
+ reason: str
23
+ expires: date
24
+ paths: tuple[str, ...] = ()
25
+
26
+ def applies_to(self, rule: str, file: str) -> bool:
27
+ if self.rule != rule:
28
+ return False
29
+ if not self.paths:
30
+ return True
31
+ if file == "(repo)":
32
+ return False
33
+ return any(fnmatch(file, pattern) for pattern in self.paths)
34
+
35
+ def to_dict(self) -> dict:
36
+ data = {
37
+ "rule": self.rule,
38
+ "reason": self.reason,
39
+ "expires": self.expires.isoformat(),
40
+ }
41
+ if self.paths:
42
+ data["paths"] = list(self.paths)
43
+ return data
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Policy:
48
+ max_drop: int = 0
49
+ fail_on_new_errors: bool = False
50
+ fail_under: int | None = None
51
+ suppressions: tuple[Suppression, ...] = ()
52
+
53
+
54
+ _ALLOWED_TOP_LEVEL = {"$schema", "version", "policy", "suppressions"}
55
+ _ALLOWED_POLICY_KEYS = {"max_drop", "fail_on_new_errors", "fail_under"}
56
+ _ALLOWED_SUPPRESSION_KEYS = {"rule", "reason", "expires", "paths"}
57
+
58
+
59
+ def _utc_today() -> date:
60
+ return datetime.now(timezone.utc).date()
61
+
62
+
63
+ def _require_int(name: str, value, *, minimum: int, maximum: int | None = None) -> int:
64
+ if isinstance(value, bool) or not isinstance(value, int):
65
+ raise ConfigError(f"{name} must be an integer")
66
+ if value < minimum or (maximum is not None and value > maximum):
67
+ if maximum is None:
68
+ raise ConfigError(f"{name} must be >= {minimum}")
69
+ raise ConfigError(f"{name} must be between {minimum} and {maximum}")
70
+ return value
71
+
72
+
73
+ def _parse_suppression(raw: object, index: int, *, today: date) -> Suppression:
74
+ name = f"suppressions[{index}]"
75
+ if not isinstance(raw, dict):
76
+ raise ConfigError(f"{name} must be a JSON object")
77
+
78
+ unknown = sorted(set(raw) - _ALLOWED_SUPPRESSION_KEYS)
79
+ if unknown:
80
+ raise ConfigError(f"unknown {name} key(s): {', '.join(unknown)}")
81
+
82
+ rule = raw.get("rule")
83
+ if not isinstance(rule, str) or not rule.strip():
84
+ raise ConfigError(f"{name}.rule must be a non-empty string")
85
+ rule = rule.strip()
86
+ if get_rule(rule) is None:
87
+ raise ConfigError(f"{name}.rule references unknown rule ID: {rule}")
88
+
89
+ reason = raw.get("reason")
90
+ if not isinstance(reason, str) or not reason.strip():
91
+ raise ConfigError(f"{name}.reason must be a non-empty string")
92
+ reason = reason.strip()
93
+ if len(reason) > 500:
94
+ raise ConfigError(f"{name}.reason must be at most 500 characters")
95
+
96
+ expires_raw = raw.get("expires")
97
+ if not isinstance(expires_raw, str):
98
+ raise ConfigError(f"{name}.expires must be an ISO date (YYYY-MM-DD)")
99
+ try:
100
+ expires = date.fromisoformat(expires_raw)
101
+ except ValueError as exc:
102
+ raise ConfigError(f"{name}.expires must be an ISO date (YYYY-MM-DD)") from exc
103
+ if expires.isoformat() != expires_raw:
104
+ raise ConfigError(f"{name}.expires must use YYYY-MM-DD format")
105
+ if expires < today:
106
+ raise ConfigError(
107
+ f"{name} expired on {expires.isoformat()}; remove it or renew it with a reviewed reason"
108
+ )
109
+
110
+ raw_paths = raw.get("paths")
111
+ paths: tuple[str, ...] = ()
112
+ if raw_paths is not None:
113
+ if not isinstance(raw_paths, list) or not raw_paths:
114
+ raise ConfigError(f"{name}.paths must be a non-empty JSON array when provided")
115
+ normalized: list[str] = []
116
+ for path_index, value in enumerate(raw_paths):
117
+ if not isinstance(value, str) or not value.strip():
118
+ raise ConfigError(f"{name}.paths[{path_index}] must be a non-empty string")
119
+ normalized.append(value.strip())
120
+ if len(set(normalized)) != len(normalized):
121
+ raise ConfigError(f"{name}.paths must not contain duplicates")
122
+ paths = tuple(normalized)
123
+
124
+ return Suppression(rule=rule, reason=reason, expires=expires, paths=paths)
125
+
126
+
127
+ def parse_policy(data: object, *, today: date | None = None) -> Policy:
128
+ if not isinstance(data, dict):
129
+ raise ConfigError("configuration root must be a JSON object")
130
+
131
+ unknown = sorted(set(data) - _ALLOWED_TOP_LEVEL)
132
+ if unknown:
133
+ raise ConfigError(f"unknown configuration key(s): {', '.join(unknown)}")
134
+
135
+ schema_uri = data.get("$schema")
136
+ if schema_uri is not None and (not isinstance(schema_uri, str) or not schema_uri.strip()):
137
+ raise ConfigError("$schema must be a non-empty string when provided")
138
+
139
+ version = data.get("version", 1)
140
+ if version != 1:
141
+ raise ConfigError("version must be 1")
142
+
143
+ raw_policy = data.get("policy", {})
144
+ if not isinstance(raw_policy, dict):
145
+ raise ConfigError("policy must be a JSON object")
146
+
147
+ unknown_policy = sorted(set(raw_policy) - _ALLOWED_POLICY_KEYS)
148
+ if unknown_policy:
149
+ raise ConfigError(f"unknown policy key(s): {', '.join(unknown_policy)}")
150
+
151
+ max_drop = _require_int("policy.max_drop", raw_policy.get("max_drop", 0), minimum=0)
152
+
153
+ fail_on_new_errors = raw_policy.get("fail_on_new_errors", False)
154
+ if not isinstance(fail_on_new_errors, bool):
155
+ raise ConfigError("policy.fail_on_new_errors must be true or false")
156
+
157
+ fail_under = raw_policy.get("fail_under")
158
+ if fail_under is not None:
159
+ fail_under = _require_int("policy.fail_under", fail_under, minimum=0, maximum=100)
160
+
161
+ raw_suppressions = data.get("suppressions", [])
162
+ if not isinstance(raw_suppressions, list):
163
+ raise ConfigError("suppressions must be a JSON array")
164
+ evaluation_date = _utc_today() if today is None else today
165
+ suppressions = tuple(
166
+ _parse_suppression(raw, index, today=evaluation_date)
167
+ for index, raw in enumerate(raw_suppressions)
168
+ )
169
+
170
+ identities = [(item.rule, item.paths) for item in suppressions]
171
+ if len(set(identities)) != len(identities):
172
+ raise ConfigError("duplicate suppressions for the same rule and path scope are not allowed")
173
+
174
+ return Policy(
175
+ max_drop=max_drop,
176
+ fail_on_new_errors=fail_on_new_errors,
177
+ fail_under=fail_under,
178
+ suppressions=suppressions,
179
+ )
180
+
181
+
182
+ def load_policy(root: Path) -> Policy:
183
+ path = root.resolve() / CONFIG_NAME
184
+ if not path.exists():
185
+ return Policy()
186
+ if not path.is_file():
187
+ raise ConfigError(f"{CONFIG_NAME} exists but is not a file")
188
+
189
+ try:
190
+ data = json.loads(path.read_text(encoding="utf-8"))
191
+ except OSError as exc:
192
+ raise ConfigError(f"could not read {CONFIG_NAME}: {exc}") from exc
193
+ except json.JSONDecodeError as exc:
194
+ raise ConfigError(
195
+ f"invalid JSON in {CONFIG_NAME} at line {exc.lineno}, column {exc.colno}: {exc.msg}"
196
+ ) from exc
197
+
198
+ try:
199
+ return parse_policy(data)
200
+ except ConfigError as exc:
201
+ raise ConfigError(f"invalid {CONFIG_NAME}: {exc}") from exc