codetruth 0.2.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.
- codetruth/__init__.py +16 -0
- codetruth/api.py +102 -0
- codetruth/cli.py +183 -0
- codetruth/core/__init__.py +0 -0
- codetruth/core/cache.py +104 -0
- codetruth/core/config.py +74 -0
- codetruth/core/deletion.py +165 -0
- codetruth/core/evidence.py +306 -0
- codetruth/core/graph.py +44 -0
- codetruth/core/models.py +193 -0
- codetruth/core/plugin.py +88 -0
- codetruth/core/report.py +107 -0
- codetruth/core/scanner.py +228 -0
- codetruth/languages/__init__.py +0 -0
- codetruth/languages/go/__init__.py +1 -0
- codetruth/languages/javascript/__init__.py +8 -0
- codetruth/languages/javascript/edges.py +361 -0
- codetruth/languages/javascript/extractor.py +402 -0
- codetruth/languages/javascript/plugin.py +52 -0
- codetruth/languages/javascript/rules.py +201 -0
- codetruth/languages/python/__init__.py +0 -0
- codetruth/languages/python/edges.py +474 -0
- codetruth/languages/python/extractor.py +271 -0
- codetruth/languages/python/plugin.py +49 -0
- codetruth/languages/python/rules.py +332 -0
- codetruth/mcp_server.py +138 -0
- codetruth/rules/python/common_dynamic.yaml +70 -0
- codetruth/rules/python/django.yaml +57 -0
- codetruth/rules/python/fastapi.yaml +38 -0
- codetruth/rules/python/orm_web.yaml +50 -0
- codetruth/runtime/__init__.py +283 -0
- codetruth-0.2.0.dist-info/METADATA +215 -0
- codetruth-0.2.0.dist-info/RECORD +37 -0
- codetruth-0.2.0.dist-info/WHEEL +5 -0
- codetruth-0.2.0.dist-info/entry_points.txt +2 -0
- codetruth-0.2.0.dist-info/licenses/LICENSE +21 -0
- codetruth-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""Layer 1 — symbol extraction for JavaScript/TypeScript via tree-sitter.
|
|
2
|
+
|
|
3
|
+
Modules are files; a module's name is its repo-relative posix path without
|
|
4
|
+
extension (e.g. 'src/utils'). Symbol ids are '<module>:<qualname>' exactly
|
|
5
|
+
like the Python plugin, so every language-agnostic layer (graph, evidence,
|
|
6
|
+
ranking, backstop, cache, config) applies unchanged.
|
|
7
|
+
|
|
8
|
+
Requires the optional dependency group: pip install codetruth[javascript]
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from ...core.models import Symbol, SymbolType
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from tree_sitter_language_pack import get_parser
|
|
20
|
+
except ImportError: # pragma: no cover
|
|
21
|
+
get_parser = None
|
|
22
|
+
|
|
23
|
+
JS_EXTS = {".js", ".jsx", ".mjs", ".cjs"}
|
|
24
|
+
TS_EXTS = {".ts", ".mts", ".cts"}
|
|
25
|
+
TSX_EXTS = {".tsx"}
|
|
26
|
+
ALL_EXTS = JS_EXTS | TS_EXTS | TSX_EXTS
|
|
27
|
+
|
|
28
|
+
SKIP_DIRS = {
|
|
29
|
+
"node_modules", ".git", ".hg", "dist", "build", "out", ".next", ".nuxt",
|
|
30
|
+
"coverage", ".turbo", ".cache", "vendor", ".codetruth", "__pycache__",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
CONFIG_EXTS = {".json", ".yaml", ".yml", ".toml", ".html"}
|
|
34
|
+
|
|
35
|
+
_FUNC_VALUE_TYPES = {"arrow_function", "function_expression", "function",
|
|
36
|
+
"generator_function"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _parser_for(path: Path):
|
|
40
|
+
if path.suffix in TS_EXTS:
|
|
41
|
+
return get_parser("typescript")
|
|
42
|
+
if path.suffix in TSX_EXTS:
|
|
43
|
+
return get_parser("tsx")
|
|
44
|
+
return get_parser("javascript")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ImportRec:
|
|
49
|
+
kind: str # named | default | namespace | side | reexport | reexport_all
|
|
50
|
+
source: str # the module specifier string ('./utils', 'react')
|
|
51
|
+
name: str # imported name ('' for default/namespace/side)
|
|
52
|
+
alias: str # local binding ('' when none, e.g. side-effect import)
|
|
53
|
+
line: int
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class ModuleInfo:
|
|
58
|
+
name: str
|
|
59
|
+
rel_path: str
|
|
60
|
+
abs_path: str
|
|
61
|
+
is_test: bool
|
|
62
|
+
source: bytes = b""
|
|
63
|
+
tree: object = None # tree_sitter.Tree
|
|
64
|
+
symbols: list[Symbol] = field(default_factory=list)
|
|
65
|
+
imports: list[ImportRec] = field(default_factory=list)
|
|
66
|
+
exports: set = field(default_factory=set) # exported local names
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def is_test_path(rel_path: str) -> bool:
|
|
70
|
+
p = rel_path.replace("\\", "/").lower()
|
|
71
|
+
base = p.rsplit("/", 1)[-1]
|
|
72
|
+
return ("/__tests__/" in f"/{p}" or ".test." in base or ".spec." in base
|
|
73
|
+
or any(part in ("tests", "test") for part in p.split("/")[:-1]))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def iter_source_files(root: Path, ignores: tuple[str, ...] = ()):
|
|
77
|
+
from ...core.config import RepoConfig
|
|
78
|
+
cfg = RepoConfig(ignore_paths=list(ignores)) if ignores else None
|
|
79
|
+
for path in sorted(root.rglob("*")):
|
|
80
|
+
if not path.is_file() or path.suffix.lower() not in ALL_EXTS:
|
|
81
|
+
continue
|
|
82
|
+
rel = path.relative_to(root)
|
|
83
|
+
if any(part in SKIP_DIRS for part in rel.parts):
|
|
84
|
+
continue
|
|
85
|
+
if path.name.endswith(".d.ts"):
|
|
86
|
+
continue # type declarations describe externals; no runtime code
|
|
87
|
+
if cfg and cfg.is_ignored(rel.as_posix()):
|
|
88
|
+
continue
|
|
89
|
+
yield path
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def iter_config_files(root: Path, ignores: tuple[str, ...] = ()):
|
|
93
|
+
from ...core.config import RepoConfig
|
|
94
|
+
cfg = RepoConfig(ignore_paths=list(ignores)) if ignores else None
|
|
95
|
+
for path in sorted(root.rglob("*")):
|
|
96
|
+
if not path.is_file() or path.suffix.lower() not in CONFIG_EXTS:
|
|
97
|
+
continue
|
|
98
|
+
rel = path.relative_to(root)
|
|
99
|
+
if any(part in SKIP_DIRS for part in rel.parts):
|
|
100
|
+
continue
|
|
101
|
+
if cfg and cfg.is_ignored(rel.as_posix()):
|
|
102
|
+
continue
|
|
103
|
+
yield path
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def module_name_for(path: Path, root: Path) -> str:
|
|
107
|
+
rel = path.relative_to(root).as_posix()
|
|
108
|
+
return rel.rsplit(".", 1)[0]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _text(node, src: bytes) -> str:
|
|
112
|
+
return src[node.start_byte:node.end_byte].decode("utf-8", "replace")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _Extractor:
|
|
116
|
+
def __init__(self, mi: ModuleInfo):
|
|
117
|
+
self.mi = mi
|
|
118
|
+
self.src = mi.source
|
|
119
|
+
|
|
120
|
+
# -- helpers --------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def _add(self, name: str, qual: str, stype: SymbolType, node,
|
|
123
|
+
parent: str, exported: bool) -> Symbol:
|
|
124
|
+
sym = Symbol(
|
|
125
|
+
id=f"{self.mi.name}:{qual}", name=name, qualname=qual, type=stype,
|
|
126
|
+
file=self.mi.rel_path, line=node.start_point[0] + 1,
|
|
127
|
+
end_line=node.end_point[0] + 1, module=self.mi.name,
|
|
128
|
+
parent=parent, exported=exported, is_public=exported,
|
|
129
|
+
is_test=self.mi.is_test,
|
|
130
|
+
)
|
|
131
|
+
self.mi.symbols.append(sym)
|
|
132
|
+
return sym
|
|
133
|
+
|
|
134
|
+
# -- main walk ------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
def run(self) -> None:
|
|
137
|
+
mi = self.mi
|
|
138
|
+
root = mi.tree.root_node
|
|
139
|
+
mi.symbols.append(Symbol(
|
|
140
|
+
id=mi.name, name=mi.name.rsplit("/", 1)[-1], qualname=mi.name,
|
|
141
|
+
type=SymbolType.MODULE, file=mi.rel_path, line=1,
|
|
142
|
+
end_line=root.end_point[0] + 1, module=mi.name, parent=None,
|
|
143
|
+
exported=True, is_public=True, is_test=mi.is_test,
|
|
144
|
+
))
|
|
145
|
+
self._walk_statements(root, prefix="", parent=mi.name, exported=False)
|
|
146
|
+
# Second pass: exported flags for names exported via clauses/CommonJS.
|
|
147
|
+
for s in mi.symbols:
|
|
148
|
+
if s.parent == mi.name and s.name in mi.exports:
|
|
149
|
+
s.exported = True
|
|
150
|
+
s.is_public = True
|
|
151
|
+
|
|
152
|
+
def _walk_statements(self, container, prefix: str, parent: str,
|
|
153
|
+
exported: bool) -> None:
|
|
154
|
+
for node in container.named_children:
|
|
155
|
+
t = node.type
|
|
156
|
+
if t == "export_statement":
|
|
157
|
+
self._handle_export(node, prefix, parent)
|
|
158
|
+
elif t in ("function_declaration", "generator_function_declaration"):
|
|
159
|
+
self._add_function(node, prefix, parent, exported)
|
|
160
|
+
elif t in ("class_declaration", "abstract_class_declaration"):
|
|
161
|
+
self._add_class(node, prefix, parent, exported)
|
|
162
|
+
elif t in ("lexical_declaration", "variable_declaration"):
|
|
163
|
+
self._add_variables(node, prefix, parent, exported)
|
|
164
|
+
elif t in ("interface_declaration", "enum_declaration"):
|
|
165
|
+
self._add_named(node, prefix, parent, exported, SymbolType.CLASS)
|
|
166
|
+
elif t == "type_alias_declaration":
|
|
167
|
+
self._add_named(node, prefix, parent, exported,
|
|
168
|
+
SymbolType.VARIABLE)
|
|
169
|
+
elif t == "import_statement":
|
|
170
|
+
self._handle_import(node)
|
|
171
|
+
elif t == "expression_statement":
|
|
172
|
+
self._handle_expression(node)
|
|
173
|
+
elif t in ("statement_block", "if_statement", "try_statement",
|
|
174
|
+
"for_statement", "while_statement", "labeled_statement"):
|
|
175
|
+
self._walk_statements(node, prefix, parent, exported)
|
|
176
|
+
|
|
177
|
+
# -- declarations -----------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
def _add_function(self, node, prefix, parent, exported):
|
|
180
|
+
name_node = node.child_by_field_name("name")
|
|
181
|
+
if name_node is None:
|
|
182
|
+
return
|
|
183
|
+
name = _text(name_node, self.src)
|
|
184
|
+
qual = f"{prefix}{name}"
|
|
185
|
+
sym = self._add(name, qual, SymbolType.FUNCTION, node, parent, exported)
|
|
186
|
+
body = node.child_by_field_name("body")
|
|
187
|
+
if body is not None:
|
|
188
|
+
self._walk_statements(body, prefix=f"{qual}.", parent=sym.id,
|
|
189
|
+
exported=False)
|
|
190
|
+
|
|
191
|
+
def _add_class(self, node, prefix, parent, exported):
|
|
192
|
+
name_node = node.child_by_field_name("name")
|
|
193
|
+
if name_node is None:
|
|
194
|
+
return
|
|
195
|
+
name = _text(name_node, self.src)
|
|
196
|
+
qual = f"{prefix}{name}"
|
|
197
|
+
sym = self._add(name, qual, SymbolType.CLASS, node, parent, exported)
|
|
198
|
+
# Superclass name recorded for the external-base caution logic.
|
|
199
|
+
heritage = next((c for c in node.named_children
|
|
200
|
+
if c.type in ("class_heritage", "extends_clause")), None)
|
|
201
|
+
if heritage is not None:
|
|
202
|
+
base = _text(heritage, self.src)
|
|
203
|
+
base = base.replace("extends", "").strip().split("{")[0].strip()
|
|
204
|
+
if base:
|
|
205
|
+
sym.bases.append(base.split("(")[0].strip())
|
|
206
|
+
body = node.child_by_field_name("body")
|
|
207
|
+
if body is None:
|
|
208
|
+
return
|
|
209
|
+
for member in body.named_children:
|
|
210
|
+
if member.type == "method_definition":
|
|
211
|
+
mname_node = member.child_by_field_name("name")
|
|
212
|
+
if mname_node is None:
|
|
213
|
+
continue
|
|
214
|
+
mname = _text(mname_node, self.src)
|
|
215
|
+
msym = self._add(mname, f"{qual}.{mname}", SymbolType.METHOD,
|
|
216
|
+
member, sym.id, exported=False)
|
|
217
|
+
# Methods are reachable on any instance — public unless the
|
|
218
|
+
# name is conventionally/lexically private.
|
|
219
|
+
msym.is_public = not mname.startswith(("_", "#"))
|
|
220
|
+
elif member.type in ("field_definition", "public_field_definition"):
|
|
221
|
+
fname_node = member.child_by_field_name("property") \
|
|
222
|
+
or member.child_by_field_name("name")
|
|
223
|
+
value = member.child_by_field_name("value")
|
|
224
|
+
if fname_node is None:
|
|
225
|
+
continue
|
|
226
|
+
fname = _text(fname_node, self.src)
|
|
227
|
+
stype = SymbolType.METHOD if value is not None \
|
|
228
|
+
and value.type in _FUNC_VALUE_TYPES else SymbolType.VARIABLE
|
|
229
|
+
fsym = self._add(fname, f"{qual}.{fname}", stype, member,
|
|
230
|
+
sym.id, exported=False)
|
|
231
|
+
fsym.is_public = not fname.startswith(("_", "#"))
|
|
232
|
+
|
|
233
|
+
def _add_named(self, node, prefix, parent, exported, stype):
|
|
234
|
+
name_node = node.child_by_field_name("name")
|
|
235
|
+
if name_node is not None:
|
|
236
|
+
name = _text(name_node, self.src)
|
|
237
|
+
self._add(name, f"{prefix}{name}", stype, node, parent, exported)
|
|
238
|
+
|
|
239
|
+
def _add_variables(self, node, prefix, parent, exported):
|
|
240
|
+
for decl in node.named_children:
|
|
241
|
+
if decl.type != "variable_declarator":
|
|
242
|
+
continue
|
|
243
|
+
name_node = decl.child_by_field_name("name")
|
|
244
|
+
if name_node is None or name_node.type != "identifier":
|
|
245
|
+
continue # destructuring handled by import logic when relevant
|
|
246
|
+
name = _text(name_node, self.src)
|
|
247
|
+
value = decl.child_by_field_name("value")
|
|
248
|
+
# `const m = require('./x')` is an import, not a symbol.
|
|
249
|
+
if value is not None and self._maybe_require(name, value):
|
|
250
|
+
continue
|
|
251
|
+
stype = SymbolType.FUNCTION if value is not None \
|
|
252
|
+
and value.type in _FUNC_VALUE_TYPES else SymbolType.VARIABLE
|
|
253
|
+
qual = f"{prefix}{name}"
|
|
254
|
+
sym = self._add(name, qual, stype, decl, parent, exported)
|
|
255
|
+
if value is not None and value.type in _FUNC_VALUE_TYPES:
|
|
256
|
+
body = value.child_by_field_name("body")
|
|
257
|
+
if body is not None and body.type == "statement_block":
|
|
258
|
+
self._walk_statements(body, prefix=f"{qual}.",
|
|
259
|
+
parent=sym.id, exported=False)
|
|
260
|
+
|
|
261
|
+
# -- imports / exports ------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
def _maybe_require(self, local_name: str, value) -> bool:
|
|
264
|
+
"""`const x = require('lit')` → namespace import; returns True if so."""
|
|
265
|
+
if value.type == "call_expression":
|
|
266
|
+
fn = value.child_by_field_name("function")
|
|
267
|
+
args = value.child_by_field_name("arguments")
|
|
268
|
+
if fn is not None and _text(fn, self.src) == "require" \
|
|
269
|
+
and args is not None and args.named_child_count == 1 \
|
|
270
|
+
and args.named_children[0].type == "string":
|
|
271
|
+
source = _text(args.named_children[0], self.src).strip("'\"`")
|
|
272
|
+
self.mi.imports.append(ImportRec(
|
|
273
|
+
"namespace", source, "", local_name,
|
|
274
|
+
value.start_point[0] + 1))
|
|
275
|
+
return True
|
|
276
|
+
return False
|
|
277
|
+
|
|
278
|
+
def _handle_import(self, node) -> None:
|
|
279
|
+
src_node = node.child_by_field_name("source")
|
|
280
|
+
if src_node is None:
|
|
281
|
+
return
|
|
282
|
+
source = _text(src_node, self.src).strip("'\"`")
|
|
283
|
+
line = node.start_point[0] + 1
|
|
284
|
+
clause = next((c for c in node.named_children
|
|
285
|
+
if c.type == "import_clause"), None)
|
|
286
|
+
if clause is None:
|
|
287
|
+
self.mi.imports.append(ImportRec("side", source, "", "", line))
|
|
288
|
+
return
|
|
289
|
+
for child in clause.named_children:
|
|
290
|
+
if child.type == "identifier": # default import
|
|
291
|
+
self.mi.imports.append(ImportRec(
|
|
292
|
+
"default", source, "default", _text(child, self.src), line))
|
|
293
|
+
elif child.type == "namespace_import":
|
|
294
|
+
ident = next((c for c in child.named_children
|
|
295
|
+
if c.type == "identifier"), None)
|
|
296
|
+
if ident is not None:
|
|
297
|
+
self.mi.imports.append(ImportRec(
|
|
298
|
+
"namespace", source, "", _text(ident, self.src), line))
|
|
299
|
+
elif child.type == "named_imports":
|
|
300
|
+
for spec in child.named_children:
|
|
301
|
+
if spec.type != "import_specifier":
|
|
302
|
+
continue
|
|
303
|
+
name_node = spec.child_by_field_name("name")
|
|
304
|
+
alias_node = spec.child_by_field_name("alias")
|
|
305
|
+
name = _text(name_node, self.src) if name_node else ""
|
|
306
|
+
alias = _text(alias_node, self.src) if alias_node else name
|
|
307
|
+
self.mi.imports.append(ImportRec(
|
|
308
|
+
"named", source, name, alias, line))
|
|
309
|
+
|
|
310
|
+
def _handle_export(self, node, prefix, parent) -> None:
|
|
311
|
+
line = node.start_point[0] + 1
|
|
312
|
+
src_node = node.child_by_field_name("source")
|
|
313
|
+
source = _text(src_node, self.src).strip("'\"`") if src_node else None
|
|
314
|
+
|
|
315
|
+
decl = node.child_by_field_name("declaration")
|
|
316
|
+
if decl is not None:
|
|
317
|
+
t = decl.type
|
|
318
|
+
if t in ("function_declaration", "generator_function_declaration"):
|
|
319
|
+
self._add_function(decl, prefix, parent, exported=True)
|
|
320
|
+
elif t in ("class_declaration", "abstract_class_declaration"):
|
|
321
|
+
self._add_class(decl, prefix, parent, exported=True)
|
|
322
|
+
elif t in ("lexical_declaration", "variable_declaration"):
|
|
323
|
+
self._add_variables(decl, prefix, parent, exported=True)
|
|
324
|
+
elif t in ("interface_declaration", "enum_declaration"):
|
|
325
|
+
self._add_named(decl, prefix, parent, True, SymbolType.CLASS)
|
|
326
|
+
elif t == "type_alias_declaration":
|
|
327
|
+
self._add_named(decl, prefix, parent, True, SymbolType.VARIABLE)
|
|
328
|
+
elif t == "identifier": # export default someName
|
|
329
|
+
self.mi.exports.add(_text(decl, self.src))
|
|
330
|
+
return
|
|
331
|
+
|
|
332
|
+
# `export * from './m'` / `export { a, b as c } [from './m']`
|
|
333
|
+
for child in node.named_children:
|
|
334
|
+
if child.type == "export_clause":
|
|
335
|
+
for spec in child.named_children:
|
|
336
|
+
if spec.type != "export_specifier":
|
|
337
|
+
continue
|
|
338
|
+
name_node = spec.child_by_field_name("name")
|
|
339
|
+
if name_node is None:
|
|
340
|
+
continue
|
|
341
|
+
name = _text(name_node, self.src)
|
|
342
|
+
if source:
|
|
343
|
+
self.mi.imports.append(ImportRec(
|
|
344
|
+
"reexport", source, name, "", line))
|
|
345
|
+
else:
|
|
346
|
+
self.mi.exports.add(name)
|
|
347
|
+
elif child.type in ("identifier",): # export default ident (fallback)
|
|
348
|
+
self.mi.exports.add(_text(child, self.src))
|
|
349
|
+
if source and not any(c.type == "export_clause"
|
|
350
|
+
for c in node.named_children):
|
|
351
|
+
self.mi.imports.append(ImportRec("reexport_all", source, "*", "",
|
|
352
|
+
line))
|
|
353
|
+
|
|
354
|
+
def _handle_expression(self, node) -> None:
|
|
355
|
+
"""CommonJS export forms: module.exports = X / exports.name = X."""
|
|
356
|
+
expr = node.named_children[0] if node.named_child_count else None
|
|
357
|
+
if expr is None or expr.type != "assignment_expression":
|
|
358
|
+
return
|
|
359
|
+
left = expr.child_by_field_name("left")
|
|
360
|
+
right = expr.child_by_field_name("right")
|
|
361
|
+
if left is None or right is None:
|
|
362
|
+
return
|
|
363
|
+
lhs = _text(left, self.src)
|
|
364
|
+
if lhs == "module.exports":
|
|
365
|
+
if right.type == "identifier":
|
|
366
|
+
self.mi.exports.add(_text(right, self.src))
|
|
367
|
+
elif right.type == "object":
|
|
368
|
+
for pair in right.named_children:
|
|
369
|
+
if pair.type == "shorthand_property_identifier":
|
|
370
|
+
self.mi.exports.add(_text(pair, self.src))
|
|
371
|
+
elif pair.type == "pair":
|
|
372
|
+
v = pair.child_by_field_name("value")
|
|
373
|
+
if v is not None and v.type == "identifier":
|
|
374
|
+
self.mi.exports.add(_text(v, self.src))
|
|
375
|
+
elif lhs.startswith("exports.") or lhs.startswith("module.exports."):
|
|
376
|
+
if right.type == "identifier":
|
|
377
|
+
self.mi.exports.add(_text(right, self.src))
|
|
378
|
+
self.mi.exports.add(lhs.rsplit(".", 1)[-1])
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def extract_repo(repo_path: Path,
|
|
382
|
+
ignores: tuple[str, ...] = ()) -> tuple[list[ModuleInfo], list[str]]:
|
|
383
|
+
if get_parser is None:
|
|
384
|
+
raise ImportError(
|
|
385
|
+
"The JavaScript plugin needs tree-sitter: "
|
|
386
|
+
"pip install codetruth[javascript]")
|
|
387
|
+
modules: list[ModuleInfo] = []
|
|
388
|
+
warnings: list[str] = []
|
|
389
|
+
for path in iter_source_files(repo_path, ignores):
|
|
390
|
+
rel = path.relative_to(repo_path).as_posix()
|
|
391
|
+
mi = ModuleInfo(name=module_name_for(path, repo_path), rel_path=rel,
|
|
392
|
+
abs_path=str(path), is_test=is_test_path(rel))
|
|
393
|
+
try:
|
|
394
|
+
mi.source = path.read_bytes()
|
|
395
|
+
mi.tree = _parser_for(path).parse(mi.source)
|
|
396
|
+
except (OSError, ValueError) as exc:
|
|
397
|
+
warnings.append(f"parse error in {rel}: {exc}")
|
|
398
|
+
modules.append(mi)
|
|
399
|
+
continue
|
|
400
|
+
_Extractor(mi).run()
|
|
401
|
+
modules.append(mi)
|
|
402
|
+
return modules, warnings
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""The JavaScript/TypeScript language plugin (beta).
|
|
2
|
+
|
|
3
|
+
Layers 2 and 4 — graph, evidence, ranking, textual backstop, persistent
|
|
4
|
+
cache, config — are the language-agnostic core; this plugin provides
|
|
5
|
+
extraction, edge building, and the JS rule set.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ...core.graph import CodeGraph
|
|
12
|
+
from ...core.models import Marker, Symbol
|
|
13
|
+
from ...core.plugin import LanguagePlugin, Rule
|
|
14
|
+
from . import edges as edges_mod
|
|
15
|
+
from . import rules as rules_mod
|
|
16
|
+
from .extractor import (ModuleInfo, extract_repo, iter_config_files,
|
|
17
|
+
iter_source_files)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class JavaScriptPlugin(LanguagePlugin):
|
|
21
|
+
name = "javascript"
|
|
22
|
+
|
|
23
|
+
def extract(self, repo_path: Path,
|
|
24
|
+
ignores: tuple[str, ...] = ()) -> tuple[list[ModuleInfo], list[str]]:
|
|
25
|
+
return extract_repo(repo_path, ignores)
|
|
26
|
+
|
|
27
|
+
def build_index(self, modules: list[ModuleInfo]) -> edges_mod.SymbolIndex:
|
|
28
|
+
return edges_mod.SymbolIndex(modules)
|
|
29
|
+
|
|
30
|
+
def build_edges(self, repo_path: Path, modules: list[ModuleInfo],
|
|
31
|
+
index: edges_mod.SymbolIndex, graph: CodeGraph,
|
|
32
|
+
markers: list[Marker]) -> None:
|
|
33
|
+
edges_mod.build_edges(modules, index, graph, markers)
|
|
34
|
+
|
|
35
|
+
def rules(self) -> list[Rule]:
|
|
36
|
+
return rules_mod.default_rules()
|
|
37
|
+
|
|
38
|
+
def symbols(self, modules: list[ModuleInfo]) -> list[Symbol]:
|
|
39
|
+
return [s for m in modules for s in m.symbols]
|
|
40
|
+
|
|
41
|
+
def config_files(self, repo_path: Path,
|
|
42
|
+
ignores: tuple[str, ...] = ()) -> list[Path]:
|
|
43
|
+
return list(iter_config_files(repo_path, ignores))
|
|
44
|
+
|
|
45
|
+
def source_files(self, repo_path: Path,
|
|
46
|
+
ignores: tuple[str, ...] = ()) -> list[Path]:
|
|
47
|
+
files = list(iter_source_files(repo_path, ignores)) \
|
|
48
|
+
+ list(iter_config_files(repo_path, ignores))
|
|
49
|
+
toml = repo_path / ".codetruth.toml"
|
|
50
|
+
if toml.is_file():
|
|
51
|
+
files.append(toml)
|
|
52
|
+
return files
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Layer 3 — semantic safety rules for JavaScript/TypeScript.
|
|
2
|
+
|
|
3
|
+
The JS ecosystem wires code through package metadata and dynamic constructs;
|
|
4
|
+
these rules surface those indirect usage paths as markers/weak edges.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ...core.models import (Edge, EdgeKind, EdgeStrength, Marker, MarkerKind,
|
|
13
|
+
SymbolType)
|
|
14
|
+
from ...core.plugin import Rule, RuleContext
|
|
15
|
+
from .extractor import _text
|
|
16
|
+
|
|
17
|
+
IDENT_RE = re.compile(r"^[A-Za-z_$][A-Za-z0-9_$]*$")
|
|
18
|
+
|
|
19
|
+
COMMON_WORDS = {
|
|
20
|
+
"main", "test", "data", "name", "type", "value", "true", "false", "null",
|
|
21
|
+
"undefined", "default", "error", "index", "utils", "string", "number",
|
|
22
|
+
"object", "click", "change", "submit", "content", "message", "props",
|
|
23
|
+
"state", "children", "class", "style", "text", "html", "json", "body",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PackageJsonEntrypointRule(Rule):
|
|
28
|
+
"""Modules referenced by package.json main/module/bin/exports are the
|
|
29
|
+
package's public surface — external consumers load them by path."""
|
|
30
|
+
id = "js-package-json-entrypoints"
|
|
31
|
+
|
|
32
|
+
def apply(self, ctx: RuleContext) -> None:
|
|
33
|
+
pkg = ctx.repo_path / "package.json"
|
|
34
|
+
if not pkg.is_file():
|
|
35
|
+
return
|
|
36
|
+
try:
|
|
37
|
+
doc = json.loads(pkg.read_text(encoding="utf-8"))
|
|
38
|
+
except (OSError, json.JSONDecodeError):
|
|
39
|
+
return
|
|
40
|
+
specs: list[str] = []
|
|
41
|
+
for key in ("main", "module", "browser", "types"):
|
|
42
|
+
if isinstance(doc.get(key), str):
|
|
43
|
+
specs.append(doc[key])
|
|
44
|
+
bin_field = doc.get("bin")
|
|
45
|
+
if isinstance(bin_field, str):
|
|
46
|
+
specs.append(bin_field)
|
|
47
|
+
elif isinstance(bin_field, dict):
|
|
48
|
+
specs.extend(v for v in bin_field.values() if isinstance(v, str))
|
|
49
|
+
specs.extend(self._flatten_exports(doc.get("exports")))
|
|
50
|
+
|
|
51
|
+
for spec in specs:
|
|
52
|
+
mod = ctx.index.resolve_source("package", "./" + spec.lstrip("./"))
|
|
53
|
+
if mod is not None:
|
|
54
|
+
ctx.add_marker(Marker(
|
|
55
|
+
mod, MarkerKind.ENTRYPOINT,
|
|
56
|
+
f"referenced by package.json ('{spec}') — loaded "
|
|
57
|
+
"externally by path", rule=self.id, file="package.json",
|
|
58
|
+
line=1))
|
|
59
|
+
|
|
60
|
+
def _flatten_exports(self, exports) -> list[str]:
|
|
61
|
+
out: list[str] = []
|
|
62
|
+
if isinstance(exports, str):
|
|
63
|
+
out.append(exports)
|
|
64
|
+
elif isinstance(exports, dict):
|
|
65
|
+
for v in exports.values():
|
|
66
|
+
out.extend(self._flatten_exports(v))
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ConstructorRule(Rule):
|
|
71
|
+
"""`constructor` is invoked implicitly via `new Class()`."""
|
|
72
|
+
id = "js-constructor"
|
|
73
|
+
|
|
74
|
+
def apply(self, ctx: RuleContext) -> None:
|
|
75
|
+
for sym in ctx.index.all_symbols():
|
|
76
|
+
if sym.type is SymbolType.METHOD and sym.name == "constructor":
|
|
77
|
+
ctx.add_marker(Marker(
|
|
78
|
+
sym.id, MarkerKind.ENTRYPOINT,
|
|
79
|
+
"constructor — invoked implicitly via `new`",
|
|
80
|
+
rule=self.id, file=sym.file, line=sym.line))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class DynamicCodeRule(Rule):
|
|
84
|
+
"""eval / new Function / non-literal dynamic import poison the module:
|
|
85
|
+
nothing in it can be proved unreachable."""
|
|
86
|
+
id = "js-dynamic-code"
|
|
87
|
+
|
|
88
|
+
def apply(self, ctx: RuleContext) -> None:
|
|
89
|
+
for mi in ctx.modules:
|
|
90
|
+
if mi.tree is None:
|
|
91
|
+
continue
|
|
92
|
+
self._scan(ctx, mi, mi.tree.root_node)
|
|
93
|
+
|
|
94
|
+
def _scan(self, ctx, mi, node) -> None:
|
|
95
|
+
if node.type == "call_expression":
|
|
96
|
+
fn = node.child_by_field_name("function")
|
|
97
|
+
if fn is not None:
|
|
98
|
+
fname = _text(fn, mi.source)
|
|
99
|
+
if fname in ("eval",):
|
|
100
|
+
ctx.add_marker(Marker(
|
|
101
|
+
mi.name, MarkerKind.DYNAMIC_MODULE,
|
|
102
|
+
f"eval() at {mi.rel_path}:{node.start_point[0] + 1} — "
|
|
103
|
+
"symbols here cannot be proved unreachable",
|
|
104
|
+
rule=self.id, file=mi.rel_path,
|
|
105
|
+
line=node.start_point[0] + 1))
|
|
106
|
+
elif node.type == "new_expression":
|
|
107
|
+
ctor = node.child_by_field_name("constructor")
|
|
108
|
+
if ctor is not None and _text(ctor, mi.source) == "Function":
|
|
109
|
+
ctx.add_marker(Marker(
|
|
110
|
+
mi.name, MarkerKind.DYNAMIC_MODULE,
|
|
111
|
+
f"new Function() at {mi.rel_path}:{node.start_point[0] + 1}",
|
|
112
|
+
rule=self.id, file=mi.rel_path,
|
|
113
|
+
line=node.start_point[0] + 1))
|
|
114
|
+
for child in node.named_children:
|
|
115
|
+
self._scan(ctx, mi, child)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class StringReferenceRule(Rule):
|
|
119
|
+
"""String literals naming a symbol or module path create weak edges —
|
|
120
|
+
config-driven wiring, dependency-injection tokens, dynamic routes."""
|
|
121
|
+
id = "js-string-references"
|
|
122
|
+
|
|
123
|
+
def apply(self, ctx: RuleContext) -> None:
|
|
124
|
+
for mi in ctx.modules:
|
|
125
|
+
if mi.tree is None:
|
|
126
|
+
continue
|
|
127
|
+
self._scan_tree(ctx, mi, mi.tree.root_node)
|
|
128
|
+
for path in ctx.config_files:
|
|
129
|
+
rel = path.relative_to(ctx.repo_path).as_posix()
|
|
130
|
+
if rel == "package.json":
|
|
131
|
+
continue # handled structurally by the entrypoint rule
|
|
132
|
+
try:
|
|
133
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
134
|
+
except OSError:
|
|
135
|
+
continue
|
|
136
|
+
for lineno, line in enumerate(text.splitlines(), 1):
|
|
137
|
+
self._match_line(ctx, line, f"file:{rel}", rel, lineno)
|
|
138
|
+
|
|
139
|
+
def _scan_tree(self, ctx, mi, node) -> None:
|
|
140
|
+
if node.type in ("string", "template_string"):
|
|
141
|
+
value = _text(node, mi.source).strip("'\"`")
|
|
142
|
+
self._match_value(ctx, value, mi.name, mi.rel_path,
|
|
143
|
+
node.start_point[0] + 1)
|
|
144
|
+
return
|
|
145
|
+
for child in node.named_children:
|
|
146
|
+
self._scan_tree(ctx, mi, child)
|
|
147
|
+
|
|
148
|
+
def _match_value(self, ctx, value: str, src, file, lineno) -> None:
|
|
149
|
+
if IDENT_RE.match(value) and len(value) >= 4 \
|
|
150
|
+
and value.lower() not in COMMON_WORDS:
|
|
151
|
+
for sid in ctx.index.by_name.get(value, []):
|
|
152
|
+
ctx.add_edge(Edge(src, sid, EdgeKind.STRING_REF,
|
|
153
|
+
EdgeStrength.WEAK, file, lineno,
|
|
154
|
+
f"string literal '{value}'"))
|
|
155
|
+
elif "/" in value:
|
|
156
|
+
mod = value.lstrip("./")
|
|
157
|
+
if mod in ctx.index.modules:
|
|
158
|
+
ctx.add_edge(Edge(src, mod, EdgeKind.STRING_REF,
|
|
159
|
+
EdgeStrength.WEAK, file, lineno,
|
|
160
|
+
f"path string '{value}'"))
|
|
161
|
+
|
|
162
|
+
def _match_line(self, ctx, line: str, src, file, lineno) -> None:
|
|
163
|
+
for token in re.findall(r"[A-Za-z_$][A-Za-z0-9_$./-]*", line):
|
|
164
|
+
if "/" in token and token.lstrip("./") in ctx.index.modules:
|
|
165
|
+
ctx.add_edge(Edge(src, token.lstrip("./"), EdgeKind.STRING_REF,
|
|
166
|
+
EdgeStrength.WEAK, file, lineno,
|
|
167
|
+
f"path reference '{token}'"))
|
|
168
|
+
elif IDENT_RE.match(token) and len(token) >= 4 \
|
|
169
|
+
and token.lower() not in COMMON_WORDS \
|
|
170
|
+
and token in ctx.index.by_name:
|
|
171
|
+
for sid in ctx.index.by_name[token]:
|
|
172
|
+
ctx.add_edge(Edge(src, sid, EdgeKind.STRING_REF,
|
|
173
|
+
EdgeStrength.WEAK, file, lineno,
|
|
174
|
+
f"config reference '{token}'"))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class DeclaredEntrypointRule(Rule):
|
|
178
|
+
""".codetruth.toml entrypoints — same contract as the Python plugin."""
|
|
179
|
+
id = "declared-entrypoints"
|
|
180
|
+
|
|
181
|
+
def apply(self, ctx: RuleContext) -> None:
|
|
182
|
+
from ...core.config import load_config
|
|
183
|
+
cfg = load_config(ctx.repo_path)
|
|
184
|
+
if not cfg.entrypoints:
|
|
185
|
+
return
|
|
186
|
+
for sym in ctx.index.all_symbols():
|
|
187
|
+
if cfg.is_declared_entrypoint(sym.id):
|
|
188
|
+
ctx.add_marker(Marker(
|
|
189
|
+
sym.id, MarkerKind.ENTRYPOINT,
|
|
190
|
+
"declared as an entry point in .codetruth.toml",
|
|
191
|
+
rule=self.id, file=sym.file, line=sym.line))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def default_rules() -> list[Rule]:
|
|
195
|
+
return [
|
|
196
|
+
DeclaredEntrypointRule(),
|
|
197
|
+
PackageJsonEntrypointRule(),
|
|
198
|
+
ConstructorRule(),
|
|
199
|
+
DynamicCodeRule(),
|
|
200
|
+
StringReferenceRule(),
|
|
201
|
+
]
|
|
File without changes
|