@guanlin4924/devflow-pi 0.1.76 → 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.
Files changed (25) hide show
  1. package/package.json +9 -2
  2. package/plugins/devflow-intel/skills/workflow-code-index/references/adapter-contract.md +8 -0
  3. package/plugins/devflow-intel/skills/workflow-code-index/references/data-model.md +1 -1
  4. package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/base.py +20 -0
  5. package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/capabilities.py +26 -0
  6. package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/generic_text.py +4 -2
  7. package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/go_ast.py +20 -0
  8. package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/helpers/DevflowJavaAstHelper.java +96 -0
  9. package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/helpers/go_ast_helper.go +96 -0
  10. package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/helpers/php_ast_helper.php +91 -0
  11. package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/helpers/tree_sitter_wasm_helper.mjs +50 -0
  12. package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/helpers/typescript_ast_helper.cjs +123 -0
  13. package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/java_ast.py +37 -0
  14. package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/native_process.py +328 -0
  15. package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/parser-capabilities.json +491 -0
  16. package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/php_ast.py +55 -0
  17. package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/python_ast.py +10 -0
  18. package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/registry.py +55 -50
  19. package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/structured_scope.py +1 -1
  20. package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/tree_sitter_wasm.py +253 -0
  21. package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/typescript_ast.py +31 -0
  22. package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +29 -12
  23. package/plugins/devflow-intel/skills/workflow-code-index/scripts/semantic.py +93 -12
  24. package/plugins/devflow-intel/skills/workflow-code-index/scripts/writers/sqlite_writer.py +26 -13
  25. package/src/constants.ts +1 -1
@@ -1,6 +1,16 @@
1
+ from .capabilities import load_capability_manifest
1
2
  from .generic_text import GenericTextAdapter
3
+ from .go_ast import GoAstAdapter
4
+ from .java_ast import JavaAstAdapter
5
+ from .native_process import UnavailableNativeAdapter
6
+ from .php_ast import PhpAstAdapter
2
7
  from .python_ast import PythonAstAdapter
3
8
  from .structured_scope import StructuredScopeAdapter
9
+ from .tree_sitter_wasm import TreeSitterWasmAdapter
10
+ from .typescript_ast import TypeScriptAstAdapter
11
+
12
+
13
+ CAPABILITY_MANIFEST = load_capability_manifest()
4
14
 
5
15
 
6
16
  class AdapterRegistry:
@@ -8,61 +18,56 @@ class AdapterRegistry:
8
18
  self.python = PythonAstAdapter()
9
19
  self.structured = StructuredScopeAdapter()
10
20
  self.generic = GenericTextAdapter()
11
- self.adapters = [self.python, self.structured, self.generic]
12
-
13
- # Keep language selection explicit. The parser implementation is
14
- # conservative and shared for now, but every supported language has a
15
- # deliberate registration instead of silently falling through to text
16
- # regexes. This is the extension point for native AST adapters.
21
+ self.typescript = TypeScriptAstAdapter(self.structured)
22
+ self.go = GoAstAdapter(self.structured)
23
+ self.php = PhpAstAdapter(self.structured)
24
+ self.java = JavaAstAdapter(self.structured)
25
+ self.native_adapters = [self.python, self.typescript, self.go, self.php, self.java]
26
+ self.adapters = [*self.native_adapters, self.structured, self.generic]
17
27
  self.language_adapters = {
18
28
  "python": self.python,
19
- "typescript": self.structured,
20
- "javascript": self.structured,
21
- "ruby": self.structured,
22
- "go": self.structured,
23
- "rust": self.structured,
24
- "php": self.structured,
25
- "java": self.structured,
26
- "kotlin": self.structured,
27
- "c": self.structured,
28
- "cpp": self.structured,
29
- "csharp": self.structured,
30
- "swift": self.structured,
31
- "dart": self.structured,
32
- "scala": self.structured,
33
- "lua": self.structured,
34
- "shell": self.structured,
35
- "vue": self.structured,
36
- "svelte": self.structured,
37
- # Data, configuration, and template types are explicitly registered too. They
38
- # currently use the safe text adapter until a native parser is
39
- # available; this keeps their parser provenance explicit and the
40
- # unified output contract unchanged.
41
- "sql": self.generic,
42
- "html": self.generic,
43
- "css": self.generic,
44
- "json": self.generic,
45
- "yaml": self.generic,
46
- "toml": self.generic,
47
- "xml": self.generic,
48
- "graphql": self.generic,
49
- "protobuf": self.generic,
50
- "hcl": self.generic,
51
- "terraform": self.generic,
52
- "dockerfile": self.generic,
53
- "markdown": self.generic,
29
+ "typescript": self.typescript,
30
+ "javascript": self.typescript,
31
+ "go": self.go,
32
+ "php": self.php,
33
+ "java": self.java,
54
34
  }
