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