@guanlin4924/devflow-pi 0.1.77 → 0.1.78

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.
@@ -1,3 +1,4 @@
1
+ from .capabilities import load_capability_manifest
1
2
  from .generic_text import GenericTextAdapter
2
3
  from .go_ast import GoAstAdapter
3
4
  from .java_ast import JavaAstAdapter
@@ -5,37 +6,11 @@ from .native_process import UnavailableNativeAdapter
5
6
  from .php_ast import PhpAstAdapter
6
7
  from .python_ast import PythonAstAdapter
7
8
  from .structured_scope import StructuredScopeAdapter
9
+ from .tree_sitter_wasm import TreeSitterWasmAdapter
8
10
  from .typescript_ast import TypeScriptAstAdapter
9
11
 
10
12
 
11
- UNAVAILABLE_NATIVE = {
12
- "ruby": ("prism-ast", "Ruby Prism integration is not installed"),
13
- "rust": ("rust-analyzer-ast", "rust-analyzer/syn integration is not installed"),
14
- "kotlin": ("kotlin-psi-ast", "Kotlin PSI/compiler integration is not installed"),
15
- "c": ("clang-ast", "Clang libTooling integration is not installed"),
16
- "cpp": ("clang-ast", "Clang libTooling integration is not installed"),
17
- "csharp": ("roslyn-ast", "Roslyn integration is not installed"),
18
- "swift": ("swift-syntax-ast", "SwiftSyntax integration is not installed"),
19
- "dart": ("dart-analyzer-ast", "Dart analyzer integration is not installed"),
20
- "scala": ("scalameta-ast", "Scalameta integration is not installed"),
21
- "lua": ("tree-sitter-lua-ast", "tree-sitter-lua integration is not installed"),
22
- "shell": ("tree-sitter-bash-ast", "tree-sitter-bash integration is not installed"),
23
- "vue": ("vue-compiler-ast", "Vue compiler integration is not installed"),
24
- "svelte": ("svelte-compiler-ast", "Svelte compiler integration is not installed"),
25
- "sql": ("sqlglot-ast", "SQLGlot integration is not installed"),
26
- "html": ("html-parser-ast", "HTML syntax adapter is not installed"),
27
- "css": ("postcss-ast", "PostCSS integration is not installed"),
28
- "json": ("json-syntax-tree", "JSON syntax adapter is not installed"),
29
- "yaml": ("yaml-syntax-tree", "YAML syntax adapter is not installed"),
30
- "toml": ("toml-syntax-tree", "TOML syntax adapter is not installed"),
31
- "xml": ("xml-syntax-tree", "XML syntax adapter is not installed"),
32
- "graphql": ("graphql-ast", "GraphQL parser integration is not installed"),
33
- "protobuf": ("protobuf-parser-ast", "Protobuf parser integration is not installed"),
34
- "hcl": ("hcl-parser-ast", "HCL parser integration is not installed"),
35
- "terraform": ("hcl-parser-ast", "HCL parser integration is not installed"),
36
- "dockerfile": ("dockerfile-parser-ast", "Dockerfile parser integration is not installed"),
37
- "markdown": ("markdown-parser-ast", "Markdown syntax adapter is not installed"),
38
- }
13
+ CAPABILITY_MANIFEST = load_capability_manifest()
39
14
 
40
15
 
41
16
  class AdapterRegistry:
@@ -61,9 +36,16 @@ class AdapterRegistry:
61
36
  "ruby", "rust", "kotlin", "c", "cpp", "csharp", "swift",
62
37
  "dart", "scala", "lua", "shell", "vue", "svelte",
63
38
  }
64
- for language, (expected, reason) in UNAVAILABLE_NATIVE.items():
39
+ for language, declaration in CAPABILITY_MANIFEST["languages"].items():
40
+ if language in self.language_adapters:
41
+ continue
42
+ expected = declaration["parser"]
43
+ reason = declaration.get("todo") or f"{expected} integration is not installed"
65
44
  fallback = self.structured if language in structured_languages else self.generic
66
- self.language_adapters[language] = UnavailableNativeAdapter(language, expected, fallback, reason)
45
+ if declaration.get("strategy") == "tree-sitter-wasm":
46
+ self.language_adapters[language] = TreeSitterWasmAdapter(language, fallback)
47
+ else:
48
+ self.language_adapters[language] = UnavailableNativeAdapter(language, expected, fallback, reason)
67
49
 
