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