jscope 0.4.2__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 (81) hide show
  1. jscope/__init__.py +1 -0
  2. jscope/__main__.py +10 -0
  3. jscope/ai/__init__.py +0 -0
  4. jscope/ai/explain.py +180 -0
  5. jscope/ai/providers.py +71 -0
  6. jscope/analyzers/__init__.py +0 -0
  7. jscope/analyzers/auth_analyzer.py +170 -0
  8. jscope/analyzers/authorization_analyzer.py +196 -0
  9. jscope/analyzers/base.py +56 -0
  10. jscope/analyzers/common.py +78 -0
  11. jscope/analyzers/dataflow_analyzer.py +192 -0
  12. jscope/analyzers/endpoint_analyzer.py +150 -0
  13. jscope/analyzers/framework_detector.py +82 -0
  14. jscope/analyzers/graphql_analyzer.py +198 -0
  15. jscope/analyzers/graphql_schema_analyzer.py +132 -0
  16. jscope/analyzers/html_analyzer.py +196 -0
  17. jscope/analyzers/manager.py +114 -0
  18. jscope/analyzers/openapi_analyzer.py +278 -0
  19. jscope/analyzers/router_analyzer.py +502 -0
  20. jscope/analyzers/secret_analyzer.py +99 -0
  21. jscope/analyzers/service_worker_analyzer.py +142 -0
  22. jscope/analyzers/source_map_analyzer.py +119 -0
  23. jscope/analyzers/storage_analyzer.py +125 -0
  24. jscope/analyzers/wasm_analyzer.py +139 -0
  25. jscope/assessment_engine.py +285 -0
  26. jscope/assessment_reporting.py +71 -0
  27. jscope/benchmark/__init__.py +1 -0
  28. jscope/benchmark/fixtures.py +105 -0
  29. jscope/benchmark/runner.py +155 -0
  30. jscope/cli.py +1241 -0
  31. jscope/config.py +72 -0
  32. jscope/core/__init__.py +0 -0
  33. jscope/core/artifacts.py +139 -0
  34. jscope/core/cache.py +198 -0
  35. jscope/core/classification.py +56 -0
  36. jscope/core/evidence.py +411 -0
  37. jscope/core/graph.py +196 -0
  38. jscope/core/redact.py +67 -0
  39. jscope/crawler/__init__.py +0 -0
  40. jscope/crawler/crawler.py +176 -0
  41. jscope/dataflow/__init__.py +1 -0
  42. jscope/dataflow/engine.py +1684 -0
  43. jscope/dataflow/modules.py +205 -0
  44. jscope/dataflow/rules.py +118 -0
  45. jscope/downloader/__init__.py +0 -0
  46. jscope/downloader/downloader.py +116 -0
  47. jscope/parsing/__init__.py +0 -0
  48. jscope/parsing/ast_engine.py +322 -0
  49. jscope/parsing/graphql_schema.py +153 -0
  50. jscope/parsing/html.py +55 -0
  51. jscope/parsing/queries.py +385 -0
  52. jscope/parsing/wasm.py +146 -0
  53. jscope/passive_discovery.py +63 -0
  54. jscope/pipeline.py +196 -0
  55. jscope/plugin/__init__.py +0 -0
  56. jscope/plugin/_worker.py +199 -0
  57. jscope/plugin/loader.py +225 -0
  58. jscope/plugin/sandbox.py +207 -0
  59. jscope/reporting/__init__.py +0 -0
  60. jscope/reporting/formats.py +208 -0
  61. jscope/reporting/human.py +137 -0
  62. jscope/reporting/json_report.py +35 -0
  63. jscope/reporting/priority.py +116 -0
  64. jscope/runtime/__init__.py +0 -0
  65. jscope/runtime/observe.py +397 -0
  66. jscope/runtime/prober.py +259 -0
  67. jscope/runtime/scope.py +288 -0
  68. jscope/security_transport.py +246 -0
  69. jscope/verify/__init__.py +1 -0
  70. jscope/verify/engine.py +290 -0
  71. jscope/workspace/__init__.py +0 -0
  72. jscope/workspace/assessment.py +133 -0
  73. jscope/workspace/common.py +25 -0
  74. jscope/workspace/diff.py +143 -0
  75. jscope/workspace/store.py +206 -0
  76. jscope-0.4.2.dist-info/METADATA +180 -0
  77. jscope-0.4.2.dist-info/RECORD +81 -0
  78. jscope-0.4.2.dist-info/WHEEL +5 -0
  79. jscope-0.4.2.dist-info/entry_points.txt +2 -0
  80. jscope-0.4.2.dist-info/licenses/LICENSE +21 -0
  81. jscope-0.4.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,322 @@