35
+ structured_languages = {
36
+ "ruby", "rust", "kotlin", "c", "cpp", "csharp", "swift",
37
+ "dart", "scala", "lua", "shell", "vue", "svelte",
38
+ }
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"
44
+ fallback = self.structured if language in structured_languages else self.generic
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)
55
49
 
56
50
  def adapter_for_file(self, codebase, file_record):
57
51
  language = file_record.get("language")
58
- return self.language_adapters.get(language, self.generic)
52
+ return self.language_adapters.get(
53
+ language,
54
+ UnavailableNativeAdapter(language or "unknown", "unregistered-native-ast", self.generic, "no native adapter is registered"),
55
+ )
59
56
 
60
57
  def adapters_for(self, codebase, files, manifests):
61
- scored = []
62
- for adapter in self.adapters:
63
- score = adapter.detect(codebase, files, manifests)
64
- if score > 0:
65
- scored.append((score, adapter))
66
- if not scored:
67
- return [GenericTextAdapter()]
68
- return [adapter for _, adapter in sorted(scored, key=lambda item: item[0], reverse=True)]
58
+ return [self.adapter_for_file(codebase, {"language": self._language(path)}) for path in files]
59
+
60
+ def capabilities(self):
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
69
+
70
+ @staticmethod
71
+ def _language(path):
72
+ from .generic_text import language_for_path
73
+ return language_for_path(path)
@@ -127,7 +127,7 @@ class StructuredScopeAdapter(Adapter):
127
127
  for target in _extract_calls(line, record["language"]):
128
128
  receiver = bool(re.search(rf"\.\s*{re.escape(target)}\s*\(", line))
129
129
  relations.append(_relation(owner, "calls", record["path"], number, self.parser_id, "heuristic", to_external=f"call:{target}", metadata={"resolution": "receiver_unresolved" if receiver else "name_only", "allow_unique_name_resolution": not receiver, "dynamic_limitations": True}))
130
- return ParseResult(symbols=[file_symbol, *symbols], relations=relations, diagnostics=[])
130
+ return ParseResult(symbols=[file_symbol, *symbols], relations=relations, diagnostics=[], actual_parser=self.parser_id)
131
131
 
132
132
 
133
133
  def _without_strings_and_comments(line: str) -> str:
