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
45 changes: 22 additions & 23 deletions apps/backend/app/analysis/architecture.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,18 +57,21 @@ class ArchitectureAnalyzer:
"""Builds the Architecture read model exclusively from sealed ri.v1 snapshots.

No filesystem read, no working-tree fallback, and no legacy
``repo_metadata['intelligence']`` read: every field is derived from
:class:`SnapshotQueryService` facts, or from ``record.file_tree`` (already
persisted repository metadata, not a filesystem walk) when no sealed
snapshot exists yet.
``repo_metadata['intelligence']`` or ``record.file_tree`` read: every field
is derived from :class:`SnapshotQueryService` facts. A repository with no
sealed snapshot for its current revision raises ``NotFoundError`` (#217),
the same 404 contract Dependencies, Review and Insights already use —
never a fallback graph built from unsealed repository metadata.
"""

def __init__(self, snapshots: SnapshotQueryService | None = None) -> None:
self.snapshots = snapshots

def build_architecture(self, record: RepositoryRecord) -> ArchitectureResponse:
if self.snapshots is not None:
self.snapshots.require_sealed_snapshot_for_current_revision(record.id)
facts = self.snapshots.architecture_facts(record.id) if self.snapshots is not None else None
modules = self._modules_from_facts(facts, record)
modules = self._modules_from_facts(facts)
frameworks = self._frameworks_from_facts(facts)
primary_language = self._primary_language_from_facts(facts)
entry_points = self._entry_points_from_facts(facts)
Expand Down Expand Up @@ -129,24 +132,17 @@ def _nodes_for_modules(self, modules: list[RepositoryModule]) -> list[ArchNode]:
files=module.files[:25],
dependencies=[],
dependents=[],
estimated_complexity="high" if len(module.files) > 30 else "medium" if len(module.files) > 10 else "low",
estimated_lines=max(len(module.files) * 80, 20),
# No producer measures size/complexity (#217): file count is
# not a line count or a complexity metric, so it is never
# used to synthesize one.
estimated_complexity="not_computed",
estimated_lines="not_computed",
tags=[module.layer, module.role, module.id.replace("module:", "")],
layer=module.layer,
)
)
return nodes

def _persisted_file_paths(self, tree: list[dict]) -> list[str]:
paths: list[str] = []
for item in tree:
if item.get("type") == "file" and isinstance(item.get("path"), str):
paths.append(item["path"])
children = item.get("children")
if isinstance(children, list):
paths.extend(self._persisted_file_paths(children))
return sorted(set(paths))

