@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.
- package/README.md +1 -36
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +149 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/test_command_policy.py +42 -5
- package/plugins/devflow-core/skills/workflow-project-context/SKILL.md +62 -787
- package/plugins/devflow-core/skills/workflow-project-context/scripts/validate_initialization_database.py +156 -0
- package/plugins/devflow-intel/extensions/devflow-intel.ts +12 -2
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/generic_text.py +14 -10
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/python_ast.py +124 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/registry.py +11 -2
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/structured_scope.py +121 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +107 -9
- package/src/cli.ts +12 -2
- package/src/constants.ts +1 -1
- package/src/initialization-store.ts +656 -0
- package/src/paths.ts +9 -0
- package/src/project-context.ts +17 -2
- package/src/project-documents.ts +111 -0
- package/src/project-initializer.ts +251 -0
- package/src/project-migration.ts +66 -0
|
@@ -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 "
|
|
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
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
return
|
|
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
|