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