Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

- The plugin folder now carries its own copy of the server code under
`client-plugin/server/src`, so a directory install that receives only that folder
starts. `python scripts/build_client_package.py --sync-vendored` rewrites it from
`src/`, and a test fails when the copy drifts. The launcher no longer falls back to
the repository's `src` and reports a missing copy in one line.
- The Claude plugin icon is the 512 px PNG, which keeps every plugin file under 256 KiB.
- The Claude plugin manifest carries directory listing fields: display name, keywords,
homepage, documentation, support, privacy and terms links, and a 1024 px icon.
- Claude Code now asks for the readable workspace and the optional state directory
Expand Down
Binary file modified client-plugin/.claude-plugin/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion client-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ private state directory** that you can leave empty for read-only access. The
Claude manifest passes these values as `${user_config.*}` launch arguments.
Portable and Codex manifests keep the REPLACE_WITH_ABSOLUTE_WORKSPACE
placeholder; replace it with the directory you want the client to read, and
select your installed Python executable. Keep the complete extracted bundle. The Windows x64 binary MCPB and ZIP include Python;
select your installed Python executable. The plugin folder carries its own copy
of the server code under `server/src`, so keep the complete folder or extracted
bundle together. The Windows x64 binary MCPB and ZIP include Python;
open the MCPB in a compatible desktop client and choose a workspace directory,
or configure the ZIP's server executable with --workspace ABSOLUTE_DIRECTORY.
No model, API key, hosting account, automatic client configuration, or publisher
Expand Down
7 changes: 4 additions & 3 deletions client-plugin/server/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
from pathlib import Path
import sys
base = Path(__file__).resolve().parents[1]
source = base / "server/src"
if not source.is_dir():
source = base.parent / "src"
source = base / "server" / "src"
if not (source / "index_graph" / "client_mcp.py").is_file():
sys.stderr.write("index: the server code is missing from the plugin folder. Reinstall the plugin.\n")
raise SystemExit(1)
sys.path.insert(0, str(source))
from index_graph.client_mcp import main
if __name__ == "__main__":
Expand Down
18 changes: 18 additions & 0 deletions client-plugin/server/src/index_graph/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""Compact JSON repository inventory maps for multi-repo workspaces."""

from __future__ import annotations

__version__ = "2.15.0"

from .classify import classify
from .config import Config, Rule, default_config, load_config
from .model import SCHEMA_VERSION, Map, RepoRow
from .route import build_route
from .scan import build_map, discover_repos, write_map

__all__ = [
"build_map", "write_map", "discover_repos",
"Map", "RepoRow", "SCHEMA_VERSION",
"Config", "Rule", "load_config", "default_config",
"classify", "build_route", "__version__",
]
4 changes: 4 additions & 0 deletions client-plugin/server/src/index_graph/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .cli import main

if __name__ == "__main__":
raise SystemExit(main())
10 changes: 10 additions & 0 deletions client-plugin/server/src/index_graph/arch/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Architecture criteria and the check that measures a graph against them."""
from __future__ import annotations

from .criteria import ArchitectureCriteria, ForbidRule, parse_architecture
from .check import Finding, check_graph

__all__ = [
"ArchitectureCriteria", "ForbidRule", "parse_architecture",
"Finding", "check_graph",
]
128 changes: 128 additions & 0 deletions client-plugin/server/src/index_graph/arch/check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""Measure a graph against an ArchitectureCriteria; produce evidence-bearing findings.