1
+ """
2
+ AST Engine
3
+ ==========
4
+
5
+ Thin, cached wrapper around tree-sitter's JavaScript grammar.
6
+
7
+ Design notes (see design-brainstorm.md §9 "Performance"):
8
+ - "Parse once, analyze many": ASTs are cached by content hash, so
9
+ re-running with a different analyzer config never re-parses unchanged
10
+ files.
11
+ - Bounded worst-case behavior: files above `max_bytes` are skipped with a
12
+ clear "skipped: size limit" marker rather than risking a hang on a
13
+ pathological minified vendor bundle.
14
+
15
+ This module intentionally exposes a small, high-level query surface
16
+ (`find_calls`, `resolve_string`, etc.) rather than leaking raw tree-sitter
17
+ node types everywhere else in the codebase -- analyzers should not need to
18
+ know tree-sitter's grammar quirks.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import json
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ import tree_sitter_javascript as _tsjs
30
+ from tree_sitter import Language, Parser
31
+
32
+ _JS_LANGUAGE = Language(_tsjs.language())
33
+ _parser = Parser(_JS_LANGUAGE)
34
+
35
+ # TypeScript / TSX grammars are optional at import time: if the wheel is missing the engine still works for
36
+ # JavaScript and reports every .ts/.tsx file as "skipped: grammar unavailable" instead of silently mis-parsing it.
37
+ _PARSERS: dict[str, Parser] = {"javascript": _parser}
38
+ try:
39
+ import tree_sitter_typescript as _tsts
40
+ _PARSERS["typescript"] = Parser(Language(_tsts.language_typescript()))
41
+ _PARSERS["tsx"] = Parser(Language(_tsts.language_tsx()))
42
+ except Exception: # noqa: BLE001 (ImportError or ABI mismatch)
43
+ pass
44
+
45
+ TS_AVAILABLE = "typescript" in _PARSERS
46
+
47
+
48
+ def language_for_path(path: str) -> str:
49
+ """Grammar name for a path: .ts/.mts/.cts -> typescript, .tsx -> tsx, anything else -> javascript."""
50
+ low = path.lower().split("#")[0]
51
+ if low.endswith((".ts", ".mts", ".cts")):
52
+ return "typescript"
53
+ if low.endswith(".tsx"):
54
+ return "tsx"
55
+ return "javascript"
56
+
57
+
58
+ def count_syntax_errors(root) -> int:
59
+ """Number of ERROR / MISSING nodes under `root` (0 == the grammar parsed the whole file cleanly)."""
60
+ n, stack = 0, [root]
61
+ while stack:
62
+ node = stack.pop()
63
+ if node.type == "ERROR" or node.is_missing:
64
+ n += 1
65
+ if node.has_error:
66
+ stack.extend(node.children)
67
+ return n
68
+
69
+ DEFAULT_MAX_BYTES = 5 * 1024 * 1024 # 5MB: generous but bounded
70
+
71
+
72
+ @dataclass
73
+ class CallSite:
74
+ """A normalized view of a call expression, e.g. fetch(...), axios.get(...)."""
75
+
76
+ callee: str # dotted callee, e.g. "fetch" or "axios.get"
77
+ args_raw: list[str] # raw source text of each argument
78
+ args_resolved: list["ResolvedValue"]
79
+ line: int
80
+ node: Any = field(repr=False, default=None)
81
+
82
+
83
+ @dataclass
84
+ class ResolvedValue:
85
+ """Result of trying to resolve an AST node to a concrete value."""
86
+
87
+ value: str | None # the resolved string, or None if unresolvable
88
+ is_literal: bool # True if it was a raw string literal
89
+ resolved_via_symbol: bool # True if resolution required following a const/let binding
90
+ is_placeholder: bool # True if it looks like an unsubstituted build placeholder
91
+
92
+
93
+ @dataclass
94
+ class ParsedFile:
95
+ path: str
96
+ source: bytes
97
+ content_hash: str
98
+ tree: Any = field(repr=False, default=None)
99
+ skipped_reason: str | None = None
100
+ origin: str = "disk" # "disk" | "sourcemap" (recovered original source)
101
+ language: str = "javascript" # grammar used: javascript | typescript | tsx
102
+ syntax_errors: int = 0 # ERROR/MISSING nodes: > 0 means this file was only PARTIALLY analyzed
103
+ memo: dict = field(default_factory=dict, repr=False) # per-file query cache (see parsing/queries.py)
104
+
105
+ @property
106
+ def text(self) -> str:
107
+ return self.source.decode("utf-8", errors="replace")
108
+
109
+ def line_of(self, node) -> int:
110
+ return node.start_point[0] + 1
111
+
112
+ def node_text(self, node) -> str:
113
+ return self.source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
114
+
115
+
116
+ class ASTEngine:
117
+ """Parses JS files with a content-hash keyed cache.
118
+
119
+ Cache is in-memory for this process by default; `cache_dir`, if given,
120
+ also persists parsed-source hashes to disk so `jscope analyze` on an
121
+ unchanged project directory can skip re-parsing across runs (this is
122
+ also what powers cheap incremental/differential analysis later).
123
+ """
124
+
125
+ def __init__(self, cache_dir: str | None = None, max_bytes: int = DEFAULT_MAX_BYTES):
126
+ self._cache: dict[str, ParsedFile] = {}
127
+ self.cache_dir = Path(cache_dir) if cache_dir else None
128
+ self.max_bytes = max_bytes
129
+ if self.cache_dir:
130
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
131
+
132
+ @staticmethod
133
+ def hash_bytes(data: bytes) -> str:
134
+ return hashlib.sha256(data).hexdigest()[:16]
135
+
136
+ def parse_file(self, path: str) -> ParsedFile:
137
+ data = Path(path).read_bytes()
138
+ return self.parse_bytes(data, path=path)
139
+
140
+ def parse_bytes(self, data: bytes, path: str = "<memory>") -> ParsedFile:
141
+ content_hash = self.hash_bytes(data)
142
+ lang = language_for_path(path)
143
+ key = content_hash if lang == "javascript" else f"{content_hash}:{lang}"
144
+ if key in self._cache:
145
+ cached = self._cache[key]
146
+ # Re-tag with the current path for reporting purposes, keep the parsed tree.
147
+ return ParsedFile(
148
+ path=path, source=cached.source, content_hash=content_hash,
149
+ tree=cached.tree, skipped_reason=cached.skipped_reason,
150
+ language=cached.language, syntax_errors=cached.syntax_errors,
151
+ )
152
+
153
+ if len(data) > self.max_bytes:
154
+ pf = ParsedFile(path=path, source=data, content_hash=content_hash, language=lang,
155
+ tree=None, skipped_reason=f"skipped: size limit ({len(data)} bytes)")
156
+ self._cache[key] = pf
157
+ return pf
158
+
159
+ parser = _PARSERS.get(lang)
160
+ if parser is None:
161
+ pf = ParsedFile(path=path, source=data, content_hash=content_hash, language=lang, tree=None,
162
+ skipped_reason=f"skipped: {lang} grammar unavailable (pip install tree-sitter-typescript)")
163
+ self._cache[key] = pf
164
+ return pf
165
+
166
+ tree = parser.parse(data)
167
+ pf = ParsedFile(path=path, source=data, content_hash=content_hash, tree=tree, language=lang,
168
+ syntax_errors=count_syntax_errors(tree.root_node) if tree.root_node.has_error else 0)
169
+ self._cache[key] = pf
170
+ return pf
171
+
172
+ # -- query helpers ----------------------------------------------------
173
+
174
+ _CALLEE_CANDIDATES = {
175
+ "fetch", "axios", "axios.get", "axios.post", "axios.put", "axios.delete",
176
+ "axios.patch", "$.ajax", "$.get", "$.post", "XMLHttpRequest",
177
+ }
178
+
179
+ def walk(self, node):
180
+ yield node
181
+ for child in node.children:
182
+ yield from self.walk(child)
183
+
184
+ def _callee_dotted_name(self, pf: ParsedFile, node) -> str | None:
185
+ """Given a call_expression's `function` child, reconstruct a dotted
186
+ name like 'axios.get' or 'fetch'."""
187
+ if node.type == "identifier":
188
+ return pf.node_text(node)
189
+ if node.type == "member_expression":
190
+ obj = node.child_by_field_name("object")
191
+ prop = node.child_by_field_name("property")
192
+ if obj is None or prop is None:
193
+ return None
194
+ obj_name = self._callee_dotted_name(pf, obj)
195
+ if obj_name is None:
196
+ return None
197
+ return f"{obj_name}.{pf.node_text(prop)}"
198
+ return None
199
+
200
+ def _local_bindings(self, pf: ParsedFile, root) -> dict[str, Any]:
201
+ """Very small const/let/var propagation: name -> value node, scoped
202
+ to the whole file (module-level heuristic, intentionally simple --
203
+ see design-brainstorm.md §12 on scoping this down honestly rather
204
+ than overclaiming full data-flow)."""
205
+ bindings: dict[str, Any] = {}
206
+ for node in self.walk(root):
207
+ if node.type in ("variable_declarator",):
208
+ name_node = node.child_by_field_name("name")
209
+ value_node = node.child_by_field_name("value")
210
+ if name_node is not None and value_node is not None and name_node.type == "identifier":
211
+ bindings[pf.node_text(name_node)] = value_node
212
+ return bindings
213
+
214
+ def resolve_value(self, pf: ParsedFile, node, bindings: dict[str, Any] | None = None,
215
+ _depth: int = 0) -> ResolvedValue:
216
+ if bindings is None:
217
+ bindings = {}
218
+ if _depth > 5 or node is None:
219
+ return ResolvedValue(None, False, False, False)
220
+
221
+ if node.type == "string":
222
+ raw = pf.node_text(node).strip("'\"`")
223
+ return ResolvedValue(raw, is_literal=(_depth == 0), resolved_via_symbol=(_depth > 0),
224
+ is_placeholder=False)
225
+
226
+ if node.type == "template_string":
227
+ raw = pf.node_text(node).strip("`")
228
+ # if it contains ${...} substitutions we can't statically resolve,
229
+ # keep the raw template shape -- still useful signal.
230
+ return ResolvedValue(raw, is_literal=(_depth == 0), resolved_via_symbol=(_depth > 0),
231
+ is_placeholder=False)
232
+
233
+ if node.type == "identifier":
234
+ name = pf.node_text(node)
235
+ if name in bindings:
236
+ return self.resolve_value(pf, bindings[name], bindings, _depth + 1)
237
+ return ResolvedValue(None, False, False, False)
238
+
239
+ if node.type == "member_expression":
240
+ text = pf.node_text(node)
241
+ if text.startswith("process.env."):
242
+ return ResolvedValue(text, is_literal=False, resolved_via_symbol=False, is_placeholder=True)
243
+ return ResolvedValue(None, False, False, False)
244
+
245
+ if node.type == "binary_expression":
246
+ # naive string-concat resolution: left + right, only if both sides resolve
247
+ left = node.child_by_field_name("left")
248
+ right = node.child_by_field_name("right")
249
+ lval = self.resolve_value(pf, left, bindings, _depth + 1)
250
+ rval = self.resolve_value(pf, right, bindings, _depth + 1)
251
+ if lval.value is not None and rval.value is not None:
252
+ return ResolvedValue(lval.value + rval.value, is_literal=False,
253
+ resolved_via_symbol=True, is_placeholder=False)
254
+ return ResolvedValue(None, False, False, False)
255
+
256
+ return ResolvedValue(None, False, False, False)
257
+
258
+ def find_calls(self, pf: ParsedFile) -> list[CallSite]:
259
+ """Find call expressions whose callee matches a known HTTP-call shape."""
260
+ if pf.tree is None:
261
+ return []
262
+ root = pf.tree.root_node
263
+ bindings = self._local_bindings(pf, root)
264
+ sites: list[CallSite] = []
265
+
266
+ for node in self.walk(root):
267
+ if node.type != "call_expression":
268
+ continue
269
+ fn_node = node.child_by_field_name("function")
270
+ if fn_node is None:
271
+ continue
272
+ dotted = self._callee_dotted_name(pf, fn_node)
273
+ if dotted is None:
274
+ continue
275
+ # match exact known callees, or a trailing method on a known object (axios.*, $.* )
276
+ base = dotted.split(".")[0]
277
+ if dotted not in self._CALLEE_CANDIDATES and base not in ("axios", "$"):
278
+ continue
279
+
280
+ args_node = node.child_by_field_name("arguments")
281
+ args_raw: list[str] = []
282
+ args_resolved: list[ResolvedValue] = []
283
+ if args_node is not None:
284
+ for child in args_node.children:
285
+ if child.type in ("(", ")", ","):
286
+ continue
287
+ args_raw.append(pf.node_text(child))
288
+ args_resolved.append(self.resolve_value(pf, child, bindings))
289
+
290
+ sites.append(CallSite(
291
+ callee=dotted, args_raw=args_raw, args_resolved=args_resolved,
292
+ line=pf.line_of(node), node=node,
293
+ ))
294
+ return sites
295
+
296
+ def find_string_literals(self, pf: ParsedFile):
297
+ """Yield (node, text, line) for every string/template literal in the file."""
298
+ if pf.tree is None:
299
+ return
300
+ for node in self.walk(pf.tree.root_node):
301
+ if node.type == "string":
302
+ yield node, pf.node_text(node).strip("'\""), pf.line_of(node)
303
+ elif node.type == "template_string":
304
+ yield node, pf.node_text(node).strip("`"), pf.line_of(node)
305
+
306
+ def is_in_dead_branch(self, pf: ParsedFile, node) -> bool:
307
+ """Heuristic: walk up from `node`; if any ancestor if_statement's
308
+ condition is a literal falsy value, treat as dead code."""
309
+ current = node.parent
310
+ while current is not None:
311
+ if current.type == "if_statement":
312
+ cond = current.child_by_field_name("condition")
313
+ if cond is not None:
314
+ text = pf.node_text(cond).strip()
315
+ if text.startswith("(") and text.endswith(")"):
316
+ text = text[1:-1].strip()
317
+ if text in ("false", "0", "'false'", '"false"'):
318
+ return True
319
+ if "NODE_ENV" in text and "development" in text:
320
+ return True
321
+ current = current.parent
322
+ return False
@@ -0,0 +1,153 @@
1
+ """Minimal, dependency-free GraphQL schema reader (SDL + introspection JSON) -> a common dict shape.
2
+
3
+ {"roots": {"query": "Query", "mutation": "Mutation", "subscription": None},
4
+ "types": {"Query": {"kind": "type", "fields": {"user": {"args": {"id": "ID!"}, "type": "User", "directives": []}}}}}
5
+
6
+ It is deliberately a *reader*, not a validator: it extracts what the offline-recon use case needs (root fields, argument
7
+ types, directives) and tolerates what it does not understand rather than rejecting the document.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from typing import Any
14
+
15
+ _TOKEN = re.compile(r'"""[\s\S]*?"""|"(?:[^"\\]|\\.)*"|#[^\n]*|[A-Za-z_]\w*|-?\d+(?:\.\d+)?|\.\.\.|[!:(){}\[\]@=,|&$]')
16
+ _KINDS = {"type", "interface", "input", "enum", "union", "scalar", "directive", "schema", "extend", "fragment"}
17
+
18
+
19
+ def _tokens(text: str) -> list[str]:
20
+ return [t for t in _TOKEN.findall(text) if not (t.startswith("#") or t.startswith('"') or t == ",")]
21
+
22
+
23
+ def _parse_type(toks: list[str], i: int) -> tuple[str, int]:
24
+ out = ""
25
+ while i < len(toks) and toks[i] == "[":
26
+ out, i = out + "[", i + 1
27
+ if i < len(toks):
28
+ out, i = out + toks[i], i + 1
29
+ while i < len(toks) and toks[i] in ("]", "!"):
30
+ out, i = out + toks[i], i + 1
31
+ return out, i
32
+
33
+
34
+ def _skip_parens(toks: list[str], i: int) -> int:
35
+ depth = 0
36
+ while i < len(toks):
37
+ if toks[i] == "(":
38
+ depth += 1
39
+ elif toks[i] == ")":
40
+ depth -= 1
41
+ if depth == 0:
42
+ return i + 1
43
+ i += 1
44
+ return i
45
+
46
+
47
+ def _directives(toks: list[str], i: int) -> tuple[list[str], int]:
48
+ names = []
49
+ while i < len(toks) and toks[i] == "@":
50
+ i += 1
51
+ if i < len(toks):
52
+ names.append(toks[i])
53
+ i += 1
54
+ if i < len(toks) and toks[i] == "(":
55
+ i = _skip_parens(toks, i)
56
+ return names, i
57
+
58
+
59
+ def parse_sdl(text: str) -> dict[str, Any]:
60
+ toks = _tokens(text)
61
+ types: dict[str, dict] = {}
62
+ roots: dict[str, str | None] = {"query": None, "mutation": None, "subscription": None}
63
+ i, n = 0, len(toks)
64
+ while i < n:
65
+ t = toks[i]
66
+ if t == "extend":
67
+ i += 1
68
+ continue
69
+ if t == "schema":
70
+ i += 1
71
+ _, i = _directives(toks, i)
72
+ if i < n and toks[i] == "{":
73
+ i += 1
74
+ while i < n and toks[i] != "}":
75
+ if toks[i] in roots and i + 2 < n and toks[i + 1] == ":":
76
+ roots[toks[i]] = toks[i + 2]
77
+ i += 3
78
+ else:
79
+ i += 1
80
+ i += 1
81
+ continue
82
+ if t in ("type", "interface", "input") and i + 1 < n:
83
+ kind, name, i = t, toks[i + 1], i + 2
84
+ if i < n and toks[i] == "implements":
85
+ while i < n and toks[i] not in ("{", "@"):
86
+ i += 1
87
+ _, i = _directives(toks, i)
88
+ fields: dict[str, dict] = {}
89
+ if i < n and toks[i] == "{":
90
+ i += 1
91
+ while i < n and toks[i] != "}":
92
+ fname = toks[i]
93
+ i += 1
94
+ args: dict[str, str] = {}
95
+ if i < n and toks[i] == "(":
96
+ j, end = i + 1, _skip_parens(toks, i)
97
+ while j < end - 1:
98
+ if j + 1 < end and toks[j + 1] == ":" and re.match(r"[A-Za-z_]", toks[j]):
99
+ aname = toks[j]
100
+ atype, j = _parse_type(toks, j + 2)
101
+ args[aname] = atype
102
+ if j < end and toks[j] == "=": # default value: skip one token
103
+ j += 2
104
+ _, j = _directives(toks, j)
105
+ else:
106
+ j += 1
107
+ i = end
108
+ ftype = ""
109
+ if i < n and toks[i] == ":":
110
+ ftype, i = _parse_type(toks, i + 1)
111
+ dirs, i = _directives(toks, i)
112
+ if kind == "input": # input fields look like `name: Type = default @d`
113
+ if i < n and toks[i] == "=":
114
+ i += 2
115
+ fields[fname] = {"args": args, "type": ftype, "directives": dirs}
116
+ types.setdefault(name, {"kind": kind, "fields": {}})["fields"].update(fields)
117
+ i += 1
118
+ continue
119
+ i += 1
120
+ for r, default in (("query", "Query"), ("mutation", "Mutation"), ("subscription", "Subscription")):
121
+ if roots[r] is None and default in types:
122
+ roots[r] = default
123
+ return {"roots": roots, "types": types}
124
+
125
+
126
+ def _unwrap(t: dict | None) -> str:
127
+ if not t:
128
+ return ""
129
+ kind, name = t.get("kind"), t.get("name")
130
+ if kind == "NON_NULL":
131
+ return _unwrap(t.get("ofType")) + "!"
132
+ if kind == "LIST":
133
+ return "[" + _unwrap(t.get("ofType")) + "]"
134
+ return name or ""
135
+
136
+
137
+ def parse_introspection(doc: dict) -> dict[str, Any]:
138
+ schema = (doc.get("data") or doc)["__schema"]
139
+ roots = {"query": (schema.get("queryType") or {}).get("name"),
140
+ "mutation": (schema.get("mutationType") or {}).get("name"),
141
+ "subscription": (schema.get("subscriptionType") or {}).get("name")}
142
+ types: dict[str, dict] = {}
143
+ for t in schema.get("types", []):
144
+ name = t.get("name") or ""
145
+ if name.startswith("__"):
146
+ continue
147
+ fields = {}
148
+ for f in (t.get("fields") or t.get("inputFields") or []):
149
+ fields[f["name"]] = {"args": {a["name"]: _unwrap(a.get("type")) for a in f.get("args", []) or []},
150
+ "type": _unwrap(f.get("type")), "directives": [],
151
+ "deprecated": bool(f.get("isDeprecated"))}
152
+ types[name] = {"kind": str(t.get("kind", "OBJECT")).lower(), "fields": fields}
153
+ return {"roots": roots, "types": types}
jscope/parsing/html.py ADDED
@@ -0,0 +1,55 @@
1
+ """HTML / Vue SFC helpers (Phase 4).
2
+
3
+ Inline `<script>` bodies (and a `.vue` file's `<script>` block) are turned into ordinary ParsedFiles so EVERY existing
4
+ analyzer (endpoints, secrets, auth, storage, GraphQL...) runs on them unchanged. Source is left-padded with newlines so
5
+ reported line numbers match the original document.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from bs4 import BeautifulSoup
11
+
12
+ from jscope.core.artifacts import KIND_VUE, Artifact
13
+ from jscope.parsing.ast_engine import ASTEngine, ParsedFile
14
+
15
+ _JS_TYPES = {"", "module", "text/javascript", "application/javascript", "text/babel", "application/x-javascript",
16
+ "text/ecmascript", "application/ecmascript"}
17
+
18
+
19
+ def soup(art: Artifact) -> BeautifulSoup:
20
+ if "soup" not in art.memo:
21
+ art.memo["soup"] = BeautifulSoup(art.text, "html.parser")
22
+ return art.memo["soup"]
23
+
24
+
25
+ def script_tags(art: Artifact):
26
+ """(tag, is_executable_js, lang) for every <script>."""
27
+ out = []
28
+ for tag in soup(art).find_all("script"):
29
+ typ = (tag.get("type") or "").strip().lower()
30
+ lang = (tag.get("lang") or "js").strip().lower()
31
+ out.append((tag, typ in _JS_TYPES, lang))
32
+ return out
33
+
34
+
35
+ def extract_inline_scripts(art: Artifact, ast: ASTEngine) -> tuple[list[ParsedFile], list[str]]:
36
+ files: list[ParsedFile] = []
37
+ diags: list[str] = []
38
+ n = 0
39
+ for tag, is_js, lang in script_tags(art):
40
+ if tag.get("src") and art.kind != KIND_VUE:
41
+ continue # external script: it is its own file
42
+ body = tag.string if tag.string is not None else "".join(tag.strings)
43
+ if not is_js or not body or not body.strip():
44
+ continue
45
+ if lang not in ("js", "javascript", "jsx"):
46
+ diags.append(f"[html] {art.path}: <script lang=\"{lang}\"> not analyzed (JavaScript grammar only)")
47
+ continue
48
+ n += 1
49
+ line = getattr(tag, "sourceline", None) or 1
50
+ padded = ("\n" * (line - 1) + body).encode("utf-8")
51
+ suffix = "#script" if art.kind == KIND_VUE else f"#inline-{n}"
52
+ pf = ast.parse_bytes(padded, path=art.path + suffix)
53
+ pf.origin = "vue-sfc" if art.kind == KIND_VUE else "html-inline"
54
+ files.append(pf)
55
+ return files, diags