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

## Unreleased

- Hard-cut Ruby extraction to the version-1 `compass.ruby.candidate` universal
evidence publisher and replace the Rails source detector with the
evidence-backed `rails-ruby` universal pack. Reopened constants coalesce by
exact graph identity, instance/singleton method spaces stay distinct,
dynamic dispatch/load/eval forms fail closed, and Ruby remains explicitly
`UniversalCandidate` pending the independent precision/recall audit.

- Preserve anonymous PHP functions and arrow functions as typed callable
`closure` nodes, and publish exact PHP trait composition as `mixes_in`
instead of collapsing it into `implements`. These additions eliminate
Expand Down
10 changes: 10 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ sidecars. Its output root now preserves the familiar flat artifact shape so
file-based workflows can transition while Compass's snapshot and store
layout remains visible and clearly owned.

## Ruby universal-candidate rebuild

The current release publishes Ruby through the version-1 universal evidence
candidate (`compass.ruby.candidate`) and the evidence-backed `rails-ruby`
framework pack. Ruby graph output is therefore regenerated on the first build
after upgrading; do not reuse a Ruby cache produced by an older Compass
publisher. Ruby is still a candidate rather than a complete-quality claim,
so retain review of ambiguous/dynamic Ruby relationships and do not treat
unresolved dynamic dispatch as a missing deterministic fact.

## Install Compass

Install the latest macOS release:
Expand Down
884 changes: 884 additions & 0 deletions advisor-plans/019-ruby-universal-candidate.md

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions advisor-plans/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ Plan 014 ships a typed pull-request risk review report and a reusable GitHub
Action. It consumes immutable history and semantic diff evidence while keeping
advisory risk separate from deterministic merge gates.

Plans 015–018 are self-contained notebook, PHP framework, execution-flow, and
MCP workflow programs planned at Compass commit `6680842c` on 2026-08-10.

Plan 019 is the Ruby universal-candidate program. It was planned at Compass
commit `b53c3ea2` on 2026-08-16. It freezes established Ruby evidence, builds an
independent Ripper oracle and qualification-only adapter, adds conservative
Ruby project/resolution semantics, converts Rails to a universal framework
pack, performs one atomic hard cut, and then measures optimization and complete
quality gates. The pinned three-corpus audit now passes (89,981 accepted
relationships, 100% observed precision, 98.5567% recall); Ruby remains
`UniversalCandidate` until a separate promotion decision.

## Execution order and status

| Plan | Title | Priority | Effort | Depends on | Status |
Expand All @@ -49,6 +61,11 @@ advisory risk separate from deterministic merge gates.
| 012 | Qualify document graphs across formats, limits, and determinism | P1 | M | 009, 010, 011 | TODO |
| 013 | Make TypeScript and JavaScript code graphs best in class | P1 | XL | —; final gate should consume 005 or equivalent | IN PROGRESS |
| 014 | Ship typed pull-request risk review and a reusable GitHub Action | P1 | L | Immutable history and semantic diff; coordinate with Compass Guard | DONE |
| 015 | Add bounded Jupyter and Databricks notebook extraction | P1 | L | — | TODO |
| 016 | Complete Composer, Blade, and Eloquent framework resolution | P1 | L | — | TODO |
| 017 | Derive bounded, ranked execution flows from entry points | P2 | L | Existing universal call graph | TODO |
| 018 | Expose five native MCP workflow prompts | P2 | M | — | TODO |
| 019 | Hard-cut Ruby to a qualified universal candidate | P1 | XL | —; final gate should consume 005 or equivalent | IN PROGRESS |

Status values: `TODO`, `IN PROGRESS`, `DONE`, `BLOCKED`, or `REJECTED`.