68
50
  def adapter_for_file(self, codebase, file_record):
69
51
  language = file_record.get("language")
@@ -76,7 +58,14 @@ class AdapterRegistry:
76
58
  return [self.adapter_for_file(codebase, {"language": self._language(path)}) for path in files]
77
59
 
78
60
  def capabilities(self):
79
- return {language: adapter.capability() for language, adapter in sorted(self.language_adapters.items())}
61
+ capabilities = {}
62
+ for language, adapter in sorted(self.language_adapters.items()):
63
+ declaration = dict(CAPABILITY_MANIFEST["languages"].get(language, {}))
64
+ runtime = adapter.capability()
65
+ declaration["declaredStatus"] = declaration.pop("status", "unknown")
66
+ declaration.update(runtime)
67
+ capabilities[language] = declaration
68
+ return capabilities
80
69
 
81
70
  @staticmethod
82
71
  def _language(path):
@@ -0,0 +1,253 @@
1
+ """Optional Tree-sitter WASM adapter.
2
+
3
+ The indexer remains usable without a grammar bundle. In that case this
4
+ adapter deliberately returns the configured fallback and a diagnostic; it
5
+ never labels lexical facts as Tree-sitter facts. A grammar is supplied by the
6
+ runtime (or DEVFLOW_TREE_SITTER_WASM_GRAMMAR_<LANG>) so the npm package does
7
+ not silently depend on a machine-specific native installation.
8
+ """
9
+
10
+ import json
11
+ import hashlib
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ from pathlib import Path
16
+ from typing import Dict, Tuple
17
+
18
+ from adapters.base import Adapter, ParseResult
19
+ from adapters.capabilities import load_capability_manifest
20
+ from adapters.generic_text import _relation, _symbol
21
+
22
+
23
+ HELPER = Path(__file__).resolve().parent / "helpers" / "tree_sitter_wasm_helper.mjs"
24
+
25
+
26
+ class TreeSitterWasmAdapter(Adapter):
27
+ name = "tree-sitter-wasm"
28
+
29
+ def __init__(self, language: str, fallback: Adapter):
30
+ self.language = language
31
+ self.fallback = fallback
32
+ self.declaration = load_capability_manifest()["languages"].get(language, {})
33
+ self._availability: Tuple[bool, str] | None = None
34
+
35
+ def version(self) -> str:
36
+ return str(self.declaration.get("adapter_version", 1))
37
+
38
+ @property
39
+ def parser_id(self) -> str:
40
+ grammar = self.declaration.get("grammar", {}).get("id", self.language)
41
+ return f"tree-sitter-wasm:{grammar}@{self.version()}"
42
+
43
+ def parser_for_file(self, codebase, file_record) -> str:
44
+ available, _ = self._availability_for(codebase)
45
+ return self.parser_id if available else self.fallback.parser_for_file(codebase, file_record)
46
+
47
+ def check_available(self, codebase=None) -> Tuple[bool, str]:
48
+ grammar = self._grammar_path(codebase)
49
+ if not grammar:
50
+ return False, "Tree-sitter WASM grammar is not configured"
51
+ pin = self._grammar_pin()
52
+ if not pin["version"] or not pin["sha256"]:
53
+ return False, "Tree-sitter WASM grammar is unpinned; configure version and sha256"
54
+ actual_hash = self._sha256(grammar)
55
+ expected_hash = pin["sha256"].removeprefix("sha256:").lower()
56
+ if actual_hash != expected_hash:
57
+ return False, f"Tree-sitter WASM grammar hash mismatch: expected {expected_hash}, got {actual_hash}"
58
+ runtime = self._runtime()
59
+ if runtime is None:
60
+ return False, "Node/Bun runtime is not available"
61
+ if not self._module_available(runtime):
62
+ return False, "web-tree-sitter module is not importable"
63
+ if not HELPER.is_file():
64
+ return False, "Tree-sitter WASM helper is missing"
65
+ return True, f"Tree-sitter WASM grammar available: {grammar} ({pin['version']}, sha256:{actual_hash})"
66
+
67
+ def capability(self) -> Dict:
68
+ available, reason = self._availability_for(None)
69
+ grammar = dict(self.declaration.get("grammar", {}))
70
+ return {
71
+ "parser": self.parser_id,
72
+ "strategy": "tree-sitter-wasm",
73
+ "native": False,
74
+ "available": available,
75
+ "status": "implemented" if available else "parser_unavailable",
76
+ "support_level": "supported" if available else ("unpinned" if self._grammar_path(None) else "parser_unavailable"),
77
+ "pin": self._grammar_pin(),
78
+ "reason": reason,
79
+ "grammar": grammar,
80
+ "fallback": self.fallback.parser_id,
81
+ "scope": "Tree-sitter WASM syntax tree; framework semantics remain a separate linker stage",
82
+ }
83
+
84
+ def parse_file(self, codebase, file_record, text) -> ParseResult:
85
+ if file_record.get("language") != self.language:
86
+ return self.fallback.parse_file(codebase, file_record, text)
87
+ available, reason = self._availability_for(codebase)
88
+ if not available:
89
+ result = self.fallback.parse_file(codebase, file_record, text)
90
+ result.diagnostics.append({
91
+ "codebase": codebase.name,
92
+ "path": file_record["path"],
93
+ "line": 1,
94
+ "severity": "warning",
95
+ "kind": "parser_unavailable",
96
+ "message": f"Tree-sitter WASM unavailable for {self.language}; fallback used: {reason}",
97
+ "parser": self.parser_id,
98
+ "metadata": {"support_level": "parser_unavailable", "fallback_parser": self.fallback.parser_id},
99
+ })
100
+ return result
101
+ try:
102
+ payload = self._parse(codebase, file_record, text)
103
+ except Exception as error:
104
+ result = self.fallback.parse_file(codebase, file_record, text)
105
+ result.diagnostics.append({
106
+ "codebase": codebase.name, "path": file_record["path"], "line": 1,
107
+ "severity": "error", "kind": "parse_failed",
108
+ "message": f"Tree-sitter WASM parse failed; fallback used: {error}",
109
+ "parser": self.parser_id,
110
+ })
111
+ return result
112
+ return self._convert(codebase, file_record, text, payload)
113
+
114
+ def _availability_for(self, codebase=None):
115
+ # A grammar/runtime is global for a scan. Codebase-specific grammar
116
+ # overrides are intentionally checked only when parsing that codebase.
117
+ if codebase is not None:
118
+ return self.check_available(codebase)
119
+ if self._availability is None:
120
+ self._availability = self.check_available(None)
121
+ return self._availability
122
+
123
+ def _grammar_path(self, codebase=None):
124
+ key = f"DEVFLOW_TREE_SITTER_WASM_GRAMMAR_{self.language.upper().replace('-', '_')}"
125
+ configured = os.environ.get(key) or self.declaration.get("grammar", {}).get("path")
126
+ if configured:
127
+ path = Path(configured)
128
+ if not path.is_absolute() and codebase is not None:
129
+ path = Path(codebase.root) / path
130
+ return str(path.resolve()) if path.is_file() else None
131
+ package = self.declaration.get("grammar", {}).get("package")
132
+ package_path = self._resolve_package_grammar(package) if package else None
133
+ return package_path
134
+
135
+ @staticmethod
136
+ def _resolve_package_grammar(package):
137
+ """Resolve a declared npm grammar without downloading at runtime.
138
+
139
+ The package is an installation-time dependency. Searching the
140
+ workspace and adapter ancestors keeps this working from both a
141
+ checkout and an installed plugin while ensuring no network fallback
142
+ can occur during indexing.
143
+ """
144
+ if not package:
145
+ return None
146
+ roots = [Path.cwd(), Path(__file__).resolve()]
147
+ seen = set()
148
+ for root in roots:
149
+ for candidate in [root, *root.parents]:
150
+ package_root = candidate / "node_modules" / package
151
+ candidates = [package_root / "grammar.wasm"]
152
+ if package_root.is_dir():
153
+ candidates.extend(sorted(package_root.glob("*.wasm")))
154
+ for node_modules in candidates:
155
+ key = str(node_modules)
156
+ if key in seen:
157
+ continue
158
+ seen.add(key)
159
+ if node_modules.is_file():
160
+ return str(node_modules.resolve())
161
+ return None
162
+
163
+ def _grammar_pin(self):
164
+ grammar = self.declaration.get("grammar", {})
165
+ prefix = f"DEVFLOW_TREE_SITTER_GRAMMAR_{self.language.upper().replace('-', '_')}"
166
+ return {
167
+ "version": os.environ.get(f"{prefix}_VERSION") or grammar.get("version"),
168
+ "sha256": os.environ.get(f"{prefix}_SHA256") or grammar.get("sha256"),
169
+ }
170
+
171
+ @staticmethod
172
+ def _sha256(path):
173
+ digest = hashlib.sha256()
174
+ with Path(path).open("rb") as handle:
175
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
176
+ digest.update(chunk)
177
+ return digest.hexdigest()
178
+
179
+ @staticmethod
180
+ def _module_available(runtime):
181
+ # Probe the module before claiming availability. The helper import is
182
+ # deliberately not the first place this is discovered: otherwise the
183
+ # snapshot would advertise a parser which only fails per file.
184
+ try:
185
+ probe = subprocess.run(
186
+ [runtime, "--input-type=module", "-e",
187
+ "import('web-tree-sitter').then(()=>process.exit(0)).catch(()=>process.exit(1))"],
188
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
189
+ timeout=5, check=False,
190
+ )
191
+ except (OSError, subprocess.TimeoutExpired):
192
+ return False
193
+ return probe.returncode == 0
194
+
195
+ @staticmethod
196
+ def _runtime():
197
+ configured = os.environ.get("DEVFLOW_TREE_SITTER_WASM_RUNTIME")
198
+ if configured:
199
+ return configured if shutil.which(configured) else None
200
+ return shutil.which("bun") or shutil.which("node")
201
+
202
+ def _parse(self, codebase, record, text):
203
+ runtime = self._runtime()
204
+ grammar = self._grammar_path(codebase)
205
+ max_bytes = int(os.environ.get("DEVFLOW_TREE_SITTER_WASM_MAX_BYTES", str(2 * 1024 * 1024)))
206
+ source_bytes = len(text.encode("utf-8"))
207
+ if source_bytes > max_bytes:
208
+ raise RuntimeError(f"source exceeds Tree-sitter WASM limit ({source_bytes} > {max_bytes} bytes)")
209
+ timeout = float(os.environ.get("DEVFLOW_TREE_SITTER_WASM_TIMEOUT", "10"))
210
+ proc = subprocess.run(
211
+ [runtime, str(HELPER), grammar],
212
+ input=json.dumps({"path": record["path"], "source": text}, ensure_ascii=False) + "\n",
213
+ text=True, encoding="utf-8", stdout=subprocess.PIPE, stderr=subprocess.PIPE,
214
+ timeout=timeout, check=False,
215
+ )
216
+ if proc.returncode != 0:
217
+ raise RuntimeError(proc.stderr.strip()[:500] or f"helper exited {proc.returncode}")
218
+ payload = json.loads(proc.stdout.splitlines()[-1])
219
+ if not payload.get("ok"):
220
+ raise RuntimeError(payload.get("error") or "Tree-sitter helper returned no result")
221
+ return payload
222
+
223
+ def _convert(self, codebase, record, text, payload):
224
+ path, content_hash = record["path"], record["content_hash"]
225
+ file_symbol = _symbol(codebase.name, record["language"], "file", Path(path).name, path, 1,
226
+ max(1, len(text.splitlines())), content_hash, qualified_name=path,
227
+ parser=self.parser_id)
228
+ symbols = [file_symbol]
229
+ by_key = {"": file_symbol}
230
+ for item in payload.get("declarations", []):
231
+ parent = by_key.get(str(item.get("parent") or ""), file_symbol)
232
+ symbol = _symbol(codebase.name, record["language"], item.get("kind", "symbol"),
233
+ item.get("name", "anonymous"), path, max(1, int(item.get("line_start", 1))),
234
+ max(1, int(item.get("line_end", item.get("line_start", 1)))), content_hash,
235
+ qualified_name=item.get("qualified_name") or f"{parent['qualified_name']}::{item.get('name', 'anonymous')}",
236
+ parser=self.parser_id)
237
+ symbol["container_id"] = parent["id"]
238
+ symbol["metadata"].update({"parse_status": payload.get("parse_status", "complete"), "grammar": self.declaration.get("grammar", {})})
239
+ symbols.append(symbol)
240
+ by_key[str(item.get("key"))] = symbol
241
+ relations = []
242
+ for symbol in symbols[1:]:
243
+ relations.append(_relation(symbol["container_id"], "contains", path, symbol["line_start"], self.parser_id, "exact", to_id=symbol["id"]))
244
+ for item in payload.get("calls", []):
245
+ owner = by_key.get(str(item.get("owner") or ""), file_symbol)
246
+ relations.append(_relation(owner["id"], "calls", path, max(1, int(item.get("line", 1))), self.parser_id,
247
+ "heuristic", to_external=f"call:{item.get('target', 'unknown')}"))
248
+ diagnostics = list(payload.get("diagnostics", []))
249
+ if payload.get("parse_status") != "complete":
250
+ diagnostics.append({"codebase": codebase.name, "path": path, "line": 1, "severity": "warning",
251
+ "kind": "parse_partial", "message": "Tree-sitter grammar produced a partial tree",
252
+ "parser": self.parser_id, "metadata": payload.get("metadata", {})})
253
+ return ParseResult(symbols=symbols, relations=relations, diagnostics=diagnostics, actual_parser=self.parser_id)
@@ -22,6 +22,7 @@ if str(SCRIPT_DIR) not in sys.path:
22
22
  sys.path.insert(0, str(SCRIPT_DIR))
