code-constraints 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_constraints/__init__.py +1 -0
- code_constraints/cli/__init__.py +0 -0
- code_constraints/cli/__main__.py +1555 -0
- code_constraints/cli/_assets/agents/cdec-architect.md +468 -0
- code_constraints/cli/_assets/agents/oop-refactor-architect.md +317 -0
- code_constraints/cli/_assets/shims/csharp/CodeConstraintsRules.cs +94 -0
- code_constraints/cli/_assets/shims/julia/CdecRules.jl +129 -0
- code_constraints/cli/_assets/shims/lua/cdec_rules.lua +92 -0
- code_constraints/cli/_assets/shims/odin/cdec_rules.odin +67 -0
- code_constraints/cli/_assets/shims/python/cdec_rules.py +94 -0
- code_constraints/cli/_assets/skills/cdec-architecture-loop/SKILL.md +152 -0
- code_constraints/cli/depstamp.py +118 -0
- code_constraints/cli/detect.py +77 -0
- code_constraints/cli/interactive.py +304 -0
- code_constraints/cli/scaffold.py +602 -0
- code_constraints/cli/update.py +157 -0
- code_constraints/core/__init__.py +41 -0
- code_constraints/core/annotations.py +217 -0
- code_constraints/core/associations.py +134 -0
- code_constraints/core/diff.py +302 -0
- code_constraints/core/editor_io.py +280 -0
- code_constraints/core/graph_model.py +681 -0
- code_constraints/core/keys.py +105 -0
- code_constraints/core/model.py +294 -0
- code_constraints/core/model_io.py +65 -0
- code_constraints/core/receivers.py +34 -0
- code_constraints/core/rules.py +177 -0
- code_constraints/core/rulesdoc.py +208 -0
- code_constraints/core/tags.py +114 -0
- code_constraints/core/ts_fingerprint.py +88 -0
- code_constraints/core/xmi_reader.py +358 -0
- code_constraints/core/xmi_writer.py +373 -0
- code_constraints/csharp/__init__.py +3 -0
- code_constraints/csharp/activity.py +250 -0
- code_constraints/csharp/conformance.py +331 -0
- code_constraints/csharp/fingerprint.py +274 -0
- code_constraints/csharp/parser.py +436 -0
- code_constraints/csharp/rules_extract.py +78 -0
- code_constraints/csharp/sequence.py +295 -0
- code_constraints/enforce/__init__.py +15 -0
- code_constraints/enforce/engine.py +122 -0
- code_constraints/enforce/model.py +74 -0
- code_constraints/julia/__init__.py +5 -0
- code_constraints/julia/conformance.py +282 -0
- code_constraints/julia/fingerprint.py +226 -0
- code_constraints/julia/parser.py +523 -0
- code_constraints/julia/rules_extract.py +216 -0
- code_constraints/lint/__init__.py +10 -0
- code_constraints/lint/baseline.py +96 -0
- code_constraints/lint/config.py +239 -0
- code_constraints/lint/engine.py +179 -0
- code_constraints/lint/pipeline.py +108 -0
- code_constraints/lint/report.py +151 -0
- code_constraints/lint/rules/__init__.py +50 -0
- code_constraints/lint/rules/base.py +200 -0
- code_constraints/lint/rules/cyclic_package_dependencies.py +69 -0
- code_constraints/lint/rules/dangling_classes.py +98 -0
- code_constraints/lint/rules/forbidden_package_references.py +47 -0
- code_constraints/lint/rules/forbidden_references.py +48 -0
- code_constraints/lint/rules/frozen_members.py +67 -0
- code_constraints/lint/rules/frozen_rules.py +105 -0
- code_constraints/lint/rules/implementation_locks.py +156 -0
- code_constraints/lint/rules/layer_dependencies.py +92 -0
- code_constraints/lint/rules/max_class_fanout.py +41 -0
- code_constraints/lint/rules/no_new_classes.py +27 -0
- code_constraints/lint/rules/no_removed_classes.py +27 -0
- code_constraints/lint/rules/reference_architecture.py +111 -0
- code_constraints/lint/rules/subclass_naming.py +71 -0
- code_constraints/lint/rules/tag_conformance.py +76 -0
- code_constraints/lock/__init__.py +73 -0
- code_constraints/lock/engine.py +395 -0
- code_constraints/lock/model.py +235 -0
- code_constraints/lock/store.py +144 -0
- code_constraints/lua/__init__.py +5 -0
- code_constraints/lua/conformance.py +239 -0
- code_constraints/lua/fingerprint.py +252 -0
- code_constraints/lua/parser.py +500 -0
- code_constraints/lua/rules_extract.py +55 -0
- code_constraints/mcp/__init__.py +20 -0
- code_constraints/mcp/__main__.py +73 -0
- code_constraints/mcp/server.py +1203 -0
- code_constraints/odin/__init__.py +5 -0
- code_constraints/odin/conformance.py +244 -0
- code_constraints/odin/fingerprint.py +159 -0
- code_constraints/odin/parser.py +471 -0
- code_constraints/odin/rules_extract.py +38 -0
- code_constraints/python/__init__.py +3 -0
- code_constraints/python/activity.py +278 -0
- code_constraints/python/conformance.py +249 -0
- code_constraints/python/fingerprint.py +231 -0
- code_constraints/python/parser.py +330 -0
- code_constraints/python/rules_extract.py +83 -0
- code_constraints/python/sequence.py +257 -0
- code_constraints/reference/__init__.py +15 -0
- code_constraints/reference/compare.py +356 -0
- code_constraints/reference/report.py +38 -0
- code_constraints/svelte/__init__.py +3 -0
- code_constraints/svelte/parser.py +523 -0
- code_constraints/typescript/__init__.py +3 -0
- code_constraints/typescript/parser.py +590 -0
- code_constraints/waivers/__init__.py +89 -0
- code_constraints/waivers/collect.py +167 -0
- code_constraints/waivers/model.py +90 -0
- code_constraints/waivers/ops.py +150 -0
- code_constraints/waivers/review.py +156 -0
- code_constraints/waivers/store.py +300 -0
- code_constraints/web/__init__.py +0 -0
- code_constraints/web/_static/assets/index-3ivBsYY4.css +1 -0
- code_constraints/web/_static/assets/index-BTzTqGFp.js +9 -0
- code_constraints/web/_static/index.html +13 -0
- code_constraints/web/app.py +1076 -0
- code_constraints-0.1.0.dist-info/METADATA +663 -0
- code_constraints-0.1.0.dist-info/RECORD +116 -0
- code_constraints-0.1.0.dist-info/WHEEL +4 -0
- code_constraints-0.1.0.dist-info/entry_points.txt +3 -0
- code_constraints-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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())
|