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