code-constraints 0.1.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.
Files changed (116) hide show
  1. code_constraints/__init__.py +1 -0
  2. code_constraints/cli/__init__.py +0 -0
  3. code_constraints/cli/__main__.py +1555 -0
  4. code_constraints/cli/_assets/agents/cdec-architect.md +468 -0
  5. code_constraints/cli/_assets/agents/oop-refactor-architect.md +317 -0
  6. code_constraints/cli/_assets/shims/csharp/CodeConstraintsRules.cs +94 -0
  7. code_constraints/cli/_assets/shims/julia/CdecRules.jl +129 -0
  8. code_constraints/cli/_assets/shims/lua/cdec_rules.lua +92 -0
  9. code_constraints/cli/_assets/shims/odin/cdec_rules.odin +67 -0
  10. code_constraints/cli/_assets/shims/python/cdec_rules.py +94 -0
  11. code_constraints/cli/_assets/skills/cdec-architecture-loop/SKILL.md +152 -0
  12. code_constraints/cli/depstamp.py +118 -0
  13. code_constraints/cli/detect.py +77 -0
  14. code_constraints/cli/interactive.py +304 -0
  15. code_constraints/cli/scaffold.py +602 -0
  16. code_constraints/cli/update.py +157 -0
  17. code_constraints/core/__init__.py +41 -0
  18. code_constraints/core/annotations.py +217 -0
  19. code_constraints/core/associations.py +134 -0
  20. code_constraints/core/diff.py +302 -0
  21. code_constraints/core/editor_io.py +280 -0
  22. code_constraints/core/graph_model.py +681 -0
  23. code_constraints/core/keys.py +105 -0
  24. code_constraints/core/model.py +294 -0
  25. code_constraints/core/model_io.py +65 -0
  26. code_constraints/core/receivers.py +34 -0
  27. code_constraints/core/rules.py +177 -0
  28. code_constraints/core/rulesdoc.py +208 -0
  29. code_constraints/core/tags.py +114 -0
  30. code_constraints/core/ts_fingerprint.py +88 -0
  31. code_constraints/core/xmi_reader.py +358 -0
  32. code_constraints/core/xmi_writer.py +373 -0
  33. code_constraints/csharp/__init__.py +3 -0
  34. code_constraints/csharp/activity.py +250 -0
  35. code_constraints/csharp/conformance.py +331 -0
  36. code_constraints/csharp/fingerprint.py +274 -0
  37. code_constraints/csharp/parser.py +436 -0
  38. code_constraints/csharp/rules_extract.py +78 -0
  39. code_constraints/csharp/sequence.py +295 -0
  40. code_constraints/enforce/__init__.py +15 -0
  41. code_constraints/enforce/engine.py +122 -0
  42. code_constraints/enforce/model.py +74 -0
  43. code_constraints/julia/__init__.py +5 -0
  44. code_constraints/julia/conformance.py +282 -0
  45. code_constraints/julia/fingerprint.py +226 -0
  46. code_constraints/julia/parser.py +523 -0
  47. code_constraints/julia/rules_extract.py +216 -0
  48. code_constraints/lint/__init__.py +10 -0
  49. code_constraints/lint/baseline.py +96 -0
  50. code_constraints/lint/config.py +239 -0
  51. code_constraints/lint/engine.py +179 -0
  52. code_constraints/lint/pipeline.py +108 -0
  53. code_constraints/lint/report.py +151 -0
  54. code_constraints/lint/rules/__init__.py +50 -0
  55. code_constraints/lint/rules/base.py +200 -0
  56. code_constraints/lint/rules/cyclic_package_dependencies.py +69 -0
  57. code_constraints/lint/rules/dangling_classes.py +98 -0
  58. code_constraints/lint/rules/forbidden_package_references.py +47 -0
  59. code_constraints/lint/rules/forbidden_references.py +48 -0
  60. code_constraints/lint/rules/frozen_members.py +67 -0
  61. code_constraints/lint/rules/frozen_rules.py +105 -0
  62. code_constraints/lint/rules/implementation_locks.py +156 -0
  63. code_constraints/lint/rules/layer_dependencies.py +92 -0
  64. code_constraints/lint/rules/max_class_fanout.py +41 -0
  65. code_constraints/lint/rules/no_new_classes.py +27 -0
  66. code_constraints/lint/rules/no_removed_classes.py +27 -0
  67. code_constraints/lint/rules/reference_architecture.py +111 -0
  68. code_constraints/lint/rules/subclass_naming.py +71 -0
  69. code_constraints/lint/rules/tag_conformance.py +76 -0
  70. code_constraints/lock/__init__.py +73 -0
  71. code_constraints/lock/engine.py +395 -0
  72. code_constraints/lock/model.py +235 -0
  73. code_constraints/lock/store.py +144 -0
  74. code_constraints/lua/__init__.py +5 -0
  75. code_constraints/lua/conformance.py +239 -0
  76. code_constraints/lua/fingerprint.py +252 -0
  77. code_constraints/lua/parser.py +500 -0
  78. code_constraints/lua/rules_extract.py +55 -0
  79. code_constraints/mcp/__init__.py +20 -0
  80. code_constraints/mcp/__main__.py +73 -0
  81. code_constraints/mcp/server.py +1203 -0
  82. code_constraints/odin/__init__.py +5 -0
  83. code_constraints/odin/conformance.py +244 -0
  84. code_constraints/odin/fingerprint.py +159 -0
  85. code_constraints/odin/parser.py +471 -0
  86. code_constraints/odin/rules_extract.py +38 -0
  87. code_constraints/python/__init__.py +3 -0
  88. code_constraints/python/activity.py +278 -0
  89. code_constraints/python/conformance.py +249 -0
  90. code_constraints/python/fingerprint.py +231 -0
  91. code_constraints/python/parser.py +330 -0
  92. code_constraints/python/rules_extract.py +83 -0
  93. code_constraints/python/sequence.py +257 -0
  94. code_constraints/reference/__init__.py +15 -0
  95. code_constraints/reference/compare.py +356 -0
  96. code_constraints/reference/report.py +38 -0
  97. code_constraints/svelte/__init__.py +3 -0
  98. code_constraints/svelte/parser.py +523 -0
  99. code_constraints/typescript/__init__.py +3 -0
  100. code_constraints/typescript/parser.py +590 -0
  101. code_constraints/waivers/__init__.py +89 -0
  102. code_constraints/waivers/collect.py +167 -0
  103. code_constraints/waivers/model.py +90 -0
  104. code_constraints/waivers/ops.py +150 -0
  105. code_constraints/waivers/review.py +156 -0
  106. code_constraints/waivers/store.py +300 -0
  107. code_constraints/web/__init__.py +0 -0
  108. code_constraints/web/_static/assets/index-3ivBsYY4.css +1 -0
  109. code_constraints/web/_static/assets/index-BTzTqGFp.js +9 -0
  110. code_constraints/web/_static/index.html +13 -0
  111. code_constraints/web/app.py +1076 -0
  112. code_constraints-0.1.0.dist-info/METADATA +663 -0
  113. code_constraints-0.1.0.dist-info/RECORD +116 -0
  114. code_constraints-0.1.0.dist-info/WHEEL +4 -0
  115. code_constraints-0.1.0.dist-info/entry_points.txt +3 -0
  116. code_constraints-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,167 @@
