Skip to content
Closed
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
241 changes: 225 additions & 16 deletions apps/backend/app/analysis/architecture.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import posixpath
import re
from collections import Counter, defaultdict

from app.extraction.lockfiles import SUPPORTED_LOCKFILE_FILENAMES
Expand Down Expand Up @@ -59,6 +60,20 @@
}


_SYMBOL_DISAMBIGUATOR = re.compile(r"#\d+$")


def _display_symbol(qualified: str) -> str:
"""Strip the uniqueness suffix a stable key carries for repeated names.

Two `@overload`-style definitions of the same name in one file are distinct
nodes, so their keys are disambiguated (``group#5``). That suffix is an
identity detail, not part of what the code calls the symbol.
"""

return _SYMBOL_DISAMBIGUATOR.sub("", qualified)


class ArchitectureAnalyzer:
"""Builds the Architecture read model exclusively from sealed ri.v1 snapshots.

Expand Down Expand Up @@ -135,8 +150,8 @@ def _nodes_for_modules(self, modules: list[RepositoryModule]) -> list[ArchNode]:
id=module.id,
name=module.name,
type=node_type, # type: ignore[arg-type]
description=f"{module.name} derived from repository intelligence at {module.path_prefix}.",
responsibilities=[f"Owns {module.role} concerns"],
description=self._module_description(module),
responsibilities=self._module_responsibilities(module),
files=module.files[:25],
dependencies=[],
dependents=[],
Expand All @@ -151,6 +166,52 @@ def _nodes_for_modules(self, modules: list[RepositoryModule]) -> list[ArchNode]:
)
return nodes

@staticmethod
def _module_description(module: RepositoryModule) -> str:
"""State what the snapshot observed, rather than restating the path.

Every clause here is an observed fact already sealed in the snapshot:
how many symbols the module defines, which of them a reader would
recognise it by, and where it lives. Nothing is inferred about what
the module is *for* -- that would be a guess, and an unsourced claim
is exactly what this product does not make.
"""

if not module.symbols:
# No symbols observed is itself worth saying plainly, rather than
# dressing the path up as a description.
return f"{module.path_prefix} — no code symbols were extracted from this module."
count = len(module.symbols)
noun = "symbol" if count == 1 else "symbols"
notable = ArchitectureAnalyzer._notable_symbols(module.symbols)
if not notable:
return f"Defines {count} {noun}."
listed = ", ".join(notable)
if count == len(notable):
# Everything it defines is named, so "including" would understate it.
return f"Defines {count} {noun}: {listed}."
return f"Defines {count} {noun}, including {listed}."

@staticmethod
def _module_responsibilities(module: RepositoryModule) -> list[str]:
"""Observed properties of the module, not a guess at its purpose.

The previous wording ("Owns unknown concerns") read as a statement
about the code when it was really a statement about the classifier
having no opinion. Where a role *was* classified it is reported as
one; where it was not, the entry is omitted rather than asserted.
"""

entries: list[str] = []
if module.role and module.role != "unknown":
entries.append(f"Classified as {module.role.replace('-', ' ')}")
file_count = len(module.files)
if file_count > 1:
entries.append(f"{file_count} files")
if module.symbols:
entries.append(f"{len(module.symbols)} observed symbols")
return entries

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

@staticmethod
def _paths_in_relationships(facts: ArchitectureSnapshotFacts) -> set[str]:
"""Files that are one end of an observed architecture relationship.

A file can be a real part of the system while defining nothing of its
own -- a package initialiser that only re-exports, a barrel module.
What makes it structural is that something resolved to it, or it
resolved to something, and both of those are sealed edges.
"""

paths: set[str] = set()
for edge in facts.edges:
for key in (edge.subject_key, edge.object_key):
if key.startswith("file:"):
paths.add(key.removeprefix("file:"))
return paths

@staticmethod
def _is_module_file(
path: str,
*,
role: str | None,
defines_symbols: bool,
related_paths: set[str],
) -> bool:
"""Whether this file is part of the system's own structure (#444).

A module has to be something the extraction actually saw: symbols it
defines, a relationship it takes part in, or a role the snapshot
classified it into (``documentation``, ``test``, ``controller``). A
file that produced none of those is not being judged unimportant --
nothing was observed about it, and inventing a module from a path is
how `.gitignore` ended up sitting in the Shared layer beside the
library itself.
"""

if defines_symbols or path in related_paths:
return True
return role is not None and role != "unknown"

def _symbols_by_file(self, facts: ArchitectureSnapshotFacts) -> dict[str, list[str]]:
"""Map file path -> names of the symbols that file defines.

