@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.
- package/package.json +9 -2
- package/plugins/devflow-intel/skills/workflow-code-index/references/adapter-contract.md +8 -0
- package/plugins/devflow-intel/skills/workflow-code-index/references/data-model.md +1 -1
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/capabilities.py +26 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/generic_text.py +3 -1
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/helpers/tree_sitter_wasm_helper.mjs +50 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/parser-capabilities.json +491 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/registry.py +20 -31
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/tree_sitter_wasm.py +253 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +26 -6
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/semantic.py +93 -12
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/writers/sqlite_writer.py +26 -13
- package/src/constants.ts +1 -1
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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@
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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):
|