Skip to content
Draft
19 changes: 19 additions & 0 deletions .github/workflows/repository-intelligence.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: Repository Intelligence CI
on:
pull_request:
push:
branches: ["feat/repository-intelligence-v1"]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: python -m pip install -e ".[test]"
- run: pytest -q
- run: repo-intel graph . --out /tmp/repository-graph.json
- run: python -c 'import json; d=json.load(open("/tmp/repository-graph.json")); assert d["graph_sha256"].startswith("sha256:")'
59 changes: 18 additions & 41 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,49 +1,26 @@
# Project: Codebase Traversal and Gemini AI Integration
# Codebase Prompting → Repository Intelligence

This repository contains two Python scripts: `base_print.py` and `base_print_ai.py`.
This repository is being evolved from file-dump prompting scripts into a deterministic repository-intelligence engine for governed software development.

## `base_print.py`
## v1 capabilities

This script traverses a given directory, collects all text files (with certain extensions), and writes their content to an output file. It handles large files by skipping them and provides a continuation mechanism using a unique key.
- repository file inventory with SHA-256 content identity;
- Python top-level symbol graph;
- Python import dependency graph;
- relative JS/TS import dependency graph;
- reverse dependency change-impact analysis;
- deterministic graph and impact digests;
- CLI suitable for CI and OpenHands/agent consumption.

### Features
- Traverses directories, ignoring specified folders and files.
- Collects text file contents up to specified limits.
- Generates a continuation key to resume processing.
- Outputs a directory tree with indicators for processed and skipped files.
No LLM is required for v1 graph construction. This keeps repository structure and change-impact evidence reproducible.

### Usage
1. **Run the Script**:
```bash
python base_print.py [directory] [continue_key]
```
- `directory`: The directory to search (default is the current directory).
- `continue_key`: The key to continue from where it left off (optional).
## CLI

2. **Output**:
- Generates `codebase_n.txt` with the collected contents and directory tree.
- Prints the continuation key if the line limit is reached.
```bash
repo-intel graph /path/to/repo --out repository-graph.json
repo-intel impact repository-graph.json src/example.py --depth 3 --out change-impact.json
```

## `base_print_ai.py`
## Legacy scripts

This script extends the functionality of `base_print.py` by integrating with the Google Gemini API. It uses the API to determine relevant files based on a user prompt and processes those files.

### Features
- Integrates with Google Gemini API for intelligent file selection.
- Processes files based on the relevance determined by the API.
- Outputs a directory tree with indicators for processed and skipped files.

### Usage
1. **Set Up API Key**:
- Ensure you have set the `API_KEY` environment variable with your Google Gemini API key.

2. **Run the Script**:
```bash
python base_print_ai.py [directory] [continue_key]
```
- `directory`: The directory to search (default is the current directory).
- `continue_key`: The key to continue from where it left off (optional).

3. **Output**:
- Generates `prompt_n.txt` with the collected contents and directory tree.
- Prompts the user for an input prompt to guide the file selection process.
`base_print.py` and `base_print_ai_model.py` are preserved as historical utilities. They are not the architectural foundation of the new repository-intelligence layer.
18 changes: 18 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

[project]
name = "codebase-prompting-repo-intelligence"
version = "0.1.0"
description = "Deterministic repository graph and change-impact engine"
requires-python = ">=3.10"

[project.optional-dependencies]
test = ["pytest>=8"]

[project.scripts]
repo-intel = "repo_intelligence.cli:main"

[tool.setuptools.packages.find]
include = ["repo_intelligence*"]
6 changes: 6 additions & 0 deletions repo_intelligence/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Deterministic repository intelligence primitives."""

from .graph import build_repository_graph
from .impact import analyze_change_impact

__all__ = ["build_repository_graph", "analyze_change_impact"]
19 changes: 19 additions & 0 deletions repo_intelligence/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from __future__ import annotations
import argparse,json
from pathlib import Path
from .graph import build_repository_graph
from .impact import analyze_change_impact


def main()->None:
p=argparse.ArgumentParser(prog="repo-intel")
sub=p.add_subparsers(dest="cmd",required=True)
g=sub.add_parser("graph"); g.add_argument("root"); g.add_argument("--out",default="repository-graph.json")
i=sub.add_parser("impact"); i.add_argument("graph"); i.add_argument("changed",nargs="+"); i.add_argument("--depth",type=int,default=3); i.add_argument("--out",default="change-impact.json")
a=p.parse_args()
if a.cmd=="graph": data=build_repository_graph(a.root)
else: data=analyze_change_impact(json.loads(Path(a.graph).read_text(encoding="utf-8")),a.changed,a.depth)
Path(a.out).write_text(json.dumps(data,indent=2,sort_keys=True)+"\n",encoding="utf-8")
print(a.out)

if __name__=="__main__": main()
114 changes: 114 additions & 0 deletions repo_intelligence/graph.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Deterministic repository graph construction.

v1 intentionally avoids LLM dependence. It builds a reproducible graph from
repository files, Python imports/symbols, and selected JS/TS imports.
"""
from __future__ import annotations

