0.0.5-alpha.5.fix.1

This commit is contained in:
2026-09-25 08:14:57 +02:00
parent f03f8640d4
commit 81c7a720f5
9 changed files with 864 additions and 124 deletions

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env python3
# file: scripts/audit_rust_export_completeness.py
# version: 1
# version: 2
"""Audit crate-root façade completeness and canonical same-crate paths."""
"""Audit Rust crate-root façade completeness and canonical same-crate references."""
from __future__ import annotations
@@ -11,6 +11,7 @@ import dataclasses
import pathlib
import re
import sys
import tomllib
from _rust_audit_common import line_depths, mask_source
@@ -28,7 +29,7 @@ _IGNORED_DIRECTORY_NAMES = frozenset({
@dataclasses.dataclass(frozen=True)
class Candidate:
"""One façade or same-crate path violation candidate."""
"""One façade or canonical-reference violation candidate."""
code: str
path: str
@@ -46,6 +47,24 @@ class Declaration:
kind: str
path: pathlib.Path
line: int
column: int
@dataclasses.dataclass(frozen=True)
class Target:
"""One Rust crate target and the module files reachable from its root."""
package: pathlib.Path
root: pathlib.Path
modules: tuple[tuple[pathlib.Path, str], ...]
@dataclasses.dataclass(frozen=True)
class CanonicalItem:
"""One visible item with its canonical crate-root name."""
declaration: Declaration
canonical_name: str
def _is_ignored(path: pathlib.Path, root: pathlib.Path) -> bool:
@@ -66,35 +85,147 @@ def crate_roots(root: pathlib.Path) -> list[pathlib.Path]:
if _is_ignored(manifest, root):
continue
crate = manifest.parent
if (crate / "src/lib.rs").is_file() or (crate / "src/main.rs").is_file():
if (crate / "src/lib.rs").is_file() or (crate / "src/main.rs").is_file() or _explicit_target_paths(crate):
crates.add(crate)
return sorted(crates)
def crate_root_source(crate: pathlib.Path) -> pathlib.Path | None:
"""Return the library or binary crate-root source when present."""
def _explicit_target_paths(crate: pathlib.Path) -> tuple[pathlib.Path, ...]:
"""Return existing explicit lib/bin target paths declared in Cargo.toml."""
manifest = crate / "Cargo.toml"
if not manifest.is_file():
return ()
try:
data = tomllib.loads(manifest.read_text(encoding="utf-8"))
except (OSError, tomllib.TOMLDecodeError):
return ()
paths: set[pathlib.Path] = set()
lib = data.get("lib")
if isinstance(lib, dict) and isinstance(lib.get("path"), str):
candidate = crate / lib["path"]
if candidate.is_file():
paths.add(candidate)
bins = data.get("bin", [])
if isinstance(bins, list):
for entry in bins:
if isinstance(entry, dict) and isinstance(entry.get("path"), str):
candidate = crate / entry["path"]
if candidate.is_file():
paths.add(candidate)
return tuple(sorted(paths))
def target_roots(crate: pathlib.Path) -> list[pathlib.Path]:
"""Return standard and explicitly configured Rust crate target roots."""
roots: set[pathlib.Path] = set(_explicit_target_paths(crate))
manifest = crate / "Cargo.toml"
package: dict[str, object] = {}
if manifest.is_file():
try:
parsed = tomllib.loads(manifest.read_text(encoding="utf-8"))
raw_package = parsed.get("package", {})
if isinstance(raw_package, dict):
package = raw_package
except (OSError, tomllib.TOMLDecodeError):
package = {}
library = crate / "src/lib.rs"
if library.is_file():
return library
binary = crate / "src/main.rs"
if binary.is_file():
return binary
roots.add(library)
if package.get("autobins", True) is not False:
main = crate / "src/main.rs"
if main.is_file():
roots.add(main)
bin_dir = crate / "src/bin"
if bin_dir.is_dir():
roots.update(path for path in bin_dir.glob("*.rs") if path.is_file())
roots.update(path for path in bin_dir.glob("*/main.rs") if path.is_file())
return sorted(roots)
def _standard_child_module_path(current: pathlib.Path, target_root: pathlib.Path, name: str) -> pathlib.Path | None:
"""Resolve a standard `mod name;` file relative to one source module."""
if current == target_root or current.name == "mod.rs":
base = current.parent
else:
base = current.parent / current.stem
direct = base / f"{name}.rs"
nested = base / name / "mod.rs"
if direct.is_file():
return direct
if nested.is_file():
return nested
return None
def module_path(crate: pathlib.Path, path: pathlib.Path) -> str:
"""Return the file-based module path represented by one Rust source file."""
def _module_children(path: pathlib.Path, target_root: pathlib.Path) -> list[tuple[pathlib.Path, str]]:
"""Return top-level external module declarations from one Rust source file."""
relative = path.relative_to(crate / "src").with_suffix("")
parts = list(relative.parts)
if parts and parts[-1] == "mod":
parts.pop()
return "::".join(parts)
text = path.read_text(encoding="utf-8")
lines = text.splitlines()
masked, _ = mask_source(text)
masked_lines = masked.splitlines()
depths = line_depths(masked)
module_pattern = re.compile(r"^\s*(?:(?:pub|pub\([^)]*\))\s+)?mod\s+([A-Za-z_][A-Za-z0-9_]*)\s*;")
path_pattern = re.compile(r'^\s*#\[path\s*=\s*"([^"]+)"\]\s*$')
found: list[tuple[pathlib.Path, str]] = []
for index, masked_line in enumerate(masked_lines):
if index >= len(depths) or depths[index] != 0:
continue
match = module_pattern.match(masked_line)
if match is None:
continue
module_name = match.group(1)
explicit: pathlib.Path | None = None
probe = index - 1
while probe >= 0 and lines[probe].lstrip().startswith("#["):
path_match = path_pattern.match(lines[probe])
if path_match is not None:
candidate = (path.parent / path_match.group(1)).resolve()
if candidate.is_file():
explicit = candidate
break
probe -= 1
child = explicit or _standard_child_module_path(path, target_root, module_name)
if child is not None:
found.append((child.resolve(), module_name))
return found
def declaration_candidates(crate: pathlib.Path, path: pathlib.Path) -> list[Declaration]:
"""Return root-level public or crate-public declarations from one module file."""
def target_modules(crate: pathlib.Path, target_root: pathlib.Path) -> dict[pathlib.Path, str]:
"""Return module files reachable from one target root and their logical paths."""
root = target_root.resolve()
modules: dict[pathlib.Path, str] = {root: ""}
pending: list[pathlib.Path] = [root]
while pending:
current = pending.pop()
parent_module = modules[current]
for child, name in _module_children(current, root):
if _is_ignored(child, crate) or child in modules:
continue
module = f"{parent_module}::{name}" if parent_module else name
modules[child] = module
pending.append(child)
return modules
def targets(crate: pathlib.Path) -> list[Target]:
"""Return independently compiled crate targets for one Cargo package."""
found: list[Target] = []
for root in target_roots(crate):
modules = target_modules(crate, root)
found.append(Target(crate, root.resolve(), tuple(sorted(modules.items(), key=lambda item: item[0].as_posix()))))
return found
def declaration_candidates(path: pathlib.Path, module: str) -> list[Declaration]:
"""Return top-level public or crate-public declarations from one module file."""
text = path.read_text(encoding="utf-8")
lines = text.splitlines()
@@ -115,12 +246,13 @@ def declaration_candidates(crate: pathlib.Path, path: pathlib.Path) -> list[Decl
continue
found.append(
Declaration(
module=module_path(crate, path),
module=module,
name=match.group(3),
visibility=match.group(1),
kind=match.group(2),
path=path,
line=line_number,
column=match.start(3),
)
)
return found
@@ -143,15 +275,11 @@ def _parse_export_item(module: str, item: str) -> tuple[tuple[str, str], str] |
return (module, name), alias
def root_exports(crate: pathlib.Path) -> dict[tuple[str, str], str]:
def root_exports(root_source: pathlib.Path) -> dict[tuple[str, str], str]:
"""Return explicit crate-root re-exports keyed by source module and symbol."""
crate_root = crate_root_source(crate)
exports: dict[tuple[str, str], str] = {}
if crate_root is None:
return exports
text = crate_root.read_text(encoding="utf-8")
text = root_source.read_text(encoding="utf-8")
pattern = re.compile(
r"^\s*pub(?:\(crate\))?\s+use\s+(?:self|crate)::(.*?);",
re.MULTILINE | re.DOTALL,
@@ -177,75 +305,171 @@ def root_exports(crate: pathlib.Path) -> dict[tuple[str, str], str]:
return exports
def externally_used(crate: pathlib.Path, declaration: Declaration) -> bool:
"""Return whether a crate-public item is clearly used outside its module file."""
def _reexport_lines(root_source: pathlib.Path) -> set[int]:
"""Return crate-root line numbers occupied by explicit re-export statements."""
text = root_source.read_text(encoding="utf-8")
pattern = re.compile(
r"^\s*pub(?:\(crate\))?\s+use\s+(?:self|crate)::.*?;",
re.MULTILINE | re.DOTALL,
)
lines: set[int] = set()
for match in pattern.finditer(text):
start = text.count("\n", 0, match.start()) + 1
end = text.count("\n", 0, match.end()) + 1
lines.update(range(start, end + 1))
return lines
def _target_declarations(target: Target) -> list[Declaration]:
"""Return visible module-level declarations for one crate target."""
return [
declaration
for path, module in target.modules
for declaration in declaration_candidates(path, module)
]
def _external_usage(target: Target, declaration: Declaration) -> bool:
"""Return whether a crate-public item is clearly referenced from another module file."""
if not declaration.module:
return True
direct = f"crate::{declaration.module}::{declaration.name}"
root_direct = f"crate::{declaration.name}"
for path in sorted(crate.rglob("*.rs")):
if _is_ignored(path, crate) or path.resolve() == declaration.path.resolve():
super_direct = f"super::{declaration.name}"
for path, _ in target.modules:
if path.resolve() == declaration.path.resolve():
continue
masked, _ = mask_source(path.read_text(encoding="utf-8"))
if direct in masked or root_direct in masked:
if direct in masked or root_direct in masked or super_direct in masked:
return True
return False
def audit_crate(workspace: pathlib.Path, crate: pathlib.Path) -> list[Candidate]:
"""Audit one Rust package for the selected strict façade contract."""
def _declaration_lookup(declarations: list[Declaration]) -> dict[tuple[pathlib.Path, int, str], Declaration]:
"""Index declaration-name occurrences that must not be treated as references."""
crate_root = crate_root_source(crate)
if crate_root is None:
return {(item.path.resolve(), item.line, item.name): item for item in declarations}
def _canonical_reference_candidates(
workspace: pathlib.Path,
target: Target,
declarations: list[Declaration],
exports: dict[tuple[str, str], str],
) -> list[Candidate]:
"""Return non-canonical references to items that have a crate-root identity."""
items: list[CanonicalItem] = []
for declaration in declarations:
if declaration.path.resolve() == target.root and not declaration.module:
items.append(CanonicalItem(declaration, declaration.name))
continue
alias = exports.get((declaration.module, declaration.name))
if alias is not None:
items.append(CanonicalItem(declaration, alias))
by_token: dict[str, list[CanonicalItem]] = {}
for item in items:
by_token.setdefault(item.declaration.name, []).append(item)
if item.canonical_name != item.declaration.name:
by_token.setdefault(item.canonical_name, []).append(item)
if not by_token:
return []
exports = root_exports(crate)
token_pattern = re.compile(r"\b(?:" + "|".join(re.escape(name) for name in sorted(by_token, key=len, reverse=True)) + r")\b")
declaration_lookup = _declaration_lookup(declarations)
root_reexport_lines = _reexport_lines(target.root)
internal_roots = {segment for _, module in target.modules if module for segment in module.split("::")}
candidates: list[Candidate] = []
for path in sorted((crate / "src").rglob("*.rs")):
if path == crate_root or _is_ignored(path, crate):
continue
for declaration in declaration_candidates(crate, path):
required = declaration.visibility == "pub" or externally_used(crate, declaration)
if required and (declaration.module, declaration.name) not in exports:
reason = "public item" if declaration.visibility == "pub" else "crate-public item used outside its module"
candidates.append(
Candidate(
code="RUST-API-201",
path=path.relative_to(workspace).as_posix(),
line=declaration.line,
message=f"{reason} `{declaration.name}` requires an explicit crate-root re-export",
)
)
long_path = re.compile(r"\bcrate::([A-Za-z_][A-Za-z0-9_:]*)::([A-Za-z_][A-Za-z0-9_]*)")
for path in sorted(crate.rglob("*.rs")):
if path == crate_root or _is_ignored(path, crate):
continue
for path, _ in target.modules:
relative = path.relative_to(workspace).as_posix()
masked, _ = mask_source(path.read_text(encoding="utf-8"))
for line_number, line in enumerate(masked.splitlines(), 1):
for match in long_path.finditer(line):
alias = exports.get((match.group(1), match.group(2)))
if alias is None:
continue
candidates.append(
Candidate(
code="RUST-IMPORT-201",
path=relative,
line=line_number,
message=(
f"long same-crate path `crate::{match.group(1)}::{match.group(2)}` "
f"must use crate-root `crate::{alias}`"
),
for match in token_pattern.finditer(line):
token = match.group(0)
for item in by_token[token]:
declaration = item.declaration
if declaration.kind == "fn" and re.match(r"\s*(?:\(|::<)", line[match.end():]) is None:
continue
prefix = line[:match.start()]
if re.search(r"(?:const|static|type|struct|enum|trait|union|fn)\s+$", prefix) is not None:
continue
if (path.resolve(), line_number, token) in declaration_lookup and token == declaration.name:
continue
if path.resolve() == target.root and line_number in root_reexport_lines:
continue
if token == item.canonical_name and re.search(r"\bcrate::\s*$", prefix) is not None:
continue
if match.start() > 0 and line[match.start() - 1] == ".":
continue
qualified = re.search(
r"([A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)*)::\s*$",
prefix,
)
if qualified is not None:
first = qualified.group(1).split("::", 1)[0]
if first not in internal_roots and first not in {"crate", "self", "super"}:
continue
candidates.append(
Candidate(
code="RUST-IMPORT-201",
path=relative,
line=line_number,
message=(
f"non-canonical same-crate reference `{token}` for shared "
f"{declaration.visibility} `{declaration.name}`; use `crate::{item.canonical_name}`"
),
)
)
)
return candidates
def audit_target(workspace: pathlib.Path, target: Target) -> list[Candidate]:
"""Audit one independently compiled Rust crate target."""
declarations = _target_declarations(target)
exports = root_exports(target.root)
candidates: list[Candidate] = []
for declaration in declarations:
if declaration.path.resolve() == target.root and not declaration.module:
continue
required = declaration.visibility == "pub" or _external_usage(target, declaration)
if required and (declaration.module, declaration.name) not in exports:
reason = "public item" if declaration.visibility == "pub" else "crate-public item used outside its module"
candidates.append(
Candidate(
code="RUST-API-201",
path=declaration.path.relative_to(workspace).as_posix(),
line=declaration.line,
message=f"{reason} `{declaration.name}` requires an explicit crate-root re-export",
)
)
candidates.extend(_canonical_reference_candidates(workspace, target, declarations, exports))
return candidates
def audit_crate(workspace: pathlib.Path, crate: pathlib.Path) -> list[Candidate]:
"""Audit every independently compiled target in one Cargo package."""
return [candidate for target in targets(crate) for candidate in audit_target(workspace, target)]
def audit_workspace(root: pathlib.Path) -> tuple[list[pathlib.Path], list[Candidate]]:
"""Return discovered crates and all façade candidates below one project root."""
"""Return discovered packages and all façade candidates below one project root."""
crates = crate_roots(root)
candidates = [candidate for crate in crates for candidate in audit_crate(root, crate)]
candidates = list({candidate for crate in crates for candidate in audit_crate(root, crate)})
candidates.sort(key=lambda candidate: (candidate.code, candidate.path, candidate.line, candidate.message))
return crates, candidates
@@ -264,27 +488,21 @@ def main() -> int:
sys.stderr.write(f"Rust export completeness audit: invalid root: {workspace}\n")
return 2
try:
crates, candidates = audit_workspace(workspace)
except (OSError, UnicodeError) as error:
sys.stderr.write(f"Rust export completeness audit: unable to read selected workspace: {error}\n")
return 2
crates, candidates = audit_workspace(workspace)
if not crates:
sys.stderr.write("Rust export completeness audit: no Rust crate root discovered\n")
sys.stderr.write(f"Rust export completeness audit: no Rust package found below {workspace}\n")
return 2
counts: dict[str, int] = {}
for candidate in candidates:
counts[candidate.code] = counts.get(candidate.code, 0) + 1
sys.stdout.write(f"Rust export completeness audit: {len(candidates)} candidate(s) across {len(crates)} crate(s)\n")
sys.stdout.write(f"Rust export completeness audit: {len(candidates)} candidate(s) across {len(crates)} package(s)\n")
for code in sorted(counts):
sys.stdout.write(f"{code}: {counts[code]}\n")
if not arguments.summary_only:
for candidate in candidates:
sys.stdout.write(f"{candidate.code} {candidate.path}:{candidate.line}: {candidate.message}\n")
return 0 if arguments.report_only or not candidates else 1