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,500 @@
1
+ """Parse a Lua project (directory of .lua files) into a `code_constraints.core.Project`.
2
+
3
+ Uses `tree_sitter` with the `tree_sitter_lua` grammar. Syntactic only — Lua is
4
+ dynamically typed, so attribute and parameter types are left empty and
5
+ association edges come from capitalised identifiers used in bodies.
6
+
7
+ Lua has no classes, so the parser recognises the standard table-plus-metatable
8
+ idiom. Within one file, a local or global table becomes a `Class` when it shows
9
+ at least one of:
10
+
11
+ * a method or function declared on it (`function T:m()` / `function T.m()`),
12
+ * the `T.__index = T` self-index that marks a prototype,
13
+ * a metatable base (`setmetatable({}, { __index = Base })` → `bases: [Base]`),
14
+ * a `---@cdec` tag written above its declaration.
15
+
16
+ That last condition matters: it means tagging a table is enough to pull it into
17
+ the model even before it has methods. A plain `local cfg = {}` data table with
18
+ none of the four is *not* promoted, which keeps the diagram free of every
19
+ scratch local in the file.
20
+
21
+ Two consequences of Lua's dynamism worth knowing:
22
+
23
+ 1. **Class tables are file-scoped.** A module is a file, so the walk resolves
24
+ `function T:m()` against tables declared in the same file only — no
25
+ cross-file guessing.
26
+ 2. **Instance attributes come from every method, not just a constructor.** Lua
27
+ has no `__init__` equivalent to privilege, so any `self.x = ...` in any
28
+ method of `T` contributes the attribute `x`. This is broader than the Python
29
+ parser's `__init__`-only rule, and deliberately so.
30
+
31
+ Free functions (`local function helper()`) have no receiver table, so they land
32
+ on a synthetic `static` class named after the file stem — the same UML
33
+ utility-class treatment the Odin parser gives package-scope procedures.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from pathlib import Path
39
+
40
+ import tree_sitter_lua
41
+ from tree_sitter import Language, Node, Parser
42
+
43
+ from code_constraints.core.model import (
44
+ Attribute,
45
+ Class,
46
+ Operation,
47
+ Package,
48
+ Parameter,
49
+ Project,
50
+ SourceLocation,
51
+ Visibility,
52
+ )
53
+ from code_constraints.core.rules import SHIM_FILENAMES
54
+ from code_constraints.lua.rules_extract import extract_rules, rules_for_statements
55
+
56
+ _LANG = Language(tree_sitter_lua.language())
57
+ _PARSER = Parser(_LANG)
58
+
59
+ _SKIP_DIR_NAMES = {".git", "node_modules", "build", "out", ".luarocks", ".cdec_cache"}
60
+
61
+ # Metafields that describe the table rather than being data on it.
62
+ _METAFIELDS = frozenset(
63
+ {
64
+ "__index", "__newindex", "__call", "__tostring", "__eq", "__lt", "__le",
65
+ "__add", "__sub", "__mul", "__div", "__mod", "__pow", "__unm", "__len",
66
+ "__concat", "__gc", "__close", "__mode", "__name", "__metatable",
67
+ }
68
+ )
69
+
70
+
71
+ def parse_project(root: str | Path) -> Project:
72
+ root_path = Path(root).resolve()
73
+ if not root_path.is_dir():
74
+ raise ValueError(f"not a directory: {root_path}")
75
+
76
+ project = Project(source_language="lua", root_path=str(root_path))
77
+ package_index: dict[str, Package] = {}
78
+
79
+ for lua_file in sorted(root_path.rglob("*.lua")):
80
+ if _should_skip(lua_file):
81
+ continue
82
+ _parse_file(lua_file, root_path, project, package_index)
83
+
84
+ return project
85
+
86
+
87
+ def _should_skip(p: Path) -> bool:
88
+ if p.name in SHIM_FILENAMES:
89
+ return True
90
+ return any(part in _SKIP_DIR_NAMES for part in p.parts)
91
+
92
+
93
+ class _Candidate:
94
+ """A table that might turn out to be a class, accumulated over one file."""
95
+
96
+ __slots__ = (
97
+ "name", "decl_nodes", "base", "self_indexed", "operations",
98
+ "static_attrs", "instance_attrs", "deps", "line",
99
+ )
100
+
101
+ def __init__(self, name: str, line: int):
102
+ self.name = name
103
+ self.decl_nodes: list[Node] = []
104
+ self.base: str = ""
105
+ self.self_indexed = False
106
+ self.operations: list[Operation] = []
107
+ self.static_attrs: list[Attribute] = []
108
+ self.instance_attrs: list[str] = []
109
+ self.deps: list[str] = []
110
+ self.line = line
111
+
112
+
113
+ def _parse_file(
114
+ file: Path,
115
+ root: Path,
116
+ project: Project,
117
+ package_index: dict[str, Package],
118
+ ) -> None:
119
+ source = file.read_bytes()
120
+ tree = _PARSER.parse(source)
121
+ rel = file.relative_to(root)
122
+ file_str = rel.as_posix()
123
+ package_qn = _qualified_package_name(rel)
124
+ _ensure_package(project, package_qn, package_index)
125
+
126
+ candidates: dict[str, _Candidate] = {}
127
+ free_functions: list[Operation] = []
128
+ free_deps: list[str] = []
129
+
130
+ for stmt in tree.root_node.named_children:
131
+ if stmt.type == "variable_declaration":
132
+ _visit_assignment(_inner_assignment(stmt), stmt, source, candidates)
133
+ elif stmt.type == "assignment_statement":
134
+ _visit_assignment(stmt, stmt, source, candidates)
135
+ elif stmt.type == "function_declaration":
136
+ _visit_function(stmt, source, candidates, free_functions, free_deps)
137
+ elif stmt.type == "function_call":
138
+ _visit_setmetatable_call(stmt, source, candidates)
139
+
140
+ pkg = _ensure_package(project, package_qn, package_index)
141
+ for cand in candidates.values():
142
+ cls = _to_class(cand, package_qn, source, file_str)
143
+ if cls is not None:
144
+ pkg.classes.append(cls)
145
+
146
+ if free_functions:
147
+ pkg.classes.append(
148
+ _module_class(rel.stem, package_qn, file_str, free_functions, free_deps)
149
+ )
150
+
151
+
152
+ # ---------- statement dispatch ----------
153
+
154
+ def _inner_assignment(decl: Node) -> Node | None:
155
+ """`local T = {}` wraps its assignment; a bare `local T` has none."""
156
+ return next((c for c in decl.named_children if c.type == "assignment_statement"), None)
157
+
158
+
159
+ def _visit_assignment(
160
+ assign: Node | None,
161
+ tag_node: Node,
162
+ source: bytes,
163
+ candidates: dict[str, _Candidate],
164
+ ) -> None:
165
+ """Handle `T = {}`, `T.__index = T`, `T.field = value`.
166
+
167
+ `tag_node` is the statement a `---@cdec` comment would sit above — the outer
168
+ `variable_declaration` for a `local`, the assignment itself otherwise.
169
+ """
170
+ if assign is None:
171
+ return
172
+ targets = next((c for c in assign.named_children if c.type == "variable_list"), None)
173
+ values = next((c for c in assign.named_children if c.type == "expression_list"), None)
174
+ if targets is None:
175
+ return
176
+
177
+ for i, target in enumerate(targets.named_children):
178
+ value = values.named_children[i] if values and i < len(values.named_children) else None
179
+
180
+ if target.type == "identifier":
181
+ name = _text(target, source)
182
+ if value is not None and _is_table_like(value):
183
+ cand = _candidate(candidates, name, tag_node)
184
+ cand.decl_nodes.append(tag_node)
185
+ base = _metatable_base(value, source)
186
+ if base:
187
+ cand.base = base
188
+ continue
189
+
190
+ if target.type == "dot_index_expression":
191
+ owner, field = _dot_parts(target, source)
192
+ if not owner or not field:
193
+ continue
194
+ cand = _candidate(candidates, owner, tag_node)
195
+ if field == "__index":
196
+ cand.self_indexed = True
197
+ cand.decl_nodes.append(tag_node)
198
+ continue
199
+ if field in _METAFIELDS:
200
+ continue
201
+ cand.static_attrs.append(
202
+ Attribute(
203
+ name=field,
204
+ type="",
205
+ visibility=_visibility(field),
206
+ is_static=True,
207
+ default=_text(value, source) if value is not None else None,
208
+ )
209
+ )
210
+
211
+
212
+ def _visit_setmetatable_call(stmt: Node, source: bytes, candidates: dict[str, _Candidate]) -> None:
213
+ """`setmetatable(Child, { __index = Base })` as a bare statement."""
214
+ callee = next((c for c in stmt.named_children if c.type == "identifier"), None)
215
+ if callee is None or _text(callee, source) != "setmetatable":
216
+ return
217
+ args = next((c for c in stmt.named_children if c.type == "arguments"), None)
218
+ if args is None:
219
+ return
220
+ named = args.named_children
221
+ if len(named) < 2 or named[0].type != "identifier":
222
+ return
223
+ base = _index_field(named[1], source)
224
+ if base:
225
+ _candidate(candidates, _text(named[0], source), stmt).base = base
226
+
227
+
228
+ def _visit_function(
229
+ node: Node,
230
+ source: bytes,
231
+ candidates: dict[str, _Candidate],
232
+ free_functions: list[Operation],
233
+ free_deps: list[str],
234
+ ) -> None:
235
+ target = next(
236
+ (
237
+ c
238
+ for c in node.children
239
+ if c.type in ("identifier", "dot_index_expression", "method_index_expression")
240
+ ),
241
+ None,
242
+ )
243
+ if target is None:
244
+ return
245
+
246
+ deps = _body_type_refs(node, source)
247
+
248
+ if target.type == "identifier":
249
+ free_functions.append(_operation(node, _text(target, source), True, source))
250
+ free_deps.extend(deps)
251
+ return
252
+
253
+ owner, name = _dot_parts(target, source)
254
+ if not owner or not name:
255
+ return
256
+ # `function T.m()` is a static/class function; `function T:m()` takes an
257
+ # implicit `self` and is an instance method.
258
+ is_static = target.type == "dot_index_expression"
259
+ cand = _candidate(candidates, owner, node)
260
+ cand.operations.append(_operation(node, name, is_static, source))
261
+ cand.deps.extend(deps)
262
+ if not is_static:
263
+ cand.instance_attrs.extend(_self_fields(node, source))
264
+ else:
265
+ # A constructor is written `function T.new()`, so its `self.x = ...`
266
+ # assignments are the instance attributes too.
267
+ cand.instance_attrs.extend(_self_fields(node, source))
268
+
269
+
270
+ # ---------- building ----------
271
+
272
+ def _candidate(candidates: dict[str, _Candidate], name: str, node: Node) -> _Candidate:
273
+ existing = candidates.get(name)
274
+ if existing is None:
275
+ existing = _Candidate(name, node.start_point[0] + 1)
276
+ candidates[name] = existing
277
+ return existing
278
+
279
+
280
+ def _to_class(cand: _Candidate, package_qn: str, source: bytes, file_str: str) -> Class | None:
281
+ rules = rules_for_statements(cand.decl_nodes, source)
282
+ is_class = bool(cand.operations or cand.self_indexed or cand.base or rules)
283
+ if not is_class:
284
+ return None
285
+
286
+ qn = f"{package_qn}.{cand.name}" if package_qn != "__root__" else cand.name
287
+ attributes = list(cand.static_attrs)
288
+ known = {a.name for a in attributes}
289
+ for field in cand.instance_attrs:
290
+ if field in known:
291
+ continue
292
+ known.add(field)
293
+ attributes.append(Attribute(name=field, type="", visibility=_visibility(field)))
294
+
295
+ return Class(
296
+ name=cand.name,
297
+ qualified_name=qn,
298
+ kind="class",
299
+ attributes=attributes,
300
+ operations=cand.operations,
301
+ bases=[cand.base] if cand.base else [],
302
+ location=SourceLocation(file=file_str, start_line=cand.line, end_line=cand.line),
303
+ rules=rules,
304
+ dependencies=_dedupe(cand.deps, drop=cand.name),
305
+ )
306
+
307
+
308
+ def _module_class(
309
+ stem: str,
310
+ package_qn: str,
311
+ file_str: str,
312
+ operations: list[Operation],
313
+ deps: list[str],
314
+ ) -> Class:
315
+ qn = f"{package_qn}.{stem}" if package_qn != "__root__" else stem
316
+ return Class(
317
+ name=stem,
318
+ qualified_name=qn,
319
+ kind="static",
320
+ operations=operations,
321
+ location=SourceLocation(file=file_str, start_line=1, end_line=1),
322
+ description=f"File-scope functions declared in {file_str}.",
323
+ dependencies=_dedupe(deps, drop=stem),
324
+ )
325
+
326
+
327
+ def _operation(node: Node, name: str, is_static: bool, source: bytes) -> Operation:
328
+ return Operation(
329
+ name=name,
330
+ parameters=_parameters(node, source),
331
+ visibility=_visibility(name),
332
+ is_static=is_static,
333
+ rules=extract_rules(node, source),
334
+ )
335
+
336
+
337
+ def _parameters(node: Node, source: bytes) -> list[Parameter]:
338
+ params = next((c for c in node.children if c.type == "parameters"), None)
339
+ if params is None:
340
+ return []
341
+ out: list[Parameter] = []
342
+ for child in params.named_children:
343
+ if child.type == "identifier":
344
+ out.append(Parameter(name=_text(child, source)))
345
+ elif child.type == "vararg_expression":
346
+ out.append(Parameter(name="..."))
347
+ return out
348
+
349
+
350
+ # ---------- expression helpers ----------
351
+
352
+ def _is_table_like(value: Node) -> bool:
353
+ """`{}` directly, or a `setmetatable(...)` call producing one."""
354
+ if value.type == "table_constructor":
355
+ return True
356
+ return value.type == "function_call" and value.child_count > 0
357
+
358
+
359
+ def _metatable_base(value: Node, source: bytes) -> str:
360
+ """Base class from `setmetatable({}, { __index = Base })`."""
361
+ if value.type != "function_call":
362
+ return ""
363
+ callee = next((c for c in value.named_children if c.type == "identifier"), None)
364
+ if callee is None or _text(callee, source) != "setmetatable":
365
+ return ""
366
+ args = next((c for c in value.named_children if c.type == "arguments"), None)
367
+ if args is None:
368
+ return ""
369
+ named = args.named_children
370
+ return _index_field(named[1], source) if len(named) >= 2 else ""
371
+
372
+
373
+ def _index_field(table: Node, source: bytes) -> str:
374
+ """The `Base` in a `{ __index = Base }` metatable literal."""
375
+ if table.type == "identifier":
376
+ return _text(table, source)
377
+ if table.type != "table_constructor":
378
+ return ""
379
+ for entry in table.named_children:
380
+ if entry.type != "field":
381
+ continue
382
+ parts = [c for c in entry.named_children]
383
+ if len(parts) >= 2 and _text(parts[0], source) == "__index":
384
+ return _text(parts[1], source)
385
+ return ""
386
+
387
+
388
+ def _dot_parts(node: Node, source: bytes) -> tuple[str, str]:
389
+ """`T.m` / `T:m` -> ("T", "m"). Nested paths keep only the last two hops."""
390
+ idents = [c for c in node.named_children if c.type in ("identifier", "dot_index_expression")]
391
+ if len(idents) < 2:
392
+ return "", ""
393
+ owner_node, name_node = idents[0], idents[-1]
394
+ owner = _text(owner_node, source)
395
+ return owner.rsplit(".", 1)[-1] if "." in owner else owner, _text(name_node, source)
396
+
397
+
398
+ def _self_fields(node: Node, source: bytes) -> list[str]:
399
+ """Field names assigned via `self.x = ...` anywhere in a function body."""
400
+ block = next((c for c in node.children if c.type == "block"), None)
401
+ if block is None:
402
+ return []
403
+ out: list[str] = []
404
+
405
+ # Walked in source order (not with a LIFO stack) so the attribute list keeps
406
+ # the order the constructor assigns them in — that order is what renders in
407
+ # the class box and what the diff compares.
408
+ def walk(current: Node) -> None:
409
+ if current.type == "assignment_statement":
410
+ targets = next(
411
+ (c for c in current.named_children if c.type == "variable_list"), None
412
+ )
413
+ if targets is not None:
414
+ for target in targets.named_children:
415
+ if target.type != "dot_index_expression":
416
+ continue
417
+ owner, field = _dot_parts(target, source)
418
+ if owner == "self" and field and field not in out:
419
+ out.append(field)
420
+ for child in current.children:
421
+ walk(child)
422
+
423
+ walk(block)
424
+ return out
425
+
426
+
427
+ def _body_type_refs(node: Node, source: bytes) -> list[str]:
428
+ """Capitalised identifiers used in a function body — candidate type refs.
429
+
430
+ Heuristic, like the C# and Odin parsers: `resolve_association` keeps only the
431
+ ones matching a project class, so locals and stdlib names drop out. Only the
432
+ block is walked, so parameter names and `---@cdec` comments never leak in.
433
+ """
434
+ block = next((c for c in node.children if c.type == "block"), None)
435
+ if block is None:
436
+ return []
437
+ out: list[str] = []
438
+ seen: set[str] = set()
439
+ stack = [block]
440
+ while stack:
441
+ current = stack.pop()
442
+ stack.extend(current.children)
443
+ if current.type != "identifier":
444
+ continue
445
+ text = _text(current, source)
446
+ if text and text[0].isupper() and text not in seen:
447
+ seen.add(text)
448
+ out.append(text)
449
+ return out
450
+
451
+
452
+ # ---------- packages / misc ----------
453
+
454
+ def _qualified_package_name(rel_file: Path) -> str:
455
+ parts = list(rel_file.parts[:-1])
456
+ return ".".join(parts) if parts else "__root__"
457
+
458
+
459
+ def _ensure_package(
460
+ project: Project, qualified_name: str, index: dict[str, Package]
461
+ ) -> Package:
462
+ if qualified_name in index:
463
+ return index[qualified_name]
464
+ if qualified_name == "__root__":
465
+ pkg = Package(name="__root__", qualified_name="__root__")
466
+ project.packages.append(pkg)
467
+ index[qualified_name] = pkg
468
+ return pkg
469
+ parts = qualified_name.split(".")
470
+ if len(parts) == 1:
471
+ pkg = Package(name=parts[0], qualified_name=qualified_name)
472
+ project.packages.append(pkg)
473
+ else:
474
+ parent = _ensure_package(project, ".".join(parts[:-1]), index)
475
+ pkg = Package(name=parts[-1], qualified_name=qualified_name)
476
+ parent.sub_packages.append(pkg)
477
+ index[qualified_name] = pkg
478
+ return pkg
479
+
480
+
481
+ def _visibility(name: str) -> Visibility:
482
+ """Leading underscore is Lua's private-by-convention marker."""
483
+ return Visibility.PRIVATE if name.startswith("_") else Visibility.PUBLIC
484
+
485
+
486
+ def _dedupe(refs: list[str], *, drop: str = "") -> list[str]:
487
+ out: list[str] = []
488
+ seen: set[str] = set()
489
+ for ref in refs:
490
+ if not ref or ref == drop or ref in seen:
491
+ continue
492
+ seen.add(ref)
493
+ out.append(ref)
494
+ return out
495
+
496
+
497
+ def _text(node: Node | None, source: bytes) -> str:
498
+ if node is None:
499
+ return ""
500
+ return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
@@ -0,0 +1,55 @@
1
+ """Recognise architectural-rule annotations on Lua 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
+ Lua has no decorator syntax, so tags ride in `---@cdec ...` comments directly
8
+ above the declaration. Parsing lives in `code_constraints.core.annotations`;
9
+ this module only knows where Lua puts the comments.
10
+
11
+ The one Lua-specific wrinkle: a class table is usually declared as
12
+ `local T = {}` and then given methods further down the file, so a tag written
13
+ above *any* statement that declares the table (the `local T = {}`, or a
14
+ `T.__index = T` line) attaches to the class. `rules_for_statements` folds those
15
+ sites together for the parser.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from tree_sitter import Node
21
+
22
+ from code_constraints.core.annotations import parse_annotation, rules_before_node
23
+ from code_constraints.core.model import RuleAnnotation
24
+
25
+
26
+ def extract_rules(decl_node: Node, source: bytes) -> list[RuleAnnotation]:
27
+ """Tags attached to a single Lua statement (`function_declaration`,
28
+ `variable_declaration`, `assignment_statement`, …)."""
29
+ return rules_before_node(decl_node, source)
30
+
31
+
32
+ def rules_for_statements(nodes: list[Node], source: bytes) -> list[RuleAnnotation]:
33
+ """Union of the tags above every statement that declares one class table.
34
+
35
+ Preserves source order and drops exact duplicates, so tagging both the
36
+ `local T = {}` and the `T.__index = T` line doesn't double up.
37
+ """
38
+ out: list[RuleAnnotation] = []
39
+ for node in nodes:
40
+ for rule in rules_before_node(node, source):
41
+ if rule not in out:
42
+ out.append(rule)
43
+ return out
44
+
45
+
46
+ def comment_is_rule_tag(node: Node, source: bytes) -> bool:
47
+ """True when `node` is a comment carrying a `@cdec` tag.
48
+
49
+ The fingerprinter drops these from the digest so applying or removing a lock
50
+ never changes the hash of the body it guards.
51
+ """
52
+ if node.type != "comment":
53
+ return False
54
+ text = source[node.start_byte : node.end_byte].decode("utf-8", "replace")
55
+ return parse_annotation(text) is not None
@@ -0,0 +1,20 @@
1
+ """code-constraints as an MCP server.
2
+
3
+ Exposes the three enforcement engines, the model pipeline, and the waiver review
4
+ loop over the Model Context Protocol, so any MCP-capable coding harness can run
5
+ `cdec` operations as tools instead of shelling out and parsing human output.
6
+
7
+ The tools call the library in-process (`code_constraints.lint`,
8
+ `code_constraints.enforce`, `code_constraints.lock`, `code_constraints.waivers`)
9
+ rather than invoking the `cdec` CLI, so results arrive as structured JSON and
10
+ issue keys line up exactly with what `cdec check` would print.
11
+
12
+ python -m code_constraints.mcp # stdio transport
13
+ cdec-mcp # same, via the console script
14
+
15
+ Requires the optional `mcp` dependency: `pip install "code-constraints[mcp]"`.
16
+ """
17
+
18
+ from code_constraints.mcp.server import build_server
19
+
20
+ __all__ = ["build_server"]
@@ -0,0 +1,73 @@
1
+ """Entry point for the code-constraints MCP server.
2
+
3
+ python -m code_constraints.mcp
4
+ cdec-mcp --project-root /path/to/repo
5
+
6
+ stdio is the default transport because that is what coding harnesses launch:
7
+ the harness spawns this process and speaks JSON-RPC over its stdin/stdout.
8
+ Which is also why the first thing `main` does is pin logging to stderr — a
9
+ stray byte on stdout corrupts the protocol stream.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import logging
16
+ import sys
17
+ from pathlib import Path
18
+
19
+
20
+ def main(argv: list[str] | None = None) -> int:
21
+ parser = argparse.ArgumentParser(
22
+ prog="cdec-mcp",
23
+ description="Run code-constraints as an MCP server.",
24
+ )
25
+ parser.add_argument(
26
+ "--project-root",
27
+ type=Path,
28
+ default=None,
29
+ metavar="DIR",
30
+ help=(
31
+ "Directory that relative tool paths resolve against "
32
+ "(default: $CDEC_PROJECT_ROOT, else the current directory)."
33
+ ),
34
+ )
35
+ parser.add_argument(
36
+ "--transport",
37
+ choices=("stdio", "sse", "streamable-http"),
38
+ default="stdio",
39
+ help="MCP transport (default: stdio, which is what coding harnesses use).",
40
+ )
41
+ parser.add_argument(
42
+ "--log-level",
43
+ default="WARNING",
44
+ choices=("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"),
45
+ help="Log verbosity. Logs always go to stderr.",
46
+ )
47
+ args = parser.parse_args(argv)
48
+
49
+ # stdout belongs to the MCP transport; everything diagnostic goes to stderr.
50
+ logging.basicConfig(
51
+ level=getattr(logging, args.log_level),
52
+ stream=sys.stderr,
53
+ format="%(levelname)s %(name)s: %(message)s",
54
+ )
55
+
56
+ try:
57
+ from code_constraints.mcp.server import build_server
58
+ except ModuleNotFoundError as exc:
59
+ if exc.name != "mcp":
60
+ raise
61
+ print(
62
+ "the MCP server needs the optional `mcp` dependency:\n"
63
+ ' pip install "code-constraints[mcp]"',
64
+ file=sys.stderr,
65
+ )
66
+ return 2
67
+
68
+ build_server(args.project_root).run(transport=args.transport)
69
+ return 0
70
+
71
+
72
+ if __name__ == "__main__":
73
+ raise SystemExit(main())