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.
Files changed (37) hide show
  1. codetruth/__init__.py +16 -0
  2. codetruth/api.py +102 -0
  3. codetruth/cli.py +183 -0
  4. codetruth/core/__init__.py +0 -0
  5. codetruth/core/cache.py +104 -0
  6. codetruth/core/config.py +74 -0
  7. codetruth/core/deletion.py +165 -0
  8. codetruth/core/evidence.py +306 -0
  9. codetruth/core/graph.py +44 -0
  10. codetruth/core/models.py +193 -0
  11. codetruth/core/plugin.py +88 -0
  12. codetruth/core/report.py +107 -0
  13. codetruth/core/scanner.py +228 -0
  14. codetruth/languages/__init__.py +0 -0
  15. codetruth/languages/go/__init__.py +1 -0
  16. codetruth/languages/javascript/__init__.py +8 -0
  17. codetruth/languages/javascript/edges.py +361 -0
  18. codetruth/languages/javascript/extractor.py +402 -0
  19. codetruth/languages/javascript/plugin.py +52 -0
  20. codetruth/languages/javascript/rules.py +201 -0
  21. codetruth/languages/python/__init__.py +0 -0
  22. codetruth/languages/python/edges.py +474 -0
  23. codetruth/languages/python/extractor.py +271 -0
  24. codetruth/languages/python/plugin.py +49 -0
  25. codetruth/languages/python/rules.py +332 -0
  26. codetruth/mcp_server.py +138 -0
  27. codetruth/rules/python/common_dynamic.yaml +70 -0
  28. codetruth/rules/python/django.yaml +57 -0
  29. codetruth/rules/python/fastapi.yaml +38 -0
  30. codetruth/rules/python/orm_web.yaml +50 -0
  31. codetruth/runtime/__init__.py +283 -0
  32. codetruth-0.2.0.dist-info/METADATA +215 -0
  33. codetruth-0.2.0.dist-info/RECORD +37 -0
  34. codetruth-0.2.0.dist-info/WHEEL +5 -0
  35. codetruth-0.2.0.dist-info/entry_points.txt +2 -0
  36. codetruth-0.2.0.dist-info/licenses/LICENSE +21 -0
  37. 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