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
Dependency diagram
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[<string>] = 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
"""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),
)