@guanlin4924/devflow-pi 0.1.62 → 0.1.63

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.
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env python3
2
+ """Validate database-backed initialization without trusting Markdown markers."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ import sqlite3
10
+ import sys
11
+ from pathlib import Path
12
+
13
+
14
+ TERMINAL_STATUSES = {"finished", "failed", "cancelled"}
15
+ VALID_COVERAGE = {"complete", "partial", "unsupported", "not_applicable", "skipped", "failed"}
16
+
17
+
18
+ def parse_args():
19
+ parser = argparse.ArgumentParser()
20
+ parser.add_argument("--database", required=True)
21
+ parser.add_argument("--project-id", required=True)
22
+ parser.add_argument("--workspace-id")
23
+ parser.add_argument("--run-id")
24
+ return parser.parse_args()
25
+
26
+
27
+ def validate(database: Path, project_id: str, workspace_id: str | None = None, run_id: str | None = None):
28
+ errors: list[str] = []
29
+ if not database.is_file():
30
+ return [f"initialization database does not exist: {database}"]
31
+ connection = sqlite3.connect(f"file:{database}?mode=ro", uri=True)
32
+ connection.row_factory = sqlite3.Row
33
+ try:
34
+ version = connection.execute("SELECT version FROM schema_meta LIMIT 1").fetchone()
35
+ if not version or version["version"] != 2:
36
+ errors.append(f"unsupported initialization schema: {version['version'] if version else 'missing'}")
37
+ if not connection.execute("SELECT 1 FROM projects WHERE id=?", (project_id,)).fetchone():
38
+ errors.append("project identity is not registered in the database")
39
+ foreign_keys = connection.execute("PRAGMA foreign_key_check").fetchall()
40
+ if foreign_keys:
41
+ errors.append(f"foreign-key violations: {len(foreign_keys)}")
42
+ if workspace_id and not connection.execute(
43
+ "SELECT 1 FROM workspaces WHERE id=? AND project_id=?", (workspace_id, project_id)
44
+ ).fetchone():
45
+ errors.append("workspace does not belong to the requested project")
46
+
47
+ where = ["project_id=?"]
48
+ parameters: list[str] = [project_id]
49
+ if workspace_id:
50
+ where.append("workspace_id=?")
51
+ parameters.append(workspace_id)
52
+ if run_id:
53
+ where.append("id=?")
54
+ parameters.append(run_id)
55
+ runs = connection.execute(
56
+ f"SELECT * FROM initialization_runs WHERE {' AND '.join(where)} ORDER BY created_at DESC",
57
+ parameters,
58
+ ).fetchall()
59
+ if run_id and not runs:
60
+ errors.append("requested initialization run was not found")
61
+ for run in runs[:1] if not run_id else runs:
62
+ if run["status"] not in TERMINAL_STATUSES | {"preparing", "collecting", "validating"}:
63
+ errors.append(f"invalid run status: {run['status']}")
64
+ results = connection.execute(
65
+ "SELECT repository,kind,status FROM collection_results WHERE run_id=?", (run["id"],)
66
+ ).fetchall()
67
+ if any(row["status"] not in VALID_COVERAGE for row in results):
68
+ errors.append("collection has an invalid coverage status")
69
+ config = connection.execute(
70
+ "SELECT config_json FROM scan_configs WHERE id=?", (run["config_id"],)
71
+ ).fetchone()
72
+ config_value = json.loads(config["config_json"]) if config else {}
73
+ required = config_value.get("requiredKinds") or ["asset", "technology", "api", "page", "data_model", "integration", "rule", "toolchain", "evidence", "capability"]
74
+ repositories = [item.get("name") for item in config_value.get("repositories", []) if item.get("name")]
75
+ accounted = {(row["repository"], row["kind"]) for row in results if row["status"] not in {"partial", "unsupported", "skipped", "failed"}}
76
+ missing = sorted(f"{repository}:{kind}" for repository in repositories for kind in required if (repository, kind) not in accounted and ("", kind) not in accounted)
77
+ if missing:
78
+ errors.append(f"required collections are unaccounted: {', '.join(missing)}")
79
+ if run["status"] == "finished":
80
+ snapshot = connection.execute(
81
+ "SELECT * FROM context_snapshots WHERE id=? AND run_id=? AND input_snapshot_id=?",
82
+ (run["published_snapshot_id"], run["id"], run["input_snapshot_id"]),
83
+ ).fetchone()
84
+ if not snapshot:
85
+ errors.append("finished run has no matching immutable context snapshot")
86
+ else:
87
+ try:
88
+ index_ref = json.loads(snapshot["index_ref_json"] or "{}")
89
+ if config_value.get("mode") == "full":
90
+ if index_ref.get("status") != "complete" or not index_ref.get("generation"):
91
+ errors.append("full initialization has no completed immutable index reference")
92
+ workspace = connection.execute("SELECT location FROM workspaces WHERE id=?", (run["workspace_id"],)).fetchone()
93
+ index_path = Path(str(index_ref.get("path", "")))
94
+ workspace_root = Path(workspace["location"]).resolve() if workspace else None
95
+ index_root = (workspace_root / index_path).resolve() if workspace_root and not index_path.is_absolute() else None
96
+ if not index_root or (index_root != workspace_root and workspace_root not in index_root.parents):
97
+ errors.append("registered code-index path escapes its workspace")
98
+ index_root = None
99
+ immutable_relative = Path(str(index_ref.get("immutablePath", "")))
100
+ immutable_root = (index_root / immutable_relative).resolve() if index_root and not immutable_relative.is_absolute() else None
101
+ if immutable_root and immutable_root != index_root and index_root not in immutable_root.parents:
102
+ immutable_root = None
103
+ errors.append("registered immutable code-index path escapes its index root")
104
+ immutable = immutable_root / "SNAPSHOT.json" if immutable_root else None
105
+ if not immutable or not immutable.is_file():
106
+ errors.append("registered immutable code-index generation is unavailable")
107
+ else:
108
+ if f"sha256:{hashlib.sha256(immutable.read_bytes()).hexdigest()}" != index_ref.get("snapshotHash"):
109
+ errors.append("registered immutable code-index snapshot hash mismatch")
110
+ if json.loads(immutable.read_text(encoding="utf-8")).get("generation") != index_ref.get("generation"):
111
+ errors.append("immutable code-index generation identity mismatch")
112
+ elif config_value.get("mode") == "fast" and (index_ref.get("status"), index_ref.get("reason")) != ("skipped", "explicit_fast_mode"):
113
+ errors.append("fast initialization lacks explicit index skip evidence")
114
+ except (OSError, ValueError, json.JSONDecodeError) as error:
115
+ errors.append(f"invalid code-index reference: {error}")
116
+ input_row = connection.execute(
117
+ "SELECT stable FROM input_snapshots WHERE id=?", (run["input_snapshot_id"],)
118
+ ).fetchone()
119
+ if not input_row or not input_row["stable"]:
120
+ errors.append("finished run was published from unstable input")
121
+ missing_evidence = connection.execute(
122
+ """SELECT COUNT(*) AS count FROM facts f
123
+ LEFT JOIN evidence e ON e.fact_id=f.id
124
+ JOIN initialization_runs r ON r.id=f.run_id
125
+ WHERE r.project_id=? AND f.evidence_level='observed' AND e.id IS NULL""",
126
+ (project_id,),
127
+ ).fetchone()["count"]
128
+ if missing_evidence:
129
+ errors.append(f"observed facts without source evidence: {missing_evidence}")
130
+ database_root = database.parent
131
+ for content in connection.execute("SELECT content_hash,relative_path FROM content_objects"):
132
+ content_path = database_root / content["relative_path"]
133
+ if not content_path.is_file():
134
+ errors.append(f"referenced content is missing: {content['content_hash']}")
135
+ elif f"sha256:{hashlib.sha256(content_path.read_bytes()).hexdigest()}" != content["content_hash"]:
136
+ errors.append(f"referenced content hash mismatch: {content['content_hash']}")
137
+ except (sqlite3.Error, ValueError, json.JSONDecodeError) as error:
138
+ errors.append(f"database validation error: {error}")
139
+ finally:
140
+ connection.close()
141
+ return errors
142
+
143
+
144
+ def main():
145
+ args = parse_args()
146
+ errors = validate(Path(args.database).resolve(), args.project_id, args.workspace_id, args.run_id)
147
+ if errors:
148
+ for error in errors:
149
+ print(error, file=sys.stderr)
150
+ return 1
151
+ print("Initialization database validation passed.")
152
+ return 0
153
+
154
+
155
+ if __name__ == "__main__":
156
+ raise SystemExit(main())
@@ -53,8 +53,12 @@ export default function devflowIntel(pi: ExtensionAPI) {
53
53
  label: "Devflow Code Index",
54
54
  description: "Scan, validate, audit, or query the deterministic project code index.",
55
55
  parameters: z.object({
56
- action: z.enum(["scan", "validate", "audit", "query"]),
56
+ action: z.enum(["scan", "validate", "audit", "query", "relations"]),
57
57
  term: z.string().optional(),
58
+ object: z.string().optional(),
59
+ snapshot: z.string().optional(),
60
+ depth: z.number().int().min(0).max(20).default(3),
61
+ includeHeuristic: z.boolean().default(false),
58
62
  limit: z.number().int().min(1).max(100).default(20),
59
63
  changedFiles: z.array(z.string()).default([]),
60
64
  }),
@@ -74,11 +78,17 @@ export default function devflowIntel(pi: ExtensionAPI) {
74
78
  args.push("--index", ".planning/code-index");
75
79
  } else if (params.action === "audit") {
76
80
  args.push("--root", ctx.cwd, "--index", ".planning/code-index", "--skill-root", skillRoot);
77
- } else {
81
+ } else if (params.action === "query") {
78
82
  if (!params.term) {
79
83
  return render({ ok: false, exitCode: 2, stdout: "", stderr: "term is required" }, {});
80
84
  }
81
85
  args.push("--index", ".planning/code-index", "--term", params.term, "--limit", String(params.limit));
86
+ if (params.snapshot) args.push("--snapshot", params.snapshot);
87
+ } else {
88
+ if (!params.object) return render({ ok: false, exitCode: 2, stdout: "", stderr: "object is required" }, {});
89
+ args.push("--index", ".planning/code-index", "--object", params.object, "--depth", String(params.depth), "--limit", String(params.limit));
90
+ if (params.snapshot) args.push("--snapshot", params.snapshot);
91
+ if (params.includeHeuristic) args.push("--include-heuristic");
82
92
  }
83
93
  return render(await run(args, ctx.cwd), { action: params.action });
84
94
  },
@@ -40,7 +40,7 @@ class GenericTextAdapter(Adapter):
40
40
  return 1
41
41
 
42
42
  def version(self) -> str:
43
- return "1"
43
+ return "2"
44
44
 
45
45
  def parse_file(self, codebase, file_record, text) -> ParseResult:
46
46
  path = file_record["path"]
@@ -77,9 +77,10 @@ class GenericTextAdapter(Adapter):
77
77
  )
78
78
  )
79
79
 
80
- line_ranges = _line_ranges([file_symbol] + extracted)
81
80
  for lineno, line in enumerate(text.splitlines(), start=1):
82
- owner = _owner_for_line(line_ranges, lineno, file_symbol["id"])
81
+ # Regex ranges are not syntax scopes. Keep generic relations at file
82
+ # level instead of presenting a guessed method owner as exact.
83
+ owner = file_symbol["id"]
83
84
  for target in _extract_imports(line, language):
84
85
  relations.append(
85
86
  _relation(
@@ -92,7 +93,7 @@ class GenericTextAdapter(Adapter):
92
93
  to_external=f"import:{target}",
93
94
  )
94
95
  )
95
- for target in _extract_calls(line):
96
+ for target in _extract_calls(line, language):
96
97
  relations.append(
97
98
  _relation(
98
99
  owner,
@@ -102,6 +103,7 @@ class GenericTextAdapter(Adapter):
102
103
  self.parser_id,
103
104
  confidence="heuristic",
104
105
  to_external=f"call:{target}",
106
+ metadata={"resolution": "unresolved_text", "scope": "file"},
105
107
  )
106
108
  )
107
109
  for relation_type, external in _extract_resources(line):
@@ -212,9 +214,12 @@ def _extract_imports(line: str, language: str) -> List[str]:
212
214
  return result
213
215
 
214
216
 
215
- def _extract_calls(line: str) -> List[str]:
217
+ def _extract_calls(line: str, language: str = "") -> List[str]:
216
218
  if line.strip().startswith(("#", "//", "*", "/*")):
217
219
  return []
220
+ # A declaration is evidence for a symbol, not a call to that symbol.
221
+ if _symbol_matches(line, language):
222
+ return []
218
223
  result = []
219
224
  for match in re.finditer(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(", line):
220
225
  name = match.group(1)
@@ -280,8 +285,7 @@ def _line_ranges(symbols: List[Dict]):
280
285
 
281
286
 
282
287
  def _owner_for_line(ranges, line, fallback):
283
- for start, end, symbol_id in ranges:
284
- if start <= line <= end:
285
- return symbol_id
286
- return fallback
287
-
288
+ candidates = [item for item in ranges if item[0] <= line <= item[1]]
289
+ if not candidates:
290
+ return fallback
291
+ return min(candidates, key=lambda item: (item[1] - item[0], -item[0]))[2]
@@ -0,0 +1,124 @@
1
+ import ast
2
+ from pathlib import Path
3
+ from typing import Dict, List
4
+
5
+ from adapters.base import Adapter, ParseResult
6
+ from adapters.generic_text import GenericTextAdapter, _relation, _symbol
7
+
8
+
9
+ class PythonAstAdapter(Adapter):
10
+ name = "python-ast"
11
+
12
+ def __init__(self):
13
+ self.fallback = GenericTextAdapter()
14
+
15
+ def detect(self, codebase, files, manifests) -> int:
16
+ return 100 if any(path.suffix.lower() == ".py" for path in files) else 0
17
+
18
+ def version(self) -> str:
19
+ return "1"
20
+
21
+ def parse_file(self, codebase, file_record, text) -> ParseResult:
22
+ if file_record["language"] != "python":
23
+ return self.fallback.parse_file(codebase, file_record, text)
24
+ path = file_record["path"]
25
+ file_symbol = _symbol(
26
+ codebase.name, "python", "file", Path(path).name, path, 1,
27
+ max(1, len(text.splitlines())), file_record["content_hash"],
28
+ qualified_name=path, parser=self.parser_id,
29
+ )
30
+ try:
31
+ tree = ast.parse(text, filename=path)
32
+ except SyntaxError as error:
33
+ fallback = self.fallback.parse_file(codebase, file_record, text)
34
+ fallback.diagnostics.append({
35
+ "codebase": codebase.name, "path": path, "line": error.lineno or 1,
36
+ "severity": "warning", "kind": "parse_failed",
37
+ "message": f"python AST parse failed; generic fallback used: {error.msg}",
38
+ "parser": self.parser_id,
39
+ })
40
+ return fallback
41
+ visitor = _PythonVisitor(codebase.name, path, file_record["content_hash"], file_symbol, self.parser_id)
42
+ visitor.visit(tree)
43
+ return ParseResult(
44
+ symbols=[file_symbol, *visitor.symbols],
45
+ relations=visitor.contains + visitor.relations,
46
+ diagnostics=[],
47
+ )
48
+
49
+
50
+ class _PythonVisitor(ast.NodeVisitor):
51
+ def __init__(self, codebase: str, path: str, content_hash: str, file_symbol: Dict, parser: str):
52
+ self.codebase = codebase
53
+ self.path = path
54
+ self.content_hash = content_hash
55
+ self.file_symbol = file_symbol
56
+ self.parser = parser
57
+ self.symbols: List[Dict] = []
58
+ self.contains: List[Dict] = []
59
+ self.relations: List[Dict] = []
60
+ self.stack: List[Dict] = [file_symbol]
61
+
62
+ def _definition(self, node, kind: str):
63
+ parent = self.stack[-1]
64
+ qualified = f"{parent['qualified_name']}::{node.name}"
65
+ symbol = _symbol(
66
+ self.codebase, "python", kind, node.name, self.path,
67
+ node.lineno, getattr(node, "end_lineno", node.lineno), self.content_hash,
68
+ qualified_name=qualified, signature=self._signature(node), parser=self.parser,
69
+ )
70
+ symbol["container_id"] = parent["id"]
71
+ symbol["metadata"]["scope_resolution"] = "python_ast"
72
+ self.symbols.append(symbol)
73
+ self.contains.append(_relation(parent["id"], "contains", self.path, node.lineno, self.parser, "exact", to_id=symbol["id"], metadata={"extraction": "python_ast"}))
74
+ self.stack.append(symbol)
75
+ self.generic_visit(node)
76
+ self.stack.pop()
77
+
78
+ def visit_ClassDef(self, node):
79
+ self._definition(node, "class")
80
+
81
+ def visit_FunctionDef(self, node):
82
+ parent_kind = self.stack[-1]["kind"]
83
+ self._definition(node, "method" if parent_kind == "class" else ("test" if node.name.startswith("test") else "function"))
84
+
85
+ def visit_AsyncFunctionDef(self, node):
86
+ self.visit_FunctionDef(node)
87
+
88
+ def visit_Import(self, node):
89
+ for alias in node.names:
90
+ self.relations.append(_relation(self.stack[-1]["id"], "imports", self.path, node.lineno, self.parser, "exact", to_external=f"import:{alias.name}", metadata={"alias": alias.asname or alias.name}))
91
+
92
+ def visit_ImportFrom(self, node):
93
+ module = "." * node.level + (node.module or "")
94
+ for alias in node.names:
95
+ self.relations.append(_relation(self.stack[-1]["id"], "imports", self.path, node.lineno, self.parser, "exact", to_external=f"import:{module}.{alias.name}".rstrip("."), metadata={"alias": alias.asname or alias.name}))
96
+
97
+ def visit_Call(self, node):
98
+ target = self._call_name(node.func)
99
+ if target:
100
+ self.relations.append(_relation(
101
+ self.stack[-1]["id"], "calls", self.path, node.lineno, self.parser,
102
+ "inferred" if "." not in target else "heuristic",
103
+ to_external=f"call:{target}",
104
+ metadata={"resolution": "lexical_name" if "." not in target else "receiver_unresolved", "extraction": "python_ast"},
105
+ ))
106
+ self.generic_visit(node)
107
+
108
+ @staticmethod
109
+ def _call_name(node):
110
+ if isinstance(node, ast.Name):
111
+ return node.id
112
+ if isinstance(node, ast.Attribute):
113
+ parent = _PythonVisitor._call_name(node.value)
114
+ return f"{parent}.{node.attr}" if parent else node.attr
115
+ return None
116
+
117
+ @staticmethod
118
+ def _signature(node):
119
+ arguments = [argument.arg for argument in [*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs]]
120
+ if node.args.vararg:
121
+ arguments.append(f"*{node.args.vararg.arg}")
122
+ if node.args.kwarg:
123
+ arguments.append(f"**{node.args.kwarg.arg}")
124
+ return f"{node.name}({', '.join(arguments)})"
@@ -1,9 +1,19 @@
1
1
  from .generic_text import GenericTextAdapter
2
+ from .python_ast import PythonAstAdapter
3
+ from .structured_scope import StructuredScopeAdapter
2
4
 
3
5
 
4
6
  class AdapterRegistry:
5
7
  def __init__(self):
6
- self.adapters = [GenericTextAdapter()]
8
+ self.adapters = [PythonAstAdapter(), StructuredScopeAdapter(), GenericTextAdapter()]
9
+
10
+ def adapter_for_file(self, codebase, file_record):
11
+ language = file_record.get("language")
12
+ if language == "python":
13
+ return self.adapters[0]
14
+ if language in {"typescript", "javascript", "ruby"}:
15
+ return self.adapters[1]
16
+ return self.adapters[-1]
7
17
 
8
18
  def adapters_for(self, codebase, files, manifests):
9
19
  scored = []
@@ -14,4 +24,3 @@ class AdapterRegistry:
14
24
  if not scored:
15
25
  return [GenericTextAdapter()]
16
26
  return [adapter for _, adapter in sorted(scored, key=lambda item: item[0], reverse=True)]
17
-
@@ -0,0 +1,121 @@
1
+ import re
2
+ from pathlib import Path
3
+ from typing import Dict, List, Tuple
4
+
5
+ from adapters.base import Adapter, ParseResult
6
+ from adapters.generic_text import GenericTextAdapter, _extract_calls, _extract_imports, _relation, _symbol
7
+
8
+
9
+ SUPPORTED = {"typescript", "javascript", "ruby"}
10
+
11
+
12
+ class StructuredScopeAdapter(Adapter):
13
+ """Conservative lexical scopes for languages unavailable to Python AST.
14
+
15
+ This adapter never labels target resolution exact. It improves declaration
16
+ boundaries and ownership while declaring receiver/dynamic gaps in metadata.
17
+ """
18
+ name = "structured-scope"
19
+
20
+ def __init__(self):
21
+ self.fallback = GenericTextAdapter()
22
+
23
+ def detect(self, codebase, files, manifests) -> int:
24
+ return 80 if any(path.suffix.lower() in {".ts", ".tsx", ".js", ".jsx", ".rb"} for path in files) else 0
25
+
26
+ def version(self) -> str:
27
+ return "1"
28
+
29
+ def parse_file(self, codebase, file_record, text) -> ParseResult:
30
+ language = file_record["language"]
31
+ if language not in SUPPORTED:
32
+ return self.fallback.parse_file(codebase, file_record, text)
33
+ return self._ruby(codebase, file_record, text) if language == "ruby" else self._brace_language(codebase, file_record, text)
34
+
35
+ def _brace_language(self, codebase, file_record, text):
36
+ lines = text.splitlines()
37
+ file_symbol = self._file(codebase, file_record, len(lines))
38
+ declarations: List[Tuple[int, str, str, str]] = []
39
+ pattern = re.compile(r"\b(class|interface|function)\s+([A-Za-z_$][\w$]*)\s*([^\n{]*)|\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>")
40
+ for number, line in enumerate(lines, 1):
41
+ clean = _without_strings_and_comments(line)
42
+ match = pattern.search(clean)
43
+ method = re.match(r"^\s*(?:(?:public|private|protected|static|async|readonly|override)\s+)*([A-Za-z_$][\w$]*)\s*\([^;]*\)\s*(?::[^={]+)?\s*\{", clean)
44
+ if not match:
45
+ if method and method.group(1) not in {"if", "for", "while", "switch", "catch", "constructor"}:
46
+ declarations.append((number, "function", method.group(1), line.strip()))
47
+ continue
48
+ keyword, named, tail, arrow = match.groups()
49
+ name = named or arrow
50
+ kind = "class" if keyword == "class" else "interface" if keyword == "interface" else "function"
51
+ declarations.append((number, kind, name, line.strip()))
52
+ symbols = self._scoped_symbols(codebase, file_record, lines, declarations, file_symbol, brace=True)
53
+ return self._relations(codebase, file_record, lines, file_symbol, symbols)
54
+
55
+ def _ruby(self, codebase, file_record, text):
56
+ lines = text.splitlines(); file_symbol = self._file(codebase, file_record, len(lines)); declarations = []
57
+ for number, line in enumerate(lines, 1):
58
+ match = re.match(r"^\s*(class|module|def)\s+(?:self\.)?([A-Za-z_][\w!?=]*)", _without_strings_and_comments(line))
59
+ if match:
60
+ keyword, name = match.groups(); declarations.append((number, "class" if keyword == "class" else "module" if keyword == "module" else "function", name, line.strip()))
61
+ symbols = self._scoped_symbols(codebase, file_record, lines, declarations, file_symbol, brace=False)
62
+ return self._relations(codebase, file_record, lines, file_symbol, symbols)
63
+
64
+ def _file(self, codebase, record, lines):
65
+ return _symbol(codebase.name, record["language"], "file", Path(record["path"]).name, record["path"], 1, max(1, lines), record["content_hash"], qualified_name=record["path"], parser=self.parser_id)
66
+
67
+ def _scoped_symbols(self, codebase, record, lines, declarations, file_symbol, brace):
68
+ symbols: List[Dict] = []
69
+ for start, kind, name, signature in declarations:
70
+ end = _brace_end(lines, start) if brace else _ruby_end(lines, start)
71
+ containers = [symbol for symbol in symbols if symbol["line_start"] < start <= symbol["line_end"]]
72
+ parent = min(containers, key=lambda item: item["line_end"] - item["line_start"]) if containers else file_symbol
73
+ adjusted = "method" if kind == "function" and parent["kind"] == "class" else kind
74
+ symbol = _symbol(codebase.name, record["language"], adjusted, name, record["path"], start, max(start, end), record["content_hash"], qualified_name=f"{parent['qualified_name']}::{name}", signature=signature[:500], parser=self.parser_id)
75
+ symbol["container_id"] = parent["id"]
76
+ symbol["metadata"].update({"scope_resolution": "structured_lexical", "scope_confidence": "inferred"})
77
+ symbols.append(symbol)
78
+ return symbols
79
+
80
+ def _relations(self, codebase, record, lines, file_symbol, symbols):
81
+ relations = []
82
+ for symbol in symbols:
83
+ relations.append(_relation(symbol["container_id"] or file_symbol["id"], "contains", record["path"], symbol["line_start"], self.parser_id, "inferred", to_id=symbol["id"], metadata={"extraction": "structured_lexical"}))
84
+ for number, line in enumerate(lines, 1):
85
+ owners = [symbol for symbol in symbols if symbol["kind"] in {"function", "method", "test"} and symbol["line_start"] < number <= symbol["line_end"]]
86
+ owner = min(owners, key=lambda item: item["line_end"] - item["line_start"])["id"] if owners else file_symbol["id"]
87
+ for target in _extract_imports(line, record["language"]):
88
+ relations.append(_relation(owner, "imports", record["path"], number, self.parser_id, "inferred", to_external=f"import:{target}", metadata={"extraction": "structured_lexical"}))
89
+ for target in _extract_calls(line, record["language"]):
90
+ receiver = bool(re.search(rf"\.\s*{re.escape(target)}\s*\(", line))
91
+ 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}))
92
+ return ParseResult(symbols=[file_symbol, *symbols], relations=relations, diagnostics=[])
93
+
94
+
95
+ def _without_strings_and_comments(line: str) -> str:
96
+ line = re.sub(r"(['\"])(?:\\.|(?!\1).)*\1", "''", line)
97
+ return re.sub(r"//.*$|#.*$", "", line)
98
+
99
+
100
+ def _brace_end(lines: List[str], start: int) -> int:
101
+ depth = 0; opened = False
102
+ for number in range(start, len(lines) + 1):
103
+ clean = _without_strings_and_comments(lines[number - 1])
104
+ for character in clean:
105
+ if character == "{": depth += 1; opened = True
106
+ elif character == "}" and opened:
107
+ depth -= 1
108
+ if depth == 0: return number
109
+ return start
110
+
111
+
112
+ def _ruby_end(lines: List[str], start: int) -> int:
113
+ depth = 0
114
+ opener = re.compile(r"^\s*(?:class|module|def|if|unless|case|begin|while|until|for)\b|\bdo\s*(?:\|[^|]*\|)?\s*$")
115
+ for number in range(start, len(lines) + 1):
116
+ clean = _without_strings_and_comments(lines[number - 1])
117
+ if opener.search(clean): depth += 1
118
+ if re.match(r"^\s*end\b", clean):
119
+ depth -= 1
120
+ if depth <= 0: return number
121
+ return start