Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/armillary/cache_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ def _row_to_metadata(row: sqlite3.Row) -> ProjectMetadata | None:
monthly_commits=extra.get("monthly_commits"),
branch_count=extra.get("branch_count"),
has_remote=extra.get("has_remote"),
# ADR 0031 — framework-aware indexing observability.
index_profile=extra.get("index_profile"),
index_files_indexed=extra.get("index_files_indexed"),
index_files_skipped=extra.get("index_files_skipped"),
status=_safe_status(row["status"]),
)

Expand All @@ -142,6 +146,10 @@ def _serialize_metadata_extra(md: ProjectMetadata) -> str | None:
"monthly_commits": md.monthly_commits,
"branch_count": md.branch_count,
"has_remote": md.has_remote,
# ADR 0031.
"index_profile": md.index_profile,
"index_files_indexed": md.index_files_indexed,
"index_files_skipped": md.index_files_skipped,
}
# Drop empty keys to keep the JSON small and the diff readable.
cleaned = {k: v for k, v in payload.items() if v not in (None, [], "")}
Expand Down
87 changes: 87 additions & 0 deletions src/armillary/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,16 @@ def scan(
"fresh project table. No-op if ~/.claude/ does not exist."
),
),
report_profiles: bool = typer.Option(
False,
"--report-profiles",
help=(
"Diagnostic for ADR 0031: after the scan, print how many "
"repos were classified into each framework profile. Helps "
"spot when profile detection misses a layout (e.g. lots of "
"repos falling into 'unknown')."
),
),
) -> None:
"""Scan umbrella folders and print the project list as JSON.

Expand Down Expand Up @@ -236,6 +246,31 @@ def scan(
)
raise typer.Exit(2)

if no_cache and report_profiles:
# Same shape of conflict: --report-profiles reads back from the
# cache (the indexer only runs on the cache code path). Skipping
# the cache means there is nothing to report on.
typer.secho(
"--report-profiles cannot be combined with --no-cache — the "
"report reads from the cache. Drop one flag.",
fg=typer.colors.RED,
err=True,
)
raise typer.Exit(2)

if no_metadata and report_profiles:
# `_index_code_blocks` skips projects whose `metadata` is None,
# which is the case for every project when `--no-metadata` is
# set. The report would always be empty / stale — better to
# reject the combination than print nothing.
typer.secho(
"--report-profiles cannot be combined with --no-metadata — "
"the indexer needs project metadata to run. Drop one flag.",
fg=typer.colors.RED,
err=True,
)
raise typer.Exit(2)

umbrellas = _resolve_umbrellas(umbrella, max_depth)
if not umbrellas:
typer.secho(
Expand All @@ -257,6 +292,11 @@ def scan(
payload = [p.model_dump(mode="json") for p in projects]
typer.echo(json.dumps(payload, indent=2, ensure_ascii=False))

if report_profiles:
with Cache() as cache:
cached_projects = cache.list_projects()
_print_profile_report(cached_projects)

if refresh_bridge:
claude_dir = Path.home() / ".claude"
if not claude_dir.is_dir():
Expand Down Expand Up @@ -284,6 +324,53 @@ def scan(
)


def _print_profile_report(projects: list) -> None:
"""Aggregate ADR 0031 indexing stats from a project list.

Prints a Rich table with one row per detected profile + totals:
repo count, total files indexed, total files filtered out by the
profile. Only repos that ran through the Steal indexer (have
``index_profile`` set on metadata) contribute. ``unknown`` is its
own row so a high count is immediately visible.
"""
from collections import defaultdict

counts: dict[str, dict[str, int]] = defaultdict(
lambda: {"repos": 0, "indexed": 0, "skipped": 0}
)
for p in projects:
md = p.metadata
if md is None or md.index_profile is None:
continue
bucket = counts[md.index_profile]
bucket["repos"] += 1
bucket["indexed"] += md.index_files_indexed or 0
bucket["skipped"] += md.index_files_skipped or 0

if not counts:
typer.secho(
"--report-profiles: no indexed projects in this scan.",
fg=typer.colors.YELLOW,
err=True,
)
return

table = Table(title="Framework profile breakdown (ADR 0031)", show_lines=False)
table.add_column("Profile", style="cyan", no_wrap=True)
table.add_column("Repos", justify="right")
table.add_column("Files indexed", justify="right")
table.add_column("Files filtered", justify="right")
for name in sorted(counts):
c = counts[name]
table.add_row(
name,
str(c["repos"]),
str(c["indexed"]),
str(c["skipped"]),
)
Console(stderr=True).print(table)


@app.command("list")
def list_projects(
type_filter: ProjectType | None = typer.Option(
Expand Down
68 changes: 62 additions & 6 deletions src/armillary/code_block_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
from dataclasses import dataclass
from pathlib import Path

from .framework_profiles import Profile

_WINDOW_SIZE = 40
_WINDOW_STRIDE = 20
_MAX_FILE_BYTES = 500 * 1024
Expand Down Expand Up @@ -86,6 +88,21 @@
)


@dataclass(frozen=True)
class RepoIndexResult:
"""Profile + counts after walking a repo (ADR 0031 observability).