Symbol stable keys are ``<path>::<qualified name>`` (#217), so the
owning file is read off the key rather than inferred. These are
observed facts already sealed in the snapshot; nothing here computes
or estimates anything.
"""

symbols: dict[str, list[str]] = defaultdict(list)
for stable_key in facts.symbol_keys:
path, separator, qualified = stable_key.partition("::")
if not separator or not path or not qualified:
continue
# Top-level definitions only. A method is defined by its class, not
# by the module, and counting every one of them turns "what does
# this module define" into a line-count proxy -- which is exactly
# the kind of synthesized measure #217 rules out.
if "." in qualified:
continue
symbols[path].append(_display_symbol(qualified))
return {path: sorted(set(names)) for path, names in symbols.items()}

@staticmethod
def _notable_symbols(qualified_names: list[str], limit: int = 4) -> list[str]:
"""The symbols a reader would recognise the module by.

The caller has already narrowed these to top-level definitions, so the
only judgement left is the oldest convention there is: a leading
underscore means the author did not mean it for the outside. Those are
dropped unless they are all there is. Ordering is deterministic, so the
same snapshot always renders the same description.
"""

public = [name for name in qualified_names if not name.startswith("_")]
chosen = public or qualified_names
return sorted(chosen)[:limit]

def _modules_from_facts(self, facts: ArchitectureSnapshotFacts | None) -> list[RepositoryModule]:
if facts is None:
# Defensive only: build_architecture requires a sealed snapshot
Expand All @@ -190,24 +329,39 @@ def _modules_from_facts(self, facts: ArchitectureSnapshotFacts | None) -> list[R
# unresolved. It stays as an honest, empty module set rather than
# ever reading `record.file_tree` (unsealed repository metadata).
return self._empty_module([])
# Dependency-manifest and lockfile paths already surface as
# dependency evidence (Dependency Graph) -- grouping them into an
# architecture module too misrepresents `package.json`/
# `pyproject.toml` as a piece of the system's own structure.
role_by_path = self._file_roles(facts)
symbols_by_path = self._symbols_by_file(facts)
related_paths = self._paths_in_relationships(facts)
# Dependency-manifest and lockfile paths already surface as dependency
# evidence (Dependency Graph) -- grouping them into an architecture
# module too misrepresents `package.json`/`pyproject.toml` as a piece
# of the system's own structure. #396 stopped there, which was right
# but narrower than the defect: `.gitignore`, `.editorconfig`,
# `LICENSE.txt` and `uv.lock` are neither manifests nor lockfiles, so
# on `pallets/click` seven of the sixteen reported modules were not
# code. `_is_module_file` is what closes that hole.
_non_module_filenames = SUPPORTED_MANIFEST_FILENAMES + SUPPORTED_LOCKFILE_FILENAMES
file_paths = sorted(
node.stable_key.removeprefix("file:")
for node in facts.nodes
if node.node_kind == "file"
and node.stable_key.startswith("file:")
and posixpath.basename(node.stable_key.removeprefix("file:")) not in _non_module_filenames
path
for path in (
node.stable_key.removeprefix("file:")
for node in facts.nodes
if node.node_kind == "file" and node.stable_key.startswith("file:")
)
if posixpath.basename(path) not in _non_module_filenames
and self._is_module_file(
path,
role=role_by_path.get(path),
defines_symbols=bool(symbols_by_path.get(path)),
related_paths=related_paths,
)
)
if not file_paths:
return self._empty_module([])
role_by_path = self._file_roles(facts)
grouped: dict[str, list[str]] = defaultdict(list)
for path in file_paths:
grouped[self._module_id(path, role_by_path.get(path))].append(path)
module_id = self._module_id(path, role_by_path.get(path), defines_symbols=bool(symbols_by_path.get(path)))
grouped[module_id].append(path)
modules: list[RepositoryModule] = []
for module_id, paths in grouped.items():
candidate_roles = [
Expand All @@ -228,14 +382,53 @@ def _modules_from_facts(self, facts: ArchitectureSnapshotFacts | None) -> list[R
layer=layer_for_role(dominant),
path_prefix=self._path_prefix(paths),
files=sorted(paths),
symbols=[],
symbols=sorted({name for path in paths for name in symbols_by_path.get(path, [])}),
dependencies=[],
)
)
return sorted(modules, key=lambda module: module.id)
return self._disambiguate_names(sorted(modules, key=lambda module: module.id))

@staticmethod
def _disambiguate_names(modules: list[RepositoryModule]) -> list[RepositoryModule]:
"""Qualify names that would otherwise collide.

A repository can hold several modules called ``utils`` -- FastAPI has
four. Rendering them all as "utils" tells the reader nothing about
which is which, so a colliding name takes on as much of its parent
path as it needs to become unique (``openapi/utils``,
``security/utils``). Names that are already unique are left alone, so
the common case stays short.
"""

by_name: dict[str, list[RepositoryModule]] = defaultdict(list)
for module in modules:
by_name[module.name].append(module)
for name, colliding in by_name.items():
if len(colliding) < 2:
continue
for module in colliding:
qualifier = ArchitectureAnalyzer._qualifying_parent(module.id.removeprefix("module:"), name)
if qualifier:
module.name = f"{qualifier}/{name}"
return modules

@staticmethod
def _qualifying_parent(path: str, name: str) -> str:
"""The nearest ancestor directory that actually distinguishes ``path``.

A directory named after the module it contains adds nothing:
`examples/termui/termui.py` qualified by its immediate parent reads
"termui/termui", which is noise where "examples/termui" is an answer.
Walk up until the ancestor says something the name does not.
"""

for segment in reversed(posixpath.dirname(path).split("/")):
if segment and segment != name:
return segment
return ""

@staticmethod
def _module_id(path: str, role: str | None) -> str:
def _module_id(path: str, role: str | None, *, defines_symbols: bool = False) -> str:
parts = [part for part in path.strip("/").split("/") if part]
if role in {"controller", "route"}:
return "module:api"
Expand All @@ -253,13 +446,29 @@ def _module_id(path: str, role: str | None) -> str:
return "module:tests"
if role == "documentation":
return "module:documentation"
# A file that defines symbols is a module in its own right. Grouping
# by the directory below the source root instead collapses a whole
# package into one opaque node -- for a single-package repository that
# is the entire library reduced to a single box, which is what this
# branch used to do to every file under `src/<package>/`.
if defines_symbols and parts:
return f"module:{path.strip('/')}"
if parts and parts[0] in {"app", "src", "backend", "frontend", "apps"} and len(parts) > 1:
return f"module:{parts[1].lower()}"
return f"module:{parts[0].lower() if parts else 'repository'}"

@staticmethod
def _module_display_name(module_id: str) -> str:
raw = module_id.removeprefix("module:")
if "/" in raw:
# A per-file module id carries the path for uniqueness; the reader
# wants the module's own name. `src/click/core.py` reads as `core`,
# and a package initialiser reads as the package it opens.
stem = posixpath.splitext(posixpath.basename(raw))[0]
if stem in {"__init__", "index", "mod"}:
parent = posixpath.basename(posixpath.dirname(raw))
return parent or stem
return stem
if "." in raw:
# `_module_id`'s fallback groups a top-level file with no
# directory nesting by its own filename (e.g. "app.py") -- that
Expand Down
11 changes: 9 additions & 2 deletions apps/backend/app/extraction/lockfiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,14 @@
json_object_member_lines,
toml_array_of_tables_lines,
)
from app.extraction.support_matrix import supported_lockfile_filenames
from app.extraction.support_matrix import disclosed_lockfile_filenames, supported_lockfile_filenames
from app.intelligence import canonical

SUPPORTED_LOCKFILE_FILENAMES = supported_lockfile_filenames()
#: Lockfile names this extractor claims only in order to disclose that it does
#: not read them (#444). Skipping one silently reports the same empty result as
#: a repository that pins nothing, which is the opposite of an honest limit.
DISCLOSED_LOCKFILE_FILENAMES = disclosed_lockfile_filenames()

#: ``package-lock.json`` revisions whose ``packages`` table this extractor reads.
SUPPORTED_NPM_LOCKFILE_VERSIONS = (2, 3)
Expand Down Expand Up @@ -116,7 +120,8 @@ def producer(self) -> str:
return f"{self.name}@{self.version}"

def supports(self, path: str) -> bool:
return posixpath.basename(path) in SUPPORTED_LOCKFILE_FILENAMES
basename = posixpath.basename(path)
return basename in SUPPORTED_LOCKFILE_FILENAMES or basename in DISCLOSED_LOCKFILE_FILENAMES

def extract(self, path: str, source: bytes) -> ExtractionResult:
text, source_diagnostic = decode_source(path, source, producer=self.producer)
Expand All @@ -141,6 +146,8 @@ def extract(self, path: str, source: bytes) -> ExtractionResult:
basename = posixpath.basename(normalized_path)
file_subject = canonical.normalize_stable_key("file", f"file:{normalized_path}")
try:
if basename in DISCLOSED_LOCKFILE_FILENAMES:
raise _UnsupportedLockfile(f"{basename} is recognised but not read, and no resolutions are claimed")
if basename == "package-lock.json":
resolutions = self._npm_resolutions(text)
lockfile_format = "npm-package-lock"
Expand Down
Loading
Loading