orchestrata_ultimate.verification.implementation_scanner

Reference › orchestrata_ultimate › verification › implementation_scanner

The evidence engine behind spec-completeness checking. It scans a generated project with AST analysis and decides, per requirement, whether something named by the requirement exists in the code. It is imported by spec_completeness and by scripts.scan_implementation.

build_project_source composes a ProjectSource: the Python CodebaseAST (via orchestrata_ast), the raw parsed trees, and a NonPythonSource collected by _non_python_scan. scan_project runs every detector in the ALL_DETECTORS registry over an EvidenceContext and returns EvidenceRecords (kind, identifier, qualified name, file, line, async flag). Detectors are built by _symbol_detector for classes, functions, __all__ exports and module basenames, plus a registration detector (a _RegistrationKeyVisitor collecting string-keyed assignments) and JS/TS class, function and basename detectors.

match_requirements_to_evidence matches requirement descriptions against evidence identifiers, never prose word overlap. _candidate_identifiers pulls identifier-shaped tokens from each description. A match yields an implemented('<spec>', '<req id>') Prolog fact keyed by requirement id, which is what the Prolog rules join on. No match is a gap; a description with no identifier-shaped token is unresolved. The MatchReport also carries diagnostics, and degraded is true when an evidence source could not be read. scan_implementation_report returns a ScanOutcome with facts plus file counts and diagnostics; scan_implementation is the facts-only wrapper. match_requirements_to_evidence raises FileNotFoundError for a missing project directory.

At a glance

Source src/orchestrata_ultimate/verification/implementation_scanner.py
Lines of code 733
Classes 8
Functions 25
Imports (internal) orchestrata_ast.analyzer, orchestrata_ast.models, orchestrata_ultimate.verification._non_python_scan
Imported by orchestrata_ultimate.verification.spec_completeness, scripts.scan_implementation
Tests that import it tests/verification/test_implementation_scanner.py, tests/verification/test_spec_completeness_js_evidence.py

Class diagram

Class diagram orchestrata_ultimate.verification.implementation_scanner

Dependency diagram

Dependency diagram orchestrata_ultimate.verification.implementation_scanner

API

Constants

Name Type Value
ALL_DETECTORS dict[str, tuple[DetectorMeta, EvidenceFn]] _build_registry()

Classes

EvidenceRecord (dataclass)

Decorators: dataclass(frozen=True) · Line 90

One piece of AST-derived proof that something was implemented.

Attribute Type Default
kind str
identifier str
qualified_name str
file_path str
line int
detector_id str
is_async bool False

DetectorMeta (dataclass)

Decorators: dataclass(frozen=True) · Line 110

Static metadata for one evidence detector.

Attribute Type Default
detector_id str
kind str

EvidenceContext (dataclass)

Decorators: dataclass(frozen=True) · Line 118

Everything a detector function needs, precomputed once per scan.

Attribute Type Default
codebase CodebaseAST
trees Mapping[str, ast.Module] field(default_factory=dict)
non_python NonPythonSource field(default_factory=NonPythonSource)

ProjectSource (dataclass)

Decorators: dataclass(frozen=True) · Line 130

The injected analysis source for a scanned project.

Attribute Type Default
codebase CodebaseAST
trees Mapping[str, ast.Module] field(default_factory=dict)
non_python NonPythonSource field(default_factory=NonPythonSource)
discovered_python_files int 0

MatchReport (dataclass)

Decorators: dataclass(frozen=True) · Line 153

The three-way split :func:match_requirements_to_evidence returns.

Attribute Type Default
implemented list[str]
gaps list[tuple[str, str]]
unresolved list[tuple[str, str]]
diagnostics tuple[str, ...] ()
Method Signature Summary
degraded property degraded() -> bool Whether some evidence source could not be read at all.

_SymbolHit (dataclass)

Decorators: dataclass(frozen=True) · Line 232

One symbol found in a module, before it becomes an EvidenceRecord.

Attribute Type Default
identifier str
qualified_name str
line int
is_async bool False

_RegistrationKeyVisitor

Bases: ast.NodeVisitor · Line 302

Collect string-literal keys assigned via obj[&lt;string&gt;] = value (e.g. self._methods["cxp.send"] = self._handle) -- the one evidence kind no :class:~orchestrata_ast.models.ModuleInfo field…

Method Signature Summary
__init__ __init__() -> None
visit_Assign visit_Assign(node: ast.Assign) -> None

ScanOutcome (dataclass)

Decorators: dataclass(frozen=True) · Line 579

Everything a caller needs to judge a scan, not merely its result.

