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,523 @@
|
|
|
1
|
+
"""Parse a Julia project (directory of .jl files) into a `code_constraints.core.Project`.
|
|
2
|
+
|
|
3
|
+
Uses `tree_sitter` with the `tree_sitter_julia` grammar. Syntactic only — there
|
|
4
|
+
is no method-table or type resolution, so a type written through an alias
|
|
5
|
+
appears as the alias.
|
|
6
|
+
|
|
7
|
+
Julia has types but no methods-inside-types, so the mapping mirrors the Odin
|
|
8
|
+
parser's:
|
|
9
|
+
|
|
10
|
+
**Functions become operations of their first argument's type.** `settle(inv::Invoice,
|
|
11
|
+
rate)` is modelled as the operation `settle` on `Invoice`. That is what makes
|
|
12
|
+
`@locked function settle(inv::Invoice)` constrain the method a reader expects,
|
|
13
|
+
and it is the honest reading of single-argument dispatch. Because a function can
|
|
14
|
+
be defined in a different file from its struct, the walk is **two-phase**: all
|
|
15
|
+
type definitions are registered first, then functions are attached.
|
|
16
|
+
|
|
17
|
+
**Functions with no struct-typed first argument become a `static` class** named
|
|
18
|
+
after the enclosing module (or the file stem), the UML utility-class idiom —
|
|
19
|
+
without it, a tag on a free function would be silently dropped.
|
|
20
|
+
|
|
21
|
+
Packages come from the directory layout, with each `module X ... end` nesting
|
|
22
|
+
further inside it — the same scheme the TypeScript parser uses for `namespace`.
|
|
23
|
+
|
|
24
|
+
Inner constructors (`Invoice(id) = new(id, 0.0)` inside the struct body) are
|
|
25
|
+
ingested as operations of their own struct, so a `@locked` inner constructor is
|
|
26
|
+
lockable like any other member.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
import tree_sitter_julia
|
|
34
|
+
from tree_sitter import Language, Node, Parser
|
|
35
|
+
|
|
36
|
+
from code_constraints.core.model import (
|
|
37
|
+
Attribute,
|
|
38
|
+
Class,
|
|
39
|
+
ClassKind,
|
|
40
|
+
Operation,
|
|
41
|
+
Package,
|
|
42
|
+
Parameter,
|
|
43
|
+
RuleAnnotation,
|
|
44
|
+
Project,
|
|
45
|
+
SourceLocation,
|
|
46
|
+
Visibility,
|
|
47
|
+
)
|
|
48
|
+
from code_constraints.core.rules import SHIM_FILENAMES
|
|
49
|
+
from code_constraints.julia.rules_extract import unwrap_macros, using_has_shim
|
|
50
|
+
|
|
51
|
+
_LANG = Language(tree_sitter_julia.language())
|
|
52
|
+
_PARSER = Parser(_LANG)
|
|
53
|
+
|
|
54
|
+
_SKIP_DIR_NAMES = {".git", "docs", "deps", ".julia", "build", ".cdec_cache"}
|
|
55
|
+
|
|
56
|
+
_TYPE_DEFINITION_TYPES = {
|
|
57
|
+
"struct_definition": "struct",
|
|
58
|
+
"abstract_definition": "abstract",
|
|
59
|
+
"primitive_definition": "struct",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_project(root: str | Path) -> Project:
|
|
64
|
+
root_path = Path(root).resolve()
|
|
65
|
+
if not root_path.is_dir():
|
|
66
|
+
raise ValueError(f"not a directory: {root_path}")
|
|
67
|
+
|
|
68
|
+
project = Project(source_language="julia", root_path=str(root_path))
|
|
69
|
+
package_index: dict[str, Package] = {}
|
|
70
|
+
class_index: dict[tuple[str, str], Class] = {}
|
|
71
|
+
pending: list[_PendingFunc] = []
|
|
72
|
+
|
|
73
|
+
for jl_file in sorted(root_path.rglob("*.jl")):
|
|
74
|
+
if _should_skip(jl_file):
|
|
75
|
+
continue
|
|
76
|
+
_parse_file(jl_file, root_path, project, package_index, class_index, pending)
|
|
77
|
+
|
|
78
|
+
_attach_functions(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 _PendingFunc:
|
|
89
|
+
"""A parsed function awaiting receiver resolution in phase 2."""
|
|
90
|
+
|
|
91
|
+
__slots__ = ("node", "rules", "source", "package_qn", "file", "scope")
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
node: Node,
|
|
96
|
+
rules: list[RuleAnnotation],
|
|
97
|
+
source: bytes,
|
|
98
|
+
package_qn: str,
|
|
99
|
+
file: str,
|
|
100
|
+
scope: str,
|
|
101
|
+
):
|
|
102
|
+
self.node = node
|
|
103
|
+
self.rules = rules
|
|
104
|
+
self.source = source
|
|
105
|
+
self.package_qn = package_qn
|
|
106
|
+
self.file = file
|
|
107
|
+
self.scope = scope
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _parse_file(
|
|
111
|
+
file: Path,
|
|
112
|
+
root: Path,
|
|
113
|
+
project: Project,
|
|
114
|
+
package_index: dict[str, Package],
|
|
115
|
+
class_index: dict[tuple[str, str], Class],
|
|
116
|
+
pending: list[_PendingFunc],
|
|
117
|
+
) -> None:
|
|
118
|
+
source = file.read_bytes()
|
|
119
|
+
tree = _PARSER.parse(source)
|
|
120
|
+
rel = file.relative_to(root)
|
|
121
|
+
file_str = rel.as_posix()
|
|
122
|
+
package_qn = _qualified_package_name(rel)
|
|
123
|
+
shim = using_has_shim(tree.root_node, source)
|
|
124
|
+
_ensure_package(project, package_qn, package_index)
|
|
125
|
+
|
|
126
|
+
_visit_block(
|
|
127
|
+
tree.root_node,
|
|
128
|
+
package_qn,
|
|
129
|
+
rel.stem,
|
|
130
|
+
project,
|
|
131
|
+
package_index,
|
|
132
|
+
class_index,
|
|
133
|
+
pending,
|
|
134
|
+
source,
|
|
135
|
+
file_str,
|
|
136
|
+
shim,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _visit_block(
|
|
141
|
+
parent: Node,
|
|
142
|
+
package_qn: str,
|
|
143
|
+
scope: str,
|
|
144
|
+
project: Project,
|
|
145
|
+
package_index: dict[str, Package],
|
|
146
|
+
class_index: dict[tuple[str, str], Class],
|
|
147
|
+
pending: list[_PendingFunc],
|
|
148
|
+
source: bytes,
|
|
149
|
+
file_str: str,
|
|
150
|
+
shim: bool,
|
|
151
|
+
) -> None:
|
|
152
|
+
for child in parent.named_children:
|
|
153
|
+
rules, node = unwrap_macros(child, source, shim)
|
|
154
|
+
if node is None:
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
if node.type == "module_definition":
|
|
158
|
+
name = _module_name(node, source)
|
|
159
|
+
nested = f"{package_qn}.{name}" if package_qn != "__root__" else name
|
|
160
|
+
_ensure_package(project, nested, package_index)
|
|
161
|
+
# `scope` stays the file stem, so receiver-less functions from every
|
|
162
|
+
# module in one file share one `static` class — matching how the Odin
|
|
163
|
+
# and Lua parsers name theirs, and avoiding a `Billing.Billing` clash
|
|
164
|
+
# when the module and its package segment have the same name.
|
|
165
|
+
_visit_block(
|
|
166
|
+
node, nested, scope, project, package_index, class_index,
|
|
167
|
+
pending, source, file_str, shim,
|
|
168
|
+
)
|
|
169
|
+
elif node.type in _TYPE_DEFINITION_TYPES:
|
|
170
|
+
cls = _ingest_type(node, rules, package_qn, source, file_str)
|
|
171
|
+
if cls is None:
|
|
172
|
+
continue
|
|
173
|
+
_ensure_package(project, package_qn, package_index).classes.append(cls)
|
|
174
|
+
class_index[(package_qn, cls.name)] = cls
|
|
175
|
+
elif node.type == "function_definition" or _is_short_function(node):
|
|
176
|
+
pending.append(
|
|
177
|
+
_PendingFunc(node, rules, source, package_qn, file_str, scope)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _module_name(node: Node, source: bytes) -> str:
|
|
182
|
+
ident = next((c for c in node.children if c.type == "identifier"), None)
|
|
183
|
+
return _text(ident, source) if ident is not None else "anon"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _is_short_function(node: Node) -> bool:
|
|
187
|
+
"""`f(x::T) = ...` — an assignment whose left side is a call."""
|
|
188
|
+
if node.type != "assignment":
|
|
189
|
+
return False
|
|
190
|
+
first = node.named_children[0] if node.named_children else None
|
|
191
|
+
return first is not None and first.type == "call_expression"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ---------- type definitions ----------
|
|
195
|
+
|
|
196
|
+
def _ingest_type(
|
|
197
|
+
node: Node,
|
|
198
|
+
rules: list[RuleAnnotation],
|
|
199
|
+
package_qn: str,
|
|
200
|
+
source: bytes,
|
|
201
|
+
file_str: str,
|
|
202
|
+
) -> Class | None:
|
|
203
|
+
head = next((c for c in node.children if c.type == "type_head"), None)
|
|
204
|
+
if head is None:
|
|
205
|
+
return None
|
|
206
|
+
name, bases = _type_head_parts(head, source)
|
|
207
|
+
if not name:
|
|
208
|
+
return None
|
|
209
|
+
qn = f"{package_qn}.{name}" if package_qn != "__root__" else name
|
|
210
|
+
|
|
211
|
+
cls = Class(
|
|
212
|
+
name=name,
|
|
213
|
+
qualified_name=qn,
|
|
214
|
+
kind=_kind_for(node),
|
|
215
|
+
bases=bases,
|
|
216
|
+
location=SourceLocation(
|
|
217
|
+
file=file_str,
|
|
218
|
+
start_line=node.start_point[0] + 1,
|
|
219
|
+
end_line=node.end_point[0] + 1,
|
|
220
|
+
),
|
|
221
|
+
rules=rules,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
for member in node.named_children:
|
|
225
|
+
if member is head:
|
|
226
|
+
continue
|
|
227
|
+
if member.type == "typed_expression":
|
|
228
|
+
field, type_text = _typed_parts(member, source)
|
|
229
|
+
if field:
|
|
230
|
+
cls.attributes.append(
|
|
231
|
+
Attribute(
|
|
232
|
+
name=field,
|
|
233
|
+
type=type_text,
|
|
234
|
+
visibility=_visibility(field),
|
|
235
|
+
# A non-`mutable struct` is immutable in Julia, so its
|
|
236
|
+
# fields genuinely are read-only.
|
|
237
|
+
is_readonly=not _is_mutable(node),
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
cls.dependencies.append(_base_name(type_text))
|
|
241
|
+
elif member.type == "identifier":
|
|
242
|
+
cls.attributes.append(
|
|
243
|
+
Attribute(
|
|
244
|
+
name=_text(member, source),
|
|
245
|
+
visibility=_visibility(_text(member, source)),
|
|
246
|
+
is_readonly=not _is_mutable(node),
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
elif member.type == "function_definition" or _is_short_function(member):
|
|
250
|
+
# Inner constructor.
|
|
251
|
+
inner_rules, inner = unwrap_macros(member, source, False)
|
|
252
|
+
operation = _operation(inner or member, inner_rules, source)
|
|
253
|
+
if operation is not None:
|
|
254
|
+
cls.operations.append(operation)
|
|
255
|
+
|
|
256
|
+
cls.dependencies = _dedupe(cls.dependencies, drop=cls.name)
|
|
257
|
+
return cls
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _kind_for(node: Node) -> ClassKind:
|
|
261
|
+
if node.type == "abstract_definition":
|
|
262
|
+
return "abstract"
|
|
263
|
+
return "struct"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _is_mutable(node: Node) -> bool:
|
|
267
|
+
return any(c.type == "mutable" or c.text == b"mutable" for c in node.children)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _type_head_parts(head: Node, source: bytes) -> tuple[str, list[str]]:
|
|
271
|
+
"""`Invoice <: AbstractInvoice` -> ("Invoice", ["AbstractInvoice"])."""
|
|
272
|
+
inner = head.named_children[0] if head.named_children else None
|
|
273
|
+
if inner is None:
|
|
274
|
+
return _text(head, source), []
|
|
275
|
+
if inner.type == "binary_expression":
|
|
276
|
+
parts = [c for c in inner.named_children if c.type != "operator"]
|
|
277
|
+
if len(parts) >= 2:
|
|
278
|
+
return _bare_type_name(parts[0], source), [_bare_type_name(parts[-1], source)]
|
|
279
|
+
return _bare_type_name(inner, source), []
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _bare_type_name(node: Node, source: bytes) -> str:
|
|
283
|
+
"""Drop type parameters: `Draft{T}` -> `Draft`."""
|
|
284
|
+
if node.type == "parametrized_type_expression":
|
|
285
|
+
ident = node.named_children[0] if node.named_children else None
|
|
286
|
+
return _text(ident, source) if ident is not None else ""
|
|
287
|
+
return _text(node, source)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _typed_parts(node: Node, source: bytes) -> tuple[str, str]:
|
|
291
|
+
"""`id::String` -> ("id", "String")."""
|
|
292
|
+
named = node.named_children
|
|
293
|
+
if len(named) < 2:
|
|
294
|
+
return _text(node, source), ""
|
|
295
|
+
return _text(named[0], source), _text(named[-1], source)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ---------- functions ----------
|
|
299
|
+
|
|
300
|
+
def _attach_functions(
|
|
301
|
+
pending: list[_PendingFunc],
|
|
302
|
+
project: Project,
|
|
303
|
+
package_index: dict[str, Package],
|
|
304
|
+
class_index: dict[tuple[str, str], Class],
|
|
305
|
+
) -> None:
|
|
306
|
+
module_classes: dict[tuple[str, str], Class] = {}
|
|
307
|
+
|
|
308
|
+
for func in pending:
|
|
309
|
+
operation = _operation(func.node, func.rules, func.source)
|
|
310
|
+
if operation is None:
|
|
311
|
+
continue
|
|
312
|
+
owner = _receiver(operation.parameters, func.package_qn, class_index)
|
|
313
|
+
if owner is not None:
|
|
314
|
+
# The receiver is `self` in UML terms; drop it from the signature.
|
|
315
|
+
operation.parameters = operation.parameters[1:]
|
|
316
|
+
else:
|
|
317
|
+
operation.is_static = True
|
|
318
|
+
owner = _module_class(func, project, package_index, module_classes)
|
|
319
|
+
owner.operations.append(operation)
|
|
320
|
+
owner.dependencies.extend(_body_type_refs(func.node, func.source))
|
|
321
|
+
owner.dependencies = _dedupe(owner.dependencies, drop=owner.name)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _module_class(
|
|
325
|
+
func: _PendingFunc,
|
|
326
|
+
project: Project,
|
|
327
|
+
package_index: dict[str, Package],
|
|
328
|
+
module_classes: dict[tuple[str, str], Class],
|
|
329
|
+
) -> Class:
|
|
330
|
+
key = (func.package_qn, func.scope)
|
|
331
|
+
existing = module_classes.get(key)
|
|
332
|
+
if existing is not None:
|
|
333
|
+
return existing
|
|
334
|
+
qn = (
|
|
335
|
+
f"{func.package_qn}.{func.scope}"
|
|
336
|
+
if func.package_qn != "__root__"
|
|
337
|
+
else func.scope
|
|
338
|
+
)
|
|
339
|
+
cls = Class(
|
|
340
|
+
name=func.scope,
|
|
341
|
+
qualified_name=qn,
|
|
342
|
+
kind="static",
|
|
343
|
+
location=SourceLocation(file=func.file, start_line=1, end_line=1),
|
|
344
|
+
description=f"Functions with no struct receiver, declared in {func.file}.",
|
|
345
|
+
)
|
|
346
|
+
_ensure_package(project, func.package_qn, package_index).classes.append(cls)
|
|
347
|
+
module_classes[key] = cls
|
|
348
|
+
return cls
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _receiver(
|
|
352
|
+
params: list[Parameter],
|
|
353
|
+
package_qn: str,
|
|
354
|
+
class_index: dict[tuple[str, str], Class],
|
|
355
|
+
) -> Class | None:
|
|
356
|
+
"""The struct a function's first argument is annotated with, if any."""
|
|
357
|
+
if not params or not params[0].type:
|
|
358
|
+
return None
|
|
359
|
+
base = _base_name(params[0].type)
|
|
360
|
+
if not base:
|
|
361
|
+
return None
|
|
362
|
+
same_package = class_index.get((package_qn, base))
|
|
363
|
+
if same_package is not None:
|
|
364
|
+
return same_package
|
|
365
|
+
matches = [cls for (_pkg, name), cls in class_index.items() if name == base]
|
|
366
|
+
return matches[0] if len(matches) == 1 else None
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _operation(node: Node, rules: list[RuleAnnotation], source: bytes) -> Operation | None:
|
|
370
|
+
call, return_type = _signature_parts(node, source)
|
|
371
|
+
if call is None:
|
|
372
|
+
return None
|
|
373
|
+
name_node = call.named_children[0] if call.named_children else None
|
|
374
|
+
if name_node is None:
|
|
375
|
+
return None
|
|
376
|
+
return Operation(
|
|
377
|
+
name=_text(name_node, source),
|
|
378
|
+
parameters=_parameters(call, source),
|
|
379
|
+
return_type=return_type,
|
|
380
|
+
visibility=Visibility.PUBLIC,
|
|
381
|
+
rules=rules,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _signature_parts(node: Node, source: bytes) -> tuple[Node | None, str]:
|
|
386
|
+
"""Locate the `call_expression` naming the function, plus its return type.
|
|
387
|
+
|
|
388
|
+
Long form nests it under `signature` (optionally through a `where_expression`
|
|
389
|
+
for type parameters and a `typed_expression` for the `::Ret` annotation);
|
|
390
|
+
short form puts it directly on the assignment's left.
|
|
391
|
+
"""
|
|
392
|
+
if node.type == "assignment":
|
|
393
|
+
first = node.named_children[0] if node.named_children else None
|
|
394
|
+
return (first if first is not None and first.type == "call_expression" else None), ""
|
|
395
|
+
|
|
396
|
+
signature = next((c for c in node.children if c.type == "signature"), None)
|
|
397
|
+
if signature is None:
|
|
398
|
+
return None, ""
|
|
399
|
+
current = signature.named_children[0] if signature.named_children else None
|
|
400
|
+
return_type = ""
|
|
401
|
+
while current is not None:
|
|
402
|
+
if current.type == "call_expression":
|
|
403
|
+
return current, return_type
|
|
404
|
+
if current.type == "typed_expression":
|
|
405
|
+
named = current.named_children
|
|
406
|
+
if len(named) >= 2:
|
|
407
|
+
return_type = _text(named[-1], source)
|
|
408
|
+
current = named[0] if named else None
|
|
409
|
+
continue
|
|
410
|
+
if current.type == "where_expression":
|
|
411
|
+
current = current.named_children[0] if current.named_children else None
|
|
412
|
+
continue
|
|
413
|
+
break
|
|
414
|
+
return None, return_type
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _parameters(call: Node, source: bytes) -> list[Parameter]:
|
|
418
|
+
arglist = next((c for c in call.children if c.type == "argument_list"), None)
|
|
419
|
+
if arglist is None:
|
|
420
|
+
return []
|
|
421
|
+
out: list[Parameter] = []
|
|
422
|
+
for arg in arglist.named_children:
|
|
423
|
+
if arg.type == "identifier":
|
|
424
|
+
out.append(Parameter(name=_text(arg, source)))
|
|
425
|
+
elif arg.type == "typed_expression":
|
|
426
|
+
name, type_text = _typed_parts(arg, source)
|
|
427
|
+
out.append(Parameter(name=name, type=type_text))
|
|
428
|
+
elif arg.type == "named_argument":
|
|
429
|
+
named = arg.named_children
|
|
430
|
+
target = named[0] if named else None
|
|
431
|
+
default = _text(named[-1], source) if len(named) >= 2 else None
|
|
432
|
+
if target is not None and target.type == "typed_expression":
|
|
433
|
+
name, type_text = _typed_parts(target, source)
|
|
434
|
+
else:
|
|
435
|
+
name, type_text = _text(target, source), ""
|
|
436
|
+
out.append(Parameter(name=name, type=type_text, default=default))
|
|
437
|
+
elif arg.type == "splat_expression":
|
|
438
|
+
inner = arg.named_children[0] if arg.named_children else None
|
|
439
|
+
out.append(Parameter(name=f"{_text(inner, source)}..."))
|
|
440
|
+
return out
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _body_type_refs(node: Node, source: bytes) -> list[str]:
|
|
444
|
+
"""Capitalised identifiers in a function body — candidate type references.
|
|
445
|
+
|
|
446
|
+
In Julia a constructor call is just a call on the type name, so this also
|
|
447
|
+
catches `Money(1)`. `resolve_association` keeps only project classes.
|
|
448
|
+
"""
|
|
449
|
+
out: list[str] = []
|
|
450
|
+
seen: set[str] = set()
|
|
451
|
+
signature = next((c for c in node.children if c.type == "signature"), None)
|
|
452
|
+
for child in node.named_children:
|
|
453
|
+
if child is signature:
|
|
454
|
+
continue
|
|
455
|
+
stack = [child]
|
|
456
|
+
while stack:
|
|
457
|
+
current = stack.pop()
|
|
458
|
+
stack.extend(current.children)
|
|
459
|
+
if current.type != "identifier":
|
|
460
|
+
continue
|
|
461
|
+
text = _text(current, source)
|
|
462
|
+
if text and text[0].isupper() and text not in seen:
|
|
463
|
+
seen.add(text)
|
|
464
|
+
out.append(text)
|
|
465
|
+
return out
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
# ---------- packages / misc ----------
|
|
469
|
+
|
|
470
|
+
def _qualified_package_name(rel_file: Path) -> str:
|
|
471
|
+
parts = list(rel_file.parts[:-1])
|
|
472
|
+
return ".".join(parts) if parts else "__root__"
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _ensure_package(
|
|
476
|
+
project: Project, qualified_name: str, index: dict[str, Package]
|
|
477
|
+
) -> Package:
|
|
478
|
+
if qualified_name in index:
|
|
479
|
+
return index[qualified_name]
|
|
480
|
+
if qualified_name == "__root__":
|
|
481
|
+
pkg = Package(name="__root__", qualified_name="__root__")
|
|
482
|
+
project.packages.append(pkg)
|
|
483
|
+
index[qualified_name] = pkg
|
|
484
|
+
return pkg
|
|
485
|
+
parts = qualified_name.split(".")
|
|
486
|
+
if len(parts) == 1:
|
|
487
|
+
pkg = Package(name=parts[0], qualified_name=qualified_name)
|
|
488
|
+
project.packages.append(pkg)
|
|
489
|
+
else:
|
|
490
|
+
parent = _ensure_package(project, ".".join(parts[:-1]), index)
|
|
491
|
+
pkg = Package(name=parts[-1], qualified_name=qualified_name)
|
|
492
|
+
parent.sub_packages.append(pkg)
|
|
493
|
+
index[qualified_name] = pkg
|
|
494
|
+
return pkg
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _visibility(name: str) -> Visibility:
|
|
498
|
+
return Visibility.PRIVATE if name.startswith("_") else Visibility.PUBLIC
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _base_name(type_text: str) -> str:
|
|
502
|
+
"""Reduce `Vector{Invoice}` / `Main.Invoice` to a bare name."""
|
|
503
|
+
text = type_text.strip().lstrip("^")
|
|
504
|
+
if "{" in text:
|
|
505
|
+
text = text.split("{", 1)[0]
|
|
506
|
+
return text.rsplit(".", 1)[-1].strip()
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _dedupe(refs: list[str], *, drop: str = "") -> list[str]:
|
|
510
|
+
out: list[str] = []
|
|
511
|
+
seen: set[str] = set()
|
|
512
|
+
for ref in refs:
|
|
513
|
+
if not ref or ref == drop or ref in seen:
|
|
514
|
+
continue
|
|
515
|
+
seen.add(ref)
|
|
516
|
+
out.append(ref)
|
|
517
|
+
return out
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _text(node: Node | None, source: bytes) -> str:
|
|
521
|
+
if node is None:
|
|
522
|
+
return ""
|
|
523
|
+
return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
|