import ast
import hashlib
import json
import os
import re
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Iterable

IGNORE_DIRS={".git",".venv","venv","node_modules","dist","build","__pycache__",".pytest_cache",".mypy_cache"}
TEXT_EXTS={".py",".js",".jsx",".ts",".tsx",".json",".md",".toml",".yaml",".yml"}
JS_IMPORT=re.compile(r"""(?:from\s+['\"]([^'\"]+)['\"]|import\s+['\"]([^'\"]+)['\"]|require\(\s*['\"]([^'\"]+)['\"]\s*\))""")


def _sha(data:bytes)->str:
return "sha256:"+hashlib.sha256(data).hexdigest()


def _canon(v:object)->str:
return json.dumps(v,sort_keys=True,separators=(",",":"),ensure_ascii=False)


def _iter_files(root:Path)->Iterable[Path]:
for base,dirs,files in os.walk(root):
dirs[:] = sorted(d for d in dirs if d not in IGNORE_DIRS)
for name in sorted(files):
p=Path(base)/name
if p.suffix.lower() in TEXT_EXTS and p.is_file() and not p.is_symlink():
yield p


def _module_for(root:Path,path:Path)->str:
rel=path.relative_to(root).with_suffix("")
parts=list(rel.parts)
if parts and parts[-1]=="__init__": parts=parts[:-1]
return ".".join(parts)


def _resolve_py_import(root:Path,current:Path,module:str,level:int)->str|None:
current_mod=_module_for(root,current)
base=current_mod.split(".")[:-1]
if level:
keep=max(0,len(base)-level+1)
base=base[:keep]
target=".".join([*base,*([module] if module else [])]).strip(".") if level else module
if not target: return None
candidates=[root/Path(*target.split(".")).with_suffix(".py"),root/Path(*target.split("."))/ "__init__.py"]
for c in candidates:
if c.exists(): return c.relative_to(root).as_posix()
return None


def _parse_python(root:Path,path:Path,text:str)->tuple[list[dict],list[str]]:
symbols=[]; deps=[]
try: tree=ast.parse(text)
except SyntaxError: return symbols,deps
for node in tree.body:
if isinstance(node,(ast.FunctionDef,ast.AsyncFunctionDef,ast.ClassDef)):
symbols.append({"name":node.name,"kind":"class" if isinstance(node,ast.ClassDef) else "function","line":getattr(node,"lineno",None)})
elif isinstance(node,ast.Import):
for alias in node.names:
d=_resolve_py_import(root,path,alias.name,0)
if d: deps.append(d)
elif isinstance(node,ast.ImportFrom):
d=_resolve_py_import(root,path,node.module or "",node.level)
if d: deps.append(d)
return sorted(symbols,key=lambda x:(x["name"],x["kind"],x["line"] or 0)),sorted(set(deps))


def _parse_js_like(root:Path,path:Path,text:str)->list[str]:
out=[]
for m in JS_IMPORT.finditer(text):
spec=next((g for g in m.groups() if g),None)
if not spec or not spec.startswith("."): continue
base=(path.parent/spec).resolve()
for c in [base,base.with_suffix(".js"),base.with_suffix(".jsx"),base.with_suffix(".ts"),base.with_suffix(".tsx"),base/"index.js",base/"index.ts"]:
try:
if c.exists() and c.is_file() and root.resolve() in c.resolve().parents:
out.append(c.relative_to(root).as_posix()); break
except OSError: pass
return sorted(set(out))


