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