@@ -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)
@@ -0,0 +1,31 @@
1
+ import shutil
2
+ import subprocess
3
+ from pathlib import Path
4
+ from typing import List, Tuple
5
+
6
+ from adapters.native_process import HELPER_DIR, NativeProcessAdapter
7
+
8
+
9
+ class TypeScriptAstAdapter(NativeProcessAdapter):
10
+ name = "typescript-compiler-ast"
11
+ languages = ("typescript", "javascript")
12
+ native_description = "TypeScript Compiler API syntax tree; declarations, imports, and calls"
13
+
14
+ def version(self) -> str:
15
+ return "1"
16
+
17
+ def check_available(self, codebase=None) -> Tuple[bool, str]:
18
+ node = shutil.which("node")
19
+ if not node:
20
+ return False, "node executable not found"
21
+ helper = HELPER_DIR / "typescript_ast_helper.cjs"
22
+ probe = subprocess.run(
23
+ [node, "-e", "require.resolve('typescript')"], cwd=str(helper.parent),
24
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
25
+ )
26
+ if probe.returncode != 0:
27
+ return False, "typescript compiler API module not found"
28
+ return True, "typescript compiler API available"
29
+
30
+ def command(self, codebase=None) -> List[str]:
31
+ return [shutil.which("node") or "node", str(HELPER_DIR / "typescript_ast_helper.cjs")]
@@ -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
@@ -99,7 +100,7 @@ def _publish_snapshot(staging: Path, output: Path, counts: Dict[str, int], works
99
100
  os.close(directory_fd)
100
101
 
101
102
 
102
- def _workspace_state(root: Path, files: List[dict], changed: Set[str], discovered_paths: Iterable[str] | None = None) -> Dict[str, object]:
103
+ def _workspace_state(root: Path, files: List[dict], changed: Set[str], discovered_paths: Iterable[str] | None = None, capabilities: Dict[str, object] | None = None) -> Dict[str, object]:
103
104
  indexed_paths = sorted(record["path"] for record in files)
104
105
  discovered = sorted(set(discovered_paths or indexed_paths))
105
106
  source_manifest_hash = _hash_lines(f"{record['path']}\0{record['content_hash']}" for record in files)
@@ -112,11 +113,7 @@ def _workspace_state(root: Path, files: List[dict], changed: Set[str], discovere
112
113
  "dirtyPathsAtBuild": sorted(changed),
113
114
  "extractors": extractors,
114
115
  "extractorSetHash": _hash_lines(extractors),
115
- "capabilities": {
116
- "python-ast@1": {"scope": "exact syntax tree", "targets": "lexical names; dynamic dispatch unresolved"},
117
- "structured-scope@1": {"scope": "inferred lexical braces/end blocks", "targets": "name-only heuristic"},
118
- "generic-text@2": {"scope": "file-level", "targets": "heuristic only"},
119
- },
116
+ "capabilities": capabilities or {},
120
117
  }
121
118
 
122
119
 
@@ -290,14 +287,27 @@ def command_scan(args) -> int:
290
287
  "parser": "pending",
291
288
  }
292
289
  adapter = registry.adapter_for_file(codebase, file_record)
293
- file_record["parser"] = adapter.parser_id
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
294
292
  result = adapter.parse_file(codebase, file_record, text)
293
+ file_record["parser"] = result.actual_parser or file_record["parser"]
295
294
  files.append(file_record)
296
295
  symbols.extend(result.symbols)
297
296
  relations.extend(result.relations)
298
297
  diagnostics.extend(result.diagnostics)
299
- adapter_entities.extend(getattr(result, "entities", []) or [])
300
- 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)
301
311
  parsed += 1
302
312
  if parsed == 1 or parsed % 250 == 0 or parsed == len(all_files):
303
313
  progress.emit("parse", f"parsed {parsed}/{len(all_files)} files in {codebase.name}", codebase=codebase.name, parsed=parsed, total=len(all_files))
@@ -319,12 +329,19 @@ def command_scan(args) -> int:
319
329
  staging = Path(tempfile.mkdtemp(prefix=f".{output.name}.build-", dir=str(output.parent)))
320
330
  try:
321
331
  generation = str(uuid.uuid4())
322
- workspace_state = _workspace_state(root, files, changed, discovered_paths)
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
+ )
323
337
  workspace_state["generatedAt"] = datetime.now(timezone.utc).isoformat()
324
338
  workspace_state["semanticExtractor"] = SEMANTIC_EXTRACTOR_VERSION
325
339
  workspace_state["extractorSetHash"] = _hash_lines([*workspace_state.get("extractors", []), SEMANTIC_EXTRACTOR_VERSION])
326
340
  progress.emit("semantic", "building semantic facts")
327
- 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)
328
345
  semantic_entities.extend(adapter_entities)
329
346
  semantic_flows.extend(adapter_flows)
330
347
  write_jsonl(staging / INDEX_FILES["files"], files)
@@ -484,7 +501,7 @@ def command_relations(args) -> int:
484
501
  truncated = len(rows) > limit
485
502
  for row in rows[:limit]:
486
503
  item = dict(row)
487
- if args.change_type:
504
+ if getattr(args, "change_type", None):
488
505
  item["impact_kind"] = args.change_type
489
506
  item["severity"] = _impact_severity(args.change_type, item["flow_type"])
490
507
  print(json.dumps(item, ensure_ascii=False, sort_keys=True))