23
23
 
24
24
  from adapters.generic_text import language_for_path
25
+ from adapters.capabilities import confidence_for_parser
25
26
  from adapters.registry import AdapterRegistry
26
27
  from semantic import SEMANTIC_EXTRACTOR_VERSION, build_semantic_facts
27
28
  from core.discovery import discover_codebases
@@ -286,15 +287,27 @@ def command_scan(args) -> int:
286
287
  "parser": "pending",
287
288
  }
288
289
  adapter = registry.adapter_for_file(codebase, file_record)
289
- file_record["parser"] = adapter.parser_for_file(codebase, file_record)
290
+ parser_for_file = getattr(adapter, "parser_for_file", None)
291
+ file_record["parser"] = parser_for_file(codebase, file_record) if parser_for_file else adapter.parser_id
290
292
  result = adapter.parse_file(codebase, file_record, text)
291
293
  file_record["parser"] = result.actual_parser or file_record["parser"]
292
294
  files.append(file_record)
293
295
  symbols.extend(result.symbols)
294
296
  relations.extend(result.relations)
295
297
  diagnostics.extend(result.diagnostics)
296
- adapter_entities.extend(getattr(result, "entities", []) or [])
297
- adapter_flows.extend(getattr(result, "flows", []) or [])
298
+ for entity in getattr(result, "entities", []) or []:
299
+ entity = dict(entity)
300
+ entity.setdefault("extractor", file_record["parser"])
301
+ entity.setdefault("confidence", confidence_for_parser(file_record["parser"], "symbol"))
302
+ entity.setdefault("metadata", {})
303
+ adapter_entities.append(entity)
304
+ for flow in getattr(result, "flows", []) or []:
305
+ flow = dict(flow)
306
+ flow.setdefault("extractor", file_record["parser"])
307
+ flow.setdefault("confidence", "heuristic")
308
+ flow.setdefault("evidence_level", "syntax")
309
+ flow.setdefault("metadata", {})
310
+ adapter_flows.append(flow)
298
311
  parsed += 1
