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.
- code_constraints/__init__.py +1 -0
- code_constraints/cli/__init__.py +0 -0
- code_constraints/cli/__main__.py +1555 -0
- code_constraints/cli/_assets/agents/cdec-architect.md +468 -0
- code_constraints/cli/_assets/agents/oop-refactor-architect.md +317 -0
- code_constraints/cli/_assets/shims/csharp/CodeConstraintsRules.cs +94 -0
- code_constraints/cli/_assets/shims/julia/CdecRules.jl +129 -0
- code_constraints/cli/_assets/shims/lua/cdec_rules.lua +92 -0
- code_constraints/cli/_assets/shims/odin/cdec_rules.odin +67 -0
- code_constraints/cli/_assets/shims/python/cdec_rules.py +94 -0
- code_constraints/cli/_assets/skills/cdec-architecture-loop/SKILL.md +152 -0
- code_constraints/cli/depstamp.py +118 -0
- code_constraints/cli/detect.py +77 -0
- code_constraints/cli/interactive.py +304 -0
- code_constraints/cli/scaffold.py +602 -0
- code_constraints/cli/update.py +157 -0
- code_constraints/core/__init__.py +41 -0
- code_constraints/core/annotations.py +217 -0
- code_constraints/core/associations.py +134 -0
- code_constraints/core/diff.py +302 -0
- code_constraints/core/editor_io.py +280 -0
- code_constraints/core/graph_model.py +681 -0
- code_constraints/core/keys.py +105 -0
- code_constraints/core/model.py +294 -0
- code_constraints/core/model_io.py +65 -0
- code_constraints/core/receivers.py +34 -0
- code_constraints/core/rules.py +177 -0
- code_constraints/core/rulesdoc.py +208 -0
- code_constraints/core/tags.py +114 -0
- code_constraints/core/ts_fingerprint.py +88 -0
- code_constraints/core/xmi_reader.py +358 -0
- code_constraints/core/xmi_writer.py +373 -0
- code_constraints/csharp/__init__.py +3 -0
- code_constraints/csharp/activity.py +250 -0
- code_constraints/csharp/conformance.py +331 -0
- code_constraints/csharp/fingerprint.py +274 -0
- code_constraints/csharp/parser.py +436 -0
- code_constraints/csharp/rules_extract.py +78 -0
- code_constraints/csharp/sequence.py +295 -0
- code_constraints/enforce/__init__.py +15 -0
- code_constraints/enforce/engine.py +122 -0
- code_constraints/enforce/model.py +74 -0
- code_constraints/julia/__init__.py +5 -0
- code_constraints/julia/conformance.py +282 -0
- code_constraints/julia/fingerprint.py +226 -0
- code_constraints/julia/parser.py +523 -0
- code_constraints/julia/rules_extract.py +216 -0
- code_constraints/lint/__init__.py +10 -0
- code_constraints/lint/baseline.py +96 -0
- code_constraints/lint/config.py +239 -0
- code_constraints/lint/engine.py +179 -0
- code_constraints/lint/pipeline.py +108 -0
- code_constraints/lint/report.py +151 -0
- code_constraints/lint/rules/__init__.py +50 -0
- code_constraints/lint/rules/base.py +200 -0
- code_constraints/lint/rules/cyclic_package_dependencies.py +69 -0
- code_constraints/lint/rules/dangling_classes.py +98 -0
- code_constraints/lint/rules/forbidden_package_references.py +47 -0
- code_constraints/lint/rules/forbidden_references.py +48 -0
- code_constraints/lint/rules/frozen_members.py +67 -0
- code_constraints/lint/rules/frozen_rules.py +105 -0
- code_constraints/lint/rules/implementation_locks.py +156 -0
- code_constraints/lint/rules/layer_dependencies.py +92 -0
- code_constraints/lint/rules/max_class_fanout.py +41 -0
- code_constraints/lint/rules/no_new_classes.py +27 -0
- code_constraints/lint/rules/no_removed_classes.py +27 -0
- code_constraints/lint/rules/reference_architecture.py +111 -0
- code_constraints/lint/rules/subclass_naming.py +71 -0
- code_constraints/lint/rules/tag_conformance.py +76 -0
- code_constraints/lock/__init__.py +73 -0
- code_constraints/lock/engine.py +395 -0
- code_constraints/lock/model.py +235 -0
- code_constraints/lock/store.py +144 -0
- code_constraints/lua/__init__.py +5 -0
- code_constraints/lua/conformance.py +239 -0
- code_constraints/lua/fingerprint.py +252 -0
- code_constraints/lua/parser.py +500 -0
- code_constraints/lua/rules_extract.py +55 -0
- code_constraints/mcp/__init__.py +20 -0
- code_constraints/mcp/__main__.py +73 -0
- code_constraints/mcp/server.py +1203 -0
- code_constraints/odin/__init__.py +5 -0
- code_constraints/odin/conformance.py +244 -0
- code_constraints/odin/fingerprint.py +159 -0
- code_constraints/odin/parser.py +471 -0
- code_constraints/odin/rules_extract.py +38 -0
- code_constraints/python/__init__.py +3 -0
- code_constraints/python/activity.py +278 -0
- code_constraints/python/conformance.py +249 -0
- code_constraints/python/fingerprint.py +231 -0
- code_constraints/python/parser.py +330 -0
- code_constraints/python/rules_extract.py +83 -0
- code_constraints/python/sequence.py +257 -0
- code_constraints/reference/__init__.py +15 -0
- code_constraints/reference/compare.py +356 -0
- code_constraints/reference/report.py +38 -0
- code_constraints/svelte/__init__.py +3 -0
- code_constraints/svelte/parser.py +523 -0
- code_constraints/typescript/__init__.py +3 -0
- code_constraints/typescript/parser.py +590 -0
- code_constraints/waivers/__init__.py +89 -0
- code_constraints/waivers/collect.py +167 -0
- code_constraints/waivers/model.py +90 -0
- code_constraints/waivers/ops.py +150 -0
- code_constraints/waivers/review.py +156 -0
- code_constraints/waivers/store.py +300 -0
- code_constraints/web/__init__.py +0 -0
- code_constraints/web/_static/assets/index-3ivBsYY4.css +1 -0
- code_constraints/web/_static/assets/index-BTzTqGFp.js +9 -0
- code_constraints/web/_static/index.html +13 -0
- code_constraints/web/app.py +1076 -0
- code_constraints-0.1.0.dist-info/METADATA +663 -0
- code_constraints-0.1.0.dist-info/RECORD +116 -0
- code_constraints-0.1.0.dist-info/WHEEL +4 -0
- code_constraints-0.1.0.dist-info/entry_points.txt +3 -0
- 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
|
+
]
|