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,282 @@
|
|
|
1
|
+
"""Body-level conformance analysis for Julia (`cdec enforce`, Engine B).
|
|
2
|
+
|
|
3
|
+
Re-parses Julia source with tree-sitter and inspects function bodies for
|
|
4
|
+
violations of architectural-rule tags. Reuses `julia.rules_extract` so "what is a
|
|
5
|
+
rule" stays identical to the UML parser, and `core.receivers.resolve_owner` so a
|
|
6
|
+
function is attributed to the same struct the model shows.
|
|
7
|
+
|
|
8
|
+
Detection sits between the Python analyzer's guesswork and the C# analyzer's
|
|
9
|
+
precision, because of one Julia fact: **construction has no dedicated syntax**.
|
|
10
|
+
`Money(1.0)` is an ordinary call, indistinguishable at the grammar level from
|
|
11
|
+
`round(1.0)`. So a call counts as a construction only when its callee is the
|
|
12
|
+
name of a type *in this project* — the parser already collected them. That is
|
|
13
|
+
narrower than Python's "callee is Capitalised" heuristic and produces no false
|
|
14
|
+
positives on stdlib calls, at the cost of missing constructions of types the
|
|
15
|
+
parse didn't see.
|
|
16
|
+
|
|
17
|
+
Field reassignment (`immutable`) is precise: an `assignment` whose target is a
|
|
18
|
+
`field_expression` rooted at the receiver argument. Note that a non-`mutable
|
|
19
|
+
struct` is already immutable to the compiler; the tag earns its keep on
|
|
20
|
+
`mutable struct`, where it says the mutability is an implementation detail.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import tree_sitter_julia
|
|
28
|
+
from tree_sitter import Language, Node, Parser
|
|
29
|
+
|
|
30
|
+
from code_constraints.core.annotations import literal_set
|
|
31
|
+
from code_constraints.core.model import Project, RuleAnnotation
|
|
32
|
+
from code_constraints.core.receivers import resolve_owner
|
|
33
|
+
from code_constraints.enforce.model import Finding
|
|
34
|
+
from code_constraints.julia.parser import (
|
|
35
|
+
_TYPE_DEFINITION_TYPES,
|
|
36
|
+
_base_name,
|
|
37
|
+
_is_short_function,
|
|
38
|
+
_operation,
|
|
39
|
+
_qualified_package_name,
|
|
40
|
+
_should_skip,
|
|
41
|
+
_text,
|
|
42
|
+
_type_head_parts,
|
|
43
|
+
)
|
|
44
|
+
from code_constraints.julia.rules_extract import unwrap_macros, using_has_shim
|
|
45
|
+
|
|
46
|
+
_LANG = Language(tree_sitter_julia.language())
|
|
47
|
+
_PARSER = Parser(_LANG)
|
|
48
|
+
|
|
49
|
+
CTOR_RULE = "no-instantiation"
|
|
50
|
+
FACTORY_RULE = "factory"
|
|
51
|
+
IMMUTABLE_RULE = "immutable"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def analyze(root: Path, project: Project) -> list[Finding]:
|
|
55
|
+
project_classes = {cls.name for cls in project.iter_classes()}
|
|
56
|
+
factory_index = _factory_index(project)
|
|
57
|
+
class_rules = {
|
|
58
|
+
cls.qualified_name: {r.name: r for r in cls.rules}
|
|
59
|
+
for cls in project.iter_classes()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
struct_index: dict[tuple[str, str], str] = {}
|
|
63
|
+
functions: list[tuple[Node, bytes, str, str, bool]] = []
|
|
64
|
+
|
|
65
|
+
for jl_file in sorted(root.rglob("*.jl")):
|
|
66
|
+
if _should_skip(jl_file):
|
|
67
|
+
continue
|
|
68
|
+
try:
|
|
69
|
+
source = jl_file.read_bytes()
|
|
70
|
+
except OSError:
|
|
71
|
+
continue
|
|
72
|
+
tree = _PARSER.parse(source)
|
|
73
|
+
rel = jl_file.relative_to(root)
|
|
74
|
+
shim = using_has_shim(tree.root_node, source)
|
|
75
|
+
_scan(
|
|
76
|
+
tree.root_node, source, _qualified_package_name(rel),
|
|
77
|
+
rel.as_posix(), shim, struct_index, functions,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
findings: list[Finding] = []
|
|
81
|
+
for node, source, package_qn, file, shim in functions:
|
|
82
|
+
_analyze_function(
|
|
83
|
+
node, source, package_qn, file, shim, struct_index,
|
|
84
|
+
class_rules, project_classes, factory_index, findings,
|
|
85
|
+
)
|
|
86
|
+
return findings
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _scan(
|
|
90
|
+
parent: Node,
|
|
91
|
+
source: bytes,
|
|
92
|
+
package_qn: str,
|
|
93
|
+
file: str,
|
|
94
|
+
shim: bool,
|
|
95
|
+
struct_index: dict[tuple[str, str], str],
|
|
96
|
+
functions: list[tuple[Node, bytes, str, str, bool]],
|
|
97
|
+
) -> None:
|
|
98
|
+
for child in parent.named_children:
|
|
99
|
+
_rules, node = unwrap_macros(child, source, shim)
|
|
100
|
+
if node is None:
|
|
101
|
+
continue
|
|
102
|
+
if node.type == "module_definition":
|
|
103
|
+
ident = next((c for c in node.children if c.type == "identifier"), None)
|
|
104
|
+
name = _text(ident, source) if ident is not None else "anon"
|
|
105
|
+
nested = f"{package_qn}.{name}" if package_qn != "__root__" else name
|
|
106
|
+
_scan(node, source, nested, file, shim, struct_index, functions)
|
|
107
|
+
elif node.type in _TYPE_DEFINITION_TYPES:
|
|
108
|
+
head = next((c for c in node.children if c.type == "type_head"), None)
|
|
109
|
+
if head is None:
|
|
110
|
+
continue
|
|
111
|
+
name, _bases = _type_head_parts(head, source)
|
|
112
|
+
if name:
|
|
113
|
+
struct_index[(package_qn, name)] = (
|
|
114
|
+
f"{package_qn}.{name}" if package_qn != "__root__" else name
|
|
115
|
+
)
|
|
116
|
+
elif node.type == "function_definition" or _is_short_function(node):
|
|
117
|
+
functions.append((child, source, package_qn, file, shim))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _analyze_function(
|
|
121
|
+
member: Node,
|
|
122
|
+
source: bytes,
|
|
123
|
+
package_qn: str,
|
|
124
|
+
file: str,
|
|
125
|
+
shim: bool,
|
|
126
|
+
struct_index: dict[tuple[str, str], str],
|
|
127
|
+
class_rules: dict[str, dict[str, RuleAnnotation]],
|
|
128
|
+
project_classes: set[str],
|
|
129
|
+
factory_index: dict[str, set[str]],
|
|
130
|
+
out: list[Finding],
|
|
131
|
+
) -> None:
|
|
132
|
+
rules, definition = unwrap_macros(member, source, shim)
|
|
133
|
+
if definition is None:
|
|
134
|
+
return
|
|
135
|
+
operation = _operation(definition, rules, source)
|
|
136
|
+
if operation is None:
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
owner_qn = resolve_owner(
|
|
140
|
+
_base_name(operation.parameters[0].type) if operation.parameters else "",
|
|
141
|
+
package_qn,
|
|
142
|
+
struct_index,
|
|
143
|
+
)
|
|
144
|
+
if owner_qn is None:
|
|
145
|
+
stem = Path(file).stem
|
|
146
|
+
owner_qn = f"{package_qn}.{stem}" if package_qn != "__root__" else stem
|
|
147
|
+
owner_name = stem
|
|
148
|
+
receiver = ""
|
|
149
|
+
else:
|
|
150
|
+
owner_name = owner_qn.rsplit(".", 1)[-1]
|
|
151
|
+
receiver = operation.parameters[0].name
|
|
152
|
+
|
|
153
|
+
func_rules = {r.name: r for r in rules}
|
|
154
|
+
owner_rules = class_rules.get(owner_qn, {})
|
|
155
|
+
name = operation.name
|
|
156
|
+
|
|
157
|
+
noinst = func_rules.get(CTOR_RULE, owner_rules.get(CTOR_RULE))
|
|
158
|
+
allow = literal_set(noinst.kwargs.get("allow", "")) if noinst is not None else None
|
|
159
|
+
|
|
160
|
+
for constructed, line in _constructions(definition, source, project_classes):
|
|
161
|
+
if constructed == owner_name:
|
|
162
|
+
# A type constructing itself is never a violation: it is how the
|
|
163
|
+
# `T.new(...)` / inner-constructor idiom is written, and neither
|
|
164
|
+
# `no_instantiation` nor `factory` is aimed at a type's own
|
|
165
|
+
# constructor — they guard construction by *other* code.
|
|
166
|
+
continue
|
|
167
|
+
if allow is not None and constructed not in allow:
|
|
168
|
+
out.append(
|
|
169
|
+
Finding(
|
|
170
|
+
rule=CTOR_RULE,
|
|
171
|
+
qualified_name=owner_qn,
|
|
172
|
+
message=(
|
|
173
|
+
f"'{owner_qn}.{name}' is tagged @no_instantiation but "
|
|
174
|
+
f"constructs '{constructed}'."
|
|
175
|
+
),
|
|
176
|
+
detail=f"{name}->{constructed}",
|
|
177
|
+
file=file,
|
|
178
|
+
line=line,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
designated = factory_index.get(constructed)
|
|
182
|
+
if designated is not None and owner_name not in designated:
|
|
183
|
+
allowed = ", ".join(sorted(designated)) or "(none)"
|
|
184
|
+
out.append(
|
|
185
|
+
Finding(
|
|
186
|
+
rule=FACTORY_RULE,
|
|
187
|
+
qualified_name=owner_qn,
|
|
188
|
+
message=(
|
|
189
|
+
f"'{owner_qn}.{name}' constructs '{constructed}' outside its "
|
|
190
|
+
f"designated factory ({allowed})."
|
|
191
|
+
),
|
|
192
|
+
detail=f"{name}->{constructed}",
|
|
193
|
+
file=file,
|
|
194
|
+
line=line,
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if IMMUTABLE_RULE in owner_rules and receiver:
|
|
199
|
+
for field, line in _receiver_assignments(definition, receiver, source):
|
|
200
|
+
out.append(
|
|
201
|
+
Finding(
|
|
202
|
+
rule=IMMUTABLE_RULE,
|
|
203
|
+
qualified_name=owner_qn,
|
|
204
|
+
message=(
|
|
205
|
+
f"'{owner_qn}' is tagged @immutable but '{name}' reassigns "
|
|
206
|
+
f"field '{field}'."
|
|
207
|
+
),
|
|
208
|
+
detail=f"{name}.{field}",
|
|
209
|
+
file=file,
|
|
210
|
+
line=line,
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _body_nodes(definition: Node) -> list[Node]:
|
|
216
|
+
"""The definition's body, excluding its signature.
|
|
217
|
+
|
|
218
|
+
Walking the whole node would read parameter type annotations as
|
|
219
|
+
constructions — `f(m::Money)` is not a call to `Money`.
|
|
220
|
+
"""
|
|
221
|
+
signature = next((c for c in definition.children if c.type == "signature"), None)
|
|
222
|
+
if signature is not None:
|
|
223
|
+
return [c for c in definition.named_children if c is not signature]
|
|
224
|
+
if definition.type == "assignment":
|
|
225
|
+
# Short form `f(x::T) = <body>`: everything right of the `=`.
|
|
226
|
+
named = definition.named_children
|
|
227
|
+
return list(named[1:]) if len(named) > 1 else []
|
|
228
|
+
return list(definition.named_children)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _constructions(
|
|
232
|
+
definition: Node, source: bytes, project_classes: set[str]
|
|
233
|
+
) -> list[tuple[str, int]]:
|
|
234
|
+
"""Calls in the body whose callee names a type defined in this project."""
|
|
235
|
+
out: list[tuple[str, int]] = []
|
|
236
|
+
for start in _body_nodes(definition):
|
|
237
|
+
stack = [start]
|
|
238
|
+
while stack:
|
|
239
|
+
node = stack.pop()
|
|
240
|
+
stack.extend(node.children)
|
|
241
|
+
if node.type != "call_expression":
|
|
242
|
+
continue
|
|
243
|
+
callee = node.named_children[0] if node.named_children else None
|
|
244
|
+
if callee is None:
|
|
245
|
+
continue
|
|
246
|
+
name = _base_name(_text(callee, source))
|
|
247
|
+
if name in project_classes:
|
|
248
|
+
out.append((name, node.start_point[0] + 1))
|
|
249
|
+
return out
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _receiver_assignments(
|
|
253
|
+
definition: Node, receiver: str, source: bytes
|
|
254
|
+
) -> list[tuple[str, int]]:
|
|
255
|
+
"""`recv.field = …` assignments inside the body."""
|
|
256
|
+
out: list[tuple[str, int]] = []
|
|
257
|
+
for start in _body_nodes(definition):
|
|
258
|
+
stack = [start]
|
|
259
|
+
while stack:
|
|
260
|
+
node = stack.pop()
|
|
261
|
+
stack.extend(node.children)
|
|
262
|
+
if node.type != "assignment":
|
|
263
|
+
continue
|
|
264
|
+
target = node.named_children[0] if node.named_children else None
|
|
265
|
+
if target is None or target.type != "field_expression":
|
|
266
|
+
continue
|
|
267
|
+
parts = [c for c in target.children if c.type == "identifier"]
|
|
268
|
+
if len(parts) >= 2 and _text(parts[0], source) == receiver:
|
|
269
|
+
out.append((_text(parts[-1], source), node.start_point[0] + 1))
|
|
270
|
+
return out
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _factory_index(project: Project) -> dict[str, set[str]]:
|
|
274
|
+
"""Created-type name -> set of class names designated to construct it."""
|
|
275
|
+
idx: dict[str, set[str]] = {}
|
|
276
|
+
for cls in project.iter_classes():
|
|
277
|
+
for rule in list(cls.rules) + [r for op in cls.operations for r in op.rules]:
|
|
278
|
+
if rule.name != FACTORY_RULE:
|
|
279
|
+
continue
|
|
280
|
+
for created in literal_set(rule.kwargs.get("creates", "")):
|
|
281
|
+
idx.setdefault(created, set()).add(cls.name)
|
|
282
|
+
return idx
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""AST fingerprinting for Julia (`cdec lock`, Engine C).
|
|
2
|
+
|
|
3
|
+
Digests come from the tree-sitter tree via `core.ts_fingerprint`, so a locked
|
|
4
|
+
element survives reformatting and relocation within its file.
|
|
5
|
+
|
|
6
|
+
Julia-specific concerns:
|
|
7
|
+
|
|
8
|
+
* **Tags wrap the definition.** `@locked function f(...) end` is a macrocall with
|
|
9
|
+
the function inside it, not a comment above it, so the digest is taken with an
|
|
10
|
+
`unwrap` that peels *rule* macros only. A non-rule macro (`@inline`,
|
|
11
|
+
`Base.@kwdef`) stays in the digest, so adding or removing one is a real
|
|
12
|
+
change, while applying or removing `@locked` is not.
|
|
13
|
+
* **Multiple dispatch means one name, many methods.** Every method of
|
|
14
|
+
`settle(::Invoice, …)` groups into the single target `Billing.Invoice.settle`,
|
|
15
|
+
so adding a new dispatch to a locked name is itself a lock violation. Those
|
|
16
|
+
methods can live in different files, which is why the group carries its own
|
|
17
|
+
source per member.
|
|
18
|
+
* **Target names must match the UML model**, so receivers resolve exactly as the
|
|
19
|
+
parser resolves them — using helpers imported from `julia.parser` rather than
|
|
20
|
+
re-derived, so the two can't drift apart.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from hashlib import sha256
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
import tree_sitter_julia
|
|
29
|
+
from tree_sitter import Language, Node, Parser
|
|
30
|
+
|
|
31
|
+
from code_constraints.core.model import Parameter
|
|
32
|
+
from code_constraints.core.ts_fingerprint import digest_members
|
|
33
|
+
from code_constraints.julia.parser import (
|
|
34
|
+
_TYPE_DEFINITION_TYPES,
|
|
35
|
+
_base_name,
|
|
36
|
+
_is_short_function,
|
|
37
|
+
_operation,
|
|
38
|
+
_qualified_package_name,
|
|
39
|
+
_should_skip,
|
|
40
|
+
_text,
|
|
41
|
+
_type_head_parts,
|
|
42
|
+
)
|
|
43
|
+
from code_constraints.julia.rules_extract import (
|
|
44
|
+
foreign_macros,
|
|
45
|
+
unwrap_macros,
|
|
46
|
+
using_has_shim,
|
|
47
|
+
)
|
|
48
|
+
from code_constraints.lock.model import LOCK_RULE, LockTarget
|
|
49
|
+
|
|
50
|
+
DIGEST_ALGO = "jl-ts/1"
|
|
51
|
+
|
|
52
|
+
_COMMENT_TYPES = frozenset({"line_comment", "block_comment"})
|
|
53
|
+
|
|
54
|
+
_LANG = Language(tree_sitter_julia.language())
|
|
55
|
+
_PARSER = Parser(_LANG)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def collect_lockables(
|
|
59
|
+
root: str | Path, *, include_docstrings: bool = False
|
|
60
|
+
) -> list[LockTarget]:
|
|
61
|
+
root_path = Path(root).resolve()
|
|
62
|
+
out: list[LockTarget] = []
|
|
63
|
+
# (package_qn, struct name) -> qualified name.
|
|
64
|
+
struct_index: dict[tuple[str, str], str] = {}
|
|
65
|
+
# Structs and functions found per file, kept for the second phase.
|
|
66
|
+
functions: list[tuple[Node, bytes, str, str, bool]] = []
|
|
67
|
+
struct_nodes: list[tuple[Node, bytes, str, str, bool]] = []
|
|
68
|
+
|
|
69
|
+
for jl_file in sorted(root_path.rglob("*.jl")):
|
|
70
|
+
if _should_skip(jl_file):
|
|
71
|
+
continue
|
|
72
|
+
try:
|
|
73
|
+
source = jl_file.read_bytes()
|
|
74
|
+
except OSError:
|
|
75
|
+
continue
|
|
76
|
+
tree = _PARSER.parse(source)
|
|
77
|
+
rel = jl_file.relative_to(root_path)
|
|
78
|
+
shim = using_has_shim(tree.root_node, source)
|
|
79
|
+
_scan(
|
|
80
|
+
tree.root_node,
|
|
81
|
+
source,
|
|
82
|
+
_qualified_package_name(rel),
|
|
83
|
+
rel.stem,
|
|
84
|
+
rel.as_posix(),
|
|
85
|
+
shim,
|
|
86
|
+
struct_index,
|
|
87
|
+
struct_nodes,
|
|
88
|
+
functions,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
for node, source, qn, file, shim in struct_nodes:
|
|
92
|
+
out.append(_target([(node, source)], qn, "class", file, shim, include_docstrings))
|
|
93
|
+
|
|
94
|
+
groups: dict[str, list[tuple[Node, bytes]]] = {}
|
|
95
|
+
meta: dict[str, tuple[str, bool]] = {}
|
|
96
|
+
for node, source, package_qn, file, shim in functions:
|
|
97
|
+
rules, definition = unwrap_macros(node, source, shim)
|
|
98
|
+
if definition is None:
|
|
99
|
+
continue
|
|
100
|
+
operation = _operation(definition, rules, source)
|
|
101
|
+
if operation is None:
|
|
102
|
+
continue
|
|
103
|
+
owner = _owner_qn(operation.parameters, package_qn, struct_index, file)
|
|
104
|
+
target = f"{owner}.{operation.name}"
|
|
105
|
+
groups.setdefault(target, []).append((node, source))
|
|
106
|
+
meta.setdefault(target, (file, shim))
|
|
107
|
+
|
|
108
|
+
for target, members in groups.items():
|
|
109
|
+
file, shim = meta[target]
|
|
110
|
+
out.append(_target(members, target, "method", file, shim, include_docstrings))
|
|
111
|
+
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _scan(
|
|
116
|
+
parent: Node,
|
|
117
|
+
source: bytes,
|
|
118
|
+
package_qn: str,
|
|
119
|
+
stem: str,
|
|
120
|
+
file: str,
|
|
121
|
+
shim: bool,
|
|
122
|
+
struct_index: dict[tuple[str, str], str],
|
|
123
|
+
struct_nodes: list[tuple[Node, bytes, str, str, bool]],
|
|
124
|
+
functions: list[tuple[Node, bytes, str, str, bool]],
|
|
125
|
+
) -> None:
|
|
126
|
+
for child in parent.named_children:
|
|
127
|
+
_rules, node = unwrap_macros(child, source, shim)
|
|
128
|
+
if node is None:
|
|
129
|
+
continue
|
|
130
|
+
if node.type == "module_definition":
|
|
131
|
+
ident = next((c for c in node.children if c.type == "identifier"), None)
|
|
132
|
+
name = _text(ident, source) if ident is not None else "anon"
|
|
133
|
+
nested = f"{package_qn}.{name}" if package_qn != "__root__" else name
|
|
134
|
+
_scan(
|
|
135
|
+
node, source, nested, stem, file, shim,
|
|
136
|
+
struct_index, struct_nodes, functions,
|
|
137
|
+
)
|
|
138
|
+
elif node.type in _TYPE_DEFINITION_TYPES:
|
|
139
|
+
head = next((c for c in node.children if c.type == "type_head"), None)
|
|
140
|
+
if head is None:
|
|
141
|
+
continue
|
|
142
|
+
name, _bases = _type_head_parts(head, source)
|
|
143
|
+
if not name:
|
|
144
|
+
continue
|
|
145
|
+
qn = f"{package_qn}.{name}" if package_qn != "__root__" else name
|
|
146
|
+
struct_index[(package_qn, name)] = qn
|
|
147
|
+
# `child`, not `node`: the tag wrapper is part of the declaration and
|
|
148
|
+
# the unwrap callback peels it during digesting.
|
|
149
|
+
struct_nodes.append((child, source, qn, file, shim))
|
|
150
|
+
elif node.type == "function_definition" or _is_short_function(node):
|
|
151
|
+
functions.append((child, source, package_qn, file, shim))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _owner_qn(
|
|
155
|
+
params: list[Parameter],
|
|
156
|
+
package_qn: str,
|
|
157
|
+
struct_index: dict[tuple[str, str], str],
|
|
158
|
+
file: str,
|
|
159
|
+
) -> str:
|
|
160
|
+
if params and params[0].type:
|
|
161
|
+
name = _base_name(params[0].type)
|
|
162
|
+
same_package = struct_index.get((package_qn, name))
|
|
163
|
+
if same_package is not None:
|
|
164
|
+
return same_package
|
|
165
|
+
matches = [qn for (_pkg, n), qn in struct_index.items() if n == name]
|
|
166
|
+
if len(matches) == 1:
|
|
167
|
+
return matches[0]
|
|
168
|
+
stem = Path(file).stem
|
|
169
|
+
return f"{package_qn}.{stem}" if package_qn != "__root__" else stem
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _target(
|
|
173
|
+
members: list[tuple[Node, bytes]],
|
|
174
|
+
target: str,
|
|
175
|
+
kind: str,
|
|
176
|
+
file: str,
|
|
177
|
+
shim: bool,
|
|
178
|
+
include_docstrings: bool,
|
|
179
|
+
) -> LockTarget:
|
|
180
|
+
def drop(node: Node, _source: bytes) -> bool:
|
|
181
|
+
# tree-sitter-julia splits comments into `line_comment` / `block_comment`
|
|
182
|
+
# — there is no `comment` node type, unlike every other grammar here. A
|
|
183
|
+
# Julia docstring is a `string_literal` preceding the definition, not a
|
|
184
|
+
# comment, so it is left alone: it belongs to the definition's structure.
|
|
185
|
+
return node.type in _COMMENT_TYPES and not include_docstrings
|
|
186
|
+
|
|
187
|
+
declared = False
|
|
188
|
+
params: dict[str, str] = {}
|
|
189
|
+
# Digest the definition each tag wraps, never the macrocall: that is what
|
|
190
|
+
# makes applying or removing `@locked` leave the digest untouched.
|
|
191
|
+
definitions: list[tuple[Node, bytes]] = []
|
|
192
|
+
for node, source in members:
|
|
193
|
+
rules, definition = unwrap_macros(node, source, shim)
|
|
194
|
+
definitions.append((definition if definition is not None else node, source))
|
|
195
|
+
for rule in rules:
|
|
196
|
+
if rule.name == LOCK_RULE:
|
|
197
|
+
declared = True
|
|
198
|
+
params = {**rule.kwargs, **params} if params else dict(rule.kwargs)
|
|
199
|
+
|
|
200
|
+
# Foreign macros are read from the *original* members (which still carry the
|
|
201
|
+
# wrappers) and folded in beside the unwrapped digest.
|
|
202
|
+
macro_texts = sorted(
|
|
203
|
+
text
|
|
204
|
+
for node, source in members
|
|
205
|
+
for text in [",".join(foreign_macros(node, source, shim))]
|
|
206
|
+
if text
|
|
207
|
+
)
|
|
208
|
+
digest_base = digest_members(definitions, drop)
|
|
209
|
+
digest = (
|
|
210
|
+
digest_base
|
|
211
|
+
if not macro_texts
|
|
212
|
+
else sha256(
|
|
213
|
+
(digest_base + "|macros:" + "|".join(macro_texts)).encode("utf-8")
|
|
214
|
+
).hexdigest()
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
return LockTarget(
|
|
218
|
+
target=target,
|
|
219
|
+
kind=kind, # type: ignore[arg-type]
|
|
220
|
+
digest=digest,
|
|
221
|
+
algo=DIGEST_ALGO,
|
|
222
|
+
file=file,
|
|
223
|
+
line=members[0][0].start_point[0] + 1,
|
|
224
|
+
declared=declared,
|
|
225
|
+
params=params,
|
|
226
|
+
)
|