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,157 @@
|
|
|
1
|
+
"""`cdec update` — self-update the installation in place.
|
|
2
|
+
|
|
3
|
+
Equivalent to re-running the standalone installer (`install/install.{sh,ps1}`):
|
|
4
|
+
pull the latest code from GitHub, re-sync Python dependencies (picking up any
|
|
5
|
+
requirement changes), and rebuild the web frontend. Operates on the git checkout
|
|
6
|
+
that this CLI is running from, using the same virtualenv (`sys.executable`).
|
|
7
|
+
|
|
8
|
+
The refreshed code takes effect on the next `cdec` invocation — the currently
|
|
9
|
+
running process keeps the modules it already imported.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from code_constraints.cli import depstamp
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class UpdateError(Exception):
|
|
23
|
+
"""Raised when the update cannot proceed (not a checkout, missing tool, …)."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def find_repo_root(start: Path | None = None) -> Path:
|
|
27
|
+
"""Walk up from this module to the git checkout that contains the project.
|
|
28
|
+
|
|
29
|
+
Looks for a directory holding both ``.git`` and ``pyproject.toml`` — that's
|
|
30
|
+
the editable checkout the installer manages (``$CDEC_HOME/repo``) or a
|
|
31
|
+
developer clone.
|
|
32
|
+
"""
|
|
33
|
+
here = (start or Path(__file__)).resolve()
|
|
34
|
+
for candidate in here.parents:
|
|
35
|
+
if (candidate / ".git").exists() and (candidate / "pyproject.toml").is_file():
|
|
36
|
+
return candidate
|
|
37
|
+
raise UpdateError(
|
|
38
|
+
"could not locate the code-constraints git checkout to update.\n"
|
|
39
|
+
"This command updates an installation created by the standalone installer "
|
|
40
|
+
"(or a git clone). If you installed code-constraints some other way, update it "
|
|
41
|
+
"the same way you installed it (e.g. `pip install -U code-constraints`)."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _current_branch(repo_root: Path, git: str) -> str:
|
|
46
|
+
out = subprocess.run(
|
|
47
|
+
[git, "-C", str(repo_root), "rev-parse", "--abbrev-ref", "HEAD"],
|
|
48
|
+
capture_output=True,
|
|
49
|
+
text=True,
|
|
50
|
+
check=True,
|
|
51
|
+
)
|
|
52
|
+
return out.stdout.strip()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _run(cmd: list[str], *, cwd: Path, step: str) -> None:
|
|
56
|
+
"""Run a child process, streaming its output. Raise UpdateError on failure."""
|
|
57
|
+
result = subprocess.run(cmd, cwd=str(cwd), check=False)
|
|
58
|
+
if result.returncode != 0:
|
|
59
|
+
raise UpdateError(f"{step} failed (exit {result.returncode}): {' '.join(cmd)}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _build_inputs(frontend_dir: Path) -> list[Path]:
|
|
63
|
+
"""Files whose change should trigger a frontend rebuild (source + configs)."""
|
|
64
|
+
candidates = [
|
|
65
|
+
frontend_dir / "src",
|
|
66
|
+
frontend_dir / "package.json",
|
|
67
|
+
frontend_dir / "package-lock.json",
|
|
68
|
+
frontend_dir / "vite.config.ts",
|
|
69
|
+
frontend_dir / "svelte.config.js",
|
|
70
|
+
frontend_dir / "tsconfig.json",
|
|
71
|
+
frontend_dir / "tsconfig.app.json",
|
|
72
|
+
frontend_dir / "tsconfig.node.json",
|
|
73
|
+
frontend_dir / "index.html",
|
|
74
|
+
]
|
|
75
|
+
return [p for p in candidates if p.exists()]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def run_update(
|
|
79
|
+
*,
|
|
80
|
+
branch: str | None = None,
|
|
81
|
+
frontend: bool = True,
|
|
82
|
+
force: bool = False,
|
|
83
|
+
echo=print,
|
|
84
|
+
) -> Path:
|
|
85
|
+
"""Perform the in-place update. Returns the repo root that was updated.
|
|
86
|
+
|
|
87
|
+
Mirrors the installer flow: ``git fetch`` + ``pull --ff-only`` → ``pip
|
|
88
|
+
install -e ".[dev]"`` (via the running interpreter) → ``npm install`` +
|
|
89
|
+
``npm run build``.
|
|
90
|
+
|
|
91
|
+
The three install steps are fingerprinted (see :mod:`code_constraints.cli.depstamp`): a
|
|
92
|
+
step is skipped when its inputs are unchanged since the last successful run,
|
|
93
|
+
unless ``force`` is set. The git pull always runs — it's cheap and it's what
|
|
94
|
+
produces the changes the fingerprints then detect.
|
|
95
|
+
"""
|
|
96
|
+
repo_root = find_repo_root()
|
|
97
|
+
|
|
98
|
+
git = shutil.which("git")
|
|
99
|
+
if git is None:
|
|
100
|
+
raise UpdateError("git is not installed or not on PATH.")
|
|
101
|
+
|
|
102
|
+
target_branch = branch or _current_branch(repo_root, git)
|
|
103
|
+
|
|
104
|
+
# 1. Pull latest code.
|
|
105
|
+
echo(f"==> Updating source ({target_branch}) in {repo_root}")
|
|
106
|
+
_run([git, "-C", str(repo_root), "fetch", "--prune", "origin", target_branch],
|
|
107
|
+
cwd=repo_root, step="git fetch")
|
|
108
|
+
_run([git, "-C", str(repo_root), "checkout", target_branch],
|
|
109
|
+
cwd=repo_root, step="git checkout")
|
|
110
|
+
_run([git, "-C", str(repo_root), "pull", "--ff-only", "origin", target_branch],
|
|
111
|
+
cwd=repo_root, step="git pull")
|
|
112
|
+
|
|
113
|
+
# 2. Re-sync Python dependencies using the venv this CLI runs from. Skip when
|
|
114
|
+
# pyproject.toml is unchanged — the editable install already reflects any
|
|
115
|
+
# source edits, so only a dependency change warrants a reinstall.
|
|
116
|
+
venv_dir = Path(sys.executable).resolve().parent.parent
|
|
117
|
+
py_stamp = venv_dir / ".cdec-stamp-python"
|
|
118
|
+
py_inputs = [repo_root / "pyproject.toml"]
|
|
119
|
+
if force or depstamp.is_changed(py_stamp, py_inputs, base=repo_root):
|
|
120
|
+
echo("==> Installing / updating Python dependencies")
|
|
121
|
+
_run([sys.executable, "-m", "pip", "install", "--upgrade", "pip"],
|
|
122
|
+
cwd=repo_root, step="pip upgrade")
|
|
123
|
+
_run([sys.executable, "-m", "pip", "install", "-e", ".[dev]"],
|
|
124
|
+
cwd=repo_root, step="pip install")
|
|
125
|
+
depstamp.write_stamp(py_stamp, py_inputs, base=repo_root)
|
|
126
|
+
else:
|
|
127
|
+
echo("==> Python dependencies unchanged — skipping pip install")
|
|
128
|
+
|
|
129
|
+
# 3. Rebuild the frontend.
|
|
130
|
+
if frontend:
|
|
131
|
+
frontend_dir = repo_root / "frontend"
|
|
132
|
+
npm = shutil.which("npm")
|
|
133
|
+
if npm is None:
|
|
134
|
+
echo("WARNING: npm not found on PATH — skipping frontend rebuild. "
|
|
135
|
+
"The web UI may be stale until you build it (Node 20+ required).")
|
|
136
|
+
elif not frontend_dir.is_dir():
|
|
137
|
+
echo(f"WARNING: {frontend_dir} not found — skipping frontend rebuild.")
|
|
138
|
+
else:
|
|
139
|
+
deps_stamp = frontend_dir / "node_modules" / ".cdec-stamp-deps"
|
|
140
|
+
deps_inputs = [frontend_dir / "package.json", frontend_dir / "package-lock.json"]
|
|
141
|
+
if force or depstamp.is_changed(deps_stamp, deps_inputs, base=repo_root):
|
|
142
|
+
echo("==> Installing frontend dependencies")
|
|
143
|
+
_run([npm, "install"], cwd=frontend_dir, step="npm install")
|
|
144
|
+
depstamp.write_stamp(deps_stamp, deps_inputs, base=repo_root)
|
|
145
|
+
else:
|
|
146
|
+
echo("==> Frontend dependencies unchanged — skipping npm install")
|
|
147
|
+
|
|
148
|
+
build_stamp = frontend_dir / "dist" / ".cdec-stamp-build"
|
|
149
|
+
build_inputs = _build_inputs(frontend_dir)
|
|
150
|
+
if force or depstamp.is_changed(build_stamp, build_inputs, base=repo_root):
|
|
151
|
+
echo("==> Rebuilding the web UI")
|
|
152
|
+
_run([npm, "run", "build"], cwd=frontend_dir, step="npm run build")
|
|
153
|
+
depstamp.write_stamp(build_stamp, build_inputs, base=repo_root)
|
|
154
|
+
else:
|
|
155
|
+
echo("==> Frontend sources unchanged — skipping npm run build")
|
|
156
|
+
|
|
157
|
+
return repo_root
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from code_constraints.core.model import (
|
|
2
|
+
Project,
|
|
3
|
+
Package,
|
|
4
|
+
Class,
|
|
5
|
+
Attribute,
|
|
6
|
+
Operation,
|
|
7
|
+
Parameter,
|
|
8
|
+
Activity,
|
|
9
|
+
ActivityNode,
|
|
10
|
+
ActivityEdge,
|
|
11
|
+
Sequence,
|
|
12
|
+
Lifeline,
|
|
13
|
+
Message,
|
|
14
|
+
SourceLocation,
|
|
15
|
+
Layout,
|
|
16
|
+
EdgeLayout,
|
|
17
|
+
DiffStatus,
|
|
18
|
+
Visibility,
|
|
19
|
+
ClassKind,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Project",
|
|
24
|
+
"Package",
|
|
25
|
+
"Class",
|
|
26
|
+
"Attribute",
|
|
27
|
+
"Operation",
|
|
28
|
+
"Parameter",
|
|
29
|
+
"Activity",
|
|
30
|
+
"ActivityNode",
|
|
31
|
+
"ActivityEdge",
|
|
32
|
+
"Sequence",
|
|
33
|
+
"Lifeline",
|
|
34
|
+
"Message",
|
|
35
|
+
"SourceLocation",
|
|
36
|
+
"Layout",
|
|
37
|
+
"EdgeLayout",
|
|
38
|
+
"DiffStatus",
|
|
39
|
+
"Visibility",
|
|
40
|
+
"ClassKind",
|
|
41
|
+
]
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Parse `@cdec` annotation comments into `RuleAnnotation`s.
|
|
2
|
+
|
|
3
|
+
The tag carrier for languages with no user-extensible decorator or attribute
|
|
4
|
+
syntax — currently Lua and Odin (see the `ANNOTATION_MARKER` note in
|
|
5
|
+
`code_constraints.core.rules` for why neither can use a no-op shim declaration).
|
|
6
|
+
A tag is a namespaced comment sitting directly above the declaration:
|
|
7
|
+
|
|
8
|
+
---@cdec sealed (Lua)
|
|
9
|
+
---@cdec locked(reason = "agreed", owner = "ann")
|
|
10
|
+
//@cdec layer("domain") (Odin)
|
|
11
|
+
//@cdec no_instantiation(allow = ["Builder"])
|
|
12
|
+
|
|
13
|
+
Shared by `code_constraints.{lua,odin}.rules_extract` so "what counts as a rule"
|
|
14
|
+
stays identical across the two, exactly as `rules_extract` is shared between each
|
|
15
|
+
language's UML parser and its enforce/lock analyzers.
|
|
16
|
+
|
|
17
|
+
Like `core.tags`, the parser is deliberately forgiving: an unparseable annotation
|
|
18
|
+
is skipped rather than raising, so a typo never fails a whole parse.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import ast
|
|
24
|
+
import re
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
from code_constraints.core.model import RuleAnnotation
|
|
28
|
+
from code_constraints.core.rules import ANNOTATION_MARKER, by_annotation_name
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from tree_sitter import Node
|
|
32
|
+
|
|
33
|
+
# `@cdec <name>` optionally followed by an argument list. Leading comment
|
|
34
|
+
# punctuation (`--`, `//`, `-`, whitespace) is skipped by searching for the
|
|
35
|
+
# marker rather than anchoring at the line start.
|
|
36
|
+
_ANNOTATION_RE = re.compile(
|
|
37
|
+
re.escape(ANNOTATION_MARKER) + r"\s+([A-Za-z_][A-Za-z0-9_]*)\s*(\(.*)?$",
|
|
38
|
+
re.DOTALL,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_annotation(text: str) -> RuleAnnotation | None:
|
|
43
|
+
"""Parse one comment's text into a `RuleAnnotation`, or None if it isn't a
|
|
44
|
+
recognised `@cdec` tag."""
|
|
45
|
+
idx = text.find(ANNOTATION_MARKER)
|
|
46
|
+
if idx < 0:
|
|
47
|
+
return None
|
|
48
|
+
match = _ANNOTATION_RE.search(text, idx)
|
|
49
|
+
if match is None:
|
|
50
|
+
return None
|
|
51
|
+
spec = by_annotation_name(match.group(1))
|
|
52
|
+
if spec is None:
|
|
53
|
+
return None
|
|
54
|
+
args, kwargs = _parse_args(match.group(2) or "")
|
|
55
|
+
return RuleAnnotation(name=spec.id, args=args, kwargs=kwargs)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def rules_from_comments(texts: list[str]) -> list[RuleAnnotation]:
|
|
59
|
+
"""Parse a run of comment texts (outermost first) into rule annotations,
|
|
60
|
+
skipping every line that isn't a `@cdec` tag."""
|
|
61
|
+
out: list[RuleAnnotation] = []
|
|
62
|
+
for text in texts:
|
|
63
|
+
rule = parse_annotation(text)
|
|
64
|
+
if rule is not None:
|
|
65
|
+
out.append(rule)
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def literal_set(source_text: str) -> set[str]:
|
|
70
|
+
"""Parse a rule argument holding a list of names into a set of strings.
|
|
71
|
+
|
|
72
|
+
Handles every shape the languages here produce for `allow=` / `creates=`,
|
|
73
|
+
because each is also a Python literal: `["Money"]` (Odin, Julia, C#),
|
|
74
|
+
`{"Money"}` (Lua table), `['Money']` (Python). Tolerant by design — an
|
|
75
|
+
unparseable value yields an empty set rather than failing the run, matching
|
|
76
|
+
`python.conformance._str_list`.
|
|
77
|
+
"""
|
|
78
|
+
if not source_text:
|
|
79
|
+
return set()
|
|
80
|
+
try:
|
|
81
|
+
value = ast.literal_eval(source_text.strip())
|
|
82
|
+
except (ValueError, SyntaxError):
|
|
83
|
+
return set()
|
|
84
|
+
if isinstance(value, (list, tuple, set)):
|
|
85
|
+
return {str(v) for v in value}
|
|
86
|
+
return {str(value)}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def rules_before_node(node: "Node", source: bytes) -> list[RuleAnnotation]:
|
|
90
|
+
"""Collect `@cdec` tags from the comment block directly above `node`.
|
|
91
|
+
|
|
92
|
+
Walks previous siblings while they are `comment` nodes on consecutive lines,
|
|
93
|
+
so the block reads like a stack of decorators. Line adjacency is required:
|
|
94
|
+
a comment separated from the declaration by a blank line is prose, not a tag
|
|
95
|
+
(tree-sitter emits no node for blank lines, so sibling order alone can't tell
|
|
96
|
+
the two apart).
|
|
97
|
+
"""
|
|
98
|
+
texts: list[str] = []
|
|
99
|
+
expected_line = node.start_point[0] - 1
|
|
100
|
+
current = node.prev_sibling
|
|
101
|
+
while current is not None and current.type == "comment":
|
|
102
|
+
if current.end_point[0] != expected_line:
|
|
103
|
+
break
|
|
104
|
+
texts.append(source[current.start_byte : current.end_byte].decode("utf-8", "replace"))
|
|
105
|
+
expected_line = current.start_point[0] - 1
|
|
106
|
+
current = current.prev_sibling
|
|
107
|
+
# Walked bottom-up; restore source order so stacked tags keep their order.
|
|
108
|
+
texts.reverse()
|
|
109
|
+
return rules_from_comments(texts)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _parse_args(raw: str) -> tuple[list[str], dict[str, str]]:
|
|
113
|
+
"""Split `(a, b = c)` into positional and keyword argument *source text*.
|
|
114
|
+
|
|
115
|
+
Values are kept verbatim (quotes and brackets included) to match the
|
|
116
|
+
`RuleAnnotation` contract used by the Python and C# extractors, so consumers
|
|
117
|
+
like `conformance._str_list` and `layer_dependencies._layer_of` read them the
|
|
118
|
+
same way regardless of source language.
|
|
119
|
+
"""
|
|
120
|
+
args: list[str] = []
|
|
121
|
+
kwargs: dict[str, str] = {}
|
|
122
|
+
inner = _balanced_inner(raw)
|
|
123
|
+
if not inner.strip():
|
|
124
|
+
return args, kwargs
|
|
125
|
+
|
|
126
|
+
for part in _split_top_level(inner):
|
|
127
|
+
part = part.strip()
|
|
128
|
+
if not part:
|
|
129
|
+
continue
|
|
130
|
+
name, sep, value = _split_keyword(part)
|
|
131
|
+
if sep:
|
|
132
|
+
kwargs[name] = value.strip()
|
|
133
|
+
else:
|
|
134
|
+
args.append(part)
|
|
135
|
+
return args, kwargs
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _balanced_inner(raw: str) -> str:
|
|
139
|
+
"""Return the contents of the leading balanced `(...)` group in `raw`.
|
|
140
|
+
|
|
141
|
+
Stops at the matching close paren so a trailing comment after the annotation
|
|
142
|
+
(`---@cdec layer("domain") -- the core`) doesn't leak into the arguments.
|
|
143
|
+
"""
|
|
144
|
+
raw = raw.lstrip()
|
|
145
|
+
if not raw.startswith("("):
|
|
146
|
+
return ""
|
|
147
|
+
depth = 0
|
|
148
|
+
quote: str | None = None
|
|
149
|
+
for i, ch in enumerate(raw):
|
|
150
|
+
if quote is not None:
|
|
151
|
+
if ch == quote and raw[i - 1 : i] != "\\":
|
|
152
|
+
quote = None
|
|
153
|
+
continue
|
|
154
|
+
if ch in "\"'":
|
|
155
|
+
quote = ch
|
|
156
|
+
elif ch in "([{":
|
|
157
|
+
depth += 1
|
|
158
|
+
elif ch in ")]}":
|
|
159
|
+
depth -= 1
|
|
160
|
+
if depth == 0:
|
|
161
|
+
return raw[1:i]
|
|
162
|
+
# Unbalanced: take everything after the opening paren rather than dropping
|
|
163
|
+
# the tag entirely.
|
|
164
|
+
return raw[1:]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _split_top_level(inner: str) -> list[str]:
|
|
168
|
+
"""Split on commas that are not nested inside brackets or quotes."""
|
|
169
|
+
parts: list[str] = []
|
|
170
|
+
depth = 0
|
|
171
|
+
quote: str | None = None
|
|
172
|
+
start = 0
|
|
173
|
+
for i, ch in enumerate(inner):
|
|
174
|
+
if quote is not None:
|
|
175
|
+
if ch == quote and inner[i - 1 : i] != "\\":
|
|
176
|
+
quote = None
|
|
177
|
+
continue
|
|
178
|
+
if ch in "\"'":
|
|
179
|
+
quote = ch
|
|
180
|
+
elif ch in "([{":
|
|
181
|
+
depth += 1
|
|
182
|
+
elif ch in ")]}":
|
|
183
|
+
depth -= 1
|
|
184
|
+
elif ch == "," and depth == 0:
|
|
185
|
+
parts.append(inner[start:i])
|
|
186
|
+
start = i + 1
|
|
187
|
+
parts.append(inner[start:])
|
|
188
|
+
return parts
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _split_keyword(part: str) -> tuple[str, bool, str]:
|
|
192
|
+
"""Split `name = value` at the first top-level `=`.
|
|
193
|
+
|
|
194
|
+
Returns `(name, is_keyword, value)`. Comparison operators (`==`, `!=`, `<=`,
|
|
195
|
+
`>=`) are not keyword separators.
|
|
196
|
+
"""
|
|
197
|
+
depth = 0
|
|
198
|
+
quote: str | None = None
|
|
199
|
+
for i, ch in enumerate(part):
|
|
200
|
+
if quote is not None:
|
|
201
|
+
if ch == quote and part[i - 1 : i] != "\\":
|
|
202
|
+
quote = None
|
|
203
|
+
continue
|
|
204
|
+
if ch in "\"'":
|
|
205
|
+
quote = ch
|
|
206
|
+
elif ch in "([{":
|
|
207
|
+
depth += 1
|
|
208
|
+
elif ch in ")]}":
|
|
209
|
+
depth -= 1
|
|
210
|
+
elif ch == "=" and depth == 0:
|
|
211
|
+
if part[i + 1 : i + 2] == "=" or part[i - 1 : i] in ("!", "<", ">", "="):
|
|
212
|
+
continue
|
|
213
|
+
name = part[:i].strip()
|
|
214
|
+
if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
|
|
215
|
+
return name, True, part[i + 1 :]
|
|
216
|
+
return "", False, part
|
|
217
|
+
return "", False, part
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Resolve a typed attribute to a project class for association edges.
|
|
2
|
+
|
|
3
|
+
Shared by every graph builder in `graph_model.py` so the class and package
|
|
4
|
+
canvases apply the same wrapper-stripping and lookup rules.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from code_constraints.core.model import Project
|
|
10
|
+
|
|
11
|
+
# Common .NET / Python collection wrappers whose inner type is the real
|
|
12
|
+
# association target. Order does not matter — these are matched by exact head.
|
|
13
|
+
COLLECTION_WRAPPERS = (
|
|
14
|
+
"List", "IList", "IReadOnlyList", "ICollection", "IReadOnlyCollection",
|
|
15
|
+
"IEnumerable", "HashSet", "ISet", "Queue", "Stack",
|
|
16
|
+
"ObservableCollection", "ConcurrentBag", "ConcurrentQueue",
|
|
17
|
+
"list", "set", "tuple", "frozenset", "Iterable", "Sequence", "Collection",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_DICTIONARY_HEADS = (
|
|
21
|
+
"Dictionary", "IDictionary", "IReadOnlyDictionary", "dict", "Map", "Mapping",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Single-arg unwrap heads (Python typing). `Optional[T]` collapses to T.
|
|
25
|
+
_OPTIONAL_HEADS = ("Optional",)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_class_index(project: Project) -> dict[str, str]:
|
|
29
|
+
"""Map both qualified and (when unambiguous) short class names → qualified name.
|
|
30
|
+
|
|
31
|
+
Cached on the Project instance so repeated callers share the work.
|
|
32
|
+
"""
|
|
33
|
+
cached: dict[str, str] | None = getattr(project, "_assoc_index", None)
|
|
34
|
+
if cached is not None:
|
|
35
|
+
return cached
|
|
36
|
+
|
|
37
|
+
index: dict[str, str] = {}
|
|
38
|
+
short_counts: dict[str, int] = {}
|
|
39
|
+
for cls in project.iter_classes():
|
|
40
|
+
index[cls.qualified_name] = cls.qualified_name
|
|
41
|
+
short_counts[cls.name] = short_counts.get(cls.name, 0) + 1
|
|
42
|
+
for cls in project.iter_classes():
|
|
43
|
+
if short_counts[cls.name] == 1:
|
|
44
|
+
index.setdefault(cls.name, cls.qualified_name)
|
|
45
|
+
|
|
46
|
+
object.__setattr__(project, "_assoc_index", index)
|
|
47
|
+
return index
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def resolve_association(raw_type: str, project: Project) -> tuple[str | None, str]:
|
|
51
|
+
"""Return (target qualified name, multiplicity) for an attribute type, or
|
|
52
|
+
(None, "") if it doesn't reference a project class.
|
|
53
|
+
|
|
54
|
+
Multiplicity is "*" for arrays / collections / dictionaries and "" otherwise.
|
|
55
|
+
"""
|
|
56
|
+
if not raw_type:
|
|
57
|
+
return None, ""
|
|
58
|
+
|
|
59
|
+
candidate = raw_type.strip()
|
|
60
|
+
multiplicity = ""
|
|
61
|
+
|
|
62
|
+
if candidate.endswith("?"):
|
|
63
|
+
candidate = candidate[:-1].strip()
|
|
64
|
+
|
|
65
|
+
while candidate.endswith("[]") or candidate.endswith("[,]"):
|
|
66
|
+
candidate = candidate.rsplit("[", 1)[0].strip()
|
|
67
|
+
multiplicity = "*"
|
|
68
|
+
|
|
69
|
+
if "<" in candidate and candidate.endswith(">"):
|
|
70
|
+
head, _, rest = candidate.partition("<")
|
|
71
|
+
inner = rest[:-1]
|
|
72
|
+
head = head.strip()
|
|
73
|
+
if head in COLLECTION_WRAPPERS or head.split(".")[-1] in COLLECTION_WRAPPERS:
|
|
74
|
+
multiplicity = "*"
|
|
75
|
+
candidate = _split_generic_args(inner)[0].strip()
|
|
76
|
+
elif head in _DICTIONARY_HEADS:
|
|
77
|
+
multiplicity = "*"
|
|
78
|
+
args = _split_generic_args(inner)
|
|
79
|
+
candidate = args[-1].strip() if args else ""
|
|
80
|
+
else:
|
|
81
|
+
candidate = head
|
|
82
|
+
|
|
83
|
+
# Python 3.9+ subscript generics: `list[T]`, `dict[K,V]`, `Optional[T]`.
|
|
84
|
+
# Mirrors the `<>` block above but with `[]` brackets.
|
|
85
|
+
elif "[" in candidate and candidate.endswith("]"):
|
|
86
|
+
head, _, rest = candidate.partition("[")
|
|
87
|
+
inner = rest[:-1]
|
|
88
|
+
head = head.strip()
|
|
89
|
+
if head in COLLECTION_WRAPPERS or head.split(".")[-1] in COLLECTION_WRAPPERS:
|
|
90
|
+
multiplicity = "*"
|
|
91
|
+
candidate = _split_generic_args(inner)[0].strip()
|
|
92
|
+
elif head in _DICTIONARY_HEADS:
|
|
93
|
+
multiplicity = "*"
|
|
94
|
+
args = _split_generic_args(inner)
|
|
95
|
+
candidate = args[-1].strip() if args else ""
|
|
96
|
+
elif head in _OPTIONAL_HEADS:
|
|
97
|
+
# Optional[T] is just T with allow-None semantics — keep
|
|
98
|
+
# multiplicity unchanged ("" by default).
|
|
99
|
+
args = _split_generic_args(inner)
|
|
100
|
+
candidate = args[0].strip() if args else ""
|
|
101
|
+
else:
|
|
102
|
+
candidate = head
|
|
103
|
+
|
|
104
|
+
candidate = candidate.split(".")[-1] if candidate else candidate
|
|
105
|
+
if not candidate:
|
|
106
|
+
return None, ""
|
|
107
|
+
|
|
108
|
+
index = build_class_index(project)
|
|
109
|
+
target = index.get(candidate) or index.get(raw_type)
|
|
110
|
+
if target is None:
|
|
111
|
+
return None, ""
|
|
112
|
+
return target, multiplicity
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _split_generic_args(inner: str) -> list[str]:
|
|
116
|
+
"""Split a comma-separated generic-arg list, respecting nested <> and []."""
|
|
117
|
+
out: list[str] = []
|
|
118
|
+
depth = 0
|
|
119
|
+
current: list[str] = []
|
|
120
|
+
for ch in inner:
|
|
121
|
+
if ch in ("<", "["):
|
|
122
|
+
depth += 1
|
|
123
|
+
current.append(ch)
|
|
124
|
+
elif ch in (">", "]"):
|
|
125
|
+
depth -= 1
|
|
126
|
+
current.append(ch)
|
|
127
|
+
elif ch == "," and depth == 0:
|
|
128
|
+
out.append("".join(current))
|
|
129
|
+
current = []
|
|
130
|
+
else:
|
|
131
|
+
current.append(ch)
|
|
132
|
+
if current:
|
|
133
|
+
out.append("".join(current))
|
|
134
|
+
return out
|