299
312
  if parsed == 1 or parsed % 250 == 0 or parsed == len(all_files):
300
313
  progress.emit("parse", f"parsed {parsed}/{len(all_files)} files in {codebase.name}", codebase=codebase.name, parsed=parsed, total=len(all_files))
@@ -316,12 +329,19 @@ def command_scan(args) -> int:
316
329
  staging = Path(tempfile.mkdtemp(prefix=f".{output.name}.build-", dir=str(output.parent)))
317
330
  try:
318
331
  generation = str(uuid.uuid4())
319
- workspace_state = _workspace_state(root, files, changed, discovered_paths, registry.capabilities())
332
+ registry_capabilities = getattr(registry, "capabilities", None)
333
+ workspace_state = _workspace_state(
334
+ root, files, changed, discovered_paths,
335
+ registry_capabilities() if registry_capabilities else {},
336
+ )
320
337
  workspace_state["generatedAt"] = datetime.now(timezone.utc).isoformat()
321
338
  workspace_state["semanticExtractor"] = SEMANTIC_EXTRACTOR_VERSION
322
339
  workspace_state["extractorSetHash"] = _hash_lines([*workspace_state.get("extractors", []), SEMANTIC_EXTRACTOR_VERSION])
323
340
  progress.emit("semantic", "building semantic facts")