`glob_to_regex` is imported lazily inside the matcher so this module can be
imported from config.py without a circular import.
"""
from __future__ import annotations

import re
from dataclasses import dataclass

from .criteria import ArchitectureCriteria


@dataclass(frozen=True)
class Finding:
rule: str
detail: str
edge: str | None
evidence: str | None


def _match(glob: str, name: str) -> bool:
from ..config import glob_to_regex
return re.match(glob_to_regex(glob), name) is not None


def _first_evidence(rel: dict) -> str | None:
sigs = rel.get("signals") or []
if not sigs:
return None
s = sigs[0]
f = s.get("file")
if not f:
return None
line = s.get("line")
return f"{f}:{line}" if line is not None else f


def _layer_of(name: str, layers: tuple[str, ...]) -> int | None:
for i, layer in enumerate(layers):
if (name == layer or name.startswith(layer + "/")
or _match(f"{layer}/**", name) or _match(f"**/{layer}", name)):
return i
return None


def check_graph(pack: dict, criteria: ArchitectureCriteria) -> list[Finding]:
findings: list[Finding] = []
relations = [r for r in pack.get("relations", []) if not r.get("external")]

# forbidden edges
for rule in criteria.forbid:
for r in relations:
frm, to = r.get("from"), r.get("to")
if to and _match(rule.from_glob, frm) and _match(rule.to_glob, to):
findings.append(Finding(
"forbid", f"{rule.from_glob} must not depend on {rule.to_glob}",
f"{frm} -> {to}", _first_evidence(r)))

# layers: lower index = lower layer; an edge from lower to higher is a violation
if criteria.layers:
for r in relations:
frm, to = r.get("from"), r.get("to")
if not to:
continue
li, lj = _layer_of(frm, criteria.layers), _layer_of(to, criteria.layers)
if li is not None and lj is not None and li < lj:
findings.append(Finding(
"layer",
f"{criteria.layers[li]} must not depend upward on {criteria.layers[lj]}",
f"{frm} -> {to}", _first_evidence(r)))

# cycle ceiling
if criteria.max_cycles is not None:
n = len(pack.get("cycles", []))
if n > criteria.max_cycles:
findings.append(Finding(
"max_cycles",
f"{n} dependency cycle(s) exceed the ceiling of {criteria.max_cycles}",
None, None))

# repo names present in the workspace (used by forbid, owns, require checks)
names: list[str] = []
if criteria.owns or criteria.require or criteria.forbid:
names_src = [r.get("from") for r in pack.get("relations", [])]
names_src += [r.get("to") for r in pack.get("relations", []) if r.get("to")]
names_src += list(pack.get("roles", {}).keys())
names = sorted({n for n in names_src if n})

# a forbid rule whose endpoints name no repo cannot be meaningfully checked:
# the forbidden edge is trivially absent because a glob is wrong, not
# because the code obeys the rule. Mirror require/owns: emit an UNVERIFIABLE
# criterion-quality finding, never a silent vacuous pass.
for rule in criteria.forbid:
if not (any(_match(rule.from_glob, n) for n in names)
and any(_match(rule.to_glob, n) for n in names)):
findings.append(Finding(
"forbid_unmatched",
f"forbid {rule.from_glob} -> {rule.to_glob} names a repo not in the workspace",
None, None))

# ownership: a declared owner glob that matches no repo is a finding
for glob, owner in criteria.owns:
if not any(_match(glob, n) for n in names):
findings.append(Finding(
"owns", f"ownership glob {glob} ({owner}) matches no repo", None, None))

# required edges (Reflexion conformance). An intended dependency between repos that
# both exist but are not connected is an 'absence' (a confirmed breach -> DRIFT). A
# require rule whose endpoints are not in the workspace at all is 'require_unmatched',
# a criterion-quality gap that reads UNVERIFIABLE, mirroring an unmatched layer.
for rule in criteria.require:
if not (any(_match(rule.from_glob, n) for n in names)
and any(_match(rule.to_glob, n) for n in names)):
findings.append(Finding(
"require_unmatched",
f"require {rule.from_glob} -> {rule.to_glob} names a repo not in the workspace",
None, None))
continue
present = any(
r.get("to") and _match(rule.from_glob, r.get("from")) and _match(rule.to_glob, r.get("to"))
for r in relations)
if not present:
findings.append(Finding(
"absence", f"{rule.from_glob} should depend on {rule.to_glob} but does not",
None, None))

return sorted(findings, key=lambda f: (f.rule, f.edge or "", f.detail))
65 changes: 65 additions & 0 deletions client-plugin/server/src/index_graph/arch/criteria.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""The architecture criterion: a rule the graph is measured against.

This module imports nothing from the rest of the package so that config.py can
import it without a cycle. The check that consumes a criterion lives in check.py.
"""
from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class ForbidRule:
from_glob: str
to_glob: str


@dataclass(frozen=True)
class RequireRule:
"""An intended dependency that must be realized. A missing one is an
'absence' in Reflexion-model conformance (the architecture you declared is
not the one the code actually built)."""
from_glob: str
to_glob: str


@dataclass(frozen=True)
class ArchitectureCriteria:
layers: tuple[str, ...] = ()
forbid: tuple[ForbidRule, ...] = ()
max_cycles: int | None = None
owns: tuple[tuple[str, str], ...] = ()
require: tuple[RequireRule, ...] = ()

@property
def declared(self) -> bool:
return bool(self.layers or self.forbid or self.require
or self.max_cycles is not None or self.owns)


def parse_architecture(data: dict) -> ArchitectureCriteria:
"""Parse the [architecture] TOML block. Raises SystemExit on malformed input."""
layers = tuple(str(x) for x in data.get("layers", []))

forbid: list[ForbidRule] = []
for idx, item in enumerate(data.get("forbid", [])):
if not isinstance(item, dict) or "from" not in item or "to" not in item:
raise SystemExit(f"[architecture] forbid[{idx}] requires 'from' and 'to'")
forbid.append(ForbidRule(str(item["from"]), str(item["to"])))