1
+ """Run the checks and project the result into one keyed issue list.
2
+
3
+ `cdec exceptions allow V-1A2B3C4D` has to answer "which issue is that?", and the
4
+ only honest answer comes from re-running the checks: a key is a hash of an
5
+ identity, not a record you can look up. Re-deriving it also means an exception
6
+ can never be granted for an issue that isn't actually there — the key must match
7
+ something reported *now*, or the command refuses.
8
+
9
+ Since every gate is a rule, this is one run of the same engine `cdec check`
10
+ uses, resolved through `lint.pipeline` with the same options. The keys only line
11
+ up if both commands look at the same source and the same baseline, and this is
12
+ what guarantees they do.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+
20
+ from code_constraints.lint.config import (
21
+ LoadedRules,
22
+ ProjectConfig,
23
+ load_project_config,
24
+ load_rules,
25
+ )
26
+ from code_constraints.lint.pipeline import PipelineError, parse_source, resolve_baseline
27
+ from code_constraints.waivers.model import Issue
28
+ from code_constraints.waivers.store import WaiverStore, ledger_paths, load_waivers
29
+
30
+
31
+ @dataclass
32
+ class CollectOptions:
33
+ """Everything that changes *which* issues exist. Mirrors `cdec check`'s
34
+ options one for one, because the keys have to line up with the report the
35
+ reviewer is holding."""
36
+
37
+ source: Path | None = None
38
+ reference: Path | None = None
39
+ base_ref: str | None = None
40
+ repo: Path = Path(".")
41
+
42
+
43
+ @dataclass
44
+ class Collected:
45
+ issues: list[Issue] = field(default_factory=list)
46
+ store: WaiverStore = field(default_factory=WaiverStore)
47
+ ledger_path: Path = Path()
48
+ config: ProjectConfig | None = None
49
+ # Rule ids whose results in `issues` are complete. `prune` needs this: an
50
+ # exception isn't stale just because the rule that reports it didn't run.
51
+ rules_ran: set[str] = field(default_factory=set)
52
+ # Engines whose results are complete, derived from `rules_ran`. An exception
53
+ # is keyed by engine, so this is the granularity pruning works at.
54
+ engines_ran: set[str] = field(default_factory=set)
55
+ # Rules that couldn't run, with the reason (e.g. a language with no
56
+ # fingerprinter). Reported rather than swallowed: a silently skipped rule
57
+ # looks exactly like a clean one.
58
+ skipped: list[tuple[str, str]] = field(default_factory=list)
59
+
60
+ def by_key(self) -> dict[str, Issue]:
61
+ return {issue.key: issue for issue in self.issues}
62
+
63
+ def open_issues(self) -> list[Issue]:
64
+ return [i for i in self.issues if not i.waived]
65
+
66
+
67
+ def collect_issues(config_dir: Path, options: CollectOptions | None = None) -> Collected:
68
+ """Every issue `cdec check` currently reports, accepted ones included.
69
+
70
+ Accepted issues stay in the list (flagged `waived=True`) so `remove` and
71
+ `prune` can reason about them.
72
+ """
73
+ opts = options or CollectOptions()
74
+ cfg = load_project_config(config_dir)
75
+ source_path = opts.source.resolve() if opts.source else cfg.source
76
+ rules_file, _ = ledger_paths(config_dir)
77
+ store = load_waivers(config_dir)
78
+
79
+ out = Collected(store=store, ledger_path=rules_file, config=cfg)
80
+
81
+ head_proj = parse_source(source_path, cfg.language)
82
+ annotated, has_diff, base_proj = resolve_baseline(
83
+ head_proj=head_proj,
84
+ lang=cfg.language,
85
+ config_dir=config_dir,
86
+ explicit_reference=opts.reference,
87
+ explicit_base_ref=opts.base_ref,
88
+ repo_path=opts.repo,
89
+ default_reference=cfg.reference,
90
+ )
91
+
92
+ # Imported here, not at module scope: `lint.engine` pulls in `lint.baseline`,
93
+ # which adapts this package's store — a module-level import would close the
94
+ # cycle while `waivers` is still initialising.
95
+ from code_constraints.lint.engine import SourceContext, run_checks
96
+
97
+ loaded: LoadedRules = load_rules(config_dir)
98
+ # Run unfiltered (`baseline=None`) so accepted issues are still visible to
99
+ # the review workflow rather than silently dropped.
100
+ report = run_checks(
101
+ annotated,
102
+ loaded.rules,
103
+ has_diff=has_diff,
104
+ baseline=None,
105
+ baseline_project=base_proj,
106
+ source_context=SourceContext(
107
+ source=source_path,
108
+ language=cfg.language,
109
+ config_dir=config_dir,
110
+ reference_path=opts.reference or cfg.reference_path,
111
+ ),
112
+ )
113
+
114
+ skipped_ids = {rule_id for rule_id, _ in report.skipped}
115
+ out.skipped = list(report.skipped)
116
+ for rule in loaded.rules:
117
+ if rule.rule_id not in skipped_ids:
118
+ out.rules_ran.add(rule.rule_id)
119
+
120
+ for v in report.violations:
121
+ issue = Issue(
122
+ engine=v.key_engine,
123
+ rule=v.key_rule or v.rule_id,
124
+ rule_id=v.rule_id,
125
+ qualified_name=v.qualified_name,
126
+ detail=v.signature or "",
127
+ message=v.message,
128
+ severity=v.severity.value,
129
+ file=v.location.file if v.location else "",
130
+ line=v.location.start_line if v.location else 0,
131
+ waivable=v.waivable,
132
+ )
133
+ out.issues.append(issue)
134
+ out.engines_ran.add(issue.engine)
135
+
136
+ # A rule that ran and found nothing still counts as "this engine ran", which
137
+ # is exactly the case where a stale exception should be pruned.
138
+ for rule in loaded.rules:
139
+ if rule.rule_id in skipped_ids:
140
+ continue
141
+ out.engines_ran.update(_engines_of(rule))
142
+
143
+ for issue in out.issues:
144
+ if issue.waivable and store.has(issue.key):
145
+ issue.waived = True
146
+ waiver = store.get(issue.key)
147
+ issue.waiver_reason = waiver.reason if waiver else ""
148
+
149
+ return out
150
+
151
+
152
+ def _engines_of(rule) -> set[str]:
153
+ """Which key-engine(s) a rule type can produce issues under.
154
+
155
+ Needed because pruning happens per engine but a clean run reports no
156
+ violations to read the engine off, and dropping every `F-` exception just
157
+ because the tag-conformance rule happened to pass would be wrong only if
158
+ that rule never ran at all.
159
+ """
160
+ return {
161
+ "tag-conformance": {"enforce"},
162
+ "implementation-locks": {"lock"},
163
+ "reference-architecture": {"reference"},
164
+ }.get(rule.type_name, {"check"})
165
+
166
+
167
+ __all__ = ["CollectOptions", "Collected", "collect_issues", "PipelineError"]
@@ -0,0 +1,90 @@
1
+ """One engine-neutral view of a reported issue.
2
+
3
+ The engines have deliberately separate result types (`Violation`, `Finding`,
4
+ `LockViolation`, `Deviation`). The review workflow needs to talk about all of
5
+ them in one list, so this is the narrow projection they share — just enough to
6
+ print a line, identify it by key, and turn it into an exception.
7
+
8
+ Adapting happens at the boundary (`collect.py`); the engines never learn about
9
+ this type, so they stay decoupled.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+
16
+ from code_constraints.core.keys import make_key
17
+ from code_constraints.waivers.store import WAIVABLE_ENGINES, Waiver, now_stamp
18
+
19
+
20
+ class NotWaivable(ValueError):
21
+ """Raised when something tries to waive an issue that must not be waived."""
22
+
23
+
24
+ @dataclass
25
+ class Issue:
26
+ """A single reported issue, from any engine."""
27
+
28
+ engine: str # "check" | "enforce" | "lock" | "reference"
29
+ rule: str # the engine's own rule identity — what the key is derived from
30
+ qualified_name: str # class qname, or lock target
31
+ detail: str = "" # discriminator; see code_constraints.core.keys
32
+ message: str = ""
33
+ severity: str = "error"
34
+ file: str = ""
35
+ line: int = 0
36
+ # The `rules.yaml` entry that surfaced this issue. Shown to the reader so a
37
+ # report points at the line of config to edit; never part of the key, so
38
+ # renaming an entry doesn't invalidate an exception granted against it.
39
+ rule_id: str = ""
40
+ # False for issues that must not be accepted as exceptions — locks.
41
+ waivable: bool = True
42
+ # True when an exception already covers this issue (it was silenced).
43
+ waived: bool = False
44
+ waiver_reason: str = ""
45
+
46
+ def __post_init__(self) -> None:
47
+ if not self.rule_id:
48
+ self.rule_id = self.rule
49
+ # The engine is the authority on waivability; a caller can only narrow.
50
+ if self.engine not in WAIVABLE_ENGINES:
51
+ self.waivable = False
52
+
53
+ @property
54
+ def key(self) -> str:
55
+ return make_key(self.engine, self.rule, self.qualified_name, self.detail)
56
+
57
+ @property
58
+ def location(self) -> str:
59
+ if not self.file:
60
+ return ""
61
+ return f"{self.file}:{self.line}" if self.line else self.file
62
+
63
+ def to_waiver(self, reason: str = "", actor: str = "") -> Waiver:
64
+ if not self.waivable:
65
+ raise NotWaivable(_not_waivable_message(self))
66
+ return Waiver(
67
+ engine=self.engine,
68
+ rule=self.rule,
69
+ qualified_name=self.qualified_name,
70
+ detail=self.detail,
71
+ reason=reason,
72
+ added=now_stamp(),
73
+ added_by=actor,
74
+ )
75
+
76
+
77
+ def _not_waivable_message(issue: Issue) -> str:
78
+ """Locks are the only non-exceptable engine, and the message has to say what
79
+ to do instead — a dead end here is a dead end for the whole workflow."""
80
+ if issue.engine == "lock":
81
+ return (
82
+ f"{issue.key} is a lock violation on '{issue.qualified_name}', and locks "
83
+ f"cannot be accepted as exceptions. A frozen implementation may only "
84
+ f"change with a lead's approval, which leaves a reviewable diff on the "
85
+ f"`locks:` section of .cdec/rules.yaml:\n"
86
+ f" cdec check --automatic-exceptions locks --force\n"
87
+ f"To drop the lock entirely, delete its @locked tag from the source and "
88
+ f"re-run that command."
89
+ )
90
+ return f"{issue.key}: issues from engine {issue.engine!r} cannot be accepted."
@@ -0,0 +1,150 @@
1
+ """Apply review decisions to the exceptions ledger.
2
+
3
+ Kept apart from the CLI so the rules about what may be waived — and what must
4
+ be refused — are testable on their own and identical whether the decision
5
+ arrives from a marked-up file, a key on the command line, or (later) an MCP
6
+ call.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Iterable
13
+
14
+ from code_constraints.core.keys import normalize_key
15
+ from code_constraints.waivers.collect import Collected
16
+ from code_constraints.waivers.model import Issue, NotWaivable
17
+ from code_constraints.waivers.review import Decision, ReviewDecisions
18
+ from code_constraints.waivers.store import Waiver, WaiverStore, default_actor
19
+
20
+
21
+ @dataclass
22
+ class ApplyResult:
23
+ allowed: list[Issue] = field(default_factory=list)
24
+ already_waived: list[Issue] = field(default_factory=list)
25
+ removed: list[Waiver] = field(default_factory=list)
26
+ # Keys that match no current issue and no existing waiver. Nearly always a
27
+ # stale report or a mistyped key, so it's an error rather than a no-op.
28
+ unknown: list[str] = field(default_factory=list)
29
+ # (key, explanation) for issues that exist but must not be waived — locks.
30
+ refused: list[tuple[str, str]] = field(default_factory=list)
31
+ # [REMOVE] on a key that wasn't waived in the first place.
32
+ not_waived: list[str] = field(default_factory=list)
33
+ malformed: list[str] = field(default_factory=list)
34
+ problems: list[str] = field(default_factory=list)
35
+
36
+ @property
37
+ def changed(self) -> bool:
38
+ return bool(self.allowed or self.removed)
39
+
40
+ @property
41
+ def failed(self) -> bool:
42
+ return bool(self.unknown or self.refused or self.malformed or self.problems)
43
+
44
+
45
+ def apply_decisions(
46
+ store: WaiverStore,
47
+ collected: Collected,
48
+ decisions: ReviewDecisions,
49
+ *,
50
+ default_reason: str = "",
51
+ actor: str | None = None,
52
+ ) -> ApplyResult:
53
+ """Apply `[ALLOW]` / `[REMOVE]` decisions to `store` in memory."""
54
+ result = ApplyResult(problems=list(decisions.problems))
55
+ who = default_actor() if actor is None else actor
56
+ index = collected.by_key()
57
+
58
+ for decision in decisions.allow:
59
+ _allow_one(store, index, decision, default_reason, who, result)
60
+ for decision in decisions.remove:
61
+ _remove_one(store, decision, result)
62
+ return result
63
+
64
+
65
+ def allow_keys(
66
+ store: WaiverStore,
67
+ collected: Collected,
68
+ keys: Iterable[str],
69
+ *,
70
+ reason: str = "",
71
+ actor: str | None = None,
72
+ ) -> ApplyResult:
73
+ """Waive issues named directly by key — the agent-friendly path."""
74
+ decisions = ReviewDecisions(allow=[Decision(key=k) for k in keys])
75
+ return apply_decisions(
76
+ store, collected, decisions, default_reason=reason, actor=actor
77
+ )
78
+
79
+
80
+ def remove_keys(store: WaiverStore, keys: Iterable[str]) -> ApplyResult:
81
+ """Withdraw waivers by key. Needs no source parse — the store is enough."""
82
+ result = ApplyResult()
83
+ for raw in keys:
84
+ _remove_one(store, Decision(key=raw), result)
85
+ return result
86
+
87
+
88
+ def prune(store: WaiverStore, collected: Collected) -> list[Waiver]:
89
+ """Drop exceptions whose issue no longer occurs, and return them.
90
+
91
+ Only prunes engines that actually ran this time round: an exception is not
92
+ stale just because the rule that would have reported it was skipped.
93
+ """
94
+ engines_ran = collected.engines_ran
95
+ live = {issue.key for issue in collected.issues}
96
+ stale = [
97
+ w for w in store.waivers if w.engine in engines_ran and w.key not in live
98
+ ]
99
+ for waiver in stale:
100
+ store.remove(waiver.key)
101
+ return stale
102
+
103
+
104
+ # ---------- internals ----------
105
+
106
+ def _allow_one(
107
+ store: WaiverStore,
108
+ index: dict[str, Issue],
109
+ decision: Decision,
110
+ default_reason: str,
111
+ actor: str,
112
+ result: ApplyResult,
113
+ ) -> None:
114
+ key = normalize_key(decision.key)
115
+ if not key:
116
+ result.malformed.append(decision.key)
117
+ return
118
+ issue = index.get(key)
119
+ if issue is None:
120
+ result.unknown.append(key)
121
+ return
122
+ reason = decision.reason or default_reason
123
+ try:
124
+ waiver = issue.to_waiver(reason=reason, actor=actor)
125
+ except NotWaivable as exc:
126
+ result.refused.append((key, str(exc)))
127
+ return
128
+ if store.add(waiver):
129
+ result.allowed.append(issue)
130
+ else:
131
+ result.already_waived.append(issue)
132
+
133
+
134
+ def _remove_one(store: WaiverStore, decision: Decision, result: ApplyResult) -> None:
135
+ key = normalize_key(decision.key)
136
+ if not key:
137
+ result.malformed.append(decision.key)
138
+ return
139
+ if not store.has(key):
140
+ result.not_waived.append(key)
141
+ return
142
+ waiver = store.remove(key)
143
+ if waiver is not None:
144
+ result.removed.append(waiver)
145
+ else:
146
+ # A hand-written key with no matching tuple: it was honoured for
147
+ # matching, and dropping it is still a real change.
148
+ result.removed.append(
149
+ Waiver(engine="check", rule="(hand-written key)", qualified_name=key)
150
+ )
@@ -0,0 +1,156 @@
1
+ """The review file: render issues for a human, read their decisions back.
2
+
3
+ This is the round trip the whole feature exists for. `cdec exceptions review`
4
+ writes a plain-text report where every issue occupies one line and leads with
5
+ its key. A reviewer (or an agent) marks the lines they accept with `[ALLOW]`
6
+ and hands the file to `cdec exceptions patch`, which applies exactly those.
7
+
8
+ The parser is deliberately forgiving, because the file is meant to be edited by
9
+ hand and pasted between tools: any line carrying a marker and a key counts, no
10
+ matter what else is on it. That means the output of `cdec check --log-out` works
11
+ as a patch file too — the review file is a convenience, not a required format.
12
+ A `#` at the start of a line comments it out, which is how you cancel a decision
13
+ without deleting the evidence.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from dataclasses import dataclass, field
20
+ from typing import Iterable
21
+
22
+ from code_constraints.core.keys import find_keys
23
+ from code_constraints.waivers.model import Issue
24
+
25
+ ALLOW_RE = re.compile(r"\[\s*ALLOW\s*(?::\s*([^\]]*?))?\s*\]", re.IGNORECASE)
26
+ REMOVE_RE = re.compile(r"\[\s*(?:REMOVE|UNALLOW|DENY)\s*(?::\s*([^\]]*?))?\s*\]", re.IGNORECASE)
27
+
28
+ # Order matters: the review file groups by engine in this order, so a reviewer
29
+ # reads the cheap decisions before the ones that need a lead.
30
+ _ENGINE_HEADINGS: dict[str, str] = {
31
+ "check": "configured architectural rules",
32
+ "enforce": "source-tag conformance — does the implementation obey its tags",
33
+ "reference": "reference-architecture gate — structural deviation",
34
+ "lock": "frozen implementations",
35
+ }
36
+
37
+
38
+ @dataclass
39
+ class Decision:
40
+ """One marked line in a reviewed file."""
41
+
42
+ key: str
43
+ reason: str = ""
44
+ line_no: int = 0
45
+ source_line: str = ""
46
+
47
+
48
+ @dataclass
49
+ class ReviewDecisions:
50
+ allow: list[Decision] = field(default_factory=list)
51
+ remove: list[Decision] = field(default_factory=list)
52
+ # Lines that carried a marker but no key — almost always a typo in a key,
53
+ # and silently ignoring them is how a reviewer thinks they allowed
54
+ # something they didn't.
55
+ problems: list[str] = field(default_factory=list)
56
+
57
+ @property
58
+ def empty(self) -> bool:
59
+ return not self.allow and not self.remove
60
+
61
+
62
+ def parse_review(text: str) -> ReviewDecisions:
63
+ """Extract the marked decisions from a reviewed report."""
64
+ out = ReviewDecisions()
65
+ for i, raw_line in enumerate(text.splitlines(), start=1):
66
+ line = raw_line.strip()
67
+ if not line or line.startswith("#"):
68
+ continue
69
+ allow = ALLOW_RE.search(line)
70
+ remove = REMOVE_RE.search(line)
71
+ if allow is None and remove is None:
72
+ continue
73
+ keys = find_keys(line)
74
+ if not keys:
75
+ marker = "ALLOW" if allow is not None else "REMOVE"
76
+ out.problems.append(
77
+ f"line {i}: [{marker}] with no issue key — nothing to apply: {line}"
78
+ )
79
+ continue
80
+ # A line marked both ways is a mistake worth surfacing rather than
81
+ # resolving by precedence.
82
+ if allow is not None and remove is not None:
83
+ out.problems.append(
84
+ f"line {i}: marked both [ALLOW] and [REMOVE] — skipped: {line}"
85
+ )
86
+ continue
87
+ if allow is not None:
88
+ reason = (allow.group(1) or "").strip()
89
+ bucket = out.allow
90
+ else:
91
+ assert remove is not None # one of the two matched, checked above
92
+ reason = (remove.group(1) or "").strip()
93
+ bucket = out.remove
94
+ for key in keys:
95
+ bucket.append(Decision(key=key, reason=reason, line_no=i, source_line=line))
96
+ return out
97
+
98
+
99
+ def render_review(issues: Iterable[Issue], *, include_waived: bool = False) -> str:
100
+ """Write the editable report."""
101
+ items = [i for i in issues if include_waived or not i.waived]
102
+ lines = list(_HEADER)
103
+ if not items:
104
+ lines.append("# No issues to review — the project is clean.")
105
+ lines.append("")
106
+ return "\n".join(lines) + "\n"
107
+
108
+ for engine in _ENGINE_HEADINGS:
109
+ group = [i for i in items if i.engine == engine]
110
+ if not group:
111
+ continue
112
+ lines.append("")
113
+ heading = _ENGINE_HEADINGS.get(engine, engine)
114
+ lines.append(f"## {heading} — {len(group)} issue(s)")
115
+ if any(not i.waivable for i in group):
116
+ lines.append(
117
+ "## NOT EXCEPTABLE: a frozen implementation changes only via "
118
+ "`cdec check --automatic-exceptions locks --force`."
119
+ )
120
+ lines.append("")
121
+ for issue in sorted(group, key=lambda i: (i.rule, i.qualified_name, i.detail)):
122
+ lines.append(render_issue_line(issue))
123
+ lines.append("")
124
+ return "\n".join(lines) + "\n"
125
+
126
+
127
+ def render_issue_line(issue: Issue) -> str:
128
+ """One issue, one line — the unit the patch parser operates on.
129
+
130
+ Everything discriminating goes on this single line (never a continuation),
131
+ because a reviewer marks lines, and a decision must be readable from the
132
+ line it is written on.
133
+ """
134
+ prefix = "- [ACCEPTED] " if issue.waived else "- "
135
+ loc = f" [{issue.location}]" if issue.location else ""
136
+ message = " ".join((issue.message or "").split())
137
+ return (
138
+ f"{prefix}[{issue.key}] [{issue.severity}] [{issue.rule}]{loc} "
139
+ f"{issue.qualified_name}: {message}"
140
+ )
141
+
142
+
143
+ _HEADER = [
144
+ "# cdec review file",
145
+ "#",
146
+ "# One line per issue. To accept an issue as known-and-allowed, add [ALLOW]",
147
+ "# anywhere on its line; add a reason with [ALLOW: why this is acceptable].",
148
+ "# To withdraw an exception already granted, mark the line [REMOVE].",
149
+ "# Lines starting with # are ignored, so commenting one out cancels it.",
150
+ "#",
151
+ "# Then apply the file:",
152
+ "# cdec exceptions patch --file <this file>",
153
+ "#",
154
+ "# Example:",
155
+ "# - [ALLOW: legacy, tracked in ARCH-42] [V-1A2B3C4D] [error] [no-new-classes] ...",
156
+ ]