324
- semantic_entities, semantic_flows, feature_bindings = build_semantic_facts(root, files, symbols, relations)
341
+ semantic_stats = {}
342
+ semantic_entities, semantic_flows, feature_bindings = build_semantic_facts(root, files, symbols, relations, stats=semantic_stats)
343
+ workspace_state["semanticScan"] = semantic_stats
344
+ progress.emit("semantic", "semantic facts completed", **semantic_stats)
325
345
  semantic_entities.extend(adapter_entities)
326
346
  semantic_flows.extend(adapter_flows)
327
347
  write_jsonl(staging / INDEX_FILES["files"], files)
@@ -481,7 +501,7 @@ def command_relations(args) -> int:
481
501
  truncated = len(rows) > limit
482
502
  for row in rows[:limit]:
483
503
  item = dict(row)
484
- if args.change_type:
504
+ if getattr(args, "change_type", None):
485
505
  item["impact_kind"] = args.change_type
486
506
  item["severity"] = _impact_severity(args.change_type, item["flow_type"])
487
507
  print(json.dumps(item, ensure_ascii=False, sort_keys=True))
@@ -5,31 +5,58 @@ does not replace framework parsers; it gives the relational index a stable
5
5
  schema so API, database, UI and feature facts can be linked incrementally.
6
6
  """
7
7
  import re
8
+ import time
8
9
  from pathlib import Path
9
10
  from typing import Dict, Iterable, List, Tuple
10
11
 
11
12
  from core.schema import short_hash, stable_key
12
13
  from core.hashing import file_sha256
13
14
 
14
- SEMANTIC_EXTRACTOR_VERSION = "semantic-linker@1"
15
+ SEMANTIC_EXTRACTOR_VERSION = "semantic-linker@2"
16
+ SEMANTIC_MAX_BYTES = 512_000
17
+ SEMANTIC_FILE_BUDGET_SECONDS = 2.0
18
+ SEMANTIC_MAX_LINE_CHARS = 100_000
19
+ SEMANTIC_LANGUAGES = {
20
+ "python", "javascript", "typescript", "vue", "svelte", "go", "rust",
21
+ "php", "java", "kotlin", "dart", "c", "cpp", "csharp", "ruby",
22
+ "swift", "scala", "lua", "shell", "sql", "html", "css",
23
+ }
24
+ SEMANTIC_LOCK_FILES = {
25
+ "package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock",
26
+ "composer.lock", "cargo.lock", "poetry.lock", "pdm.lock", "go.sum",
27
+ }
28
+ SEMANTIC_GENERATED_DIRS = {
29
+ "node_modules", "vendor", "dist", "build", "coverage", "target", ".next",
30
+ ".nuxt", "_nuxt", ".cache", "generated", ".generated", "__generated__", "snapshots",
31
+ }
15
32
 
16
33
 
17
34
  def build_semantic_facts(root: Path, files: Iterable[dict], symbols: Iterable[dict],
18
- relations: Iterable[dict]) -> Tuple[List[dict], List[dict], List[dict]]:
35
+ relations: Iterable[dict], stats: Dict | None = None) -> Tuple[List[dict], List[dict], List[dict]]:
19
36
  entities: List[dict] = []
20
37
  flows: List[dict] = []
21
38
  bindings: List[dict] = []
22
39
  records = list(files)
40
+ summary = {"processed": 0, "timedOut": 0, "skipped": {}}
23
41
  symbol_by_path = {}
24
42
  for symbol in symbols:
25
43
  symbol_by_path.setdefault(symbol.get("path"), []).append(symbol)
26
44
 
27
45
  for file_number, file_record in enumerate(records, 1):
28
46
  if file_number == 1 or file_number % 250 == 0 or file_number == len(records):
29
- print(f"[code-index][semantic] processed {file_number}/{len(records)} files", flush=True)
47
+ skipped_total = sum(summary["skipped"].values())
48
+ print(f"[code-index][semantic] visited {file_number}/{len(records)} files; processed={summary['processed']} skipped={skipped_total} timed_out={summary['timedOut']}", flush=True)
30
49
  path = file_record["path"]
50
+ skip_reason = semantic_skip_reason(file_record)
51
+ if skip_reason:
52
+ summary["skipped"][skip_reason] = summary["skipped"].get(skip_reason, 0) + 1
53
+ continue
31
54
  source = _read(root, path)
32
55
  if source is None:
56
+ summary["skipped"]["unreadable"] = summary["skipped"].get("unreadable", 0) + 1
57
+ continue
58
+ if _looks_minified_or_compressed(path, source):
59
+ summary["skipped"]["minified_or_compressed"] = summary["skipped"].get("minified_or_compressed", 0) + 1
33
60
  continue
34
61
  try:
35
62
  if file_sha256(root / path) != file_record.get("content_hash"):
@@ -38,21 +65,36 @@ def build_semantic_facts(root: Path, files: Iterable[dict], symbols: Iterable[di
38
65
  continue
39
66
  except OSError:
40
67
  continue
68
+ deadline = time.monotonic() + SEMANTIC_FILE_BUDGET_SECONDS
69
+ file_entities: List[dict] = []
70
+ file_flows: List[dict] = []
71
+ file_bindings: List[dict] = []
41
72
  language = file_record.get("language", "")
42
73
  feature = _feature_from_path(path)
43
74
  if feature:
44
75
  feature_id = f"feature:{stable_key(feature)}"
45
- entities.append(_entity(feature_id, "feature", feature, file_record, 1, 1,
46
- "heuristic", "path_feature_inference"))
76
+ file_entities.append(_entity(feature_id, "feature", feature, file_record, 1, 1,
77
+ "heuristic", "path_feature_inference"))
47
78
  for symbol in symbol_by_path.get(path, []):
48
79
  if symbol.get("kind") in {"route", "component", "class", "function", "method"}:
49
- bindings.append(_binding(feature_id, symbol["id"], "static_inferred", "heuristic", path, symbol.get("line_start", 1)))
80
+ file_bindings.append(_binding(feature_id, symbol["id"], "static_inferred", "heuristic", path, symbol.get("line_start", 1)))
50
81
 
51
82
  state_ids = set()
83
+ timed_out = False
52
84
  for line_no, line in enumerate(source.splitlines(), 1):
53
- _extract_api(line, path, file_record, line_no, entities, flows)
54
- _extract_db(line, path, file_record, line_no, entities, flows)
55
- _extract_ui(line, path, file_record, line_no, entities, flows, state_ids)
85
+ if time.monotonic() > deadline:
86
+ timed_out = True
87
+ break
88
+ _extract_api(line, path, file_record, line_no, file_entities, file_flows)
89
+ _extract_db(line, path, file_record, line_no, file_entities, file_flows)
90
+ _extract_ui(line, path, file_record, line_no, file_entities, file_flows, state_ids)
91
+ if timed_out:
92
+ summary["timedOut"] += 1
93
+ continue
94
+ entities.extend(file_entities)
95
+ flows.extend(file_flows)
96
+ bindings.extend(file_bindings)
97
+ summary["processed"] += 1
56
98
 
57
99
  # Existing structural relations remain the source of method call edges;
58
100
  # expose them in the semantic graph without changing their meaning.
@@ -67,11 +109,48 @@ def build_semantic_facts(root: Path, files: Iterable[dict], symbols: Iterable[di
67
109
  "line_start": relation.get("evidence_line"),
68
110
  "confidence": relation.get("confidence", "heuristic"),
69
111
  "evidence_level": "syntax",
112
+ "extractor": relation.get("parser", "unknown"),
70
113
  "metadata": relation.get("metadata", {}),
71
114
  })
115
+ if stats is not None:
116
+ stats.clear()
117
+ stats.update(summary)
72
118
  return _dedupe_entities(entities), _dedupe_flows(flows), _dedupe_bindings(bindings)
73
119
 
74
120
 
121
+ def semantic_skip_reason(record: dict):
122
+ path = Path(record.get("path", ""))
123
+ parts = tuple(part.lower() for part in path.parts)
124
+ name = path.name.lower()
125
+ if name in SEMANTIC_LOCK_FILES or name.endswith((".lock", ".lockb")):
126
+ return "lock_file"
127
+ if any(part in SEMANTIC_GENERATED_DIRS for part in parts):
128
+ return "generated_path"
129
+ path_text = "/".join(parts)
130
+ if any(marker in path_text for marker in ("public/assets/", "public/build/", "public/_nuxt/")):
131
+ return "generated_path"
132
+ if "public" in parts:
133
+ public_index = parts.index("public")
134
+ if any(part in {"assets", "build", "dist", "static", "_nuxt"} for part in parts[public_index + 1:-1]):
135
+ return "generated_path"
136
+ if name.endswith((".min.js", ".min.css", ".bundle.js", ".chunk.js", ".map", ".g.dart", ".freezed.dart", ".pb.go", ".pb.cc", "_pb2.py", ".designer.cs")) or ".generated." in name:
137
+ return "generated_asset"
138
+ if int(record.get("size_bytes") or 0) > SEMANTIC_MAX_BYTES:
139
+ return "too_large"
140
+ if record.get("language", "") not in SEMANTIC_LANGUAGES:
141
+ return "unsupported_language"
142
+ return None
143
+
144
+
145
+ def _looks_minified_or_compressed(path: str, source: str) -> bool:
146
+ if not source:
147
+ return False
148
+ lines = source.splitlines()
149
+ if any(len(line) > SEMANTIC_MAX_LINE_CHARS for line in lines):
150
+ return True
151
+ return len(source) > 100_000 and len(lines) <= 5
152
+
153
+
75
154
  def _extract_api(line, path, record, line_no, entities, flows):
76
155
  # Framework-neutral route declarations and client URLs.
77
156
  match = re.search(r"(?:@(?:app|router|api)\.(get|post|put|patch|delete)\s*\(|(?:GET|POST|PUT|PATCH|DELETE)\s+)(['\"])(/[^'\"]+)\2", line, re.I)
@@ -144,15 +223,17 @@ def _extract_ui(line, path, record, line_no, entities, flows, state_ids=None):
144
223
 
145
224
 
146
225
  def _entity(entity_id, entity_type, name, record, start, end, confidence, extractor, metadata=None):
147
- return {"id": entity_id, "entity_type": entity_type, "name": name, "codebase": record["codebase"], "path": record["path"], "line_start": start, "line_end": end, "content_hash": record["content_hash"], "confidence": confidence, "extractor": extractor, "metadata": metadata or {}}
226
+ extractor_id = extractor if "@" in extractor else f"{SEMANTIC_EXTRACTOR_VERSION}:{extractor}"
227
+ return {"id": entity_id, "entity_type": entity_type, "name": name, "codebase": record["codebase"], "path": record["path"], "line_start": start, "line_end": end, "content_hash": record["content_hash"], "confidence": confidence, "extractor": extractor_id, "metadata": metadata or {}}
148
228
 
149
229
 
150
230
  def _flow(source_id, target_id, flow_type, path, line, confidence, extractor, metadata=None):
151
- return {"source_id": source_id, "target_id": target_id, "flow_type": flow_type, "source_path": path, "line_start": line, "confidence": confidence, "evidence_level": "static_source", "extractor": extractor, "metadata": metadata or {}}
231
+ extractor_id = extractor if "@" in extractor else f"{SEMANTIC_EXTRACTOR_VERSION}:{extractor}"
232
+ return {"source_id": source_id, "target_id": target_id, "flow_type": flow_type, "source_path": path, "line_start": line, "confidence": confidence, "evidence_level": "static_source", "extractor": extractor_id, "metadata": metadata or {}}
152
233
 
153
234
 
154
235
  def _binding(feature_id, entity_id, source, confidence, path, line):
155
- return {"feature_id": feature_id, "entity_id": entity_id, "binding_type": source, "confidence": confidence, "source_path": path, "line_start": line}
236
+ return {"feature_id": feature_id, "entity_id": entity_id, "binding_type": source, "confidence": confidence, "source_path": path, "line_start": line, "extractor": f"{SEMANTIC_EXTRACTOR_VERSION}:path_feature_binding"}
156
237
 
157
238
 
158
239
  def _read(root, path):