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,144 @@
1
+ """The approved-digest ledger — the `locks:` section of `.cdec/rules.yaml`.
2
+
3
+ The audit trail for frozen implementations. It is meant to be committed and to
4
+ sit behind a CODEOWNERS entry so only leads can approve a re-baseline;
5
+ `cdec check --automatic-exceptions locks` refuses to overwrite a drifted digest
6
+ without `--force` precisely so the diff on this file is the review artefact.
7
+
8
+ Shape (in the tool-managed tail of `rules.yaml`, see
9
+ `code_constraints.core.rulesdoc`):
10
+
11
+ locks:
12
+ - target: orders.Receipt.formatted
13
+ kind: method
14
+ algo: py-ast/1
15
+ digest: 3f9a…
16
+ file: orders/billing.py
17
+ locked_at: "2026-08-02T10:15:00Z"
18
+ locked_by: alice
19
+ reason: agreed receipt formatting
20
+
21
+ A standalone `.cdec/locks.yaml` was the old home. It is still read when present
22
+ (so an existing project keeps working on upgrade) and folded into `rules.yaml`
23
+ by `cdec init --migrate`.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from datetime import datetime, timezone
29
+ from pathlib import Path
30
+ from typing import Iterable
31
+
32
+ from code_constraints.core.rulesdoc import RULES_FILENAME, RulesFileError, load_document, write_sections
33
+ from code_constraints.lock.model import LockEntry
34
+
35
+ LOCKS_FILENAME = "locks.yaml" # legacy standalone ledger
36
+ LOCKFILE_VERSION = 1
37
+
38
+
39
+ class LockfileError(ValueError):
40
+ """Raised when the lockfile exists but can't be interpreted."""
41
+
42
+
43
+ def load_locks(config_dir: Path) -> dict[str, LockEntry]:
44
+ """Read the ledger into a target -> entry map.
45
+
46
+ Accepts either a `.cdec/` directory (the normal call) or a direct path to a
47
+ YAML file holding a `locks:` list. An absent ledger is an empty one —
48
+ nothing is locked yet — which is not an error.
49
+ """
50
+ rules_file, legacy_file = _ledger_paths(config_dir)
51
+ items: list = []
52
+ path = rules_file
53
+ for candidate in (rules_file, legacy_file):
54
+ if candidate is None or not candidate.is_file():
55
+ continue
56
+ try:
57
+ raw = load_document(candidate)
58
+ except RulesFileError as exc:
59
+ raise LockfileError(str(exc)) from exc
60
+ version = raw.get("version", LOCKFILE_VERSION)
61
+ if not isinstance(version, int) or version > LOCKFILE_VERSION:
62
+ raise LockfileError(
63
+ f"{candidate}: lockfile version {version!r} is newer than this "
64
+ f"code-constraints supports (max {LOCKFILE_VERSION}); upgrade with "
65
+ f"`cdec update`."
66
+ )
67
+ found = raw.get("locks") or []
68
+ if not isinstance(found, list):
69
+ raise LockfileError(f"{candidate}: 'locks' must be a list")
70
+ if found:
71
+ # `rules.yaml` wins outright: once migrated, a leftover locks.yaml
72
+ # must not resurrect entries a lead deliberately released.
73
+ items, path = found, candidate
74
+ break
75
+
76
+ out: dict[str, LockEntry] = {}
77
+ for i, item in enumerate(items):
78
+ if not isinstance(item, dict):
79
+ raise LockfileError(f"{path}: locks[{i}] must be a mapping")
80
+ target = str(item.get("target") or "")
81
+ digest = str(item.get("digest") or "")
82
+ if not target or not digest:
83
+ raise LockfileError(f"{path}: locks[{i}] needs both 'target' and 'digest'")
84
+ out[target] = LockEntry(
85
+ target=target,
86
+ kind=str(item.get("kind") or "class"),
87
+ digest=digest,
88
+ algo=str(item.get("algo") or ""),
89
+ file=str(item.get("file") or ""),
90
+ locked_at=str(item.get("locked_at") or ""),
91
+ locked_by=str(item.get("locked_by") or ""),
92
+ reason=str(item.get("reason") or ""),
93
+ via_pattern=bool(item.get("via_pattern") or False),
94
+ )
95
+ return out
96
+
97
+
98
+ def write_locks(config_dir: Path, entries: Iterable[LockEntry]) -> Path:
99
+ """Write the ledger into `rules.yaml`, sorted so the file diffs cleanly.
100
+
101
+ Only the `locks:` section is rewritten — every hand-written rule and comment
102
+ above it is preserved byte for byte. Returns the file written.
103
+ """
104
+ rules_file, _ = _ledger_paths(config_dir)
105
+ write_sections(
106
+ rules_file,
107
+ {"locks": [_entry_to_dict(e) for e in sorted(entries, key=lambda e: e.target)]},
108
+ )
109
+ return rules_file
110
+
111
+
112
+ def _ledger_paths(config_dir: Path) -> tuple[Path, Path | None]:
113
+ """(rules.yaml, legacy locks.yaml) for a `.cdec/` dir.
114
+
115
+ Passing a YAML file directly is also honoured — tests and `--lockfile`-style
116
+ overrides point straight at one — in which case there is no legacy fallback.
117
+ """
118
+ if config_dir.suffix in (".yaml", ".yml"):
119
+ return config_dir, None
120
+ return config_dir / RULES_FILENAME, config_dir / LOCKS_FILENAME
121
+
122
+
123
+ def now_stamp() -> str:
124
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
125
+
126
+
127
+ def _entry_to_dict(e: LockEntry) -> dict[str, object]:
128
+ out: dict[str, object] = {
129
+ "target": e.target,
130
+ "kind": e.kind,
131
+ "algo": e.algo,
132
+ "digest": e.digest,
133
+ }
134
+ if e.file:
135
+ out["file"] = e.file
136
+ if e.locked_at:
137
+ out["locked_at"] = e.locked_at
138
+ if e.locked_by:
139
+ out["locked_by"] = e.locked_by
140
+ if e.reason:
141
+ out["reason"] = e.reason
142
+ if e.via_pattern:
143
+ out["via_pattern"] = True
144
+ return out
@@ -0,0 +1,5 @@
1
+ """Lua language support: UML parsing, rule recognition, conformance and locks."""
2
+
3
+ from code_constraints.lua.parser import parse_project
4
+
5
+ __all__ = ["parse_project"]
@@ -0,0 +1,239 @@
1
+ """Body-level conformance analysis for Lua (`cdec enforce`, Engine B).
2
+
3
+ Re-parses Lua source with tree-sitter and inspects function bodies for violations
4
+ of architectural-rule tags. Reuses `lua.rules_extract` so "what is a rule" stays
5
+ identical to the UML parser.
6
+
7
+ Lua is the loosest of the supported languages, so detection is explicitly
8
+ heuristic — closer to the Python analyzer than to the C# one:
9
+
10
+ * **Construction** has no syntax of its own. Two idioms are recognised:
11
+ `T.new(...)` / `T:new(...)` where `T` names a class in the project, and a
12
+ direct `setmetatable(tbl, T)`. A bare `T(...)` call is *not* treated as
13
+ construction, because in Lua that is an ordinary `__call`, not a constructor.
14
+ * **Field reassignment** is `self.x = …` outside the constructor. "The
15
+ constructor" means a static function on the class named `new`, `create` or
16
+ `init` — Lua has no `__init__` to privilege, so the convention is named
17
+ explicitly here rather than guessed per project.
18
+
19
+ `allow` is the escape hatch when the heuristic is wrong, exactly as in Python.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from pathlib import Path
25
+
26
+ import tree_sitter_lua
27
+ from tree_sitter import Language, Node, Parser
28
+
29
+ from code_constraints.core.annotations import literal_set
30
+ from code_constraints.core.model import Project, RuleAnnotation
31
+ from code_constraints.enforce.model import Finding
32
+ from code_constraints.lua.parser import (
33
+ _dot_parts,
34
+ _qualified_package_name,
35
+ _should_skip,
36
+ _text,
37
+ )
38
+ from code_constraints.lua.rules_extract import extract_rules
39
+
40
+ _LANG = Language(tree_sitter_lua.language())
41
+ _PARSER = Parser(_LANG)
42
+
43
+ CTOR_RULE = "no-instantiation"
44
+ FACTORY_RULE = "factory"
45
+ IMMUTABLE_RULE = "immutable"
46
+
47
+ # Function names treated as constructors: `self.x = …` in these is initialisation,
48
+ # not reassignment.
49
+ _CONSTRUCTOR_NAMES = frozenset({"new", "create", "init", "_init", "_new"})
50
+
51
+
52
+ def analyze(root: Path, project: Project) -> list[Finding]:
53
+ project_classes = {cls.name for cls in project.iter_classes()}
54
+ factory_index = _factory_index(project)
55
+ class_rules = {
56
+ cls.qualified_name: {r.name: r for r in cls.rules}
57
+ for cls in project.iter_classes()
58
+ }
59
+
60
+ findings: list[Finding] = []
61
+ for lua_file in sorted(root.rglob("*.lua")):
62
+ if _should_skip(lua_file):
63
+ continue
64
+ try:
65
+ source = lua_file.read_bytes()
66
+ except OSError:
67
+ continue
68
+ tree = _PARSER.parse(source)
69
+ rel = lua_file.relative_to(root)
70
+ package_qn = _qualified_package_name(rel)
71
+ for stmt in tree.root_node.named_children:
72
+ if stmt.type != "function_declaration":
73
+ continue
74
+ _analyze_function(
75
+ stmt, source, package_qn, rel, class_rules,
76
+ project_classes, factory_index, findings,
77
+ )
78
+ return findings
79
+
80
+
81
+ def _analyze_function(
82
+ node: Node,
83
+ source: bytes,
84
+ package_qn: str,
85
+ rel: Path,
86
+ class_rules: dict[str, dict[str, RuleAnnotation]],
87
+ project_classes: set[str],
88
+ factory_index: dict[str, set[str]],
89
+ out: list[Finding],
90
+ ) -> None:
91
+ target = next(
92
+ (
93
+ c
94
+ for c in node.children
95
+ if c.type in ("identifier", "dot_index_expression", "method_index_expression")
96
+ ),
97
+ None,
98
+ )
99
+ if target is None:
100
+ return
101
+ block = next((c for c in node.children if c.type == "block"), None)
102
+ if block is None:
103
+ return
104
+
105
+ if target.type == "identifier":
106
+ owner_name = rel.stem
107
+ name = _text(target, source)
108
+ else:
109
+ owner_name, name = _dot_parts(target, source)
110
+ if not owner_name or not name:
111
+ return
112
+ owner_qn = (
113
+ f"{package_qn}.{owner_name}" if package_qn != "__root__" else owner_name
114
+ )
115
+
116
+ func_rules = {r.name: r for r in extract_rules(node, source)}
117
+ owner_rules = class_rules.get(owner_qn, {})
118
+ file = rel.as_posix()
119
+
120
+ noinst = func_rules.get(CTOR_RULE, owner_rules.get(CTOR_RULE))
121
+ allow = literal_set(noinst.kwargs.get("allow", "")) if noinst is not None else None
122
+
123
+ for constructed, line in _constructions(block, source, project_classes):
124
+ if constructed == owner_name:
125
+ # A type constructing itself is never a violation: it is how the
126
+ # `T.new(...)` / inner-constructor idiom is written, and neither
127
+ # `no_instantiation` nor `factory` is aimed at a type's own
128
+ # constructor — they guard construction by *other* code.
129
+ continue
130
+ if allow is not None and constructed not in allow:
131
+ out.append(
132
+ Finding(
133
+ rule=CTOR_RULE,
134
+ qualified_name=owner_qn,
135
+ message=(
136
+ f"'{owner_qn}.{name}' is tagged @cdec no_instantiation but "
137
+ f"constructs '{constructed}'."
138
+ ),
139
+ detail=f"{name}->{constructed}",
140
+ file=file,
141
+ line=line,
142
+ )
143
+ )
144
+ designated = factory_index.get(constructed)
145
+ if designated is not None and owner_name not in designated:
146
+ allowed = ", ".join(sorted(designated)) or "(none)"
147
+ out.append(
148
+ Finding(
149
+ rule=FACTORY_RULE,
150
+ qualified_name=owner_qn,
151
+ message=(
152
+ f"'{owner_qn}.{name}' constructs '{constructed}' outside its "
153
+ f"designated factory ({allowed})."
154
+ ),
155
+ detail=f"{name}->{constructed}",
156
+ file=file,
157
+ line=line,
158
+ )
159
+ )
160
+
161
+ if IMMUTABLE_RULE in owner_rules and name not in _CONSTRUCTOR_NAMES:
162
+ for field, line in _self_assignments(block, source):
163
+ out.append(
164
+ Finding(
165
+ rule=IMMUTABLE_RULE,
166
+ qualified_name=owner_qn,
167
+ message=(
168
+ f"'{owner_qn}' is tagged @cdec immutable but '{name}' "
169
+ f"reassigns field 'self.{field}'."
170
+ ),
171
+ detail=f"{name}.{field}",
172
+ file=file,
173
+ line=line,
174
+ )
175
+ )
176
+
177
+
178
+ def _constructions(
179
+ block: Node, source: bytes, project_classes: set[str]
180
+ ) -> list[tuple[str, int]]:
181
+ """`T.new(...)` / `T:new(...)` calls and `setmetatable(t, T)` allocations."""
182
+ out: list[tuple[str, int]] = []
183
+ stack = [block]
184
+ while stack:
185
+ node = stack.pop()
186
+ stack.extend(node.children)
187
+ if node.type != "function_call":
188
+ continue
189
+ line = node.start_point[0] + 1
190
+ callee = node.named_children[0] if node.named_children else None
191
+ if callee is None:
192
+ continue
193
+
194
+ if callee.type in ("dot_index_expression", "method_index_expression"):
195
+ owner, member = _dot_parts(callee, source)
196
+ if owner in project_classes and member in _CONSTRUCTOR_NAMES:
197
+ out.append((owner, line))
198
+ continue
199
+
200
+ if callee.type == "identifier" and _text(callee, source) == "setmetatable":
201
+ args = next((c for c in node.named_children if c.type == "arguments"), None)
202
+ named = args.named_children if args else []
203
+ if len(named) >= 2 and named[1].type == "identifier":
204
+ metatable = _text(named[1], source)
205
+ if metatable in project_classes:
206
+ out.append((metatable, line))
207
+ return out
208
+
209
+
210
+ def _self_assignments(block: Node, source: bytes) -> list[tuple[str, int]]:
211
+ out: list[tuple[str, int]] = []
212
+ stack = [block]
213
+ while stack:
214
+ node = stack.pop()
215
+ stack.extend(node.children)
216
+ if node.type != "assignment_statement":
217
+ continue
218
+ targets = next((c for c in node.named_children if c.type == "variable_list"), None)
219
+ if targets is None:
220
+ continue
221
+ for target in targets.named_children:
222
+ if target.type != "dot_index_expression":
223
+ continue
224
+ owner, field = _dot_parts(target, source)
225
+ if owner == "self" and field:
226
+ out.append((field, node.start_point[0] + 1))
227
+ return out
228
+
229
+
230
+ def _factory_index(project: Project) -> dict[str, set[str]]:
231
+ """Created-type name -> set of class names designated to construct it."""
232
+ idx: dict[str, set[str]] = {}
233
+ for cls in project.iter_classes():
234
+ for rule in list(cls.rules) + [r for op in cls.operations for r in op.rules]:
235
+ if rule.name != FACTORY_RULE:
236
+ continue
237
+ for created in literal_set(rule.kwargs.get("creates", "")):
238
+ idx.setdefault(created, set()).add(cls.name)
239
+ return idx
@@ -0,0 +1,252 @@
1
+ """AST fingerprinting for Lua (`cdec lock`, Engine C).
2
+
3
+ Digests come from the tree-sitter tree via `core.ts_fingerprint`, so a locked
4
+ element survives reformatting and relocation within its file.
5
+
6
+ Lua-specific concerns:
7
+
8
+ * **A class is not one node.** The table-plus-metatable idiom spreads a class
9
+ across several top-level statements (`local T = {}`, `T.__index = T`, each
10
+ `function T:m()`), so a class target digests the whole group of statements that
11
+ belong to `T`. That is the right semantics for a freeze: adding a method to a
12
+ locked class is a change to the class.
13
+ * **Tags are comments**, so `---@cdec locked` lines are dropped from the digest
14
+ explicitly rather than incidentally — otherwise `include_docstrings` would let
15
+ applying a lock change the very digest it records.
16
+ * **Target names match the UML model**, so a target read from `cdec lock list` is
17
+ the target `cdec lock set` accepts. Naming reuses `lua.parser` helpers rather
18
+ than re-deriving them, so the two can't drift apart.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from pathlib import Path
24
+
25
+ import tree_sitter_lua
26
+ from tree_sitter import Language, Node, Parser
27
+
28
+ from code_constraints.core.ts_fingerprint import digest_members
29
+ from code_constraints.lock.model import LOCK_RULE, LockTarget
30
+ from code_constraints.lua.parser import (
31
+ _dot_parts,
32
+ _inner_assignment,
33
+ _qualified_package_name,
34
+ _should_skip,
35
+ _text,
36
+ )
37
+ from code_constraints.lua.rules_extract import comment_is_rule_tag, extract_rules
38
+
39
+ DIGEST_ALGO = "lua-ts/1"
40
+
41
+ _LANG = Language(tree_sitter_lua.language())
42
+ _PARSER = Parser(_LANG)
43
+
44
+
45
+ def collect_lockables(
46
+ root: str | Path, *, include_docstrings: bool = False
47
+ ) -> list[LockTarget]:
48
+ root_path = Path(root).resolve()
49
+ out: list[LockTarget] = []
50
+
51
+ for lua_file in sorted(root_path.rglob("*.lua")):
52
+ if _should_skip(lua_file):
53
+ continue
54
+ try:
55
+ source = lua_file.read_bytes()
56
+ except OSError:
57
+ continue
58
+ tree = _PARSER.parse(source)
59
+ rel = lua_file.relative_to(root_path)
60
+ out.extend(
61
+ _collect_file(
62
+ tree.root_node,
63
+ source,
64
+ _qualified_package_name(rel),
65
+ rel,
66
+ include_docstrings,
67
+ )
68
+ )
69
+ return out
70
+
71
+
72
+ def _collect_file(
73
+ root: Node,
74
+ source: bytes,
75
+ package_qn: str,
76
+ rel: Path,
77
+ include_docstrings: bool,
78
+ ) -> list[LockTarget]:
79
+ file = rel.as_posix()
80
+ # Table name -> the statements that *declare* it (`local T = {}`,
81
+ # `T.__index = T`, `T.field = …`). Kept apart from its function
82
+ # declarations, because a class's own tags come only from these: a
83
+ # `---@cdec locked` above `function T:m()` locks the method, not the class.
84
+ declarations: dict[str, list[Node]] = {}
85
+ # (table, member) -> the function declarations for it.
86
+ methods: dict[tuple[str, str], list[Node]] = {}
87
+ # Tables carrying a self-index or a metatable base — class evidence even
88
+ # with no methods, matching how the parser promotes a table.
89
+ marked: set[str] = set()
90
+ free: dict[str, list[Node]] = {}
91
+
92
+ for stmt in root.named_children:
93
+ if stmt.type == "function_declaration":
94
+ target = next(
95
+ (
96
+ c
97
+ for c in stmt.children
98
+ if c.type
99
+ in ("identifier", "dot_index_expression", "method_index_expression")
100
+ ),
101
+ None,
102
+ )
103
+ if target is None:
104
+ continue
105
+ if target.type == "identifier":
106
+ free.setdefault(_text(target, source), []).append(stmt)
107
+ continue
108
+ owner, name = _dot_parts(target, source)
109
+ if owner and name:
110
+ declarations.setdefault(owner, [])
111
+ methods.setdefault((owner, name), []).append(stmt)
112
+ continue
113
+
114
+ assign = _inner_assignment(stmt) if stmt.type == "variable_declaration" else (
115
+ stmt if stmt.type == "assignment_statement" else None
116
+ )
117
+ if assign is None:
118
+ continue
119
+ for name, is_marker in _assigned_tables(assign, source):
120
+ declarations.setdefault(name, []).append(stmt)
121
+ if is_marker:
122
+ marked.add(name)
123
+
124
+ out: list[LockTarget] = []
125
+ for table, decls in declarations.items():
126
+ member_nodes = [
127
+ node for (owner, _name), nodes in methods.items() if owner == table
128
+ for node in nodes
129
+ ]
130
+ rules = [r for node in decls for r in extract_rules(node, source)]
131
+ # Mirror the parser's promotion rule: a bare `local cfg = {}` data table
132
+ # is not a class, so it is not a lockable either.
133
+ if not (member_nodes or table in marked or rules):
134
+ continue
135
+ qn = f"{package_qn}.{table}" if package_qn != "__root__" else table
136
+ statements = sorted(decls + member_nodes, key=lambda n: n.start_byte)
137
+ out.append(
138
+ _target(
139
+ [(node, source) for node in statements],
140
+ qn,
141
+ "class",
142
+ file,
143
+ include_docstrings,
144
+ tag_nodes=decls,
145
+ )
146
+ )
147
+
148
+ for (table, name), nodes in methods.items():
149
+ owner = f"{package_qn}.{table}" if package_qn != "__root__" else table
150
+ out.append(
151
+ _target(
152
+ [(node, source) for node in nodes],
153
+ f"{owner}.{name}",
154
+ "method",
155
+ file,
156
+ include_docstrings,
157
+ )
158
+ )
159
+
160
+ for name, nodes in free.items():
161
+ owner = f"{package_qn}.{rel.stem}" if package_qn != "__root__" else rel.stem
162
+ out.append(
163
+ _target(
164
+ [(node, source) for node in nodes],
165
+ f"{owner}.{name}",
166
+ "function",
167
+ file,
168
+ include_docstrings,
169
+ )
170
+ )
171
+
172
+ return out
173
+
174
+
175
+ def _assigned_tables(assign: Node, source: bytes) -> list[tuple[str, bool]]:
176
+ """Table names an assignment contributes to, each with a "this is class
177
+ evidence" flag.
178
+
179
+ `T = {}` names `T` but proves nothing on its own; `T.__index = …` and
180
+ `T = setmetatable(…)` are the metatable markers that make it a prototype.
181
+ """
182
+ targets = next((c for c in assign.named_children if c.type == "variable_list"), None)
183
+ if targets is None:
184
+ return []
185
+ values = next((c for c in assign.named_children if c.type == "expression_list"), None)
186
+ out: list[tuple[str, bool]] = []
187
+ for i, target in enumerate(targets.named_children):
188
+ value = values.named_children[i] if values and i < len(values.named_children) else None
189
+ if target.type == "identifier":
190
+ marker = value is not None and _is_setmetatable(value, source)
191
+ out.append((_text(target, source), marker))
192
+ elif target.type == "dot_index_expression":
193
+ owner, field = _dot_parts(target, source)
194
+ if owner:
195
+ out.append((owner, field == "__index"))
196
+ return out
197
+
198
+
199
+ def _is_setmetatable(value: Node, source: bytes) -> bool:
200
+ if value.type != "function_call":
201
+ return False
202
+ callee = next((c for c in value.named_children if c.type == "identifier"), None)
203
+ return callee is not None and _text(callee, source) == "setmetatable"
204
+
205
+
206
+ def _target(
207
+ members: list[tuple[Node, bytes]],
208
+ target: str,
209
+ kind: str,
210
+ file: str,
211
+ include_docstrings: bool,
212
+ tag_nodes: list[Node] | None = None,
213
+ ) -> LockTarget:
214
+ """`tag_nodes` narrows where the `@locked` tag may be read from.
215
+
216
+ A class digests its methods as well as its declaration statements, but a tag
217
+ above `function T:m()` locks the *method*; only tags above the declaration
218
+ statements lock the class.
219
+ """
220
+
221
+ def drop(node: Node, source: bytes) -> bool:
222
+ if node.type != "comment":
223
+ return False
224
+ # A rule tag is never part of the implementation, whatever the docstring
225
+ # setting: applying or removing `---@cdec locked` must leave the digest
226
+ # of the body it guards untouched.
227
+ if comment_is_rule_tag(node, source):
228
+ return True
229
+ return not include_docstrings
230
+
231
+ declared = False
232
+ params: dict[str, str] = {}
233
+ source = members[0][1]
234
+ tagged = (
235
+ [(node, source) for node in tag_nodes] if tag_nodes is not None else members
236
+ )
237
+ for node, source in tagged:
238
+ for rule in extract_rules(node, source):
239
+ if rule.name == LOCK_RULE:
240
+ declared = True
241
+ params = {**rule.kwargs, **params} if params else dict(rule.kwargs)
242
+
243
+ return LockTarget(
244
+ target=target,
245
+ kind=kind, # type: ignore[arg-type]
246
+ digest=digest_members(members, drop),
247
+ algo=DIGEST_ALGO,
248
+ file=file,
249
+ line=members[0][0].start_point[0] + 1,
250
+ declared=declared,
251
+ params=params,
252
+ )