require: list[RequireRule] = []
for idx, item in enumerate(data.get("require", [])):
if not isinstance(item, dict) or "from" not in item or "to" not in item:
raise SystemExit(f"[architecture] require[{idx}] requires 'from' and 'to'")
require.append(RequireRule(str(item["from"]), str(item["to"])))

mc = data.get("max_cycles", None)
if mc is not None and (isinstance(mc, bool) or not isinstance(mc, int) or mc < 0):
raise SystemExit("[architecture] max_cycles must be a non-negative integer")

owns_raw = data.get("owns", {})
if not isinstance(owns_raw, dict):
raise SystemExit("[architecture] owns must be a table of glob = owner")
owns = tuple(sorted((str(k), str(v)) for k, v in owns_raw.items()))

return ArchitectureCriteria(layers, tuple(forbid), mc, owns, tuple(require))
4 changes: 4 additions & 0 deletions client-plugin/server/src/index_graph/bench/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""Token economy: index's structural pack vs the source it reads (the no-eval-gap number)."""
from .economy import SCHEMA, bench_workspace

__all__ = ["SCHEMA", "bench_workspace"]
90 changes: 90 additions & 0 deletions client-plugin/server/src/index_graph/bench/economy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Token economy: how much smaller is index's structural pack than the source it reads.

The thesis the research converges on (a structural map is cheaper than letting an
agent read files) is here turned into a number anyone can reproduce on their own
workspace. index reads the manifests and sources of every ecosystem to build the
graph; it emits one compact structural pack. This measures the ratio between the two.

Bytes are exact and model-agnostic. The token figures use the common ~4 bytes/token
approximation, but the reduction RATIO is independent of that constant (it divides out),
so the headline number does not depend on any tokenizer.
"""
from __future__ import annotations

import json
from pathlib import Path

from ..context.pack import to_json
from ..freshness.fingerprint import relevant_files
from ..graph.build import build_graph

SCHEMA = "index.bench/1"
BYTES_PER_TOKEN = 4 # common ~4 bytes/token approximation; the reduction ratio does not depend on it


def _source_bytes(repo_paths: dict[str, Path]) -> tuple[int, int]:
"""Total bytes and file count of the graph-relevant files index reads."""
total = files = 0
for root in repo_paths.values():
for p in relevant_files(root):
try:
total += p.stat().st_size
except OSError:
continue
files += 1
return total, files


def _compact_bytes(obj) -> int:
return len(json.dumps(obj, sort_keys=True, separators=(",", ":")).encode("utf-8"))


def _grounding(pack: dict) -> dict:
"""Faithfulness of the reduction: what fraction of the internal dependency
edges the compact pack KEEPS are backed by real file:line source evidence.

A byte reduction is only honest if it fabricates nothing. grep-and-truncate
or an LLM summary can drop or invent structure; index's every retained edge
cites the import that produced it. This turns 'the reduction is faithful'
into a measured number, not a promise: grounded internal edges / internal
edges. 1.0 means every structural fact kept is provably in the source."""
internal = [r for r in pack.get("relations", []) if not r.get("external")]
grounded = [r for r in internal
if any(s.get("file") for s in r.get("signals", []))]
total = len(internal)
return {
"internal_edges": total,
"grounded_edges": len(grounded),
# a workspace with no internal edges grounded NOTHING: report the honest
# null, not a vacuous 1.0 that reads as perfect faithfulness for a
# reduction that had no structure to fabricate or preserve
"edge_grounding": (round(len(grounded) / total, 4) if total else None),
"note": ("fraction of kept dependency edges carrying file:line evidence; "
"1.0 = the reduction fabricates no structure; null = no "
"internal edges to ground"),
}


def bench_workspace(repo_paths: dict[str, Path], *, use_graph_cache: bool = True) -> dict:
"""The bytes index reads vs the bytes of the structural pack it emits, AND
the faithfulness of that reduction (every kept edge grounded in source).

Deterministic for a fixed workspace, so the report is re-checkable like every
other index verdict.
"""
src_bytes, n_files = _source_bytes(repo_paths)
pack = to_json(build_graph(repo_paths, use_cache=use_graph_cache))
pack_bytes = _compact_bytes(pack)
reduction = round(src_bytes / pack_bytes, 1) if pack_bytes else None
return {
"schema": SCHEMA,
"repos": len(repo_paths),
"source_files": n_files,
"source_bytes": src_bytes,
"pack_bytes": pack_bytes,
"reduction": reduction,
"bytes_per_token": BYTES_PER_TOKEN,
"approx_tokens_source": src_bytes // BYTES_PER_TOKEN,
"approx_tokens_pack": pack_bytes // BYTES_PER_TOKEN,
"faithfulness": _grounding(pack),
}
Loading
Loading