orchestrata_ultimate.verification._non_python_scan
Reference › orchestrata_ultimate › verification › _non_python_scan
Collects non-Python implementation evidence for the implementation_scanner, its only importer. collect_non_python_source walks a project for JS/TS files (.js, .mjs, .jsx, .ts, .tsx) and HTML/CSS files (matched by basename only), skipping directories such as node_modules, .git, dist and build. For JS/TS it calls _run_js_ts_serializer, which launches scripts/serialize_js_ts_ast.py as a subprocess (resolved from the project root, not the working directory) with a timeout of 120 seconds by default, and _symbols_from_ast_json turns its JSON into NonPythonSymbol records. The result is a NonPythonSource.
Failures never raise. Every failure the call can observe (missing script, missing Node, non-zero exit, timeout, unreadable or invalid output) becomes a short diagnostic string, so the scanner can report a degraded scan instead of mistaking an unreadable source for nothing to find. A malformed JS/TS file is not detected as a diagnostic: it shows as a non-zero js_ts_file_count with no symbols.
At a glance
| Source | src/orchestrata_ultimate/verification/_non_python_scan.py |
| Lines of code | 380 |
| Classes | 2 |
| Functions | 5 |
| Imports (internal) | orchestrata_ultimate.core.paths |
| Imported by | orchestrata_ultimate.verification.implementation_scanner |
| Tests that import it | tests/verification/test_implementation_scanner.py, tests/verification/test_non_python_scan_coverage.py |
Class diagram
Dependency diagram
API
Constants
| Name | Type | Value |
|---|---|---|
JS_TS_EXTENSIONS |
tuple[str, ...] |
('.js', '.mjs', '.jsx', '.ts', '.tsx') |
BASENAME_ONLY_EXTENSIONS |
tuple[str, ...] |
('.html', '.css') |
NON_PYTHON_EXTENSIONS |
tuple[str, ...] |
JS_TS_EXTENSIONS + BASENAME_ONLY_EXTENSIONS |
_SKIP_DIRS |
frozenset[str] |
frozenset({'node_modules', '.git', '__pycache__', 'dist', 'build', '.next', 'co… |
Classes
NonPythonSymbol (dataclass)
Decorators: dataclass(frozen=True) · Line 91
One JS/TS class or function, as extracted by the Node serializer.
| Attribute | Type | Default |
|---|---|---|
kind |
str |
|
name |
str |
|
qualified_name |
str |
|
file_path |
str |
|
line |
int |
|
is_async |
bool |
False |
NonPythonSource (dataclass)
Decorators: dataclass(frozen=True) · Line 103
Non-Python evidence ingredients for :class:ProjectSource.
| Attribute | Type | Default |
|---|---|---|
basenames |
tuple[tuple[str, str], ...] |
() |
js_ts_symbols |
tuple[NonPythonSymbol, ...] |
() |
js_ts_diagnostic |
str | None |
None |
js_ts_file_count |
int |
0 |
Functions
| Function | Signature | Summary |
|---|---|---|
_collect_non_python_files |
_collect_non_python_files(root: Path) -> list[Path] |
Every JS/TS/HTML/CSS file under root, skipping _SKIP_DIRS. |
_named |
_named(entry: Mapping[str, object]) -> bool |
Whether entry carries a usable identifier name. |
_symbols_from_ast_json |
_symbols_from_ast_json(data: Mapping[str, object]) -> list[NonPythonSymbol] |
Turn serialize_js_ts_ast.py's output JSON into flat symbols. |
_run_js_ts_serializer |
_run_js_ts_serializer(project_dir: Path, *, name: str, env: Mapping[str, str] | None, timeout: float, script_path: Path | None = None) -> tuple[list[NonPythonSymbol], str | None] |
Shell out to scripts/serialize_js_ts_ast.py (which shells out to Node itself) and turn its JSON output into symbols. |
collect_non_python_source |
collect_non_python_source(project_dir: Path, *, name: str, env: Mapping[str, str] | None = None, timeout: float = 120.0) -> NonPythonSource |
Compose a :class:NonPythonSource for project_dir. |
Source
"""Non-Python evidence collection for :mod:`implementation_scanner`.
Restores the non-Python half of implementation evidence lost when the
scanner was rebuilt AST-precise around
:class:`orchestrata_ast.analyzer.CodebaseAnalyzer` (2026-08-12,
``plans/contracts/implementation-scanner-ast.contract.json``) --
``CodebaseAnalyzer.analyze()`` finds Python files ONLY
(``orchestrata_ast/analyzer.py:_find_python_files``), so every requirement
implemented in JS/TS/HTML/CSS silently read as a gap.
This module is the ONE seam that walks a filesystem for non-Python files --
mirrors ``implementation_scanner.build_project_source``'s own seam for the
Python half (``TestNoFilesystemWalk`` in the scanner's test suite proves
*that* module contains no ``os.walk``/``rglob``/``glob``/``open()`` of its
own; this module is where that responsibility for non-Python evidence
lives instead, kept out of the scanner's own source on purpose).
Coverage by extension (the 7 non-Python extensions master's regex scanner
collected, restored here AST-precisely where an AST layer exists):
- ``.js`` ``.mjs`` ``.jsx`` ``.ts`` ``.tsx`` -- AST-precise class/function
evidence via ``scripts/serialize_js_ts_ast.py`` (a Node.js/TypeScript-
compiler-API parse, shelled out to as a SUBPROCESS -- never imported as
``scripts.*``, which the contract above forbids from ``src/``).
- ``.html`` ``.css`` -- basename evidence ONLY. There is no AST layer for
either in this codebase; pretending otherwise would silently under- or
over-claim evidence, so this is a documented limitation, not an
oversight. A future HTML/CSS AST source (if one is ever added) plugs in
here without changing :mod:`implementation_scanner`'s detector shape.
- Every collected file (regardless of extension) also contributes its
extension-less basename as evidence -- master's universal-basename rule,
restored for parity with the Python side's own ``module-presence``
detector (:func:`implementation_scanner._modules_of`), which already
does this for ``.py`` files.
Node-toolchain diagnostics: whether the JS/TS serializer runs at all is
observable, not implicit. :func:`collect_non_python_source` invokes it
ONLY when at least one JS/TS-extension file was found by this module's own
walk; :data:`NonPythonSource.js_ts_diagnostic` is ``None`` in that "nothing
to parse" case. When JS/TS files ARE present but the subprocess fails --
missing ``node`` executable, a parse error, a timeout, or unreadable
output -- ``js_ts_diagnostic`` carries a short human-readable reason and
:data:`NonPythonSource.js_ts_symbols` is empty. Both cases return an empty
symbol tuple; only the diagnostic field tells them apart, which is what
lets a caller distinguish "no JS/TS files" from "could not parse JS/TS"
instead of reading both as an identical, silent zero.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from orchestrata_ultimate.core.paths import get_project_root
#: Extensions the Node/TypeScript-compiler-API serializer understands.
JS_TS_EXTENSIONS: tuple[str, ...] = (".js", ".mjs", ".jsx", ".ts", ".tsx")
#: Extensions with basename-only evidence -- no AST layer exists for them.
BASENAME_ONLY_EXTENSIONS: tuple[str, ...] = (".html", ".css")
#: The full non-Python evidence-relevant extension set this module collects.
NON_PYTHON_EXTENSIONS: tuple[str, ...] = JS_TS_EXTENSIONS + BASENAME_ONLY_EXTENSIONS
#: Directories never walked into. Mirrors ``serialize_js_ts_ast.js``'s own
#: ``SKIP_DIRS`` exactly (installed dependencies, VCS metadata, and build
#: output are never evidence, and walking ``node_modules`` on a real
#: generated project would otherwise turn every third-party package file
#: into a spurious basename-evidence hit).
_SKIP_DIRS: frozenset[str] = frozenset(
{
"node_modules",
".git",
"__pycache__",
"dist",
"build",
".next",
"coverage",
".ts-tmp",
}
)
@dataclass(frozen=True)
class NonPythonSymbol:
"""One JS/TS class or function, as extracted by the Node serializer."""
kind: str # "class" | "function"
name: str
qualified_name: str
file_path: str
line: int
is_async: bool = False
@dataclass(frozen=True)
class NonPythonSource:
"""Non-Python evidence ingredients for :class:`ProjectSource`.
``basenames`` is ``(stem, file_path)`` for every collected non-Python
file. ``js_ts_symbols`` is empty in THREE distinct situations, and the
caller must be able to tell them apart -- conflating them is the exact
defect this module exists to close:
1. No JS/TS files were found, so the serializer was never invoked.
``js_ts_file_count == 0`` and ``js_ts_diagnostic is None``.
2. The toolchain could not run (missing script, missing/broken
``node``, non-zero exit, timeout, unreadable or malformed output).
``js_ts_diagnostic`` carries the reason.
3. The serializer ran and reported success but extracted nothing from
``js_ts_file_count > 0`` files. ``js_ts_diagnostic is None``,
because nothing FAILED -- this is what a file with no top-level
symbols looks like, and also what a malformed file looks like
after the TypeScript compiler error-recovers from it (measured:
``export class {{{`` exits 0 reporting ``Classes: 0, Functions:
0``). The counts, not the diagnostic, are what expose it.
``js_ts_diagnostic`` therefore means "could not look", never "looked
and found nothing" -- and it is only honest about case 3 in company
with ``js_ts_file_count``.
"""
basenames: tuple[tuple[str, str], ...] = ()
js_ts_symbols: tuple[NonPythonSymbol, ...] = ()
js_ts_diagnostic: str | None = None
js_ts_file_count: int = 0
def _collect_non_python_files(root: Path) -> list[Path]:
"""Every JS/TS/HTML/CSS file under *root*, skipping ``_SKIP_DIRS``.
The one directory walk this module exists to own (see the module
docstring) -- kept out of :mod:`implementation_scanner` itself.
"""
found: list[Path] = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [name for name in dirnames if name not in _SKIP_DIRS]
here = Path(dirpath)
found.extend(
here / fname
for fname in filenames
if Path(fname).suffix in NON_PYTHON_EXTENSIONS
)
return sorted(found)
#: Names the TypeScript compiler emits for symbols that HAVE no name. An
#: anonymous default export (``export default function () {}``) serializes
#: as ``<anonymous>``; a malformed file the compiler error-recovers from
#: serializes as ``""``. Neither is an identifier a requirement can cite,
#: and emitting them puts ``implemented('SPEC', '')`` -- an empty Prolog
#: atom -- into the fact stream. Measured on real serializer output, not
#: assumed: see tests/verification/test_implementation_scanner.py.
_UNNAMED_SYMBOL_NAMES = frozenset({"", "<anonymous>"})
#: Cap on a single diagnostic line. A diagnostic exists to be READ on a
#: terminal; an unbounded one scrolls the summary it accompanies off the
#: screen, which defeats the point of surfacing it.
_DIAGNOSTIC_MAX_CHARS = 300
def _named(entry: Mapping[str, object]) -> bool:
"""Whether *entry* carries a usable identifier name.
Guards the three symbol loops below: absent key, non-string value, and
the compiler's placeholder names for genuinely unnamed symbols all
resolve to ``False`` so they never reach ``EvidenceRecord.identifier``.
"""
name = entry.get("name")
if not isinstance(name, str):
return False
return name.strip() not in _UNNAMED_SYMBOL_NAMES
def _symbols_from_ast_json(data: Mapping[str, object]) -> list[NonPythonSymbol]:
"""Turn ``serialize_js_ts_ast.py``'s output JSON into flat symbols.
Defensive on shape (``.get`` throughout): a malformed or
schema-drifted document degrades to fewer symbols found, never a
``KeyError`` crash of the whole scan.
"""
symbols: list[NonPythonSymbol] = []
modules = data.get("modules", [])
if not isinstance(modules, list):
return symbols
for module in modules:
if not isinstance(module, dict):
continue
module_file_path = str(module.get("file_path", ""))
for func in module.get("functions", []) or []:
if not isinstance(func, dict) or not _named(func):
continue
symbols.append(
NonPythonSymbol(
kind="function",
name=str(func["name"]),
qualified_name=str(func.get("qualified_name", func["name"])),
file_path=str(func.get("file_path", module_file_path)),
line=int(func.get("line", 0) or 0),
is_async=bool(func.get("is_async", False)),
)
)
for cls in module.get("classes", []) or []:
if not isinstance(cls, dict) or not _named(cls):
continue
symbols.append(
NonPythonSymbol(
kind="class",
name=str(cls["name"]),
qualified_name=str(cls.get("qualified_name", cls["name"])),
file_path=str(cls.get("file_path", module_file_path)),
line=int(cls.get("line", 0) or 0),
)
)
for method in cls.get("methods", []) or []:
if not isinstance(method, dict) or not _named(method):
continue
symbols.append(
NonPythonSymbol(
kind="function",
name=str(method["name"]),
qualified_name=str(
method.get("qualified_name", method["name"])
),
file_path=str(method.get("file_path", module_file_path)),
line=int(method.get("line", 0) or 0),
is_async=bool(method.get("is_async", False)),
)
)
return symbols
def _run_js_ts_serializer(
project_dir: Path,
*,
name: str,
env: Mapping[str, str] | None,
timeout: float,
script_path: Path | None = None,
) -> tuple[list[NonPythonSymbol], str | None]:
"""Shell out to ``scripts/serialize_js_ts_ast.py`` (which shells out to
Node itself) and turn its JSON output into symbols.
*script_path* defaults to resolving from the repo root
(:func:`get_project_root`), never relative to ``cwd`` -- this module
may run from any working directory. The explicit-override seam
(mirrors ``serialize_js_ts_ast.py``'s own ``_serialize(...,
script_path=...)``) exists so the missing-script diagnostic branch is
reachable in a test without relocating the real file on disk. Returns
``(symbols, diagnostic)``: *diagnostic* is ``None`` when the
subprocess ran and its output was read, otherwise a short reason
covering every failure THIS CALL can observe -- missing script,
missing/broken ``node``, non-zero exit, timeout, unreadable output,
invalid JSON.
It does NOT cover a malformed source file: the TypeScript compiler
error-recovers, exits 0, and reports ``Classes: 0, Functions: 0``, so
this call cannot tell that from a file with no top-level symbols
(measured -- ``export class {{{`` behaves exactly so). That case is
exposed by ``NonPythonSource.js_ts_file_count`` being non-zero while
``js_ts_symbols`` is empty, not by a diagnostic. Promising otherwise
here would be the write-only-comfort version of the very defect this
module exists to close.
"""
if script_path is None:
script_path = get_project_root() / "scripts" / "serialize_js_ts_ast.py"
if not script_path.is_file():
return [], f"JS/TS serializer script not found: {script_path}"
with tempfile.TemporaryDirectory(prefix="impl-scan-jsts-") as tmp:
output_path = Path(tmp) / "ast.json"
cmd = [
sys.executable,
str(script_path),
"serialize",
"--source",
str(project_dir),
"--name",
name,
"--output",
str(output_path),
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
env=dict(env) if env is not None else None,
timeout=timeout,
)
except subprocess.TimeoutExpired:
return [], f"JS/TS serializer timed out after {timeout}s"
except OSError as exc:
return [], f"JS/TS serializer could not be launched: {exc}"
if result.returncode != 0:
# The LAST non-empty line, not a tail of five: when the child
# dies on a Python traceback the final line is the exception
# ("FileNotFoundError: ... 'node'") and the four above it are
# frame noise that buries it in an operator's stderr.
diagnostic_lines = [
line.strip()
for line in (result.stderr or result.stdout or "").splitlines()
if line.strip()
]
tail = (
diagnostic_lines[-1][:_DIAGNOSTIC_MAX_CHARS]
if diagnostic_lines
else f"exit code {result.returncode}"
)
return [], f"JS/TS serializer failed: {tail}"
try:
raw_text = output_path.read_text()
except OSError as exc:
return [], f"JS/TS serializer output unreadable: {exc}"
try:
data = json.loads(raw_text)
except json.JSONDecodeError as exc:
return [], f"JS/TS serializer produced invalid JSON: {exc}"
if not isinstance(data, dict):
return [], "JS/TS serializer produced a non-object JSON document"
return _symbols_from_ast_json(data), None
def collect_non_python_source(
project_dir: Path,
*,
name: str,
env: Mapping[str, str] | None = None,
timeout: float = 120.0,
) -> NonPythonSource:
"""Compose a :class:`NonPythonSource` for *project_dir*.
The one entry point :func:`implementation_scanner.build_project_source`
composes for the non-Python half of evidence -- it walks *project_dir*
once (:func:`_collect_non_python_files`), then invokes the JS/TS
serializer ONLY when the walk found at least one JS/TS-extension file
(never unconditionally: an all-HTML/CSS or all-Python project must
never pay for or diagnose a Node invocation it has no use for).
Args:
project_dir: Root directory to scan (already validated by the
caller -- this function does not check it exists).
name: Project name, forwarded to the JS/TS serializer for its own
diagnostics.
env: Optional explicit environment for the JS/TS serializer
subprocess (the injection seam tests use to exercise a broken
Node toolchain without monkeypatching); ``None`` inherits the
current process environment, the production default.
timeout: Seconds to wait for the JS/TS serializer subprocess.
"""
files = _collect_non_python_files(project_dir)
if not files:
return NonPythonSource()
basenames = tuple((path.stem, str(path)) for path in files)
js_ts_count = sum(1 for path in files if path.suffix in JS_TS_EXTENSIONS)
if not js_ts_count:
return NonPythonSource(basenames=basenames)
symbols, diagnostic = _run_js_ts_serializer(
project_dir, name=name, env=env, timeout=timeout
)
return NonPythonSource(
basenames=basenames,
js_ts_symbols=tuple(symbols),
js_ts_diagnostic=diagnostic,
js_ts_file_count=js_ts_count,
)