Expand Down Expand Up @@ -81,6 +98,14 @@ Status values: `TODO`, `IN PROGRESS`, `DONE`, `BLOCKED`, or `REJECTED`.
- Plan 014 consumes immutable history and semantic diff evidence, preserves the
boundary between advisory risk and deterministic gates, and ships the
reusable GitHub review Action.
- Plans 015 and 016 are independent language/framework enrichments. Plan 017
can consume their facts later but does not depend on them. Plan 018 is an
independent MCP/DX addition.
- Plan 019 is deliberately staged: established behavior and independent truth
are frozen first; identity precedes extraction; the emitter, resolver, and
Rails pack stay qualification-only until one atomic production hard cut;
optimization follows semantic parity; and complete promotion remains gated
by the 2,000-record quality audit.

## Direction options not promoted to implementation plans

Expand Down
6 changes: 4 additions & 2 deletions benchmarks/performance/compass/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,9 @@ def _source_line_range(root: Path, source_file: str, location: str) -> tuple[int
return start, end, hashlib.sha256(normalized).hexdigest()


def _capability_for_relation(relation: str) -> str:
def _capability_for_relation(relation: str, adapter: str | None = None) -> str:
if adapter == "ruby" and relation == "implements":
return "traits"
return {
"accesses": "members",
"calls": "calls",
Expand Down Expand Up @@ -294,7 +296,7 @@ def _compass_accepted_candidates(
"candidateSource": "compass_graph",
"suggestedPool": "accepted",
"adapter": adapter,
"capability": _capability_for_relation(relation),
"capability": _capability_for_relation(relation, adapter),
"language": source_node.language,
"relation": relation,
"confidence": confidence,
Expand Down
241 changes: 241 additions & 0 deletions benchmarks/performance/compass/occurrences.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import re
import selectors
import subprocess
import tempfile
import time
import tokenize

Expand Down Expand Up @@ -289,6 +290,11 @@ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
_TYPESCRIPT_ORACLE_TIMEOUT_SECONDS = 90.0
_TYPESCRIPT_ORACLE_OUTPUT_BYTES = 64 * 1024 * 1024
_TYPESCRIPT_ORACLE_MAX_TYPED_FACTS = 500_000
_RUBY_ORACLE_SCHEMA = "compass.ruby-source-oracle/1"
_RUBY_ORACLE_PROVIDER = "ruby_ripper_4_0_6"
_RUBY_ORACLE_SCRIPT = Path(__file__).resolve().parents[3] / "scripts" / "ruby_source_oracle.rb"
_RUBY_ORACLE_TIMEOUT_SECONDS = 600.0
_RUBY_ORACLE_OUTPUT_BYTES = 512 * 1024 * 1024


def _bounded_node_oracle(root: Path) -> bytes:
Expand Down Expand Up @@ -1401,6 +1407,235 @@ def _typescript_compiler_inventory(root: Path) -> SourceConstructInventory:
return _typescript_inventory_from_payload(payload, root)


def _bounded_ruby_oracle(root: Path) -> tuple[bytes, dict[str, object]]:
"""Run the Ripper oracle with explicit duration and output bounds."""

if not _RUBY_ORACLE_SCRIPT.is_file():
raise RuntimeError(f"Ruby source oracle is missing: {_RUBY_ORACLE_SCRIPT}")
with tempfile.TemporaryDirectory(prefix="compass-ruby-source-oracle-") as directory:
output = Path(directory) / "ruby-source-oracle.json"
command = (
"ruby",
str(_RUBY_ORACLE_SCRIPT),
"--root",
str(root),
"--output",
str(output),
)
try:
completed = subprocess.run(
command,
cwd=_RUBY_ORACLE_SCRIPT.parents[1],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
timeout=_RUBY_ORACLE_TIMEOUT_SECONDS,
)
except subprocess.TimeoutExpired as error:
raise RuntimeError(
f"Ruby source oracle exceeded {_RUBY_ORACLE_TIMEOUT_SECONDS:.0f}s"
) from error
if completed.returncode != 0:
detail = completed.stderr.decode("utf-8", errors="replace").strip()
raise RuntimeError(
"Ruby source oracle failed"
+ (f": {detail[:2_000]}" if detail else "")
)
try:
raw = output.read_bytes()
except OSError as error:
raise RuntimeError(f"Ruby source oracle did not write output: {error}") from error
if len(raw) > _RUBY_ORACLE_OUTPUT_BYTES:
raise RuntimeError(
"Ruby source oracle output exceeds "
f"{_RUBY_ORACLE_OUTPUT_BYTES} bytes"
)
try:
payload = json.loads(raw)
except json.JSONDecodeError as error:
raise RuntimeError(f"invalid Ruby source oracle JSON: {error}") from error
if not isinstance(payload, dict) or payload.get("schema") != _RUBY_ORACLE_SCHEMA:
raise RuntimeError("Ruby source oracle schema is invalid")
return raw, payload


def _ruby_inventory_from_payload(
root: Path,
payload: Mapping[str, object],
) -> SourceConstructInventory:
files = payload.get("files")
if not isinstance(files, list):
raise RuntimeError("Ruby source oracle files must be an array")
constructs: list[SourceConstruct] = []
rejected: list[str] = []
parsed = 0
relation_capabilities = {
"aliases": "aliases",
"calls": "calls",
"constructs": "construction",
"extends": "base_types",
"imports": "imports",
"uses_trait": "traits",
}
for file_index, item in enumerate(files):
if not isinstance(item, dict):
raise RuntimeError(f"Ruby oracle files[{file_index}] must be an object")
relative = item.get("path")
status = item.get("status")
if not isinstance(relative, str) or not relative:
raise RuntimeError(f"Ruby oracle files[{file_index}].path is invalid")
safe_relative = _safe_oracle_file(relative, f"Ruby oracle files[{file_index}].path")
if status not in {"ok", "partial"}:
raise RuntimeError(f"Ruby oracle files[{file_index}].status is invalid")
source_path = (root / safe_relative).resolve()
try:
source_path.relative_to(root)
except ValueError as error:
raise RuntimeError(f"Ruby oracle file escapes the source root: {relative}") from error
if not source_path.is_file():
raise RuntimeError(f"Ruby oracle file is missing: {relative}")
if status != "ok":
rejected.append(safe_relative)
continue
parsed += 1
contents = source_path.read_bytes()
declarations = item.get("declarations", [])
if not isinstance(declarations, list):
raise RuntimeError(f"Ruby oracle {relative}.declarations is invalid")
for declaration_index, declaration in enumerate(declarations):
context = f"Ruby oracle {relative}.declarations[{declaration_index}]"
if not isinstance(declaration, dict):
raise RuntimeError(f"{context} must be an object")
kind = declaration.get("kind")
qualified_name = declaration.get("qualifiedName")
anchor = declaration.get("anchor")
if (
not isinstance(kind, str)
or kind not in {"class", "module", "method"}
or not isinstance(qualified_name, str)
or not qualified_name
or not isinstance(anchor, dict)
):
raise RuntimeError(f"{context} has invalid identity fields")
start = anchor.get("startByte")
end = anchor.get("endByte")
line = anchor.get("startLine")
if (
isinstance(start, bool)
or not isinstance(start, int)
or isinstance(end, bool)
or not isinstance(end, int)
or isinstance(line, bool)
or not isinstance(line, int)
or start < 0
or end <= start
or line <= 0
or end > len(contents)
):
raise RuntimeError(f"{context}.anchor is invalid")
if "#" in qualified_name:
owner = qualified_name.rsplit("#", 1)[0]
elif "." in qualified_name and kind == "method":
owner = qualified_name.rsplit(".", 1)[0]
elif "::" in qualified_name:
owner = qualified_name.rsplit("::", 1)[0]
else:
owner = safe_relative
constructs.append(
SourceConstruct(
safe_relative,
"contains",
"ownership",
owner,
qualified_name,
kind,
start,
end,
line,
)
)
relations = item.get("relations", [])
if not isinstance(relations, list):
raise RuntimeError(f"Ruby oracle {relative}.relations is invalid")
for relation_index, relation in enumerate(relations):
context = f"Ruby oracle {relative}.relations[{relation_index}]"
if not isinstance(relation, dict):
raise RuntimeError(f"{context} must be an object")
relation_name = relation.get("relation")
source = relation.get("source")
target = relation.get("target")
anchor = relation.get("anchor")
if (
not isinstance(relation_name, str)
or relation_name not in relation_capabilities
or not isinstance(source, str)
or not source
or not isinstance(target, str)
or not target
or not isinstance(anchor, dict)
):
raise RuntimeError(f"{context} has invalid identity fields")
start = anchor.get("startByte")
end = anchor.get("endByte")
line = anchor.get("startLine")
if (
isinstance(start, bool)
or not isinstance(start, int)
or isinstance(end, bool)
or not isinstance(end, int)
or isinstance(line, bool)
or not isinstance(line, int)
or start < 0
or end <= start
or line <= 0
or end > len(contents)
):
raise RuntimeError(f"{context}.anchor is invalid")
# The oracle's anchor is a byte range, not a line approximation.
if not contents[start:end]:
raise RuntimeError(f"{context}.anchor is empty")
normalized_relation = (
"instantiates" if relation_name == "constructs" else relation_name
)
if normalized_relation == "uses_trait":
normalized_relation = "implements"
constructs.append(
SourceConstruct(
safe_relative,
normalized_relation,
relation_capabilities[relation_name],
source,
target,
relation.get("operation")
if isinstance(relation.get("operation"), str)
else None,
start,
end,
line,
)
)
ruby_version = payload.get("rubyVersion")
ruby_revision = payload.get("rubyRevision")
metadata = []
if isinstance(ruby_version, str) and ruby_version:
metadata.append(("rubyVersion", ruby_version))
if isinstance(ruby_revision, str) and ruby_revision:
metadata.append(("rubyRevision", ruby_revision))
return SourceConstructInventory(
tuple(sorted(set(constructs), key=_source_construct_key)),
len(files),
parsed,
tuple(sorted(rejected)),
tuple(sorted(metadata)),
)


def _ruby_ripper_inventory(root: Path) -> SourceConstructInventory:
_raw, payload = _bounded_ruby_oracle(root)
return _ruby_inventory_from_payload(root, payload)


def _collector_only_construct_parser(
_root: Path,
_path: Path,
Expand Down Expand Up @@ -1428,6 +1663,12 @@ def _collector_only_construct_parser(
_collector_only_construct_parser,
_typescript_compiler_inventory,
),
"ruby": ConstructProvider(
_RUBY_ORACLE_PROVIDER,
(".rb", ".rake"),
_collector_only_construct_parser,
_ruby_ripper_inventory,
),
}


Expand Down
19 changes: 19 additions & 0 deletions benchmarks/performance/tests/test_correctness.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,25 @@ def test_typescript_oracle_payload_preserves_unicode_byte_ranges(self) -> None:
source_construct_inventory_sha256("typescript", inventory),
)

def test_ruby_ripper_provider_is_pinned_byte_deterministic_and_typed(self) -> None:
root = Path(__file__).resolve().parents[3] / "fixtures" / "code-graph" / "qualification"
first = independent_source_inventory(root, "ruby")
second = independent_source_inventory(root, "ruby")
self.assertEqual(first.scanned_files, 1)
self.assertEqual(first.parsed_files, 1)
self.assertEqual(first.rejected_files, ())
self.assertEqual(first.provider_metadata, second.provider_metadata)
self.assertEqual(
source_construct_inventory_sha256("ruby", first),
source_construct_inventory_sha256("ruby", second),
)
self.assertIn(("rubyVersion", "4.0.6"), first.provider_metadata)
self.assertIn(("rubyRevision", "03b6d3f8898a28604fe6cb00eae3226b821168f4"), first.provider_metadata)
self.assertGreaterEqual(len(first.constructs), 20)
trait = next(construct for construct in first.constructs if construct.relation == "implements")
source = (root / trait.source_file).read_bytes()
self.assertEqual(source[trait.start_byte : trait.end_byte], b"Auditable")

def test_typescript_oracle_payload_rejects_incomplete_coverage(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
Expand Down
Loading
Loading