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,471 @@
|
|
|
1
|
+
"""Parse an Odin project (directory of .odin files) into a `code_constraints.core.Project`.
|
|
2
|
+
|
|
3
|
+
Uses `tree_sitter` with the `tree_sitter_odin` grammar. Like the C# and
|
|
4
|
+
TypeScript parsers this is a syntactic parse — there is no semantic resolution,
|
|
5
|
+
so a type reference through an import alias appears as the alias.
|
|
6
|
+
|
|
7
|
+
Two mapping decisions worth knowing, both forced by Odin having no classes:
|
|
8
|
+
|
|
9
|
+
**Procedures become operations of their receiver.** Odin declares procedures at
|
|
10
|
+
package scope, so `scale :: proc(v: ^Vec, k: f64)` is modelled as the operation
|
|
11
|
+
`scale` on `Vec` — the receiver is the first parameter, with `^`/`[]`/`[dynamic]`
|
|
12
|
+
wrappers stripped. This is what makes `//@cdec locked` on a procedure constrain
|
|
13
|
+
the method a reader would expect. Because a procedure can be declared in a
|
|
14
|
+
different file from its struct, the walk is **two-phase**: every file is parsed
|
|
15
|
+
and its structs registered first, then procedures are attached.
|
|
16
|
+
|
|
17
|
+
**Free procedures become a `static` class.** A procedure whose first parameter
|
|
18
|
+
isn't a project struct has no receiver, so it lands on a synthetic class named
|
|
19
|
+
after its file stem with `kind="static"` — the UML utility-class idiom. Without
|
|
20
|
+
this, a tag on a free procedure would be silently dropped.
|
|
21
|
+
|
|
22
|
+
Odin's other subtype mechanism, struct embedding (`using base: Base`), maps to
|
|
23
|
+
`bases`, so embedded structs draw as inheritance.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
import tree_sitter_odin
|
|
31
|
+
from tree_sitter import Language, Node, Parser
|
|
32
|
+
|
|
33
|
+
from code_constraints.core.model import (
|
|
34
|
+
Attribute,
|
|
35
|
+
Class,
|
|
36
|
+
Operation,
|
|
37
|
+
Package,
|
|
38
|
+
Parameter,
|
|
39
|
+
Project,
|
|
40
|
+
SourceLocation,
|
|
41
|
+
Visibility,
|
|
42
|
+
)
|
|
43
|
+
from code_constraints.core.rules import SHIM_FILENAMES
|
|
44
|
+
from code_constraints.odin.rules_extract import extract_rules
|
|
45
|
+
|
|
46
|
+
_LANG = Language(tree_sitter_odin.language())
|
|
47
|
+
_PARSER = Parser(_LANG)
|
|
48
|
+
|
|
49
|
+
_SKIP_DIR_NAMES = {".git", "build", "bin", "out", ".cdec_cache"}
|
|
50
|
+
|
|
51
|
+
_TYPE_DECL_TYPES = {
|
|
52
|
+
"struct_declaration": "struct",
|
|
53
|
+
"enum_declaration": "enum",
|
|
54
|
+
"union_declaration": "class",
|
|
55
|
+
"bit_field_declaration": "struct",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# Type wrappers stripped when resolving a parameter type to a receiver struct.
|
|
59
|
+
_TYPE_PREFIXES = ("^", "[dynamic]", "[]", "*")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_project(root: str | Path) -> Project:
|
|
63
|
+
root_path = Path(root).resolve()
|
|
64
|
+
if not root_path.is_dir():
|
|
65
|
+
raise ValueError(f"not a directory: {root_path}")
|
|
66
|
+
|
|
67
|
+
project = Project(source_language="odin", root_path=str(root_path))
|
|
68
|
+
package_index: dict[str, Package] = {}
|
|
69
|
+
# (package_qn, struct name) -> Class, for receiver resolution in phase 2.
|
|
70
|
+
class_index: dict[tuple[str, str], Class] = {}
|
|
71
|
+
pending: list[_PendingProc] = []
|
|
72
|
+
|
|
73
|
+
for odin_file in sorted(root_path.rglob("*.odin")):
|
|
74
|
+
if _should_skip(odin_file):
|
|
75
|
+
continue
|
|
76
|
+
_parse_file(odin_file, root_path, project, package_index, class_index, pending)
|
|
77
|
+
|
|
78
|
+
_attach_procedures(pending, project, package_index, class_index)
|
|
79
|
+
return project
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _should_skip(p: Path) -> bool:
|
|
83
|
+
if p.name in SHIM_FILENAMES:
|
|
84
|
+
return True
|
|
85
|
+
return any(part in _SKIP_DIR_NAMES for part in p.parts)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _PendingProc:
|
|
89
|
+
"""A parsed procedure awaiting receiver resolution in phase 2."""
|
|
90
|
+
|
|
91
|
+
__slots__ = ("node", "source", "package_qn", "file", "stem")
|
|
92
|
+
|
|
93
|
+
def __init__(self, node: Node, source: bytes, package_qn: str, file: str, stem: str):
|
|
94
|
+
self.node = node
|
|
95
|
+
self.source = source
|
|
96
|
+
self.package_qn = package_qn
|
|
97
|
+
self.file = file
|
|
98
|
+
self.stem = stem
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _parse_file(
|
|
102
|
+
file: Path,
|
|
103
|
+
root: Path,
|
|
104
|
+
project: Project,
|
|
105
|
+
package_index: dict[str, Package],
|
|
106
|
+
class_index: dict[tuple[str, str], Class],
|
|
107
|
+
pending: list[_PendingProc],
|
|
108
|
+
) -> None:
|
|
109
|
+
source = file.read_bytes()
|
|
110
|
+
tree = _PARSER.parse(source)
|
|
111
|
+
rel = file.relative_to(root)
|
|
112
|
+
file_str = rel.as_posix()
|
|
113
|
+
package_qn = _package_name(tree.root_node, source, rel)
|
|
114
|
+
_ensure_package(project, package_qn, package_index)
|
|
115
|
+
|
|
116
|
+
for child in tree.root_node.named_children:
|
|
117
|
+
if child.type in _TYPE_DECL_TYPES:
|
|
118
|
+
cls = _ingest_type(child, package_qn, source, file_str)
|
|
119
|
+
if cls is None:
|
|
120
|
+
continue
|
|
121
|
+
pkg = _ensure_package(project, package_qn, package_index)
|
|
122
|
+
pkg.classes.append(cls)
|
|
123
|
+
class_index[(package_qn, cls.name)] = cls
|
|
124
|
+
elif child.type == "procedure_declaration":
|
|
125
|
+
pending.append(_PendingProc(child, source, package_qn, file_str, rel.stem))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _package_name(root: Node, source: bytes, rel: Path) -> str:
|
|
129
|
+
"""Package qualified name: the directory path, or the declared `package`
|
|
130
|
+
name for files at the project root.
|
|
131
|
+
|
|
132
|
+
Odin already scopes one package per directory, so the directory path gives
|
|
133
|
+
the same grouping with the nesting the package diagram wants.
|
|
134
|
+
"""
|
|
135
|
+
parts = list(rel.parts[:-1])
|
|
136
|
+
if parts:
|
|
137
|
+
return ".".join(parts)
|
|
138
|
+
for child in root.named_children:
|
|
139
|
+
if child.type == "package_declaration":
|
|
140
|
+
ident = next((c for c in child.children if c.type == "identifier"), None)
|
|
141
|
+
if ident is not None:
|
|
142
|
+
return _text(ident, source)
|
|
143
|
+
return "__root__"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _ensure_package(
|
|
147
|
+
project: Project, qualified_name: str, index: dict[str, Package]
|
|
148
|
+
) -> Package:
|
|
149
|
+
if qualified_name in index:
|
|
150
|
+
return index[qualified_name]
|
|
151
|
+
if qualified_name == "__root__":
|
|
152
|
+
pkg = Package(name="__root__", qualified_name="__root__")
|
|
153
|
+
project.packages.append(pkg)
|
|
154
|
+
index[qualified_name] = pkg
|
|
155
|
+
return pkg
|
|
156
|
+
parts = qualified_name.split(".")
|
|
157
|
+
if len(parts) == 1:
|
|
158
|
+
pkg = Package(name=parts[0], qualified_name=qualified_name)
|
|
159
|
+
project.packages.append(pkg)
|
|
160
|
+
else:
|
|
161
|
+
parent = _ensure_package(project, ".".join(parts[:-1]), index)
|
|
162
|
+
pkg = Package(name=parts[-1], qualified_name=qualified_name)
|
|
163
|
+
parent.sub_packages.append(pkg)
|
|
164
|
+
index[qualified_name] = pkg
|
|
165
|
+
return pkg
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------- type declarations ----------
|
|
169
|
+
|
|
170
|
+
def _ingest_type(node: Node, package_qn: str, source: bytes, file_str: str) -> Class | None:
|
|
171
|
+
name_node = next((c for c in node.children if c.type == "identifier"), None)
|
|
172
|
+
if name_node is None:
|
|
173
|
+
return None
|
|
174
|
+
name = _text(name_node, source)
|
|
175
|
+
qn = f"{package_qn}.{name}" if package_qn != "__root__" else name
|
|
176
|
+
|
|
177
|
+
cls = Class(
|
|
178
|
+
name=name,
|
|
179
|
+
qualified_name=qn,
|
|
180
|
+
kind=_TYPE_DECL_TYPES[node.type], # type: ignore[arg-type]
|
|
181
|
+
location=SourceLocation(
|
|
182
|
+
file=file_str,
|
|
183
|
+
start_line=node.start_point[0] + 1,
|
|
184
|
+
end_line=node.end_point[0] + 1,
|
|
185
|
+
),
|
|
186
|
+
rules=extract_rules(node, source),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
if node.type == "enum_declaration":
|
|
190
|
+
_ingest_enum_members(node, cls, source)
|
|
191
|
+
elif node.type == "union_declaration":
|
|
192
|
+
_ingest_union_variants(node, cls, source)
|
|
193
|
+
else:
|
|
194
|
+
_ingest_fields(node, cls, source)
|
|
195
|
+
|
|
196
|
+
cls.dependencies = _dedupe(cls.dependencies, drop=cls.name)
|
|
197
|
+
return cls
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _ingest_fields(node: Node, cls: Class, source: bytes) -> None:
|
|
201
|
+
for field in node.named_children:
|
|
202
|
+
if field.type != "field":
|
|
203
|
+
continue
|
|
204
|
+
type_node = next((c for c in field.children if c.type == "type"), None)
|
|
205
|
+
type_text = _text(type_node, source) if type_node else ""
|
|
206
|
+
embedded = any(c.type == "using" or _text(c, source) == "using" for c in field.children)
|
|
207
|
+
names = [
|
|
208
|
+
_text(c, source)
|
|
209
|
+
for c in field.children
|
|
210
|
+
if c.type == "identifier"
|
|
211
|
+
]
|
|
212
|
+
if embedded:
|
|
213
|
+
# `using base: Base` is Odin's subtype embedding — model it as
|
|
214
|
+
# inheritance rather than as a plain field.
|
|
215
|
+
if type_text:
|
|
216
|
+
cls.bases.append(_base_name(type_text))
|
|
217
|
+
continue
|
|
218
|
+
for field_name in names:
|
|
219
|
+
cls.attributes.append(
|
|
220
|
+
Attribute(
|
|
221
|
+
name=field_name,
|
|
222
|
+
type=type_text,
|
|
223
|
+
visibility=Visibility.PUBLIC,
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
if type_text:
|
|
227
|
+
cls.dependencies.append(_strip_wrappers(type_text))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _ingest_enum_members(node: Node, cls: Class, source: bytes) -> None:
|
|
231
|
+
started = False
|
|
232
|
+
for child in node.children:
|
|
233
|
+
if child.type == "{":
|
|
234
|
+
started = True
|
|
235
|
+
continue
|
|
236
|
+
if not started or child.type == "}":
|
|
237
|
+
continue
|
|
238
|
+
if child.type == "identifier":
|
|
239
|
+
cls.attributes.append(
|
|
240
|
+
Attribute(
|
|
241
|
+
name=_text(child, source),
|
|
242
|
+
type=cls.name,
|
|
243
|
+
visibility=Visibility.PUBLIC,
|
|
244
|
+
is_static=True,
|
|
245
|
+
is_readonly=True,
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _ingest_union_variants(node: Node, cls: Class, source: bytes) -> None:
|
|
251
|
+
for child in node.named_children:
|
|
252
|
+
if child.type != "type":
|
|
253
|
+
continue
|
|
254
|
+
variant = _text(child, source)
|
|
255
|
+
cls.attributes.append(
|
|
256
|
+
Attribute(
|
|
257
|
+
name=variant,
|
|
258
|
+
type=variant,
|
|
259
|
+
visibility=Visibility.PUBLIC,
|
|
260
|
+
is_static=True,
|
|
261
|
+
is_readonly=True,
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
cls.dependencies.append(_strip_wrappers(variant))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ---------- procedures ----------
|
|
268
|
+
|
|
269
|
+
def _attach_procedures(
|
|
270
|
+
pending: list[_PendingProc],
|
|
271
|
+
project: Project,
|
|
272
|
+
package_index: dict[str, Package],
|
|
273
|
+
class_index: dict[tuple[str, str], Class],
|
|
274
|
+
) -> None:
|
|
275
|
+
"""Phase 2: attach each procedure to its receiver struct, or to the file's
|
|
276
|
+
synthetic module class."""
|
|
277
|
+
module_classes: dict[tuple[str, str], Class] = {}
|
|
278
|
+
|
|
279
|
+
for proc in pending:
|
|
280
|
+
name_node = next((c for c in proc.node.children if c.type == "identifier"), None)
|
|
281
|
+
proc_node = next((c for c in proc.node.children if c.type == "procedure"), None)
|
|
282
|
+
if name_node is None or proc_node is None:
|
|
283
|
+
continue
|
|
284
|
+
|
|
285
|
+
params = _parameters(proc_node, proc.source)
|
|
286
|
+
owner = _receiver(params, proc.package_qn, class_index)
|
|
287
|
+
operation = Operation(
|
|
288
|
+
name=_text(name_node, proc.source),
|
|
289
|
+
# The receiver is `self` in UML terms, so drop it from the signature.
|
|
290
|
+
parameters=params[1:] if owner is not None else params,
|
|
291
|
+
return_type=_return_type(proc_node, proc.source),
|
|
292
|
+
visibility=_visibility(proc.node, proc.source),
|
|
293
|
+
is_static=owner is None,
|
|
294
|
+
rules=extract_rules(proc.node, proc.source),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
if owner is None:
|
|
298
|
+
owner = _module_class(proc, project, package_index, module_classes)
|
|
299
|
+
owner.operations.append(operation)
|
|
300
|
+
owner.dependencies.extend(_body_type_refs(proc_node, proc.source))
|
|
301
|
+
owner.dependencies = _dedupe(owner.dependencies, drop=owner.name)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _module_class(
|
|
305
|
+
proc: _PendingProc,
|
|
306
|
+
project: Project,
|
|
307
|
+
package_index: dict[str, Package],
|
|
308
|
+
module_classes: dict[tuple[str, str], Class],
|
|
309
|
+
) -> Class:
|
|
310
|
+
"""The synthetic `static` class collecting a file's receiver-less procedures."""
|
|
311
|
+
key = (proc.package_qn, proc.stem)
|
|
312
|
+
existing = module_classes.get(key)
|
|
313
|
+
if existing is not None:
|
|
314
|
+
return existing
|
|
315
|
+
qn = f"{proc.package_qn}.{proc.stem}" if proc.package_qn != "__root__" else proc.stem
|
|
316
|
+
cls = Class(
|
|
317
|
+
name=proc.stem,
|
|
318
|
+
qualified_name=qn,
|
|
319
|
+
kind="static",
|
|
320
|
+
location=SourceLocation(file=proc.file, start_line=1, end_line=1),
|
|
321
|
+
description=f"Package-scope procedures declared in {proc.file}.",
|
|
322
|
+
)
|
|
323
|
+
_ensure_package(project, proc.package_qn, package_index).classes.append(cls)
|
|
324
|
+
module_classes[key] = cls
|
|
325
|
+
return cls
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _receiver(
|
|
329
|
+
params: list[Parameter],
|
|
330
|
+
package_qn: str,
|
|
331
|
+
class_index: dict[tuple[str, str], Class],
|
|
332
|
+
) -> Class | None:
|
|
333
|
+
"""The struct a procedure's first parameter names, if any.
|
|
334
|
+
|
|
335
|
+
Same-package first (Odin's normal case), then any package — an unqualified
|
|
336
|
+
match across packages is the best a syntactic parse can do, and mirrors the
|
|
337
|
+
C# parser's documented lack of type resolution.
|
|
338
|
+
"""
|
|
339
|
+
base = _receiver_name(params)
|
|
340
|
+
if not base:
|
|
341
|
+
return None
|
|
342
|
+
same_package = class_index.get((package_qn, base))
|
|
343
|
+
if same_package is not None:
|
|
344
|
+
return same_package
|
|
345
|
+
matches = [cls for (_pkg, name), cls in class_index.items() if name == base]
|
|
346
|
+
return matches[0] if len(matches) == 1 else None
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _receiver_name(params: list[Parameter]) -> str:
|
|
350
|
+
"""Bare type name of a procedure's first parameter — its receiver candidate.
|
|
351
|
+
|
|
352
|
+
Shared with `odin.fingerprint` so lock target names resolve to exactly the
|
|
353
|
+
same owner the model shows.
|
|
354
|
+
"""
|
|
355
|
+
if not params:
|
|
356
|
+
return ""
|
|
357
|
+
base = _strip_wrappers(params[0].type)
|
|
358
|
+
return base.rsplit(".", 1)[-1] if base else ""
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _parameters(proc_node: Node, source: bytes) -> list[Parameter]:
|
|
362
|
+
params_node = next((c for c in proc_node.children if c.type == "parameters"), None)
|
|
363
|
+
if params_node is None:
|
|
364
|
+
return []
|
|
365
|
+
out: list[Parameter] = []
|
|
366
|
+
for param in params_node.named_children:
|
|
367
|
+
if param.type != "parameter":
|
|
368
|
+
continue
|
|
369
|
+
names = [c for c in param.children if c.type == "identifier"]
|
|
370
|
+
type_node = next((c for c in param.children if c.type == "type"), None)
|
|
371
|
+
default = _default_value(param, source)
|
|
372
|
+
type_text = _text(type_node, source) if type_node else ""
|
|
373
|
+
if not names:
|
|
374
|
+
# `proc(^Vec)` — an unnamed parameter is still positionally typed.
|
|
375
|
+
out.append(Parameter(name="", type=type_text, default=default))
|
|
376
|
+
continue
|
|
377
|
+
for name_node in names:
|
|
378
|
+
out.append(
|
|
379
|
+
Parameter(name=_text(name_node, source), type=type_text, default=default)
|
|
380
|
+
)
|
|
381
|
+
return out
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _default_value(param: Node, source: bytes) -> str | None:
|
|
385
|
+
for i, child in enumerate(param.children):
|
|
386
|
+
if child.type == "=" or _text(child, source) == "=":
|
|
387
|
+
value = next((c for c in param.children[i + 1 :] if c.is_named), None)
|
|
388
|
+
return _text(value, source) if value is not None else None
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _return_type(proc_node: Node, source: bytes) -> str:
|
|
393
|
+
"""The declared result type. Odin puts it in a `type` child after `->`, so
|
|
394
|
+
it is distinguished from parameter types by position, not by field name."""
|
|
395
|
+
seen_arrow = False
|
|
396
|
+
for child in proc_node.children:
|
|
397
|
+
if child.type == "->" or _text(child, source) == "->":
|
|
398
|
+
seen_arrow = True
|
|
399
|
+
continue
|
|
400
|
+
if seen_arrow and child.type == "type":
|
|
401
|
+
return _text(child, source)
|
|
402
|
+
return ""
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _visibility(decl: Node, source: bytes) -> Visibility:
|
|
406
|
+
attrs = next((c for c in decl.children if c.type == "attributes"), None)
|
|
407
|
+
if attrs is not None and "private" in _text(attrs, source):
|
|
408
|
+
return Visibility.PRIVATE
|
|
409
|
+
return Visibility.PUBLIC
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _body_type_refs(proc_node: Node, source: bytes) -> list[str]:
|
|
413
|
+
"""Capitalised identifiers used inside a procedure body — candidate type
|
|
414
|
+
references for association edges.
|
|
415
|
+
|
|
416
|
+
Heuristic, like the C# parser: `resolve_association` keeps only the ones that
|
|
417
|
+
match a project class, so locals and builtins drop out. Only the block is
|
|
418
|
+
walked, so parameter names and the `//@cdec` comments above never leak in.
|
|
419
|
+
"""
|
|
420
|
+
block = next((c for c in proc_node.children if c.type == "block"), None)
|
|
421
|
+
if block is None:
|
|
422
|
+
return []
|
|
423
|
+
out: list[str] = []
|
|
424
|
+
seen: set[str] = set()
|
|
425
|
+
stack = [block]
|
|
426
|
+
while stack:
|
|
427
|
+
node = stack.pop()
|
|
428
|
+
stack.extend(node.children)
|
|
429
|
+
if node.type != "identifier":
|
|
430
|
+
continue
|
|
431
|
+
text = _text(node, source)
|
|
432
|
+
if text and text[0].isupper() and text not in seen:
|
|
433
|
+
seen.add(text)
|
|
434
|
+
out.append(text)
|
|
435
|
+
return out
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
# ---------- helpers ----------
|
|
439
|
+
|
|
440
|
+
def _strip_wrappers(type_text: str) -> str:
|
|
441
|
+
"""Reduce `^Vec` / `[dynamic]Vec` / `[]Vec` to `Vec`."""
|
|
442
|
+
text = type_text.strip()
|
|
443
|
+
changed = True
|
|
444
|
+
while changed:
|
|
445
|
+
changed = False
|
|
446
|
+
for prefix in _TYPE_PREFIXES:
|
|
447
|
+
if text.startswith(prefix):
|
|
448
|
+
text = text[len(prefix) :].strip()
|
|
449
|
+
changed = True
|
|
450
|
+
return text
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _base_name(type_text: str) -> str:
|
|
454
|
+
return _strip_wrappers(type_text).rsplit(".", 1)[-1]
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _dedupe(refs: list[str], *, drop: str = "") -> list[str]:
|
|
458
|
+
out: list[str] = []
|
|
459
|
+
seen: set[str] = set()
|
|
460
|
+
for ref in refs:
|
|
461
|
+
if not ref or ref == drop or ref in seen:
|
|
462
|
+
continue
|
|
463
|
+
seen.add(ref)
|
|
464
|
+
out.append(ref)
|
|
465
|
+
return out
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _text(node: Node | None, source: bytes) -> str:
|
|
469
|
+
if node is None:
|
|
470
|
+
return ""
|
|
471
|
+
return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Recognise architectural-rule annotations on Odin declarations.
|
|
2
|
+
|
|
3
|
+
Shared by the UML parser, the `cdec enforce` conformance analyzer and the
|
|
4
|
+
`cdec lock` fingerprinter, so "what counts as a rule" stays identical across all
|
|
5
|
+
three — the same contract the Python and C# extractors hold.
|
|
6
|
+
|
|
7
|
+
Odin's `@(...)` attributes are a closed set the compiler validates, so tags ride
|
|
8
|
+
in `//@cdec ...` comments directly above the declaration instead. Parsing lives
|
|
9
|
+
in `code_constraints.core.annotations`; this module only knows where Odin puts
|
|
10
|
+
the comments.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from tree_sitter import Node
|
|
16
|
+
|
|
17
|
+
from code_constraints.core.annotations import rules_before_node
|
|
18
|
+
from code_constraints.core.model import RuleAnnotation
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def extract_rules(decl_node: Node, source: bytes) -> list[RuleAnnotation]:
|
|
22
|
+
"""Tags attached to an Odin declaration (`struct_declaration`,
|
|
23
|
+
`procedure_declaration`, …)."""
|
|
24
|
+
return rules_before_node(decl_node, source)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def comment_is_rule_tag(node: Node, source: bytes) -> bool:
|
|
28
|
+
"""True when `node` is a comment carrying a `@cdec` tag.
|
|
29
|
+
|
|
30
|
+
The fingerprinter drops these from the digest so applying or removing a lock
|
|
31
|
+
never changes the hash of the body it guards.
|
|
32
|
+
"""
|
|
33
|
+
if node.type != "comment":
|
|
34
|
+
return False
|
|
35
|
+
from code_constraints.core.annotations import parse_annotation
|
|
36
|
+
|
|
37
|
+
text = source[node.start_byte : node.end_byte].decode("utf-8", "replace")
|
|
38
|
+
return parse_annotation(text) is not None
|