def _empty_module(self, files: list[str]) -> list[RepositoryModule]:
return [
RepositoryModule(
Expand Down Expand Up @@ -178,11 +174,14 @@ def _file_roles(self, facts: ArchitectureSnapshotFacts) -> dict[str, str]:
roles[assertion.subject_key.removeprefix("file:")] = classification
return roles

def _modules_from_facts(
self, facts: ArchitectureSnapshotFacts | None, record: RepositoryRecord
) -> list[RepositoryModule]:
def _modules_from_facts(self, facts: ArchitectureSnapshotFacts | None) -> list[RepositoryModule]:
if facts is None:
return self._empty_module(self._persisted_file_paths(record.file_tree or []))
# Defensive only: build_architecture requires a sealed snapshot
# before calling this whenever self.snapshots is configured, so a
# production caller never reaches this branch with real facts
# unresolved. It stays as an honest, empty module set rather than
# ever reading `record.file_tree` (unsealed repository metadata).
return self._empty_module([])
file_paths = sorted(
node.stable_key.removeprefix("file:")
for node in facts.nodes
Expand Down Expand Up @@ -311,8 +310,8 @@ def _dependency_nodes(self, facts: ArchitectureSnapshotFacts | None) -> list[Arc
files=sorted({entry.path for entry in evidence}),
dependencies=[],
dependents=[],
estimated_complexity="low",
estimated_lines=0,
estimated_complexity="not_computed",
estimated_lines="not_computed",
tags=["external", "dependency"],
layer="external",
)
Expand Down
15 changes: 5 additions & 10 deletions apps/backend/app/api/routes/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@ def cancel_analysis(
response_model=ArchitectureResponse,
responses=documented_responses(
200,
"Architecture model derived from repository intelligence.",
"Architecture model derived from the sealed repository-intelligence snapshot "
"bound to the repository's current revision. A repository with no sealed "
"snapshot yet returns 404, never a fallback graph (#217).",
{
"repositoryId": _REPOSITORY_ID,
"repositoryName": "example-service",
Expand All @@ -147,15 +149,8 @@ def cancel_analysis(
"edges": [],
"modules": [],
"requestFlow": [],
"relationshipSnapshotId": None,
"diagnostics": [
{
"code": "ARCH-REL-NOT-EXTRACTED",
"category": "relationship extraction",
"severity": "info",
"message": "No sealed repository-intelligence snapshot is available for relationship analysis.",
}
],
"relationshipSnapshotId": "snap_example",
"diagnostics": [],
"summary": {
"language": "Python",
"framework": "FastAPI",
Expand Down
1 change: 0 additions & 1 deletion apps/backend/app/reports/builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,6 @@ def build_architecture_document(architecture: ArchitectureResponse) -> ReportDoc
fields=[
("Type", node.type.replace("-", " ")),
("Layer", node.layer.replace("-", " ")),
("Size Class", node.estimated_complexity),
],
bullets=[f"File: {path}" for path in node.files[:10]],
)
Expand Down
8 changes: 6 additions & 2 deletions apps/backend/app/schemas/architecture.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,12 @@ class ArchNode(CamelModel):
files: list[str]
dependencies: list[str]
dependents: list[str]
estimated_complexity: Literal["low", "medium", "high"]
estimated_lines: int
# No repository-intelligence producer measures complexity or line counts
# today (#217): these are never a synthesized guess (e.g. file count * 80).
# A real value can only appear once a named heuristic with a truth class
# backs it; until then every node reports "not_computed" explicitly.
estimated_complexity: Literal["low", "medium", "high", "not_computed"]
estimated_lines: int | Literal["not_computed"]
tags: list[str]
layer: str
parent_module: str | None = None
Expand Down
29 changes: 6 additions & 23 deletions apps/backend/tests/test_architecture_relationships.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,33 +413,16 @@ def record_evidence_batch(_conn, _cursor, statement, parameters, _context, _exec
assert max(evidence_query_parameter_counts) <= ARCHITECTURE_EVIDENCE_BATCH_SIZE + 1


def test_architecture_without_snapshot_does_not_claim_isolation(auth_client):
def test_architecture_without_a_sealed_snapshot_returns_404(auth_client):
# Leave the analysis job queued (worker not drained) so no snapshot is sealed:
# this exercises the genuine "no sealed snapshot" architecture response, which
# durable analysis otherwise reaches only in the window before the worker runs.
# this exercises the genuine "no sealed snapshot" case, which durable analysis
# otherwise reaches only in the window before the worker runs. Architecture must
# 404 the same way Dependencies/Review/Insights already do (#217) rather than
# falling back to a graph built from unsealed repository metadata.
repository = _upload(
auth_client, {"src/lonely/index.ts": b"export const lonely = 1;\n"}, analyse=False
)

response = auth_client.get(f"/analysis/{repository['id']}/architecture")

assert response.status_code == 200
architecture = response.json()
assert architecture["relationshipSnapshotId"] is None
assert architecture["edges"] == []
assert architecture["nodes"][0]["relationshipState"] == "not-extracted"
assert architecture["diagnostics"] == [
{
"code": "ARCH-REL-NOT-EXTRACTED",
"category": "relationship extraction",
"severity": "info",
"message": "No sealed repository-intelligence snapshot is available for relationship analysis.",
"path": None,
"startLine": None,
"endLine": None,
"subjectKey": None,
"objectKey": None,
"details": None,
"nodeIds": None,
}
]
assert response.status_code == 404
9 changes: 4 additions & 5 deletions apps/backend/tests/test_ingestion_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,11 @@ def test_analysis_read_endpoints_return_typed_defaults_before_worker_runs(auth_c
record = session.get(RepositoryRecord, repository_id)
assert "intelligence" not in (record.repo_metadata or {})

# Architecture is sealed-snapshot-bound (#217), same as Dependencies/Review/
# Insights: no snapshot yet means 404, never a fallback graph built from
# unsealed repository metadata.
architecture_response = auth_client.get(f"/analysis/{repository_id}/architecture")
assert architecture_response.status_code == 200
architecture = architecture_response.json()
assert architecture["edges"] == []
assert architecture["relationshipSnapshotId"] is None
assert architecture["diagnostics"][0]["code"] == "ARCH-REL-NOT-EXTRACTED"
assert architecture_response.status_code == 404

# Dependency Graph is sealed-snapshot-bound (#158), same as Review/Insights:
# no snapshot yet means 404, not a typed-empty 200.
Expand Down
23 changes: 23 additions & 0 deletions apps/backend/tests/test_legacy_read_model_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,26 @@ def test_product_code_has_no_legacy_intelligence_read_path():
f"{path.relative_to(PRODUCT_ROOT)}:{node.lineno}: reads repo_metadata.get('intelligence')"
)
assert violations == []


#: Analytical consumers per AGENTS.md's canonical-boundary rule: architecture,
#: dependencies, review, insights, AI and exports. `app/services` and
#: `app/workers` are deliberately excluded -- they legitimately read/write
#: `record.file_tree` as part of ingestion (populating the field the sealed
#: snapshot is built from), not as a canonical-source bypass.
ANALYTICAL_CONSUMER_DIRS = ("analysis", "graph", "review", "insights", "ai", "reports")


def test_analytical_consumers_never_read_repository_file_tree():
"""Regression guard for Issue #217: no analytical consumer may derive its
output from ``RepositoryRecord.file_tree`` -- a sealed ``ri.v1`` snapshot,
or an explicit missing-snapshot state, is the only allowed source."""

violations: list[str] = []
for consumer_dir in ANALYTICAL_CONSUMER_DIRS:
for path in sorted((PRODUCT_ROOT / consumer_dir).rglob("*.py")):
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, ast.Attribute) and node.attr == "file_tree":
violations.append(f"{path.relative_to(PRODUCT_ROOT)}:{node.lineno}: reads .file_tree")
assert violations == []
9 changes: 3 additions & 6 deletions apps/backend/tests/test_prototype_journey.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,12 +204,9 @@ def test_journey_reports_honest_state_before_a_snapshot_exists(auth_client):
# Enqueue but do not drain the worker: no snapshot is sealed.
assert auth_client.post(f"/analysis/{repository_id}/start").status_code == 200

architecture = auth_client.get(f"/analysis/{repository_id}/architecture").json()
assert architecture["relationshipSnapshotId"] is None
assert any(
diagnostic["code"] == "ARCH-REL-NOT-EXTRACTED"
for diagnostic in architecture["diagnostics"]
)
# Architecture 404s rather than falling back to a graph built from unsealed
# repository metadata (#217) -- the same honest contract as the manifest below.
assert auth_client.get(f"/analysis/{repository_id}/architecture").status_code == 404

explanation = auth_client.get(
f"/analysis/{repository_id}/architecture/authentication"
Expand Down
11 changes: 6 additions & 5 deletions apps/backend/tests/test_rate_limit.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,12 +269,13 @@ def test_export_endpoint_is_charged_against_the_heavy_budget(limited_client):
to the (looser) default class this would never hit 429."""
repository_id = _import_sample(limited_client) # 1st heavy hit

# "architecture" is used here (not "dependencies") because this repository
# was only imported, never analysed, and Dependency Graph is now
# sealed-snapshot-bound (#158) — it would 404 rather than exercise the
# rate-limit budget this test actually targets.
# Every export target is sealed-snapshot-bound (Dependencies since #158,
# Architecture since #217), and this repository was only imported, never
# analysed — so the export 404s. That still proves the request reached the
# route handler (past the rate limiter), which is all this regression needs;
# only a 429 would mean the budget wasn't charged.
payload = {"repositoryId": repository_id, "target": "architecture", "format": "json"}
assert limited_client.post("/export", json=payload).status_code == 200 # 2nd heavy hit
assert limited_client.post("/export", json=payload).status_code == 404 # 2nd heavy hit

blocked = limited_client.post("/export", json=payload) # 3rd heavy hit
assert blocked.status_code == 429
Expand Down
58 changes: 40 additions & 18 deletions apps/frontend/src/app/pages/ArchitecturePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,32 +41,54 @@ export function ArchitecturePage() {
);
}

if (!architecture.model) {
if (architecture.loading) {
return (
<div className="h-full flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
{architecture.loading && (
<>
<div className="h-4 w-4 rounded-full border-2 border-primary border-t-transparent animate-spin" />
<span className="text-sm text-muted-foreground">Loading architecture model...</span>
</>
)}
{architecture.error && (
<div className="text-center">
<p className="text-sm text-destructive mb-2">{architecture.error}</p>
<button
onClick={architecture.retry}
className="text-xs text-primary hover:underline"
>
Retry
</button>
</div>
)}
<div className="h-4 w-4 rounded-full border-2 border-primary border-t-transparent animate-spin" />
<span className="text-sm text-muted-foreground">Loading architecture model...</span>
</div>
</div>
);
}

// A repository can be analysed yet still have no sealed ri.v1 snapshot
// (#217, same 404 contract as Dependencies/Review/Insights). That must read
// as "run analysis again", never as a silent, indistinguishable empty graph.
if (architecture.noSnapshot) {
return (
<div className="h-full flex flex-col">
<PageHeader title="Architecture" description="Explore the architecture of your codebase" />
<EmptyState
icon={Network}
title="No sealed snapshot yet"
description="This repository has no sealed Repository Intelligence snapshot for its current revision. Analyse it again to generate one."
action={{ label: 'Run analysis', onClick: () => navigate('/upload') }}
/>
</div>
);
}

if (architecture.error) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<p className="text-sm text-destructive mb-2">{architecture.error}</p>
<button
onClick={architecture.retry}
className="text-xs text-primary hover:underline"
>
Retry
</button>
</div>
</div>
);
}

if (!architecture.model) {
return null;
}

return (
<div className="h-[calc(100vh-8rem)] -m-6 flex flex-col">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3 sm:px-6">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,30 @@ describe('ArchitectureNode', () => {
expect(node.getAttribute('aria-label')).toContain('12 files');
expect(node.getAttribute('aria-label')).toContain('high complexity');
});

it('never renders a complexity badge or claim when complexity is not computed (#217)', () => {
const props = {
id: 'module:billing',
type: 'architectureNode',
data: {
label: 'Billing',
nodeType: 'service',
layer: 'business-logic',
relationshipState: 'connected',
description: 'Handles billing.',
filesCount: 4,
complexity: 'not_computed',
},
} as NodeProps<ArchFlowNode>;

render(
<ReactFlowProvider>
<ArchitectureNode {...props} />
</ReactFlowProvider>,
);

expect(screen.queryByText('not_computed')).not.toBeInTheDocument();
const node = screen.getByRole('group', { name: /Billing/ });
expect(node.getAttribute('aria-label')).not.toContain('complexity');
});
});
Loading
Loading