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,523 @@
|
|
|
1
|
+
"""Parse a Svelte 5 project (a directory of .svelte + .ts files) into a
|
|
2
|
+
`code_constraints.core.Project`.
|
|
3
|
+
|
|
4
|
+
Each `.svelte` file is modelled as a single UML class — the component itself.
|
|
5
|
+
The script block (between `<script>` and `</script>`, including `lang="ts"`
|
|
6
|
+
and `context="module"` variants) is fed to the TypeScript parser, with the
|
|
7
|
+
following Svelte-specific twists:
|
|
8
|
+
|
|
9
|
+
- Top-level `let` / `const` declarations inside the script become attributes
|
|
10
|
+
of the component class. `let foo = $state(...)`, `let { ... } = $props()`,
|
|
11
|
+
and `let bar = $derived(...)` are all recognised — the rune helpers are
|
|
12
|
+
captured in the attribute's default so it's obvious from the diagram what
|
|
13
|
+
kind of reactive value it is.
|
|
14
|
+
- Top-level `function` declarations become operations of the component class.
|
|
15
|
+
- Any *class* / *interface* / *enum* / *type alias* defined inside the script
|
|
16
|
+
block flows through to the package as a regular TypeScript class.
|
|
17
|
+
- Imports are tracked so a component reference in the markup
|
|
18
|
+
(`<Foo prop={x} />`) can be resolved to the imported component's qualified
|
|
19
|
+
name. Resolved component dependencies are surfaced as attributes whose type
|
|
20
|
+
matches the imported component's class name — that way the existing
|
|
21
|
+
`resolve_association` machinery wires up the association edges without any
|
|
22
|
+
Svelte-specific code in the renderer.
|
|
23
|
+
|
|
24
|
+
Plain `.ts` files alongside `.svelte` files are also parsed (using
|
|
25
|
+
`code_constraints.typescript.parser`) so a component's helper module shows up too.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import re
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from tree_sitter import Node
|
|
34
|
+
|
|
35
|
+
from code_constraints.core.model import (
|
|
36
|
+
Attribute,
|
|
37
|
+
Class,
|
|
38
|
+
Operation,
|
|
39
|
+
Package,
|
|
40
|
+
Parameter,
|
|
41
|
+
Project,
|
|
42
|
+
SourceLocation,
|
|
43
|
+
Visibility,
|
|
44
|
+
)
|
|
45
|
+
from code_constraints.typescript.parser import (
|
|
46
|
+
_PARSER_TS,
|
|
47
|
+
_ensure_package,
|
|
48
|
+
_qualified_package_name,
|
|
49
|
+
_strip_type_annotation,
|
|
50
|
+
_text,
|
|
51
|
+
parse_module_body,
|
|
52
|
+
)
|
|
53
|
+
from code_constraints.typescript.parser import _parameter_from as _ts_parameter_from
|
|
54
|
+
from code_constraints.typescript import parse_project as _ts_parse_project # noqa: F401 (kept for clarity)
|
|
55
|
+
from code_constraints.typescript.parser import _iter_ts_files, _should_skip
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_SCRIPT_RE = re.compile(
|
|
59
|
+
r"<script\b([^>]*)>(.*?)</script\s*>", re.DOTALL | re.IGNORECASE
|
|
60
|
+
)
|
|
61
|
+
_STYLE_RE = re.compile(r"<style\b[^>]*>.*?</style\s*>", re.DOTALL | re.IGNORECASE)
|
|
62
|
+
_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
63
|
+
# Component tag: opening `<` then a capital letter and JS-identifier chars,
|
|
64
|
+
# then either whitespace, a slash, or the closing `>`.
|
|
65
|
+
_COMPONENT_TAG_RE = re.compile(r"<([A-Z][A-Za-z0-9_$.]*)\b")
|
|
66
|
+
|
|
67
|
+
# `Foo.svelte` is the convention; `index.svelte` -> use the parent directory
|
|
68
|
+
# name as the component name.
|
|
69
|
+
_SVELTE_SUFFIX = ".svelte"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse_project(root: str | Path) -> Project:
|
|
73
|
+
root_path = Path(root).resolve()
|
|
74
|
+
if not root_path.is_dir():
|
|
75
|
+
raise ValueError(f"not a directory: {root_path}")
|
|
76
|
+
|
|
77
|
+
project = Project(source_language="svelte", root_path=str(root_path))
|
|
78
|
+
package_index: dict[str, Package] = {}
|
|
79
|
+
|
|
80
|
+
# First, parse every plain .ts / .tsx file so component imports can
|
|
81
|
+
# resolve to project classes regardless of file order.
|
|
82
|
+
for ts_file in sorted(_iter_ts_files(root_path)):
|
|
83
|
+
_parse_ts_file(ts_file, root_path, project, package_index)
|
|
84
|
+
|
|
85
|
+
# Then ingest .svelte files.
|
|
86
|
+
for svelte_file in sorted(root_path.rglob(f"*{_SVELTE_SUFFIX}")):
|
|
87
|
+
if _should_skip(svelte_file):
|
|
88
|
+
continue
|
|
89
|
+
_parse_svelte_file(svelte_file, root_path, project, package_index)
|
|
90
|
+
|
|
91
|
+
return project
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------- plain .ts files ----------
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _parse_ts_file(
|
|
98
|
+
file: Path,
|
|
99
|
+
root: Path,
|
|
100
|
+
project: Project,
|
|
101
|
+
package_index: dict[str, Package],
|
|
102
|
+
) -> None:
|
|
103
|
+
source_bytes = file.read_bytes()
|
|
104
|
+
tree = _PARSER_TS.parse(source_bytes)
|
|
105
|
+
rel = file.relative_to(root)
|
|
106
|
+
file_str = rel.as_posix()
|
|
107
|
+
package_qn = _qualified_package_name(rel)
|
|
108
|
+
_ensure_package(project, package_qn, package_index)
|
|
109
|
+
parse_module_body(
|
|
110
|
+
tree.root_node,
|
|
111
|
+
source_bytes,
|
|
112
|
+
package_qn=package_qn,
|
|
113
|
+
project=project,
|
|
114
|
+
package_index=package_index,
|
|
115
|
+
file_str=file_str,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------- .svelte files ----------
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _parse_svelte_file(
|
|
123
|
+
file: Path,
|
|
124
|
+
root: Path,
|
|
125
|
+
project: Project,
|
|
126
|
+
package_index: dict[str, Package],
|
|
127
|
+
) -> None:
|
|
128
|
+
rel = file.relative_to(root)
|
|
129
|
+
file_str = rel.as_posix()
|
|
130
|
+
package_qn = _qualified_package_name(rel)
|
|
131
|
+
pkg = _ensure_package(project, package_qn, package_index)
|
|
132
|
+
|
|
133
|
+
text = file.read_text(encoding="utf-8", errors="replace")
|
|
134
|
+
component_name = _component_name_from(file)
|
|
135
|
+
component_qn = (
|
|
136
|
+
f"{package_qn}.{component_name}"
|
|
137
|
+
if package_qn and package_qn != "__root__"
|
|
138
|
+
else component_name
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
component = Class(
|
|
142
|
+
name=component_name,
|
|
143
|
+
qualified_name=component_qn,
|
|
144
|
+
kind="class",
|
|
145
|
+
location=SourceLocation(
|
|
146
|
+
file=file_str,
|
|
147
|
+
start_line=1,
|
|
148
|
+
end_line=len(text.splitlines()) or 1,
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Strip styles + comments so markup scanning isn't fooled by tag-like
|
|
153
|
+
# content inside them.
|
|
154
|
+
markup_text = _STYLE_RE.sub("", text)
|
|
155
|
+
markup_text = _COMMENT_RE.sub("", markup_text)
|
|
156
|
+
|
|
157
|
+
imports: dict[str, str] = {} # local-name -> module path string
|
|
158
|
+
component_description: str | None = None
|
|
159
|
+
|
|
160
|
+
for script_match in _SCRIPT_RE.finditer(markup_text):
|
|
161
|
+
script_body = script_match.group(2)
|
|
162
|
+
# Pad with newlines so script tree-sitter line numbers don't end up
|
|
163
|
+
# confusingly close to the markup ones if we ever surface them.
|
|
164
|
+
script_bytes = script_body.encode("utf-8")
|
|
165
|
+
tree = _PARSER_TS.parse(script_bytes)
|
|
166
|
+
desc = _ingest_script(
|
|
167
|
+
tree.root_node,
|
|
168
|
+
script_bytes,
|
|
169
|
+
component=component,
|
|
170
|
+
package_qn=package_qn,
|
|
171
|
+
project=project,
|
|
172
|
+
package_index=package_index,
|
|
173
|
+
imports=imports,
|
|
174
|
+
file_str=file_str,
|
|
175
|
+
)
|
|
176
|
+
if desc and component_description is None:
|
|
177
|
+
component_description = desc
|
|
178
|
+
|
|
179
|
+
# Now scan markup for component references and turn each unique one
|
|
180
|
+
# whose import we resolved into an attribute (so `resolve_association`
|
|
181
|
+
# picks it up as a dependency).
|
|
182
|
+
markup_only = _SCRIPT_RE.sub("", markup_text)
|
|
183
|
+
referenced: list[str] = []
|
|
184
|
+
seen_refs: set[str] = set()
|
|
185
|
+
for m in _COMPONENT_TAG_RE.finditer(markup_only):
|
|
186
|
+
name = m.group(1)
|
|
187
|
+
# If the import was `import * as Util from "..."`, the user writes
|
|
188
|
+
# `<Util.Foo />`. Track the head identifier in that case.
|
|
189
|
+
head = name.split(".")[0]
|
|
190
|
+
if head not in imports:
|
|
191
|
+
continue
|
|
192
|
+
if name in seen_refs:
|
|
193
|
+
continue
|
|
194
|
+
seen_refs.add(name)
|
|
195
|
+
referenced.append(name)
|
|
196
|
+
|
|
197
|
+
for ref_name in referenced:
|
|
198
|
+
head = ref_name.split(".")[0]
|
|
199
|
+
target_module = imports[head]
|
|
200
|
+
target_qn = _resolve_component_qn(target_module, file, root, ref_name)
|
|
201
|
+
# Surface the dependency as an attribute. `resolve_association`
|
|
202
|
+
# matches by the trailing identifier so a value like
|
|
203
|
+
# `target_qn = "app.components.Foo"` will edge-link to that class
|
|
204
|
+
# if it exists in the project.
|
|
205
|
+
component.attributes.append(
|
|
206
|
+
Attribute(
|
|
207
|
+
name=_attr_name_for_ref(ref_name),
|
|
208
|
+
type=target_qn,
|
|
209
|
+
visibility=Visibility.PRIVATE,
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
if component_description is not None:
|
|
214
|
+
component.description = component_description
|
|
215
|
+
|
|
216
|
+
pkg.classes.append(component)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _component_name_from(file: Path) -> str:
|
|
220
|
+
stem = file.stem
|
|
221
|
+
if stem.lower() in ("index", "+page", "+layout"):
|
|
222
|
+
# Fall back to the parent directory name for SvelteKit route files /
|
|
223
|
+
# barrel-style `index.svelte`s.
|
|
224
|
+
return file.parent.name or stem
|
|
225
|
+
return stem
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _resolve_component_qn(
|
|
229
|
+
module_path: str, importer: Path, root: Path, ref_name: str
|
|
230
|
+
) -> str:
|
|
231
|
+
"""Resolve a `from "..."` module path to a project-qualified class name.
|
|
232
|
+
|
|
233
|
+
Returns the bare component name if the import is to a node_modules
|
|
234
|
+
package or otherwise unresolvable — the renderer will then just show it
|
|
235
|
+
as an unattached label, which is exactly right for external components.
|
|
236
|
+
"""
|
|
237
|
+
# `ref_name` may be "Foo" or "Util.Foo" — the trailing component is the
|
|
238
|
+
# class we're after.
|
|
239
|
+
short_name = ref_name.split(".")[-1]
|
|
240
|
+
if not module_path.startswith(".") and not module_path.startswith("/"):
|
|
241
|
+
return short_name
|
|
242
|
+
target = (importer.parent / module_path).resolve()
|
|
243
|
+
candidates = [
|
|
244
|
+
target,
|
|
245
|
+
target.with_suffix(".svelte"),
|
|
246
|
+
target.with_suffix(".ts"),
|
|
247
|
+
target.with_suffix(".tsx"),
|
|
248
|
+
target / f"{short_name}.svelte",
|
|
249
|
+
target / "index.svelte",
|
|
250
|
+
target / "index.ts",
|
|
251
|
+
]
|
|
252
|
+
for c in candidates:
|
|
253
|
+
if c.exists() and c.is_file():
|
|
254
|
+
try:
|
|
255
|
+
rel = c.relative_to(root)
|
|
256
|
+
except ValueError:
|
|
257
|
+
continue
|
|
258
|
+
package_qn = _qualified_package_name(rel)
|
|
259
|
+
if c.suffix == _SVELTE_SUFFIX:
|
|
260
|
+
stem = c.stem
|
|
261
|
+
if stem.lower() in ("index", "+page", "+layout"):
|
|
262
|
+
stem = c.parent.name or stem
|
|
263
|
+
if package_qn and package_qn != "__root__":
|
|
264
|
+
return f"{package_qn}.{stem}"
|
|
265
|
+
return stem
|
|
266
|
+
# Plain .ts: a re-export barrel. Best effort: assume the short
|
|
267
|
+
# name matches a class declared in that file.
|
|
268
|
+
if package_qn and package_qn != "__root__":
|
|
269
|
+
return f"{package_qn}.{short_name}"
|
|
270
|
+
return short_name
|
|
271
|
+
return short_name
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _attr_name_for_ref(ref_name: str) -> str:
|
|
275
|
+
"""Turn a component tag like `Foo` or `Util.Foo` into a stable attribute
|
|
276
|
+
slot name. Prefixed with `_` so the visibility heuristic still flags it
|
|
277
|
+
as protected/internal."""
|
|
278
|
+
safe = ref_name.replace(".", "_")
|
|
279
|
+
return f"_{safe[0].lower()}{safe[1:]}" if safe else "_ref"
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# ---------- script block ingestion ----------
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _ingest_script(
|
|
286
|
+
root: Node,
|
|
287
|
+
source: bytes,
|
|
288
|
+
*,
|
|
289
|
+
component: Class,
|
|
290
|
+
package_qn: str,
|
|
291
|
+
project: Project,
|
|
292
|
+
package_index: dict[str, Package],
|
|
293
|
+
imports: dict[str, str],
|
|
294
|
+
file_str: str,
|
|
295
|
+
) -> str | None:
|
|
296
|
+
"""Walk a parsed `<script>` body. Returns the first JSDoc block found
|
|
297
|
+
above a top-level declaration, used as the component description."""
|
|
298
|
+
first_doc: str | None = None
|
|
299
|
+
for node in root.named_children:
|
|
300
|
+
unwrapped = node
|
|
301
|
+
if node.type == "export_statement" and node.named_child_count == 1:
|
|
302
|
+
unwrapped = node.named_children[0]
|
|
303
|
+
|
|
304
|
+
if unwrapped.type == "import_statement":
|
|
305
|
+
_record_import(unwrapped, source, imports)
|
|
306
|
+
continue
|
|
307
|
+
|
|
308
|
+
if unwrapped.type == "lexical_declaration":
|
|
309
|
+
for attr in _attributes_from_lexical(unwrapped, source):
|
|
310
|
+
# De-dupe against any same-named attribute already on the
|
|
311
|
+
# component (e.g. from a prior script block).
|
|
312
|
+
if not any(a.name == attr.name for a in component.attributes):
|
|
313
|
+
component.attributes.append(attr)
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
if unwrapped.type == "function_declaration":
|
|
317
|
+
component.operations.append(_operation_from_function(unwrapped, source))
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
if unwrapped.type in {
|
|
321
|
+
"class_declaration",
|
|
322
|
+
"abstract_class_declaration",
|
|
323
|
+
"interface_declaration",
|
|
324
|
+
"enum_declaration",
|
|
325
|
+
"type_alias_declaration",
|
|
326
|
+
"internal_module",
|
|
327
|
+
}:
|
|
328
|
+
# Let the TS parser ingest these as regular classes.
|
|
329
|
+
parse_module_body(
|
|
330
|
+
_Wrap([unwrapped]), # type: ignore[arg-type]
|
|
331
|
+
source,
|
|
332
|
+
package_qn=package_qn,
|
|
333
|
+
project=project,
|
|
334
|
+
package_index=package_index,
|
|
335
|
+
file_str=file_str,
|
|
336
|
+
)
|
|
337
|
+
continue
|
|
338
|
+
|
|
339
|
+
return first_doc
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class _Wrap:
|
|
343
|
+
"""Minimal Node-shaped wrapper letting us reuse `parse_module_body` for
|
|
344
|
+
a hand-picked list of top-level declarations.
|
|
345
|
+
|
|
346
|
+
`parse_module_body` only touches `.named_children`, so we expose just that.
|
|
347
|
+
"""
|
|
348
|
+
|
|
349
|
+
def __init__(self, children: list[Node]) -> None:
|
|
350
|
+
self.named_children = children
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _record_import(node: Node, source: bytes, imports: dict[str, str]) -> None:
|
|
354
|
+
"""Populate `imports` with every binding introduced by an import_statement."""
|
|
355
|
+
# The module path lives in a `string` child of the import_statement.
|
|
356
|
+
module_node = next((c for c in node.named_children if c.type == "string"), None)
|
|
357
|
+
if module_node is None:
|
|
358
|
+
return
|
|
359
|
+
module_path = _string_value(module_node, source)
|
|
360
|
+
|
|
361
|
+
clause = next(
|
|
362
|
+
(c for c in node.named_children if c.type == "import_clause"), None
|
|
363
|
+
)
|
|
364
|
+
if clause is None:
|
|
365
|
+
return
|
|
366
|
+
for child in clause.named_children:
|
|
367
|
+
if child.type == "identifier":
|
|
368
|
+
imports[_text(child, source)] = module_path
|
|
369
|
+
elif child.type == "namespace_import":
|
|
370
|
+
for c in child.named_children:
|
|
371
|
+
if c.type == "identifier":
|
|
372
|
+
imports[_text(c, source)] = module_path
|
|
373
|
+
elif child.type == "named_imports":
|
|
374
|
+
for spec in child.named_children:
|
|
375
|
+
if spec.type != "import_specifier":
|
|
376
|
+
continue
|
|
377
|
+
idents = [c for c in spec.named_children if c.type == "identifier"]
|
|
378
|
+
# `import { Foo as Bar }` → idents are [Foo, Bar]; we want Bar
|
|
379
|
+
# as the binding. `import { Foo }` → idents = [Foo].
|
|
380
|
+
if idents:
|
|
381
|
+
imports[_text(idents[-1], source)] = module_path
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _string_value(node: Node, source: bytes) -> str:
|
|
385
|
+
"""Extract the literal text of a `string` node (drops the surrounding
|
|
386
|
+
quotes; does not handle escape sequences)."""
|
|
387
|
+
for c in node.named_children:
|
|
388
|
+
if c.type == "string_fragment":
|
|
389
|
+
return _text(c, source)
|
|
390
|
+
raw = _text(node, source)
|
|
391
|
+
if len(raw) >= 2 and raw[0] in ("'", '"', "`"):
|
|
392
|
+
return raw[1:-1]
|
|
393
|
+
return raw
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _attributes_from_lexical(node: Node, source: bytes) -> list[Attribute]:
|
|
397
|
+
"""One `let`/`const` declaration can introduce multiple variables (object
|
|
398
|
+
destructuring); return one Attribute per leaf binding."""
|
|
399
|
+
out: list[Attribute] = []
|
|
400
|
+
for declarator in node.named_children:
|
|
401
|
+
if declarator.type != "variable_declarator":
|
|
402
|
+
continue
|
|
403
|
+
name_node = declarator.child_by_field_name("name") or _first_named_child(
|
|
404
|
+
declarator
|
|
405
|
+
)
|
|
406
|
+
if name_node is None:
|
|
407
|
+
continue
|
|
408
|
+
type_node = next(
|
|
409
|
+
(c for c in declarator.named_children if c.type == "type_annotation"),
|
|
410
|
+
None,
|
|
411
|
+
)
|
|
412
|
+
type_text = (
|
|
413
|
+
_strip_type_annotation(_text(type_node, source)) if type_node else ""
|
|
414
|
+
)
|
|
415
|
+
# Find the initializer (named child that isn't the name or type).
|
|
416
|
+
init = None
|
|
417
|
+
for c in declarator.named_children:
|
|
418
|
+
if c is name_node or c is type_node:
|
|
419
|
+
continue
|
|
420
|
+
init = c
|
|
421
|
+
if name_node.type in ("object_pattern", "array_pattern"):
|
|
422
|
+
for sub in _flatten_pattern(name_node, source):
|
|
423
|
+
out.append(
|
|
424
|
+
Attribute(
|
|
425
|
+
name=sub,
|
|
426
|
+
type=_rune_type_hint(init, source) or "",
|
|
427
|
+
visibility=Visibility.PUBLIC,
|
|
428
|
+
default=_rune_default(init, source),
|
|
429
|
+
)
|
|
430
|
+
)
|
|
431
|
+
else:
|
|
432
|
+
out.append(
|
|
433
|
+
Attribute(
|
|
434
|
+
name=_text(name_node, source),
|
|
435
|
+
type=type_text,
|
|
436
|
+
visibility=Visibility.PUBLIC,
|
|
437
|
+
default=_rune_default(init, source),
|
|
438
|
+
)
|
|
439
|
+
)
|
|
440
|
+
return out
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _flatten_pattern(pattern: Node, source: bytes) -> list[str]:
|
|
444
|
+
"""Pull binding names out of an object_pattern / array_pattern."""
|
|
445
|
+
out: list[str] = []
|
|
446
|
+
for child in pattern.named_children:
|
|
447
|
+
if child.type == "shorthand_property_identifier_pattern":
|
|
448
|
+
out.append(_text(child, source))
|
|
449
|
+
elif child.type == "identifier":
|
|
450
|
+
out.append(_text(child, source))
|
|
451
|
+
elif child.type == "object_assignment_pattern":
|
|
452
|
+
# `{ foo = default }` — first named child is the binding.
|
|
453
|
+
first = _first_named_child(child)
|
|
454
|
+
if first is not None:
|
|
455
|
+
out.append(_text(first, source))
|
|
456
|
+
elif child.type == "pair_pattern":
|
|
457
|
+
# `{ foo: bar }` — `bar` is the binding name.
|
|
458
|
+
for c in child.named_children:
|
|
459
|
+
if c.type in ("identifier", "shorthand_property_identifier_pattern"):
|
|
460
|
+
out.append(_text(c, source))
|
|
461
|
+
elif child.type in ("object_pattern", "array_pattern"):
|
|
462
|
+
out.extend(_flatten_pattern(child, source))
|
|
463
|
+
return out
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
_RUNE_NAMES = ("$state", "$derived", "$props", "$bindable", "$effect")
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _rune_default(init: Node | None, source: bytes) -> str | None:
|
|
470
|
+
"""Surface rune helpers in the default field so the diagram label makes
|
|
471
|
+
it obvious that an attribute is reactive."""
|
|
472
|
+
if init is None:
|
|
473
|
+
return None
|
|
474
|
+
text = _text(init, source).strip()
|
|
475
|
+
if not text:
|
|
476
|
+
return None
|
|
477
|
+
# Truncate long initializers — keep just enough to be a useful hint.
|
|
478
|
+
if len(text) > 60:
|
|
479
|
+
text = text[:60] + "…"
|
|
480
|
+
return text
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _rune_type_hint(init: Node | None, source: bytes) -> str | None:
|
|
484
|
+
"""If a destructured binding came from `$props()`, mark its type as
|
|
485
|
+
`Props` so the diagram hints at the source."""
|
|
486
|
+
if init is None:
|
|
487
|
+
return None
|
|
488
|
+
raw = _text(init, source).strip()
|
|
489
|
+
for rune in _RUNE_NAMES:
|
|
490
|
+
if raw.startswith(rune + "("):
|
|
491
|
+
return rune
|
|
492
|
+
return None
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _operation_from_function(node: Node, source: bytes) -> Operation:
|
|
496
|
+
name_node = node.child_by_field_name("name")
|
|
497
|
+
name = _text(name_node, source) if name_node else ""
|
|
498
|
+
return_node = next(
|
|
499
|
+
(c for c in node.named_children if c.type == "type_annotation"),
|
|
500
|
+
None,
|
|
501
|
+
)
|
|
502
|
+
return_type = (
|
|
503
|
+
_strip_type_annotation(_text(return_node, source)) if return_node else ""
|
|
504
|
+
)
|
|
505
|
+
params: list[Parameter] = []
|
|
506
|
+
params_node = node.child_by_field_name("parameters")
|
|
507
|
+
if params_node is not None:
|
|
508
|
+
for p in params_node.named_children:
|
|
509
|
+
if p.type not in ("required_parameter", "optional_parameter"):
|
|
510
|
+
continue
|
|
511
|
+
params.append(_ts_parameter_from(p, source))
|
|
512
|
+
return Operation(
|
|
513
|
+
name=name,
|
|
514
|
+
parameters=params,
|
|
515
|
+
return_type=return_type,
|
|
516
|
+
visibility=Visibility.PUBLIC,
|
|
517
|
+
is_static=False,
|
|
518
|
+
is_abstract=False,
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _first_named_child(node: Node) -> Node | None:
|
|
523
|
+
return node.named_children[0] if node.named_child_count else None
|