feat: per-cluster import extraction + JSON output
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

- _extract_imports scans source files for language-agnostic import patterns
- analyze_clusters tracks file paths through frequency_filter via identity
- Each cluster output includes its unique imports and file paths
- --json flag outputs structured JSON for LLM prompt injection
- Flat mode (--cluster false) also extracts imports
This commit is contained in:
tobjend 2026-07-03 21:52:02 +02:00
parent 73b94af959
commit 2d4fc8eed5
2 changed files with 92 additions and 5 deletions

View file

@ -7,7 +7,9 @@ Runs the full Phase 1.0 pipeline over a directory of source files.
""" """
import argparse import argparse
import json
import os import os
import re
import sys import sys
from pathlib import Path, PurePath from pathlib import Path, PurePath
from collections import Counter from collections import Counter
@ -17,6 +19,16 @@ import pathspec
from .code import preprocess_by_method, _extract_call_tokens from .code import preprocess_by_method, _extract_call_tokens
from bex.ensemble import infer_ensemble from bex.ensemble import infer_ensemble
IMPORT_PATTERNS = [
re.compile(r"^\s*import\s+"),
re.compile(r"^\s*from\s+"),
re.compile(r"^\s*require\s+"),
re.compile(r"^\s*require_relative\s+"),
re.compile(r"^\s*#\s*include\s+"),
re.compile(r"^\s*use\s+"),
re.compile(r"^\s*include\s+"),
]
SUPPORTED_EXTENSIONS = { SUPPORTED_EXTENSIONS = {
".py", ".js", ".ts", ".kt", ".rb", ".go", ".rs", ".java", ".c", ".cpp", ".py", ".js", ".ts", ".kt", ".rb", ".go", ".rs", ".java", ".c", ".cpp",
} }
@ -46,6 +58,30 @@ def _match_glob(filepath, pattern):
return spec.match_file(filepath) return spec.match_file(filepath)
def _extract_imports(file_paths):
"""Extract unique import lines from source files.
Scans top 200 lines of each file for common import patterns
across all 10 supported languages. Deduplicates across files.
"""
seen = set()
result = []
for fp in sorted(file_paths):
try:
with open(fp) as f:
for i, line in enumerate(f):
if i >= 200:
break
stripped = line.strip()
if any(p.match(stripped) for p in IMPORT_PATTERNS):
if stripped not in seen:
seen.add(stripped)
result.append(stripped)
except OSError:
continue
return result
def scan_directory(dir_path, gitignore_spec=None): def scan_directory(dir_path, gitignore_spec=None):
"""Walk dir_path, return dict mapping extension → [file paths]. """Walk dir_path, return dict mapping extension → [file paths].
@ -165,15 +201,18 @@ def analyze_clusters(file_paths, extension, min_coverage=0.2, prefer=None, kmax=
"""Run full pipeline with clustering: preprocess → cluster → per-cluster infer. """Run full pipeline with clustering: preprocess → cluster → per-cluster infer.
Returns: Returns:
list of (label, ensemble_result_dict, method_count) tuples. list of (label, ensemble_result_dict, method_count, meta) tuples.
meta = {"files": set(paths), "imports": [sorted_import_lines]}.
""" """
sequences = [] sequences = []
seq_files = []
for fp in file_paths: for fp in file_paths:
with open(fp) as f: with open(fp) as f:
code = f.read() code = f.read()
for method_seq in preprocess_by_method(fp, code): for method_seq in preprocess_by_method(fp, code):
if method_seq: if method_seq:
sequences.append(method_seq) sequences.append(method_seq)
seq_files.append(fp)
if not sequences: if not sequences:
return [] return []
@ -183,9 +222,15 @@ def analyze_clusters(file_paths, extension, min_coverage=0.2, prefer=None, kmax=
results = [] results = []
for label, cluster_seqs in clusters: for label, cluster_seqs in clusters:
cluster_fps = set()
for seq in cluster_seqs:
idx = next(i for i, s in enumerate(sequences) if s is seq)
cluster_fps.add(seq_files[idx])
imports = _extract_imports(cluster_fps)
symbol_seqs = [[text for _, text, _ in seq] for seq in cluster_seqs] symbol_seqs = [[text for _, text, _ in seq] for seq in cluster_seqs]
result = infer_ensemble(symbol_seqs, kmax=kmax, N=N, prefer=prefer) result = infer_ensemble(symbol_seqs, kmax=kmax, N=N, prefer=prefer)
results.append((label, result, len(cluster_seqs))) meta = {"files": cluster_fps, "imports": imports}
results.append((label, result, len(cluster_seqs), meta))
return results return results
@ -258,7 +303,7 @@ def analyze_directory(
) )
else: else:
r = infer(files, ext, min_coverage=min_coverage, prefer=prefer, kmax=kmax) r = infer(files, ext, min_coverage=min_coverage, prefer=prefer, kmax=kmax)
results[ext] = [("(all methods)", r, 0)] results[ext] = [("(all methods)", r, 0, {"files": set(files), "imports": _extract_imports(files)})]
return results return results
@ -292,9 +337,40 @@ def _parse_args(argv=None):
"--ngram-size", type=int, default=3, "--ngram-size", type=int, default=3,
help="N-gram length for clustering (default: 3)", help="N-gram length for clustering (default: 3)",
) )
parser.add_argument(
"--format", choices=["text", "json"], default="text",
help="Output format (default: text)",
)
parser.add_argument(
"--json", action="store_true", dest="json_flag",
help="Shortcut for --format json",
)
return parser.parse_args(argv) return parser.parse_args(argv)
def _build_json_output(results):
"""Convert results dict to a compact JSON structure for prompt injection."""
output = []
for ext, clusters in results.items():
lang = {"language": ext, "conventions": []}
total_methods = 0
for label, result, count, meta in clusters:
total_methods += count
entry = {
"label": label,
"method_count": count,
}
if result and result.get("best"):
entry["algorithm"] = result["best"]["algorithm"]
entry["grammar"] = result["best"]["grammar"]
entry["mdl_score"] = round(result["best"]["mdl_score"], 1)
entry["imports"] = meta.get("imports", [])
lang["conventions"].append(entry)
lang["total_methods"] = total_methods
output.append(lang)
return json.dumps(output, indent=2)
def main(): def main():
args = _parse_args() args = _parse_args()
results = analyze_directory( results = analyze_directory(
@ -304,9 +380,14 @@ def main():
kmax=args.kmax, kmax=args.kmax,
include=args.include, include=args.include,
) )
if args.json_flag or args.format == "json":
print(_build_json_output(results))
return
for ext, clusters in results.items(): for ext, clusters in results.items():
print(f"\n{ext}:") print(f"\n{ext}:")
for label, result, count in clusters: for label, result, count, meta in clusters:
if result and result.get("best"): if result and result.get("best"):
best = result["best"] best = result["best"]
print(f" ╰─ {label} ({count} methods)") print(f" ╰─ {label} ({count} methods)")
@ -315,6 +396,12 @@ def main():
print(f" MDL: {best['mdl_score']}") print(f" MDL: {best['mdl_score']}")
else: else:
print(f" ╰─ {label} ({count} methods) — no grammar") print(f" ╰─ {label} ({count} methods) — no grammar")
imps = meta.get("imports", [])
if imps:
joined = " | ".join(imps[:6])
print(f" Imports: {joined}")
if len(imps) > 6:
print(f" ... and {len(imps) - 6} more")
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -138,7 +138,7 @@ def test_analyze_directory_include_glob():
results = analyze_directory(td, include="**/src/main/**") results = analyze_directory(td, include="**/src/main/**")
assert ".py" in results assert ".py" in results
assert len(results[".py"]) >= 1 assert len(results[".py"]) >= 1
for label, r, count in results[".py"]: for label, r, count, meta in results[".py"]:
if r and r.get("best"): if r and r.get("best"):
assert r["best"]["grammar"] is not None assert r["best"]["grammar"] is not None
print(" PASS test_analyze_directory_include_glob") print(" PASS test_analyze_directory_include_glob")