def build_repository_graph(root:str|Path)->dict:
root=Path(root).resolve()
if not root.is_dir(): raise ValueError("root must be an existing directory")
files=[]; edges=[]; symbols=[]
for path in _iter_files(root):
rel=path.relative_to(root).as_posix()
raw=path.read_bytes()
try: text=raw.decode("utf-8")
except UnicodeDecodeError: continue
entry={"path":rel,"bytes":len(raw),"sha256":_sha(raw),"extension":path.suffix.lower()}
deps=[]
if path.suffix.lower()==".py":
syms,deps=_parse_python(root,path,text)
for s in syms: symbols.append({"file":rel,**s})
elif path.suffix.lower() in {".js",".jsx",".ts",".tsx"}:
deps=_parse_js_like(root,path,text)
files.append(entry)
for dep in deps: edges.append({"from":rel,"to":dep,"type":"imports"})
files=sorted(files,key=lambda x:x["path"])
edges=sorted(edges,key=lambda x:(x["from"],x["to"],x["type"]))
symbols=sorted(symbols,key=lambda x:(x["file"],x["line"] or 0,x["name"]))
core={"schema_version":"1.0","root_name":root.name,"files":files,"symbols":symbols,"edges":edges}
core["graph_sha256"]=_sha(_canon(core).encode())
return core
37 changes: 37 additions & 0 deletions repo_intelligence/impact.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Change-impact analysis over a deterministic repository graph."""
from __future__ import annotations

import hashlib,json
from collections import deque


def _canon(v:object)->str:
return json.dumps(v,sort_keys=True,separators=(",",":"),ensure_ascii=False)


def _sha(s:str)->str:
return "sha256:"+hashlib.sha256(s.encode()).hexdigest()


def analyze_change_impact(graph:dict,changed_paths:list[str],max_depth:int=3)->dict:
if max_depth<0: raise ValueError("max_depth must be >= 0")
known={f["path"] for f in graph.get("files",[]) if isinstance(f,dict) and "path" in f}
changed=sorted(set(changed_paths))
unknown=sorted(p for p in changed if p not in known)
reverse={p:set() for p in known}
for e in graph.get("edges",[]):
if isinstance(e,dict) and e.get("type")=="imports" and e.get("from") in known and e.get("to") in known:
reverse[e["to"]].add(e["from"])
distance={p:0 for p in changed if p in known}
q=deque(sorted(distance))
while q:
cur=q.popleft(); d=distance[cur]
if d>=max_depth: continue
for dep in sorted(reverse.get(cur,())):
if dep not in distance:
distance[dep]=d+1; q.append(dep)
impacted=[{"path":p,"distance":distance[p]} for p in sorted(distance,key=lambda x:(distance[x],x))]
symbols=[s for s in graph.get("symbols",[]) if isinstance(s,dict) and s.get("file") in distance]
core={"schema_version":"1.0","graph_sha256":graph.get("graph_sha256"),"changed_paths":changed,"unknown_paths":unknown,"max_depth":max_depth,"impacted_files":impacted,"impacted_symbols":symbols}
core["impact_sha256"]=_sha(_canon(core))
return core
21 changes: 21 additions & 0 deletions tests/test_repository_intelligence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from pathlib import Path
import json
from repo_intelligence.graph import build_repository_graph
from repo_intelligence.impact import analyze_change_impact


def test_graph_and_impact_are_deterministic(tmp_path:Path):
(tmp_path/"a.py").write_text("from b import f\n\ndef g():\n return f()\n",encoding="utf-8")
(tmp_path/"b.py").write_text("def f():\n return 1\n",encoding="utf-8")
g1=build_repository_graph(tmp_path); g2=build_repository_graph(tmp_path)
assert g1==g2
assert {"from":"a.py","to":"b.py","type":"imports"} in g1["edges"]
impact=analyze_change_impact(g1,["b.py"])
assert impact["impacted_files"]==[{"path":"b.py","distance":0},{"path":"a.py","distance":1}]


def test_unknown_changed_path_is_reported(tmp_path:Path):
(tmp_path/"x.py").write_text("x=1\n",encoding="utf-8")
graph=build_repository_graph(tmp_path)
impact=analyze_change_impact(graph,["missing.py"])
assert impact["unknown_paths"]==["missing.py"]
Loading