Returned alongside the block list so :mod:`scan_service` can
persist ``files_indexed`` / ``files_skipped`` on the project
metadata blob — that's the regression guard against a profile
that detects a custom Rails layout and silently indexes nothing.
"""

profile_name: str
files_indexed: int
files_skipped: int


@dataclass(frozen=True)
class CodeBlock:
"""A 40-line window from a source file, plus a heuristic symbol.
Expand All @@ -106,32 +123,71 @@ class CodeBlock:
updated_at: float


def build_blocks_for_repo(repo_path: Path) -> list[CodeBlock]:
"""Return every 40-line window across all tracked files in a repo.
def build_blocks_for_repo(
repo_path: Path,
*,
profile: Profile | None = None,
) -> list[CodeBlock]:
"""Return every 40-line window across tracked files in a repo.

When ``profile`` is set (ADR 0031), only files whose path is
accepted by ``profile.accepts(rel)`` reach window extraction —
everything else (migrations, generated assets, fixtures) is
filtered out before it can dilute Steal's index. ``profile=None``
keeps ADR 0027's original "everything tracked" behaviour and is
the default so existing callers don't change.

Non-git directories (no ``.git``) and files matching the skip lists
are filtered out. Size-capped at 500 KB per file; binaries are
detected by null-byte sniff on the first 8 KB. Errors on individual
files never escape — that file is skipped and the walk continues.
"""
blocks, _ = build_blocks_with_stats(repo_path, profile=profile)
return blocks


def build_blocks_with_stats(
repo_path: Path,
*,
profile: Profile | None = None,
) -> tuple[list[CodeBlock], RepoIndexResult]:
"""Same as :func:`build_blocks_for_repo` but also return profile stats.

``files_indexed`` counts files that produced at least one block;
``files_skipped`` counts files filtered out by the profile (does
not include files dropped by the per-file skip rules — those are
universal and not profile-specific).
"""
profile_name = profile.name if profile is not None else "unknown"
if not (repo_path / ".git").exists():
return []
return [], RepoIndexResult(profile_name, 0, 0)

tracked = _git_ls_files(repo_path)
blocks: list[CodeBlock] = []
repo_str = str(repo_path)
files_indexed = 0
files_skipped_by_profile = 0

for rel in tracked:
if profile is not None and not profile.accepts(rel):
files_skipped_by_profile += 1
continue
abs_path = repo_path / rel
if _should_skip(rel, abs_path):
continue
try:
file_blocks = _blocks_for_file(repo_str, abs_path)
except (OSError, UnicodeDecodeError):
continue
blocks.extend(file_blocks)

return blocks
if file_blocks:
files_indexed += 1
blocks.extend(file_blocks)

return blocks, RepoIndexResult(
profile_name=profile_name,
files_indexed=files_indexed,
files_skipped=files_skipped_by_profile,
)


# ----- git listing ----------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion src/armillary/code_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

from .cache import default_db_path as _default_project_db_path

SCHEMA_VERSION = 1
SCHEMA_VERSION = 2 # ADR 0031: force rebuild so old "everything" indexes are dropped.

_SCHEMA_SQL = """
CREATE TABLE code_blocks_meta (
Expand Down
50 changes: 50 additions & 0 deletions src/armillary/feedback_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,21 @@
);
CREATE INDEX IF NOT EXISTS idx_steal_feedback_hash
ON steal_feedback(query_hash);

-- ADR 0031: continuous precision signal. A row per query that came
-- back empty so we can spot profiles over-pruning before users
-- complain. `path` and `start_line` mirror `steal_feedback`'s shape
-- but are unused for no-result events (kept NULL-equivalent: empty
-- string and 0) — schema parity buys nothing here, a sibling table
-- buys clarity.
CREATE TABLE IF NOT EXISTS steal_no_results (
id INTEGER PRIMARY KEY,
query_hash TEXT NOT NULL,
query TEXT NOT NULL,
seen_at REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_steal_no_results_hash
ON steal_no_results(query_hash);
"""


Expand Down Expand Up @@ -82,6 +97,41 @@ def vote_counts(query: str) -> dict[str, int]:
return out


def record_no_results(query: str) -> None:
"""Log that ``query`` returned no Steal hits (ADR 0031 signal).

Cheap append. Aggregated by :func:`no_results_count` so we can
detect profiles over-pruning a query class — Arvid's continuous
feedback loop instead of a one-shot precision benchmark.
"""
normalised = query.strip()
if not normalised:
return
query_hash = _hash_query(normalised)
with CodeIndex() as idx:
_ensure_feedback_table(idx.conn)
idx.conn.execute(
"INSERT INTO steal_no_results (query_hash, query, seen_at) VALUES (?,?,?)",
(query_hash, normalised, time.time()),
)
idx.conn.commit()


def no_results_count(query: str) -> int:
"""Return how many times ``query`` has been logged as zero-hit."""
normalised = query.strip()
if not normalised:
return 0
query_hash = _hash_query(normalised)
with CodeIndex() as idx:
_ensure_feedback_table(idx.conn)
row = idx.conn.execute(
"SELECT COUNT(*) FROM steal_no_results WHERE query_hash = ?",
(query_hash,),
).fetchone()
return int(row[0]) if row else 0


def _hash_query(query: str) -> str:
return hashlib.sha1(query.lower().encode("utf-8")).hexdigest() # noqa: S324

Expand Down
Loading
Loading