Attribute Type Default
facts list[str]
diagnostics tuple[str, ...] ()
python_discovered int 0
python_parsed int 0
js_ts_files int 0
other_files int 0
js_ts_symbols int 0
Method Signature Summary
degraded property degraded() -> bool Whether an evidence source could not be read (see :attr:MatchReport.degraded -- same distinction, same reason).

Functions

Function Signature Summary
build_context build_context(codebase: CodebaseAST, trees: Mapping[str, ast.Module] | None = None, non_python: NonPythonSource | None = None) -> EvidenceContext Build an :class:EvidenceContext for codebase (mirrors factory.detectors.rules.build_context).
build_project_source build_project_source(project_dir: str, *, name: str = 'project', env: Mapping[str, str] | None = None) -> ProjectSource Compose a :class:ProjectSource for project_dir.
_classes_of _classes_of(module: ModuleInfo) -> Iterable[_SymbolHit] Every class definition in module -- real ast.ClassDef nodes.
_functions_of _functions_of(module: ModuleInfo) -> Iterable[_SymbolHit] Every function/method in module -- real FunctionDef nodes, so is_async comes straight off the parsed node, no separate regex.
_exports_of _exports_of(module: ModuleInfo) -> Iterable[_SymbolHit] Every name in the module's parsed __all__ (list OR tuple, any number of lines -- CodebaseAnalyzer already parses the literal value rather than matching one export per line).
_modules_of _modules_of(module: ModuleInfo) -> Iterable[_SymbolHit] The module itself, keyed by file basename -- file/gadget presence.
_symbol_detector _symbol_detector(detector_id: str, kind: str, extractor: Callable[[ModuleInfo], Iterable[_SymbolHit]]) -> EvidenceFn Build a detector: one :class:EvidenceRecord per hit extractor yields, over every module in the injected :class:CodebaseAST.
_subscript_string_key _subscript_string_key(slice_node: ast.expr) -> str | None Return the literal string key of a Subscript slice, or None.
_registration_detector _registration_detector(ctx: EvidenceContext) -> list[EvidenceRecord] String-keyed registration assignments, via the raw per-module tree the injected :class:ProjectSource already carries (ctx.trees) -- never a re-open, never a regex.
_symbol_registry _symbol_registry() -> dict[str, tuple[DetectorMeta, EvidenceFn]]
_registration_registry _registration_registry() -> dict[str, tuple[DetectorMeta, EvidenceFn]]
_non_python_basename_detector _non_python_basename_detector(ctx: EvidenceContext) -> list[EvidenceRecord] Extension-less basename evidence for every collected JS/TS/HTML/CSS file (master's universal-basename rule -- see :mod:orchestrata_ultimate.verification._non_python_scan).
_js_ts_class_detector _js_ts_class_detector(ctx: EvidenceContext) -> list[EvidenceRecord] AST-precise JS/TS class evidence -- via the Node/TypeScript-compiler parse in scripts/serialize_js_ts_ast.py, never a regex over source text.
_js_ts_function_detector _js_ts_function_detector(ctx: EvidenceContext) -> list[EvidenceRecord] AST-precise JS/TS function (and class-method) evidence, via the same Node/TypeScript-compiler parse as :func:_js_ts_class_detector.
_non_python_registry _non_python_registry() -> dict[str, tuple[DetectorMeta, EvidenceFn]]
_build_registry _build_registry() -> dict[str, tuple[DetectorMeta, EvidenceFn]] Build the detector registry (see :data:ALL_DETECTORS).
scan_project scan_project(codebase: CodebaseAST, *, detectors: dict[str, tuple[DetectorMeta, EvidenceFn]] = ALL_DETECTORS, trees: Mapping[str, ast.Module] | None = None, non_python: NonPythonSource | None = None) -> list[EvidenceRecord] Run every detector in detectors over codebase.
_looks_like_identifier_word _looks_like_identifier_word(word: str) -> bool Is a single bare word (no backticks, no dot) identifier-shaped rather than ordinary English?
_candidate_identifiers _candidate_identifiers(description: str) -> list[str] Extract identifier-SHAPED tokens from a requirement description: backtick-quoted spans, dotted names, and bare CamelCase/snake_case words.
_identifier_matches _identifier_matches(candidate: str, evidence_identifier: str) -> bool Exact match (case-insensitive) on the bare identifier, or as a dotted suffix of a qualified name -- a structural comparison between two known tokens, never a substring/word-overlap scan of free text.
_find_matching_evidence _find_matching_evidence(candidates: list[str], evidence: list[EvidenceRecord]) -> EvidenceRecord | None Return the first evidence record any candidate identifier resolves to (by identifier or qualified name), or None.
_source_diagnostics _source_diagnostics(source: ProjectSource) -> tuple[str, ...] Every "could not look" reason carried by source.
scan_implementation_report scan_implementation_report(spec_id: str, project_dir: str, *, env: Mapping[str, str] | None = None) -> ScanOutcome Scan project_dir and return facts TOGETHER WITH how the scan went.
scan_implementation scan_implementation(spec_id: str, project_dir: str) -> list[str] Scan project_dir and return Prolog implemented/2 facts, one per distinct AST-derived evidence identifier.
match_requirements_to_evidence match_requirements_to_evidence(spec_id: str, requirements: list[tuple[str, str]], project_dir: str, *, env: Mapping[str, str] | None = None) -> MatchReport Match parsed requirements against AST-derived implementation evidence.

Source

Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/src/orchestrata_ultimate/verification/implementation_scanner.py

"""Scan a project's AST for evidence that spec requirements were implemented.

The evidence half of the spec-completeness check. :mod:`parse_spec_requirements`
turns a spec checklist into ``spec_requirement/3`` facts; this module scans a
project for proof each requirement was built and emits ``implemented/2``
facts. :mod:`spec_completeness` joins them into the QA volk's ``spec_coverage``
figure and gap list.

Rebuilt 2026-08-12 (plans/contracts/implementation-scanner-ast.contract.json)
around the AST layer instead of ``os.walk`` + five module-level regexes run
over raw source TEXT: a class name mentioned only in a comment, a docstring,
or a string literal can no longer count as evidence, because none of those
ever produce an AST node.

Mirrors :mod:`orchestrata_ultimate.factory.detectors` -- the codebase's own
measured registry pattern (composition + factory + injection, no ABC):

- :data:`EvidenceFn` is a ``Callable`` ALIAS, not a base class (mirrors
  ``factory/detectors/rules.py``'s ``RuleFn``).
- :data:`ALL_DETECTORS` is a ``dict[str, tuple[DetectorMeta, EvidenceFn]]``
  built by merged builder functions (mirrors ``rules.py``'s
  ``ALL_RULES``/``_build_registry``).
- :func:`_symbol_detector` is a closure factory: detectors differing only by
  which symbol kind and which attribute they read are built from ONE
  factory rather than copy-pasted (mirrors ``rules.py``'s ``_lexicon_rule``).
- :func:`scan_project` takes ``detectors=`` as an INJECTED parameter
  defaulting to the global registry (mirrors ``engine.py``'s
  ``run_detectors(rules=...)``) -- this is what makes the design provably
  extensible and testable with no patching.
- The one evidence kind no :class:`~orchestrata_ast.models.ModuleInfo` field
  carries -- string-keyed registration such as
  ``self._methods["cxp.send"] = handler`` -- is collected by an
  ``ast.NodeVisitor`` SUBCLASS, the one place this package already uses
  inheritance (:class:`orchestrata_ultimate.verification.review.
  checks_structure.NestingVisitor`).

Composition, never construction: the AST snapshot comes from
:class:`orchestrata_ast.analyzer.CodebaseAnalyzer`, which owns the directory
walk and the file parsing. This module never calls ``os.walk``/``rglob``/
``glob`` and never opens a source file itself; :func:`build_project_source`
is the one seam where a filesystem root becomes an injected structure.

Non-Python evidence (JS/TS/HTML/CSS -- restored 2026-08-13, see
:mod:`orchestrata_ultimate.verification._non_python_scan`) is injected the
SAME way, from a SEPARATE collaborator that owns its own filesystem walk
and its own subprocess call to the JS/TS AST serializer
(``scripts/serialize_js_ts_ast.py``) -- kept out of THIS module on purpose,
so the "no filesystem walk in here" invariant above stays literally true.
``ProjectSource.non_python`` / ``EvidenceContext.non_python`` carry that
half of the evidence; ``non-python-basename``, ``js-ts-class-definition``,
and ``js-ts-function-definition`` are its three registry entries, additive
to the four Python detectors and the registration detector above.
"""

from __future__ import annotations

import ast
import re
from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass, field
from pathlib import Path

from orchestrata_ast.analyzer import CodebaseAnalyzer
from orchestrata_ast.models import CodebaseAST, ModuleInfo
from orchestrata_ultimate.verification._non_python_scan import (
    NonPythonSource,
    collect_non_python_source,
)

__all__ = [
    "ALL_DETECTORS",
    "DetectorMeta",
    "EvidenceContext",
    "EvidenceFn",
    "EvidenceRecord",
    "MatchReport",
    "NonPythonSource",
    "ProjectSource",
    "ScanOutcome",
    "build_context",
    "build_project_source",
    "match_requirements_to_evidence",
    "scan_implementation",
    "scan_implementation_report",
    "scan_project",
]


@dataclass(frozen=True)
class EvidenceRecord:
    """One piece of AST-derived proof that something was implemented.

    ``identifier`` is the primary token requirement-matching compares
    against (a bare name or a registration key); ``qualified_name`` is the
    fully dotted form where one exists, and equals ``identifier`` where it
    doesn't (e.g. a registration key has no enclosing qualified path of its
    own beyond the module it was found in).
    """

    kind: str  # "class" | "function" | "export" | "module" | "registration"
    identifier: str
    qualified_name: str
    file_path: str
    line: int
    detector_id: str
    is_async: bool = False


@dataclass(frozen=True)
class DetectorMeta:
    """Static metadata for one evidence detector."""

    detector_id: str
    kind: str


@dataclass(frozen=True)
class EvidenceContext:
    """Everything a detector function needs, precomputed once per scan."""

    codebase: CodebaseAST
    trees: Mapping[str, ast.Module] = field(default_factory=dict)
    non_python: NonPythonSource = field(default_factory=NonPythonSource)


EvidenceFn = Callable[[EvidenceContext], list[EvidenceRecord]]


@dataclass(frozen=True)
class ProjectSource:
    """The injected analysis source for a scanned project.

    Bundles the AST toolkit's structured snapshot with the raw per-module
    trees :class:`CodebaseAnalyzer` already parsed while building it --
    reused, never re-opened, for the one evidence kind no
    :class:`~orchestrata_ast.models.ModuleInfo` field carries (see
    :func:`_registration_detector`). ``non_python`` is the non-Python half
    of evidence (JS/TS/HTML/CSS -- see
    :mod:`orchestrata_ultimate.verification._non_python_scan`), composed
    the same way: injected, never constructed by a detector.
    """

    codebase: CodebaseAST
    trees: Mapping[str, ast.Module] = field(default_factory=dict)
    non_python: NonPythonSource = field(default_factory=NonPythonSource)
    #: Python files the analyzer DISCOVERED. ``len(trees)`` is how many it
    #: parsed; a shortfall means files were skipped as unparseable, and
    #: their evidence is missing from any scan built on this source.
    discovered_python_files: int = 0


@dataclass(frozen=True)
class MatchReport:
    """The three-way split :func:`match_requirements_to_evidence` returns.

    ``unresolved`` is distinct from ``gaps`` (the contract's honesty rule):
    a requirement whose description names no identifier-shaped token at all
    cannot be judged implemented or missing from structural evidence alone
    -- it is reported unresolved, never silently folded into either bucket.
    """

    implemented: list[str]
    gaps: list[tuple[str, str]]
    unresolved: list[tuple[str, str]]
    diagnostics: tuple[str, ...] = ()

    @property
    def degraded(self) -> bool:
        """Whether some evidence source could not be read at all.

        A ``gaps`` entry means "looked, found nothing". ``degraded`` means
        "could not look" -- so a consumer must never read a gap list from
        a degraded report as evidence that work is missing. Keeping these
        apart is the whole point: the original defect was a scan that lost
        every non-Python source and reported the result as clean gaps.
        """
        return bool(self.diagnostics)


def build_context(
    codebase: CodebaseAST,
    trees: Mapping[str, ast.Module] | None = None,
    non_python: NonPythonSource | None = None,
) -> EvidenceContext:
    """Build an :class:`EvidenceContext` for *codebase* (mirrors
    ``factory.detectors.rules.build_context``)."""
    return EvidenceContext(
        codebase=codebase,
        trees=trees if trees is not None else {},
        non_python=non_python if non_python is not None else NonPythonSource(),
    )


def build_project_source(
    project_dir: str,
    *,
    name: str = "project",
    env: Mapping[str, str] | None = None,
) -> ProjectSource:
    """Compose a :class:`ProjectSource` for *project_dir*.

    The one place THIS module touches the filesystem for the Python half:
    it hands that job to :class:`CodebaseAnalyzer`, which owns its own
    walk and parse. This function itself never calls
    ``os.walk``/``rglob``/``glob`` and never opens a source file -- it
    only checks the root exists and composes the analyzer, then reuses
    the trees the analyzer already parsed (``analyzer.trees``) instead of
    re-opening every file. The non-Python half is composed the same way,
    from a different collaborator that owns ITS OWN walk (see
    :mod:`orchestrata_ultimate.verification._non_python_scan`).

    ``env`` is the injection seam for the JS/TS serializer subprocess
    (forwarded to :func:`collect_non_python_source`) -- ``None`` inherits
    the current process environment in production; tests pass an explicit
    mapping to exercise a broken Node toolchain without monkeypatching.
    """
    root = Path(project_dir)
    if not root.is_dir():
        raise FileNotFoundError(f"Project directory not found: {project_dir}")
    analyzer = CodebaseAnalyzer(name, root)
    codebase = analyzer.analyze()
    non_python = collect_non_python_source(root, name=name, env=env)
    return ProjectSource(
        codebase=codebase,
        trees=dict(analyzer.trees),
        non_python=non_python,
        discovered_python_files=analyzer.discovered_python_files,
    )


@dataclass(frozen=True)
class _SymbolHit:
    """One symbol found in a module, before it becomes an EvidenceRecord."""

    identifier: str
    qualified_name: str
    line: int
    is_async: bool = False


def _classes_of(module: ModuleInfo) -> Iterable[_SymbolHit]:
    """Every class definition in *module* -- real ``ast.ClassDef`` nodes."""
    for cls in module.classes:
        yield _SymbolHit(cls.name, cls.qualified_name, cls.line)


def _functions_of(module: ModuleInfo) -> Iterable[_SymbolHit]:
    """Every function/method in *module* -- real ``FunctionDef`` nodes, so
    ``is_async`` comes straight off the parsed node, no separate regex."""
    for func in module.functions:
        yield _SymbolHit(func.name, func.qualified_name, func.line, func.is_async)
    for cls in module.classes:
        for method in cls.methods:
            yield _SymbolHit(
                method.name, method.qualified_name, method.line, method.is_async
            )


def _exports_of(module: ModuleInfo) -> Iterable[_SymbolHit]:
    """Every name in the module's parsed ``__all__`` (list OR tuple, any
    number of lines -- ``CodebaseAnalyzer`` already parses the literal
    value rather than matching one export per line)."""
    for name in module.all_exports:
        yield _SymbolHit(name, f"{module.path}.{name}", 0)


def _modules_of(module: ModuleInfo) -> Iterable[_SymbolHit]:
    """The module itself, keyed by file basename -- file/gadget presence."""
    yield _SymbolHit(Path(module.file_path).stem, module.path, 0)


def _symbol_detector(
    detector_id: str,
    kind: str,
    extractor: Callable[[ModuleInfo], Iterable[_SymbolHit]],
) -> EvidenceFn:
    """Build a detector: one :class:`EvidenceRecord` per hit *extractor*
    yields, over every module in the injected :class:`CodebaseAST`.

    The factory this repo already uses for detectors differing only by
    symbol kind + attribute read (mirrors ``rules.py``'s ``_lexicon_rule``).
    """

    def _detect(ctx: EvidenceContext) -> list[EvidenceRecord]:
        return [
            EvidenceRecord(
                kind=kind,
                identifier=hit.identifier,
                qualified_name=hit.qualified_name,
                file_path=module.file_path,
                line=hit.line,
                detector_id=detector_id,
                is_async=hit.is_async,
            )
            for module in ctx.codebase.modules
            for hit in extractor(module)
        ]

    return _detect


class _RegistrationKeyVisitor(ast.NodeVisitor):
    """Collect string-literal keys assigned via ``obj[<string>] = value``
    (e.g. ``self._methods["cxp.send"] = self._handle``) -- the one evidence
    kind no :class:`~orchestrata_ast.models.ModuleInfo` field carries.

    The one place this module uses a subclass, following the package's
    existing precedent for AST-traversal inheritance
    (:class:`orchestrata_ultimate.verification.review.checks_structure.
    NestingVisitor`). The same text inside a comment, a docstring, or an
    unrelated string literal never reaches this visitor: comments have no
    AST node, and a bare string constant that is not a ``Subscript``
    assignment target is never visited as a registration.
    """

    def __init__(self) -> None:
        self.hits: list[tuple[str, int]] = []

    def visit_Assign(self, node: ast.Assign) -> None:
        for target in node.targets:
            if isinstance(target, ast.Subscript):
                key = _subscript_string_key(target.slice)
                if key is not None:
                    self.hits.append((key, node.lineno))
        self.generic_visit(node)


def _subscript_string_key(slice_node: ast.expr) -> str | None:
    """Return the literal string key of a ``Subscript`` slice, or ``None``."""
    if isinstance(slice_node, ast.Constant) and isinstance(slice_node.value, str):
        return slice_node.value
    return None


def _registration_detector(ctx: EvidenceContext) -> list[EvidenceRecord]:
    """String-keyed registration assignments, via the raw per-module tree
    the injected :class:`ProjectSource` already carries (``ctx.trees``) --
    never a re-open, never a regex."""
    records: list[EvidenceRecord] = []
    for module in ctx.codebase.modules:
        tree = ctx.trees.get(module.file_path)
        if tree is None:
            continue
        visitor = _RegistrationKeyVisitor()
        visitor.visit(tree)
        records.extend(
            EvidenceRecord(
                kind="registration",
                identifier=key,
                qualified_name=f"{module.path}:{key}",
                file_path=module.file_path,
                line=line,
                detector_id="registration-key",
            )
            for key, line in visitor.hits
        )
    return records


def _symbol_registry() -> dict[str, tuple[DetectorMeta, EvidenceFn]]:
    return {
        "class-definition": (
            DetectorMeta("class-definition", "class"),
            _symbol_detector("class-definition", "class", _classes_of),
        ),
        "function-definition": (
            DetectorMeta("function-definition", "function"),
            _symbol_detector("function-definition", "function", _functions_of),
        ),
        "export": (
            DetectorMeta("export", "export"),
            _symbol_detector("export", "export", _exports_of),
        ),
        "module-presence": (
            DetectorMeta("module-presence", "module"),
            _symbol_detector("module-presence", "module", _modules_of),
        ),
    }


def _registration_registry() -> dict[str, tuple[DetectorMeta, EvidenceFn]]:
    return {
        "registration-key": (
            DetectorMeta("registration-key", "registration"),
            _registration_detector,
        ),
    }


def _non_python_basename_detector(ctx: EvidenceContext) -> list[EvidenceRecord]:
    """Extension-less basename evidence for every collected JS/TS/HTML/CSS
    file (master's universal-basename rule -- see
    :mod:`orchestrata_ultimate.verification._non_python_scan`). Python
    basenames are already covered by ``module-presence``; ``.html``/
    ``.css`` have no AST layer, so basename is the whole of their
    evidence (a documented limitation, not an oversight)."""
    return [
        EvidenceRecord(
            kind="module",
            identifier=stem,
            qualified_name=file_path,
            file_path=file_path,
            line=0,
            detector_id="non-python-basename",
        )
        for stem, file_path in ctx.non_python.basenames
    ]


def _js_ts_class_detector(ctx: EvidenceContext) -> list[EvidenceRecord]:
    """AST-precise JS/TS class evidence -- via the Node/TypeScript-compiler
    parse in ``scripts/serialize_js_ts_ast.py``, never a regex over
    source text."""
    return [
        EvidenceRecord(
            kind="class",
            identifier=symbol.name,
            qualified_name=symbol.qualified_name,
            file_path=symbol.file_path,
            line=symbol.line,
            detector_id="js-ts-class-definition",
        )
        for symbol in ctx.non_python.js_ts_symbols
        if symbol.kind == "class"
    ]


def _js_ts_function_detector(ctx: EvidenceContext) -> list[EvidenceRecord]:
    """AST-precise JS/TS function (and class-method) evidence, via the
    same Node/TypeScript-compiler parse as :func:`_js_ts_class_detector`."""
    return [
        EvidenceRecord(
            kind="function",
            identifier=symbol.name,
            qualified_name=symbol.qualified_name,
            file_path=symbol.file_path,
            line=symbol.line,
            detector_id="js-ts-function-definition",
            is_async=symbol.is_async,
        )
        for symbol in ctx.non_python.js_ts_symbols
        if symbol.kind == "function"
    ]


def _non_python_registry() -> dict[str, tuple[DetectorMeta, EvidenceFn]]:
    return {
        "non-python-basename": (
            DetectorMeta("non-python-basename", "module"),
            _non_python_basename_detector,
        ),
        "js-ts-class-definition": (
            DetectorMeta("js-ts-class-definition", "class"),
            _js_ts_class_detector,
        ),
        "js-ts-function-definition": (
            DetectorMeta("js-ts-function-definition", "function"),
            _js_ts_function_detector,
        ),
    }


def _build_registry() -> dict[str, tuple[DetectorMeta, EvidenceFn]]:
    """Build the detector registry (see :data:`ALL_DETECTORS`)."""
    return {
        **_symbol_registry(),
        **_registration_registry(),
        **_non_python_registry(),
    }


ALL_DETECTORS: dict[str, tuple[DetectorMeta, EvidenceFn]] = _build_registry()


def scan_project(
    codebase: CodebaseAST,
    *,
    detectors: dict[str, tuple[DetectorMeta, EvidenceFn]] = ALL_DETECTORS,
    trees: Mapping[str, ast.Module] | None = None,
    non_python: NonPythonSource | None = None,
) -> list[EvidenceRecord]:
    """Run every detector in *detectors* over *codebase*.

    Mirrors ``run_detectors(rules=...)``: the registry is an INJECTED
    parameter defaulting to the module global, which is what lets a caller
    add one detector and see the output change with zero edits to this
    function's body -- and what lets tests supply real parsed fixtures
    with no patching. ``non_python`` defaults to empty (Python-only
    behaviour, byte-identical to before the non-Python detectors existed)
    when the caller does not supply one -- exactly like ``trees``.
    """
    ctx = build_context(codebase, trees, non_python)
    records: list[EvidenceRecord] = []
    for _detector_id, (_meta, detector_fn) in detectors.items():
        records.extend(detector_fn(ctx))
    return records


# ---------------------------------------------------------------------------
# Requirement matching -- structured identifiers, never prose word overlap.
# ---------------------------------------------------------------------------

_BACKTICK_RE = re.compile(r"`([^`]+)`")
_DOTTED_TOKEN_RE = re.compile(
    r"\b[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+\b"
)
_BARE_WORD_RE = re.compile(r"\b[A-Za-z_][A-Za-z0-9_]*\b")


def _looks_like_identifier_word(word: str) -> bool:
    """Is a single bare word (no backticks, no dot) identifier-shaped
    rather than ordinary English?

    True for a snake_case word (contains ``_``), a word containing a
    digit, or true CamelCase (an uppercase letter somewhere other than
    position 0, AND at least one lowercase letter). A sentence-capitalized
    plain word ("Client", "First") or an all-caps acronym ("RPC", "CXP")
    is deliberately excluded -- neither one is a structural claim by
    itself; this is still identifier-SHAPE recognition, not a word-overlap
    scan of the description's prose.
    """
    if "_" in word or any(c.isdigit() for c in word):
        return True
    has_upper = any(c.isupper() for c in word)
    has_lower = any(c.islower() for c in word)
    if not (has_upper and has_lower):
        return False
    return any(c.isupper() for c in word[1:])


def _candidate_identifiers(description: str) -> list[str]:
    """Extract identifier-SHAPED tokens from a requirement description:
    backtick-quoted spans, dotted names, and bare CamelCase/snake_case
    words. Never a bag-of-words split -- ordinary English words are
    excluded by shape (see :func:`_looks_like_identifier_word`), which is
    what keeps this from becoming the fuzzy prose-vs-text matching this
    module replaces. An empty result means the description names nothing
    structural at all.
    """
    tokens: set[str] = set()
    tokens.update(m.group(1).strip() for m in _BACKTICK_RE.finditer(description))
    tokens.update(m.group(0) for m in _DOTTED_TOKEN_RE.finditer(description))
    tokens.update(
        m.group(0)
        for m in _BARE_WORD_RE.finditer(description)
        if _looks_like_identifier_word(m.group(0))
    )
    return sorted(token for token in tokens if token)


def _identifier_matches(candidate: str, evidence_identifier: str) -> bool:
    """Exact match (case-insensitive) on the bare identifier, or as a
    dotted suffix of a qualified name -- a structural comparison between
    two known tokens, never a substring/word-overlap scan of free text."""
    candidate_lower = candidate.lower()
    evidence_lower = evidence_identifier.lower()
    return (
        candidate_lower == evidence_lower
        or evidence_lower.endswith("." + candidate_lower)
        or candidate_lower.endswith("." + evidence_lower)
    )


def _find_matching_evidence(
    candidates: list[str], evidence: list[EvidenceRecord]
) -> EvidenceRecord | None:
    """Return the first evidence record any candidate identifier resolves
    to (by identifier or qualified name), or ``None``."""
    for candidate in candidates:
        for record in evidence:
            if _identifier_matches(candidate, record.identifier) or _identifier_matches(
                candidate, record.qualified_name
            ):
                return record
    return None


@dataclass(frozen=True)
class ScanOutcome:
    """Everything a caller needs to judge a scan, not merely its result.

    ``facts`` alone cannot be judged: an empty list means "this project
    implements nothing" AND "an evidence source could not be read", and
    those demand opposite responses. ``diagnostics`` separates them, and
    the counts let a caller state what was actually examined rather than
    inferring it from silence.
    """

    facts: list[str]
    diagnostics: tuple[str, ...] = ()
    python_discovered: int = 0
    python_parsed: int = 0
    js_ts_files: int = 0
    other_files: int = 0
    js_ts_symbols: int = 0

    @property
    def degraded(self) -> bool:
        """Whether an evidence source could not be read (see
        :attr:`MatchReport.degraded` -- same distinction, same reason)."""
        return bool(self.diagnostics)


def _source_diagnostics(source: ProjectSource) -> tuple[str, ...]:
    """Every "could not look" reason carried by *source*.

    Central so that BOTH public entry points -- :func:`scan_implementation_report`
    and :func:`match_requirements_to_evidence` -- surface the same set. A
    diagnostic reachable from only one of them is how the last fix left a
    write-only field: computed, correct, and read by nobody.
    """
    diagnostics: list[str] = []
    skipped_python = source.discovered_python_files - len(source.trees)
    if skipped_python > 0:
        diagnostics.append(
            f"{skipped_python} of {source.discovered_python_files} Python "
            "file(s) could not be parsed and contributed no evidence"
        )
    if source.non_python.js_ts_diagnostic is not None:
        diagnostics.append(source.non_python.js_ts_diagnostic)
    return tuple(diagnostics)


def scan_implementation_report(
    spec_id: str, project_dir: str, *, env: Mapping[str, str] | None = None
) -> ScanOutcome:
    """Scan *project_dir* and return facts TOGETHER WITH how the scan went.

    The full-fidelity entry point. :func:`scan_implementation` is the
    facts-only convenience wrapper over this; anything that must not
    mistake "could not look" for "nothing to find" calls this instead.

    ``env`` is forwarded to the JS/TS serializer subprocess (see
    :func:`build_project_source`).
    """
    source = build_project_source(project_dir, name=spec_id, env=env)
    evidence = scan_project(
        source.codebase, trees=source.trees, non_python=source.non_python
    )
    identifiers = sorted({record.identifier for record in evidence})
    facts: list[str] = []
    for identifier in identifiers:
        safe_identifier = identifier.replace("'", "\\'")
        facts.append(f"implemented('{spec_id}', '{safe_identifier}')")
    non_python = source.non_python
    return ScanOutcome(
        facts=facts,
        diagnostics=_source_diagnostics(source),
        python_discovered=source.discovered_python_files,
        python_parsed=len(source.trees),
        js_ts_files=non_python.js_ts_file_count,
        other_files=len(non_python.basenames) - non_python.js_ts_file_count,
        js_ts_symbols=len(non_python.js_ts_symbols),
    )


def scan_implementation(spec_id: str, project_dir: str) -> list[str]:
    """Scan *project_dir* and return Prolog ``implemented/2`` facts, one
    per distinct AST-derived evidence identifier.

    Thin wrapper over :func:`scan_implementation_report` that DISCARDS the
    diagnostics. Callers that need to tell an empty result apart from a
    failed one must call that function directly.
    """
    return scan_implementation_report(spec_id, project_dir).facts


def match_requirements_to_evidence(
    spec_id: str,
    requirements: list[tuple[str, str]],
    project_dir: str,
    *,
    env: Mapping[str, str] | None = None,
) -> MatchReport:
    """Match parsed requirements against AST-derived implementation evidence.

    Composes a :class:`ProjectSource` for *project_dir*, scans it with the
    default detector registry, then matches each requirement's description
    against STRUCTURED evidence identifiers -- never prose word overlap. A
    requirement whose description names no identifier-shaped token at all
    is reported ``unresolved`` (see :class:`MatchReport`), distinct from a
    requirement that names one but finds no matching evidence (``gaps``).

    Args:
        spec_id: Spec number (e.g., ``"014"``).
        requirements: List of ``(req_id, description)`` tuples.
        project_dir: Root directory of the generated project.

    Returns:
        A :class:`MatchReport` with ``implemented`` (Prolog fact strings),
        ``gaps``, and ``unresolved`` -- both the latter as ``(req_id,
        description)`` tuples.

    Raises:
        FileNotFoundError: When *project_dir* does not exist.
    """
    source = build_project_source(project_dir, name=spec_id, env=env)
    evidence = scan_project(
        source.codebase, trees=source.trees, non_python=source.non_python
    )

    implemented_facts: list[str] = []
    gaps: list[tuple[str, str]] = []
    unresolved: list[tuple[str, str]] = []

    for req_id, desc in requirements:
        candidates = _candidate_identifiers(desc)
        if not candidates:
            unresolved.append((req_id, desc))
            continue
        match = _find_matching_evidence(candidates, evidence)
        if match is not None:
            # Keyed by REQUIREMENT ID, which is what spec_completeness.pl
            # joins on: `missing_requirement(SpecId, ReqId, Desc) :-
            # spec_requirement(SpecId, ReqId, Desc), \+ implemented(SpecId,
            # ReqId)`. This previously emitted the DESCRIPTION, which can
            # never unify with a ReqId -- so implementation_coverage/3
            # returned 0 for every spec in every language regardless of the
            # evidence found. Measured before the fix: an exact-matching
            # fact yielded {'implemented_count': 0, 'spec_complete': False}.
            # A description is also a fragile join key -- prose punctuation
            # (the backticks around an identifier, say) silently breaks it.
            safe_req_id = req_id.replace("'", "\\'")
            implemented_facts.append(f"implemented('{spec_id}', '{safe_req_id}')")
        else:
            gaps.append((req_id, desc))

    return MatchReport(
        implemented=implemented_facts,
        gaps=gaps,
        unresolved=unresolved,
        diagnostics=_source_diagnostics(source),
    )