Initial LinuxCNC WebAssembly workspace snapshot
This commit is contained in:
16
scripts/build-wasm.sh
Executable file
16
scripts/build-wasm.sh
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
build_dir="${repo_root}/build-wasm"
|
||||
native_build_dir="${repo_root}/build-native"
|
||||
|
||||
emcmake cmake -S "${repo_root}/wasm-core" -B "${build_dir}" -G Ninja -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build "${build_dir}"
|
||||
|
||||
cmake -S "${repo_root}/wasm-core" -B "${native_build_dir}" -G Ninja -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build "${native_build_dir}"
|
||||
ctest --test-dir "${native_build_dir}" --output-on-failure
|
||||
printf 'wasm_build=%s\n' "${build_dir}/linuxcnc-sim-core.js"
|
||||
printf 'native_test=%s\n' "${native_build_dir}/sim_api_test"
|
||||
printf 'native_protocol_test=%s\n' "${native_build_dir}/sim_protocol_test"
|
||||
315
scripts/context_packet.py
Normal file
315
scripts/context_packet.py
Normal file
@@ -0,0 +1,315 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate bounded, auditable task context packets.
|
||||
|
||||
The selector applies byte and count limits before writing a packet. It never
|
||||
creates an over-budget packet for a later validator to reject.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
PROFILES = {
|
||||
"doc": {"tokens": 2048, "bytes": 8192, "evidence_bytes": 2048, "files": 8},
|
||||
"feature": {"tokens": 4096, "bytes": 16384, "evidence_bytes": 6144, "files": 16},
|
||||
"contract": {"tokens": 6144, "bytes": 24576, "evidence_bytes": 10240, "files": 24},
|
||||
"milestone": {"tokens": 8192, "bytes": 32768, "evidence_bytes": 14336, "files": 32},
|
||||
}
|
||||
|
||||
SEGMENTS = ("header", "baseline", "scope", "contracts", "evidence", "risks", "next", "forbidden")
|
||||
|
||||
# Compact codes keep the audit trail inside the packet budget. The human
|
||||
# explanation is stable and can be rendered by the reader without repeating
|
||||
# large strings in every packet.
|
||||
EXCLUSION_REASONS = {
|
||||
"E1": "duplicate_path",
|
||||
"E2": "evidence_item_bytes_exceed_budget",
|
||||
"E3": "evidence_file_count_exceed_budget",
|
||||
"E4": "evidence_bytes_exceed_budget",
|
||||
"E5": "context_remaining_bytes_insufficient",
|
||||
}
|
||||
|
||||
|
||||
class PacketError(ValueError):
|
||||
"""Raised when a packet cannot be generated without violating a budget."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Candidate:
|
||||
path: str
|
||||
size: int
|
||||
sha256: str | None
|
||||
kind: str
|
||||
priority: int
|
||||
required: bool
|
||||
|
||||
|
||||
def _json_bytes(value: Any) -> bytes:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _candidate(raw: dict[str, Any], base_dir: Path) -> Candidate:
|
||||
path_value = str(raw.get("path", ""))
|
||||
if not path_value:
|
||||
raise PacketError("candidate is missing path")
|
||||
path = Path(path_value)
|
||||
if "size" in raw:
|
||||
size = int(raw["size"])
|
||||
else:
|
||||
file_path = path if path.is_absolute() else base_dir / path
|
||||
try:
|
||||
size = file_path.stat().st_size
|
||||
except OSError as exc:
|
||||
raise PacketError(f"cannot stat evidence {path_value}: {exc}") from exc
|
||||
if size < 0:
|
||||
raise PacketError(f"evidence size must be non-negative: {path_value}")
|
||||
sha256 = raw.get("sha256")
|
||||
if sha256 is None and raw.get("hash_file", False):
|
||||
file_path = path if path.is_absolute() else base_dir / path
|
||||
sha256 = _sha256_file(file_path)
|
||||
return Candidate(
|
||||
path=path_value,
|
||||
size=size,
|
||||
sha256=str(sha256) if sha256 is not None else None,
|
||||
kind=str(raw.get("kind", "artifact")),
|
||||
priority=int(raw.get("priority", 0)),
|
||||
required=bool(raw.get("required", False)),
|
||||
)
|
||||
|
||||
|
||||
def _evidence_item(candidate: Candidate) -> dict[str, Any]:
|
||||
item: dict[str, Any] = {"path": candidate.path, "bytes": candidate.size, "kind": candidate.kind}
|
||||
if candidate.sha256:
|
||||
item["sha256"] = candidate.sha256
|
||||
return item
|
||||
|
||||
|
||||
def _packet_bytes(packet: dict[str, Any]) -> int:
|
||||
return len(_json_bytes(packet))
|
||||
|
||||
|
||||
def _context_bytes(packet: dict[str, Any], evidence_used: int) -> int:
|
||||
"""Return the bytes consumed by the envelope and referenced evidence.
|
||||
|
||||
Evidence is referenced by path in the JSON envelope, but the referenced
|
||||
payload is read into the task context by the consumer. Counting both
|
||||
here prevents a packet that is small on disk from expanding past the
|
||||
context limit when it is loaded.
|
||||
"""
|
||||
|
||||
return _packet_bytes(packet) + evidence_used
|
||||
|
||||
|
||||
def _finalize_packet_size(packet: dict[str, Any]) -> int:
|
||||
"""Store the self-consistent serialized size in the budget metadata."""
|
||||
|
||||
packet["budget"]["packet_bytes"] = 0
|
||||
for _ in range(4):
|
||||
packet_bytes = _packet_bytes(packet)
|
||||
if packet["budget"]["packet_bytes"] == packet_bytes:
|
||||
return packet_bytes
|
||||
packet["budget"]["packet_bytes"] = packet_bytes
|
||||
raise PacketError("could not stabilize serialized packet size")
|
||||
|
||||
|
||||
def _base_packet(input_data: dict[str, Any], context_id: str, packet_id: str) -> dict[str, Any]:
|
||||
packet: dict[str, Any] = {
|
||||
"context_id": context_id,
|
||||
"packet_id": packet_id,
|
||||
"task": str(input_data.get("task", "")),
|
||||
"header": dict(input_data.get("header", {})),
|
||||
"baseline": dict(input_data.get("baseline", {})),
|
||||
"scope": dict(input_data.get("scope", {})),
|
||||
"contracts": list(input_data.get("contracts", [])),
|
||||
"evidence": {"selected": [], "excluded": []},
|
||||
"risks": list(input_data.get("risks", [])),
|
||||
"next": list(input_data.get("next", []))[:3],
|
||||
"forbidden": list(input_data.get("forbidden", [])),
|
||||
}
|
||||
return packet
|
||||
|
||||
|
||||
def _budget_metadata(limits: dict[str, int], selected: list[dict[str, Any]], excluded: list[dict[str, Any]], evidence_used: int) -> dict[str, Any]:
|
||||
return {
|
||||
"profile": limits["profile"],
|
||||
"token_limit": limits["tokens"],
|
||||
"byte_limit": limits["bytes"],
|
||||
"evidence_byte_limit": limits["evidence_bytes"],
|
||||
"evidence_file_limit": limits["files"],
|
||||
"selected_evidence_bytes": evidence_used,
|
||||
"selected_evidence_files": len(selected),
|
||||
"excluded_evidence_files": len(excluded),
|
||||
# Reserve the complete decimal width of the configured limit while
|
||||
# selecting. This keeps the selection check conservative.
|
||||
"packet_bytes": limits["bytes"],
|
||||
}
|
||||
|
||||
|
||||
def generate_packet(
|
||||
input_data: dict[str, Any],
|
||||
*,
|
||||
context_id: str,
|
||||
packet_id: str,
|
||||
profile: str = "feature",
|
||||
total_bytes: int | None = None,
|
||||
evidence_bytes: int | None = None,
|
||||
max_files: int | None = None,
|
||||
base_dir: Path = Path("."),
|
||||
) -> dict[str, Any]:
|
||||
"""Select evidence under all budgets and return a packet within its limit."""
|
||||
|
||||
if profile not in PROFILES:
|
||||
raise PacketError(f"unknown profile: {profile}")
|
||||
limits = dict(PROFILES[profile])
|
||||
limits["profile"] = profile
|
||||
if total_bytes is not None:
|
||||
limits["bytes"] = int(total_bytes)
|
||||
if evidence_bytes is not None:
|
||||
limits["evidence_bytes"] = int(evidence_bytes)
|
||||
if max_files is not None:
|
||||
limits["files"] = int(max_files)
|
||||
if any(limits[key] < 0 for key in ("tokens", "bytes", "evidence_bytes", "files")):
|
||||
raise PacketError("budgets must be non-negative")
|
||||
|
||||
raw_candidates = input_data.get("candidates", [])
|
||||
if not isinstance(raw_candidates, list):
|
||||
raise PacketError("candidates must be a list")
|
||||
candidates = []
|
||||
for raw in raw_candidates:
|
||||
if not isinstance(raw, dict):
|
||||
raise PacketError("each candidate must be an object")
|
||||
candidates.append(_candidate(raw, base_dir))
|
||||
candidates.sort(key=lambda item: (-int(item.required), -item.priority, item.path))
|
||||
packet = _base_packet(input_data, context_id, packet_id)
|
||||
skeleton_bytes = _packet_bytes(packet)
|
||||
if skeleton_bytes > limits["bytes"]:
|
||||
raise PacketError(
|
||||
f"context skeleton exceeds byte budget ({skeleton_bytes}>{limits['bytes']}); "
|
||||
"reduce task metadata or use a larger profile"
|
||||
)
|
||||
selected: list[dict[str, Any]] = []
|
||||
excluded: list[dict[str, Any]] = []
|
||||
evidence_used = 0
|
||||
seen: set[str] = set()
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate.path in seen:
|
||||
excluded.append({"path": candidate.path, "reason_code": "E1"})
|
||||
continue
|
||||
seen.add(candidate.path)
|
||||
item = _evidence_item(candidate)
|
||||
reason: str | None = None
|
||||
if candidate.size > limits["evidence_bytes"]:
|
||||
reason = "E2"
|
||||
elif len(selected) >= limits["files"]:
|
||||
reason = "E3"
|
||||
elif evidence_used + candidate.size > limits["evidence_bytes"]:
|
||||
reason = "E4"
|
||||
else:
|
||||
trial = dict(packet)
|
||||
trial_selected = selected + [item]
|
||||
trial["evidence"] = {"selected": trial_selected, "excluded": excluded}
|
||||
trial["budget"] = _budget_metadata(
|
||||
limits, trial_selected, excluded, evidence_used + candidate.size
|
||||
)
|
||||
# This check is intentionally performed before selection is committed.
|
||||
trial_context_bytes = _context_bytes(trial, evidence_used + candidate.size)
|
||||
if trial_context_bytes > limits["bytes"]:
|
||||
reason = "E5"
|
||||
if reason:
|
||||
excluded.append({"path": candidate.path, "reason_code": reason})
|
||||
if candidate.required:
|
||||
raise PacketError(f"required evidence cannot fit ({reason}): {candidate.path}")
|
||||
continue
|
||||
selected.append(item)
|
||||
evidence_used += candidate.size
|
||||
|
||||
# The exclusion audit trail is part of the packet, so it can consume the
|
||||
# remaining context space. Remove optional selections until the complete
|
||||
# packet (selected evidence, exclusions and budget metadata) fits.
|
||||
while True:
|
||||
packet["evidence"] = {"selected": selected, "excluded": excluded}
|
||||
packet["budget"] = _budget_metadata(limits, selected, excluded, evidence_used)
|
||||
packet_bytes = _finalize_packet_size(packet)
|
||||
context_bytes = packet_bytes + evidence_used
|
||||
if packet_bytes <= limits["bytes"] and context_bytes <= limits["bytes"]:
|
||||
break
|
||||
removable_index = next(
|
||||
(index for index in range(len(selected) - 1, -1, -1)
|
||||
if not any(item["path"] == selected[index]["path"] and candidate.required
|
||||
for candidate in candidates)),
|
||||
None,
|
||||
)
|
||||
if removable_index is None:
|
||||
raise PacketError("context skeleton and required evidence exceed byte budget")
|
||||
removed = selected.pop(removable_index)
|
||||
evidence_used -= int(removed["bytes"])
|
||||
excluded.append({
|
||||
"path": removed["path"],
|
||||
"reason_code": "E5",
|
||||
})
|
||||
return packet
|
||||
|
||||
|
||||
def _load(path: Path) -> dict[str, Any]:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
value = json.load(handle)
|
||||
if not isinstance(value, dict):
|
||||
raise PacketError("input must be a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def main(argv: Iterable[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--input", required=True, type=Path, help="candidate/context JSON")
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
parser.add_argument("--context-id", required=True)
|
||||
parser.add_argument("--packet-id", required=True)
|
||||
parser.add_argument("--profile", choices=sorted(PROFILES), default="feature")
|
||||
parser.add_argument("--base-dir", type=Path, default=Path("."))
|
||||
parser.add_argument("--total-bytes", type=int)
|
||||
parser.add_argument("--evidence-bytes", type=int)
|
||||
parser.add_argument("--max-files", type=int)
|
||||
args = parser.parse_args(list(argv) if argv is not None else None)
|
||||
try:
|
||||
packet = generate_packet(
|
||||
_load(args.input),
|
||||
context_id=args.context_id,
|
||||
packet_id=args.packet_id,
|
||||
profile=args.profile,
|
||||
total_bytes=args.total_bytes,
|
||||
evidence_bytes=args.evidence_bytes,
|
||||
max_files=args.max_files,
|
||||
base_dir=args.base_dir,
|
||||
)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = _json_bytes(packet)
|
||||
# Do not expose a partially written packet to a concurrent validator.
|
||||
temporary = args.output.with_name(f".{args.output.name}.tmp")
|
||||
temporary.write_bytes(payload)
|
||||
if len(payload) != packet["budget"]["packet_bytes"]:
|
||||
raise PacketError("serialized packet size changed before write")
|
||||
temporary.replace(args.output)
|
||||
except (PacketError, OSError, json.JSONDecodeError) as exc:
|
||||
print(f"context packet generation failed: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
227
scripts/generate_config_resources.py
Normal file
227
scripts/generate_config_resources.py
Normal file
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate resource dependency graphs for every LinuxCNC simulation INI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
CONFIG_ROOT = Path("linuxcnc/configs/sim")
|
||||
RESOURCE_EXTENSIONS = {
|
||||
".ini": "ini", ".hal": "hal", ".tbl": "tool_table", ".var": "parameter_file",
|
||||
".ngc": "gcode", ".nc": "gcode", ".tap": "gcode", ".py": "python",
|
||||
".xml": "ui_descriptor", ".ui": "ui_descriptor", ".glade": "ui_descriptor",
|
||||
".tcl": "script", ".clp": "classicladder", ".pref": "preference",
|
||||
".prefs": "preference", ".dat": "data", ".jpg": "image", ".jpeg": "image",
|
||||
".png": "image", ".gif": "image", ".stl": "model", ".obj": "model",
|
||||
}
|
||||
KEY_KINDS = {
|
||||
"HALFILE": "hal", "POSTGUI_HALFILE": "hal", "TOOL_TABLE": "tool_table",
|
||||
"PARAMETER_FILE": "parameter_file", "POSITION_FILE": "data", "LOG_FILE": "log",
|
||||
"MDI_HISTORY_FILE": "data", "PREFERENCE_FILE_PATH": "preference",
|
||||
"INTRO_GRAPHIC": "image", "PROGRAM_PREFIX": "program_directory",
|
||||
"SUBROUTINE_PATH": "subroutine_directory", "USER_COMMAND": "user_command",
|
||||
"SHUTDOWN": "script", "APP": "application",
|
||||
}
|
||||
HAL_COMMANDS = {"source": "hal_source", "loadusr": "hal_user_component"}
|
||||
TOKEN_RE = re.compile(r"(?<![A-Za-z0-9_./~-])([^\s\"'=;,]+)")
|
||||
EXT_RE = re.compile(r"\.(?:ini|hal|tbl|var|ngc|nc|tap|py|xml|ui|glade|tcl|clp|pref|prefs|dat|png|jpg|jpeg|gif|stl|obj)$", re.I)
|
||||
_SHA_CACHE: dict[Path, str] = {}
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
path = path.resolve()
|
||||
cached = _SHA_CACHE.get(path)
|
||||
if cached:
|
||||
return cached
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
value = digest.hexdigest()
|
||||
_SHA_CACHE[path] = value
|
||||
return value
|
||||
|
||||
|
||||
def rel(root: Path, path: Path) -> str:
|
||||
return path.resolve().relative_to(root.resolve()).as_posix()
|
||||
|
||||
|
||||
def clean_value(value: str) -> str:
|
||||
value = value.split("#", 1)[0].split(";", 1)[0].strip().strip('"').strip("'")
|
||||
return value
|
||||
|
||||
|
||||
def parse_ini(path: Path) -> list[tuple[str, str, int]]:
|
||||
section = ""
|
||||
rows: list[tuple[str, str, int]] = []
|
||||
for line_number, raw in enumerate(path.read_text(encoding="utf-8", errors="strict").splitlines(), 1):
|
||||
line = raw.strip()
|
||||
if not line or line.startswith(("#", ";")):
|
||||
continue
|
||||
if line.startswith("[") and "]" in line:
|
||||
section = line[1:line.index("]")].strip().upper()
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
rows.append((section, key.strip().upper(), clean_value(value)))
|
||||
return rows
|
||||
|
||||
|
||||
def resolve_reference(repo_root: Path, config_root: Path, base: Path, value: str) -> tuple[Path | None, str]:
|
||||
value = clean_value(value)
|
||||
if not value:
|
||||
return None, "empty"
|
||||
if value.startswith(("~", "$", "${")):
|
||||
return None, "host_or_variable_path"
|
||||
candidate = Path(value)
|
||||
if not candidate.is_absolute():
|
||||
candidate = base / candidate
|
||||
candidate = candidate.resolve()
|
||||
try:
|
||||
candidate.relative_to(repo_root.resolve())
|
||||
except ValueError:
|
||||
return None, "outside_repo"
|
||||
return candidate, "path"
|
||||
|
||||
|
||||
def tokens_with_extensions(value: str) -> list[str]:
|
||||
return [match.group(1) for match in TOKEN_RE.finditer(value) if EXT_RE.search(match.group(1))]
|
||||
|
||||
|
||||
def config_files(config_root: Path) -> list[Path]:
|
||||
return sorted(path for path in config_root.rglob("*") if path.is_file())
|
||||
|
||||
|
||||
def add_row(rows: list[dict[str, object]], repo_root: Path, config_root: Path, entrypoint: Path,
|
||||
target: Path | None, raw: str, kind: str, edge_kind: str, line: int, status: str | None = None,
|
||||
origin: Path | None = None) -> None:
|
||||
entry_id = rel(repo_root, entrypoint)
|
||||
if target is None:
|
||||
target_path = raw
|
||||
target_sha = None
|
||||
target_status = status or "unresolved"
|
||||
else:
|
||||
target_path = rel(repo_root, target) if target.exists() else rel(repo_root, target)
|
||||
target_sha = sha256(target) if target.is_file() else None
|
||||
target_status = "resolved_file" if target.is_file() else ("resolved_directory" if target.is_dir() else "missing")
|
||||
rows.append({
|
||||
"resource_source_id": f"resource:{entry_id}:{line}:{edge_kind}:{target_path}",
|
||||
"entrypoint": entry_id,
|
||||
"path": target_path,
|
||||
"resource_kind": kind,
|
||||
"edge_kind": edge_kind,
|
||||
"line": line,
|
||||
"raw_value": raw,
|
||||
"source_sha": sha256(origin or entrypoint),
|
||||
"target_sha256": target_sha,
|
||||
"origin_path": rel(repo_root, origin or entrypoint),
|
||||
"parse_status": target_status,
|
||||
})
|
||||
|
||||
|
||||
def scan_entrypoint(repo_root: Path, config_root: Path, entrypoint: Path) -> list[dict[str, object]]:
|
||||
rows: list[dict[str, object]] = []
|
||||
ini_rows = parse_ini(entrypoint)
|
||||
row_numbers = {(section, key, value): index for index, (section, key, value) in enumerate(ini_rows, 1)}
|
||||
for section, key, value in ini_rows:
|
||||
if key in KEY_KINDS:
|
||||
kind = KEY_KINDS[key]
|
||||
if key in {"SUBROUTINE_PATH"}:
|
||||
values = value.split(":")
|
||||
elif key == "PROGRAM_PREFIX":
|
||||
values = [value]
|
||||
else:
|
||||
values = [value]
|
||||
for item in values:
|
||||
target, status = resolve_reference(repo_root, config_root, entrypoint.parent, item)
|
||||
add_row(rows, repo_root, config_root, entrypoint, target, item, kind, f"ini:{section}:{key}", row_numbers[(section, key, value)], status if target is None else None)
|
||||
elif section == "FILTER" and key not in {"PROGRAM_EXTENSION"}:
|
||||
for token in tokens_with_extensions(value):
|
||||
target, status = resolve_reference(repo_root, config_root, entrypoint.parent, token)
|
||||
add_row(rows, repo_root, config_root, entrypoint, target, token, "filter", "ini:filter_command", row_numbers[(section, key, value)], status if target is None else None)
|
||||
elif key in {"EMBED_TAB_COMMAND", "DISPLAY", "HALCMD", "TASK", "EMCIO"}:
|
||||
for token in tokens_with_extensions(value):
|
||||
target, status = resolve_reference(repo_root, config_root, entrypoint.parent, token)
|
||||
add_row(rows, repo_root, config_root, entrypoint, target, token, RESOURCE_EXTENSIONS.get(Path(token).suffix.lower(), "external"), f"ini:{section}:{key}", row_numbers[(section, key, value)], status if target is None else None)
|
||||
|
||||
seen_paths = {str(row["path"]) for row in rows}
|
||||
for path in sorted(path for path in entrypoint.parent.rglob("*") if path.is_file() and path != entrypoint):
|
||||
path_key = rel(repo_root, path)
|
||||
if path.suffix.lower() in RESOURCE_EXTENSIONS and path_key not in seen_paths:
|
||||
add_row(rows, repo_root, config_root, entrypoint, path, rel(repo_root, path), RESOURCE_EXTENSIONS[path.suffix.lower()], "directory_member", 0)
|
||||
seen_paths.add(path_key)
|
||||
|
||||
seen_ids = {str(existing["resource_source_id"]) for existing in rows}
|
||||
for row in parse_hal_references(repo_root, config_root, entrypoint, rows):
|
||||
if row["resource_source_id"] not in seen_ids:
|
||||
rows.append(row)
|
||||
seen_ids.add(str(row["resource_source_id"]))
|
||||
return rows
|
||||
|
||||
|
||||
def parse_hal_references(repo_root: Path, config_root: Path, entrypoint: Path, rows: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||
output: list[dict[str, object]] = []
|
||||
hal_paths = [Path(str(row["path"])) for row in rows if row["resource_kind"] == "hal" and row["parse_status"] == "resolved_file"]
|
||||
for relative_path in hal_paths:
|
||||
hal = repo_root / relative_path
|
||||
for line_number, raw in enumerate(hal.read_text(encoding="utf-8", errors="strict").splitlines(), 1):
|
||||
parts = raw.strip().split()
|
||||
if not parts or parts[0] not in HAL_COMMANDS:
|
||||
continue
|
||||
command = parts[0]
|
||||
candidates = tokens_with_extensions(raw)
|
||||
for token in candidates:
|
||||
target, status = resolve_reference(repo_root, config_root, hal.parent, token)
|
||||
add_row(output, repo_root, config_root, entrypoint, target, token, RESOURCE_EXTENSIONS.get(Path(token).suffix.lower(), "external"), f"hal:{command}", line_number, status if target is None else None, hal)
|
||||
return output
|
||||
|
||||
|
||||
def generate(repo_root: Path, output: Path, report_path: Path) -> tuple[int, str]:
|
||||
config_root = repo_root / CONFIG_ROOT
|
||||
if not config_root.is_dir():
|
||||
raise FileNotFoundError(f"required config root is missing: {CONFIG_ROOT}")
|
||||
entrypoints = sorted(path for path in config_root.rglob("*.ini") if path.is_file())
|
||||
rows: list[dict[str, object]] = []
|
||||
for entrypoint in entrypoints:
|
||||
rows.extend(scan_entrypoint(repo_root, config_root, entrypoint))
|
||||
rows.sort(key=lambda row: str(row["resource_source_id"]))
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with output.open("w", encoding="utf-8", newline="\n") as handle:
|
||||
for row in rows:
|
||||
handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n")
|
||||
digest = sha256(output)
|
||||
report = {
|
||||
"schema": "config-resources-report.v1",
|
||||
"entrypoints": len(entrypoints),
|
||||
"resources": len(rows),
|
||||
"missing": sum(1 for row in rows if row["parse_status"] == "missing"),
|
||||
"unresolved": sum(1 for row in rows if row["parse_status"] in {"unresolved", "host_or_variable_path", "outside_repo"}),
|
||||
"output_sha256": digest,
|
||||
"entrypoint_paths": [rel(repo_root, path) for path in entrypoints],
|
||||
}
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path.write_text(json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8")
|
||||
return len(rows), digest
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repo-root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||
parser.add_argument("--output", type=Path, default=Path("coverage/config-resources.jsonl"))
|
||||
parser.add_argument("--report", type=Path, default=Path("coverage/config-resources.report.json"))
|
||||
args = parser.parse_args()
|
||||
root = args.repo_root.resolve()
|
||||
count, digest = generate(root, root / args.output, root / args.report)
|
||||
print(f"config_resources={count} sha256={digest}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
167
scripts/generate_dependency_closure.py
Normal file
167
scripts/generate_dependency_closure.py
Normal file
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a deterministic dependency graph from normalized coverage records."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
NORMALIZED = Path("coverage/normalized-sources.jsonl")
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def read_jsonl(path: Path) -> list[dict[str, object]]:
|
||||
rows: list[dict[str, object]] = []
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
for line_number, line in enumerate(handle, 1):
|
||||
if not line.strip():
|
||||
continue
|
||||
row = json.loads(line)
|
||||
if not isinstance(row, dict):
|
||||
raise ValueError(f"{path}:{line_number}: expected object")
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def canonical_path(value: object) -> str | None:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
raw = value.replace("\\", "/").strip()
|
||||
if raw.startswith("/"):
|
||||
return None
|
||||
parts: list[str] = []
|
||||
for part in raw.split("/"):
|
||||
if part in ("", "."):
|
||||
continue
|
||||
if part == "..":
|
||||
if not parts:
|
||||
return None
|
||||
parts.pop()
|
||||
else:
|
||||
parts.append(part)
|
||||
return "/".join(parts) or None
|
||||
|
||||
|
||||
def generate(root: Path, output: Path, report_path: Path) -> tuple[int, str]:
|
||||
normalized_path = root / NORMALIZED
|
||||
if not normalized_path.is_file():
|
||||
raise FileNotFoundError(f"required normalized index is missing: {NORMALIZED}")
|
||||
records = read_jsonl(normalized_path)
|
||||
file_ids = {
|
||||
str(row["path"]): str(row["source_id"])
|
||||
for row in records if row.get("origin_index") == "source" and row.get("path")
|
||||
}
|
||||
nodes = {str(row["source_id"]) for row in records}
|
||||
edges: list[dict[str, object]] = []
|
||||
edge_counts: Counter[str] = Counter()
|
||||
duplicate_edges: list[dict[str, object]] = []
|
||||
unresolved: list[dict[str, object]] = []
|
||||
|
||||
def target_for(path_value: object, status: str = "unresolved") -> tuple[str, str]:
|
||||
path = canonical_path(path_value)
|
||||
if path and path in file_ids:
|
||||
return file_ids[path], "resolved"
|
||||
if path and (root / path).is_file():
|
||||
return f"unindexed:{path}", "unindexed"
|
||||
return f"unresolved:{path or str(path_value) or 'empty'}", status
|
||||
|
||||
def add_edge(from_id: str, to_id: str, edge_kind: str, record: dict[str, object], status: str, raw_value: object = None) -> None:
|
||||
key = f"{from_id}|{edge_kind}|{to_id}|{record.get('line', '')}"
|
||||
edge_counts[key] += 1
|
||||
if edge_counts[key] > 1:
|
||||
duplicate_edges.append({"key": key, "occurrence": edge_counts[key], "source_id": record.get("source_id")})
|
||||
edge_id = f"edge:{key}" + (f"~dup{edge_counts[key]}" if edge_counts[key] > 1 else "")
|
||||
row = {
|
||||
"edge_id": edge_id,
|
||||
"from_source_id": from_id,
|
||||
"to_source_id": to_id,
|
||||
"edge_kind": edge_kind,
|
||||
"raw_value": raw_value,
|
||||
"evidence_path": record.get("hash_path") or record.get("path"),
|
||||
"evidence_line": record.get("line"),
|
||||
"evidence_source_id": record.get("source_id"),
|
||||
"parse_status": status,
|
||||
}
|
||||
edges.append(row)
|
||||
nodes.add(to_id)
|
||||
if status not in {"resolved", "parsed"}:
|
||||
unresolved.append({"edge_id": edge_id, "to_source_id": to_id, "status": status, "raw_value": raw_value})
|
||||
|
||||
for record in records:
|
||||
source_id = str(record["source_id"])
|
||||
index_name = str(record.get("origin_index"))
|
||||
if index_name == "resource":
|
||||
origin_path = canonical_path(record.get("hash_path"))
|
||||
from_id = file_ids.get(origin_path, source_id)
|
||||
target_id, status = target_for(record.get("path"), str(record.get("target_status") or "unresolved"))
|
||||
add_edge(from_id, target_id, f"resource:{record.get('edge_kind') or record.get('resource_kind') or 'reference'}", record, status, record.get("raw_value"))
|
||||
elif index_name == "test":
|
||||
entry_path = canonical_path(record.get("path"))
|
||||
entry_file = file_ids.get(entry_path)
|
||||
if entry_file:
|
||||
add_edge(entry_file, source_id, "test_entrypoint", record, "resolved", record.get("command"))
|
||||
for field, edge_kind in (("inputs", "test_input"), ("expected", "test_expected"), ("config_refs", "test_config_ref")):
|
||||
values = record.get(field, [])
|
||||
if not isinstance(values, list):
|
||||
continue
|
||||
for value in sorted(str(item) for item in values):
|
||||
target_id, status = target_for(value)
|
||||
add_edge(source_id, target_id, edge_kind, record, status, value)
|
||||
elif index_name in {"symbol", "document"}:
|
||||
related = record.get("related_source_ids", [])
|
||||
if isinstance(related, list):
|
||||
for related_id in sorted(str(item) for item in related):
|
||||
add_edge(related_id, source_id, "declares" if index_name == "symbol" else "documents", record, "resolved", record.get("path"))
|
||||
|
||||
edges.sort(key=lambda row: str(row["edge_id"]))
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with output.open("w", encoding="utf-8", newline="\n") as handle:
|
||||
for row in edges:
|
||||
handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n")
|
||||
digest = sha256(output)
|
||||
incoming = Counter(str(edge["to_source_id"]) for edge in edges)
|
||||
outgoing = Counter(str(edge["from_source_id"]) for edge in edges)
|
||||
orphan_nodes = sorted(node for node in nodes if not incoming[node] and not outgoing[node] and not node.startswith(("unresolved:", "unindexed:")))
|
||||
report = {
|
||||
"schema": "dependency-closure-report.v1",
|
||||
"normalized_input_sha256": sha256(normalized_path),
|
||||
"nodes": len(nodes),
|
||||
"edges": len(edges),
|
||||
"resolved_edges": sum(1 for edge in edges if edge["parse_status"] == "resolved"),
|
||||
"unresolved_edges": len(unresolved),
|
||||
"edge_kinds": dict(sorted(Counter(str(edge["edge_kind"]) for edge in edges).items())),
|
||||
"duplicate_edges": duplicate_edges,
|
||||
"unresolved": unresolved,
|
||||
"orphan_nodes": orphan_nodes,
|
||||
"output_sha256": digest,
|
||||
}
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path.write_text(json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8")
|
||||
return len(edges), digest
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repo-root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||
parser.add_argument("--output", type=Path, default=Path("coverage/dependency-closure.jsonl"))
|
||||
parser.add_argument("--report", type=Path, default=Path("coverage/dependency-closure.report.json"))
|
||||
args = parser.parse_args()
|
||||
root = args.repo_root.resolve()
|
||||
count, digest = generate(root, root / args.output, root / args.report)
|
||||
print(f"dependency_edges={count} sha256={digest}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
241
scripts/generate_document_capabilities.py
Normal file
241
scripts/generate_document_capabilities.py
Normal file
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a deterministic, source-addressable LinuxCNC document index.
|
||||
|
||||
The index is intentionally a source index, not a coverage claim. Every
|
||||
heading in the Master Documentation include graph and every heading in the
|
||||
project's migration documents becomes a planned capability candidate. Files
|
||||
that cannot be classified are retained with ``needs_review`` rather than
|
||||
silently discarded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
MASTER = Path("linuxcnc/docs/src/Master_Documentation.adoc")
|
||||
PROJECT_DOCS = (
|
||||
Path("docs/linuxcnc-web-wasm-source-map.md"),
|
||||
Path("docs/linuxcnc-web-wasm-simulation-coverage.md"),
|
||||
)
|
||||
INCLUDE_RE = re.compile(r"^\s*include::([^\[]+)\[.*\]\s*$")
|
||||
ASCIIDOC_ANCHOR_RE = re.compile(r"^\s*\[\[([^,\]]+)(?:,[^\]]*)?\]\]\s*$")
|
||||
ASCIIDOC_BLOCK_ID_RE = re.compile(r"^\s*\[#([^\]]+)\]\s*$")
|
||||
ASCIIDOC_HEADING_RE = re.compile(r"^(={1,6})\s+(.+?)\s*$")
|
||||
MARKDOWN_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*#*\s*$")
|
||||
SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
DOMAIN_RULES = (
|
||||
("getting-started", "getting_started"),
|
||||
("user", "user"),
|
||||
("config", "configuration"),
|
||||
("hal", "hal"),
|
||||
("ladder", "classicladder"),
|
||||
("motion", "motion"),
|
||||
("gcode", "gcode"),
|
||||
("remap", "remap"),
|
||||
("gui", "gui"),
|
||||
("plasma", "plasma"),
|
||||
("tooldatabase", "tooldata"),
|
||||
("drivers", "real_hardware"),
|
||||
("examples", "examples"),
|
||||
("integrator", "integrator"),
|
||||
("install", "installation"),
|
||||
("source-highlight", "documentation"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Heading:
|
||||
level: int
|
||||
title: str
|
||||
anchor: str | None
|
||||
line: int
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def slugify(title: str) -> str:
|
||||
value = title.lower().replace("&", " and ")
|
||||
value = SLUG_RE.sub("-", value).strip("-")
|
||||
return value or "section"
|
||||
|
||||
|
||||
def parse_headings(path: Path) -> list[Heading]:
|
||||
lines = path.read_text(encoding="utf-8", errors="strict").splitlines()
|
||||
headings: list[Heading] = []
|
||||
pending_anchor: str | None = None
|
||||
for line_number, line in enumerate(lines, 1):
|
||||
anchor_match = ASCIIDOC_ANCHOR_RE.match(line) or ASCIIDOC_BLOCK_ID_RE.match(line)
|
||||
if anchor_match:
|
||||
pending_anchor = anchor_match.group(1).strip()
|
||||
continue
|
||||
match = ASCIIDOC_HEADING_RE.match(line) or MARKDOWN_HEADING_RE.match(line)
|
||||
if not match:
|
||||
continue
|
||||
headings.append(Heading(len(match.group(1)), match.group(2).strip(), pending_anchor, line_number))
|
||||
pending_anchor = None
|
||||
return headings
|
||||
|
||||
|
||||
def include_graph(repo_root: Path, master: Path = MASTER) -> tuple[list[Path], list[dict[str, str]]]:
|
||||
ordered: list[Path] = []
|
||||
seen: set[Path] = set()
|
||||
missing: list[dict[str, str]] = []
|
||||
|
||||
def visit(path: Path) -> None:
|
||||
path = path.resolve()
|
||||
if path in seen:
|
||||
return
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"included documentation file is missing: {path}")
|
||||
seen.add(path)
|
||||
ordered.append(path)
|
||||
for line in path.read_text(encoding="utf-8", errors="strict").splitlines():
|
||||
match = INCLUDE_RE.match(line)
|
||||
if match:
|
||||
include_path = (path.parent / match.group(1)).resolve()
|
||||
if not include_path.is_file():
|
||||
missing.append({
|
||||
"document": relative_path(repo_root, path),
|
||||
"include": match.group(1),
|
||||
"resolved_path": relative_path(repo_root, include_path),
|
||||
})
|
||||
continue
|
||||
visit(include_path)
|
||||
|
||||
visit((repo_root / master).resolve())
|
||||
return ordered, missing
|
||||
|
||||
|
||||
def classify(path: Path, title: str) -> tuple[str, str]:
|
||||
lowered = "/".join(path.parts).lower()
|
||||
for token, domain in DOMAIN_RULES:
|
||||
if token in lowered:
|
||||
return domain, "mapped"
|
||||
title_lower = title.lower()
|
||||
if any(token in title_lower for token in ("g-code", "m-code", "o-code", "rs274")):
|
||||
return "gcode", "mapped"
|
||||
if any(token in title_lower for token in ("hardware", "driver", "mesa", "gpio")):
|
||||
return "real_hardware", "needs_review"
|
||||
if path.suffix.lower() in {".md", ".adoc"} and path.as_posix().startswith("docs/"):
|
||||
return "web_wasm_governance", "mapped"
|
||||
return "unclassified", "needs_review"
|
||||
|
||||
|
||||
def relative_path(repo_root: Path, path: Path) -> str:
|
||||
return path.resolve().relative_to(repo_root.resolve()).as_posix()
|
||||
|
||||
|
||||
def make_entries(repo_root: Path) -> tuple[list[dict[str, object]], dict[str, object]]:
|
||||
paths, missing_includes = include_graph(repo_root)
|
||||
for project_doc in PROJECT_DOCS:
|
||||
path = (repo_root / project_doc).resolve()
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"project migration document is missing: {project_doc}")
|
||||
if path not in paths:
|
||||
paths.append(path)
|
||||
|
||||
entries: list[dict[str, object]] = []
|
||||
report: dict[str, object] = {
|
||||
"schema": "document-capabilities-report.v1",
|
||||
"documents": len(paths),
|
||||
"headings": 0,
|
||||
"unclassified": [],
|
||||
"duplicate_ids": [],
|
||||
"empty_documents": [],
|
||||
"missing_includes": missing_includes,
|
||||
}
|
||||
used_ids: set[str] = set()
|
||||
for path in paths:
|
||||
relative = relative_path(repo_root, path)
|
||||
document_sha = sha256(path)
|
||||
headings = parse_headings(path)
|
||||
if not headings:
|
||||
report["empty_documents"].append(relative)
|
||||
headings = [Heading(1, path.stem, None, 1)]
|
||||
anchor_counts: dict[str, int] = {}
|
||||
parent_stack: list[tuple[int, str]] = []
|
||||
for heading in headings:
|
||||
base_anchor = heading.anchor or slugify(heading.title)
|
||||
anchor_counts[base_anchor] = anchor_counts.get(base_anchor, 0) + 1
|
||||
anchor = base_anchor if anchor_counts[base_anchor] == 1 else f"{base_anchor}-{anchor_counts[base_anchor]}"
|
||||
capability_id = f"doc:{relative}#{anchor}"
|
||||
if capability_id in used_ids:
|
||||
report["duplicate_ids"].append(capability_id)
|
||||
continue
|
||||
used_ids.add(capability_id)
|
||||
while parent_stack and parent_stack[-1][0] >= heading.level:
|
||||
parent_stack.pop()
|
||||
parent_anchor = parent_stack[-1][1] if parent_stack else None
|
||||
parent_stack.append((heading.level, anchor))
|
||||
domain, mapping_status = classify(path, heading.title)
|
||||
entry = {
|
||||
"capability_source_id": capability_id,
|
||||
"document": relative,
|
||||
"anchor": anchor,
|
||||
"title": heading.title,
|
||||
"domain": domain,
|
||||
"source_sha": document_sha,
|
||||
"line": heading.line,
|
||||
"level": heading.level,
|
||||
"parent_anchor": parent_anchor,
|
||||
"mapping_status": mapping_status,
|
||||
"sim_status": "planned",
|
||||
"web_status": "planned",
|
||||
}
|
||||
entries.append(entry)
|
||||
report["headings"] += 1
|
||||
if mapping_status != "mapped":
|
||||
report["unclassified"].append({"capability_source_id": capability_id, "domain": domain})
|
||||
entries.sort(key=lambda item: str(item["capability_source_id"]))
|
||||
return entries, report
|
||||
|
||||
|
||||
def write_json(path: Path, value: object) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def write_jsonl(path: Path, entries: Iterable[dict[str, object]]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8", newline="\n") as handle:
|
||||
for entry in entries:
|
||||
handle.write(json.dumps(entry, ensure_ascii=False, sort_keys=True, separators=(",", ":")))
|
||||
handle.write("\n")
|
||||
|
||||
|
||||
def generate(repo_root: Path, output: Path, report_path: Path) -> tuple[int, str]:
|
||||
entries, report = make_entries(repo_root)
|
||||
write_jsonl(output, entries)
|
||||
report["output_sha256"] = sha256(output)
|
||||
write_json(report_path, report)
|
||||
return len(entries), str(report["output_sha256"])
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repo-root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||
parser.add_argument("--output", type=Path, default=Path("coverage/document-capabilities.jsonl"))
|
||||
parser.add_argument("--report", type=Path, default=Path("coverage/document-capabilities.report.json"))
|
||||
args = parser.parse_args()
|
||||
root = args.repo_root.resolve()
|
||||
count, digest = generate(root, root / args.output, root / args.report)
|
||||
print(f"document_capabilities={count} sha256={digest}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
145
scripts/generate_source_inventory.py
Executable file
145
scripts/generate_source_inventory.py
Executable file
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the reproducible LinuxCNC source and coverage seed indexes.
|
||||
|
||||
The source index is deliberately mechanical: every file in a declared input
|
||||
root is recorded with its size and digest. The coverage seed is not an
|
||||
implementation claim; entries remain ``planned`` until an owner assigns a
|
||||
feature, adapter, test and oracle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
ROOTS = (
|
||||
("doc", Path("linuxcnc/docs")),
|
||||
("source", Path("linuxcnc/src")),
|
||||
("config", Path("linuxcnc/configs/sim")),
|
||||
("test", Path("linuxcnc/tests")),
|
||||
("unit_test", Path("linuxcnc/unit_tests")),
|
||||
("implementation", Path("wasm-core")),
|
||||
("implementation", Path("web")),
|
||||
)
|
||||
BUILD_FILES = (
|
||||
Path("linuxcnc/VERSION"),
|
||||
Path("linuxcnc/meson.build"),
|
||||
)
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def source_kind(root_kind: str, path: Path) -> str:
|
||||
if root_kind == "implementation":
|
||||
if path.suffix in {".h", ".hpp"}:
|
||||
return "web_wasm_interface"
|
||||
if path.suffix in {".c", ".cc", ".cpp", ".mjs", ".js"}:
|
||||
return "web_wasm_implementation"
|
||||
return "web_wasm_build_or_test"
|
||||
if root_kind == "doc":
|
||||
return "documentation"
|
||||
if root_kind in {"test", "unit_test"}:
|
||||
return "test"
|
||||
if root_kind == "config":
|
||||
return "simulation_config"
|
||||
if path.name.endswith(".comp"):
|
||||
return "hal_component_source"
|
||||
return "source"
|
||||
|
||||
|
||||
def iter_files(repo_root: Path) -> Iterable[tuple[str, Path]]:
|
||||
for root_kind, relative_root in ROOTS:
|
||||
root = repo_root / relative_root
|
||||
if not root.is_dir():
|
||||
raise FileNotFoundError(f"required inventory root is missing: {relative_root}")
|
||||
for path in sorted(path for path in root.rglob("*") if path.is_file()):
|
||||
yield root_kind, path
|
||||
for relative_path in BUILD_FILES:
|
||||
path = repo_root / relative_path
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"required build source is missing: {relative_path}")
|
||||
yield "build", path
|
||||
|
||||
|
||||
def make_entry(repo_root: Path, root_kind: str, path: Path) -> dict[str, object]:
|
||||
relative = path.relative_to(repo_root).as_posix()
|
||||
source_id = f"{root_kind}:{relative}"
|
||||
return {
|
||||
"source_id": source_id,
|
||||
"path": relative,
|
||||
"kind": source_kind(root_kind, path),
|
||||
"bytes": path.stat().st_size,
|
||||
"sha256": sha256(path),
|
||||
}
|
||||
|
||||
|
||||
def make_manifest_entry(source: dict[str, object]) -> dict[str, object]:
|
||||
source_id = str(source["source_id"])
|
||||
return {
|
||||
"id": f"seed:{source_id}",
|
||||
"feature_id": None,
|
||||
"source": source_id,
|
||||
"source_sha": source["sha256"],
|
||||
"domain": "unassigned",
|
||||
"kind": source["kind"],
|
||||
"adaptation": None,
|
||||
"dependencies": [],
|
||||
"inputs": [source["path"]],
|
||||
"outputs": [],
|
||||
"errors": [],
|
||||
"sim_status": "planned",
|
||||
"web_status": "planned",
|
||||
"test_id": None,
|
||||
"oracle": None,
|
||||
"golden": None,
|
||||
"difference": None,
|
||||
"coverage_dimensions": [],
|
||||
"context_id": None,
|
||||
"exclusion_reason": None,
|
||||
"mapping_status": "needs_owner",
|
||||
}
|
||||
|
||||
|
||||
def write_jsonl(path: Path, entries: Iterable[dict[str, object]]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8", newline="\n") as handle:
|
||||
for entry in entries:
|
||||
handle.write(json.dumps(entry, ensure_ascii=False, sort_keys=True, separators=(",", ":")))
|
||||
handle.write("\n")
|
||||
|
||||
|
||||
def generate(repo_root: Path, source_index: Path, manifest: Path) -> tuple[int, int]:
|
||||
sources = [make_entry(repo_root, kind, path) for kind, path in iter_files(repo_root)]
|
||||
sources.sort(key=lambda item: str(item["source_id"]))
|
||||
write_jsonl(source_index, sources)
|
||||
write_jsonl(manifest, (make_manifest_entry(source) for source in sources))
|
||||
return len(sources), len(sources)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repo-root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||
parser.add_argument("--source-index", type=Path, default=Path("coverage/source-index.jsonl"))
|
||||
parser.add_argument("--manifest", type=Path, default=Path("coverage/manifest.jsonl"))
|
||||
args = parser.parse_args()
|
||||
source_count, manifest_count = generate(
|
||||
args.repo_root.resolve(),
|
||||
args.repo_root.resolve() / args.source_index,
|
||||
args.repo_root.resolve() / args.manifest,
|
||||
)
|
||||
print(f"source_index={source_count} manifest_seed={manifest_count}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
159
scripts/generate_source_symbols.py
Normal file
159
scripts/generate_source_symbols.py
Normal file
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate deterministic source symbol and HAL component entry indexes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
ROOTS = (
|
||||
Path("linuxcnc/src/emc"),
|
||||
Path("linuxcnc/src/hal"),
|
||||
Path("linuxcnc/lib/python"),
|
||||
)
|
||||
EXTENSIONS = {
|
||||
".c": "c", ".h": "c", ".cc": "cpp", ".hh": "cpp", ".cpp": "cpp",
|
||||
".hpp": "cpp", ".cxx": "cpp", ".hxx": "cpp", ".py": "python", ".comp": "hal_comp",
|
||||
}
|
||||
IDENT = r"[A-Za-z_][A-Za-z0-9_]*"
|
||||
KEYWORDS = {"if", "for", "while", "switch", "catch", "sizeof", "return", "do"}
|
||||
DEFINE_RE = re.compile(rf"^\s*#\s*define\s+({IDENT})\b")
|
||||
C_FUNCTION_RE = re.compile(
|
||||
rf"^\s*(?:(?:static|inline|extern|const|volatile|unsigned|signed|long|short|struct|class|enum|[A-Za-z_][\w:*&<>]*)\s+)+({IDENT})\s*\([^;{{}}]*\)\s*(?:const\s*)?(?:\{{.*|;)?\s*$"
|
||||
)
|
||||
C_TYPE_RE = re.compile(rf"^\s*(?:typedef\s+)?(?:struct|class|enum|union)\s+({IDENT})\b")
|
||||
PY_DEF_RE = re.compile(rf"^\s*(async\s+)?def\s+({IDENT})\s*\(")
|
||||
PY_CLASS_RE = re.compile(rf"^\s*class\s+({IDENT})\b")
|
||||
COMP_PATTERNS = (
|
||||
("component", re.compile(rf"^\s*component\s+({IDENT})\b")),
|
||||
("function", re.compile(rf"^\s*function\s+({IDENT})\b")),
|
||||
("option", re.compile(rf"^\s*option\s+({IDENT})\b")),
|
||||
("pin", re.compile(rf"^\s*pin\s+(?:in|out|io)\s+{IDENT}\s+({IDENT})\b")),
|
||||
("param", re.compile(rf"^\s*param\s+(?:r|rw)\s+{IDENT}\s+({IDENT})\b")),
|
||||
("variable", re.compile(rf"^\s*variable\s+{IDENT}\s+({IDENT})\b")),
|
||||
)
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def files(repo_root: Path) -> Iterable[Path]:
|
||||
for root in ROOTS:
|
||||
absolute = repo_root / root
|
||||
if not absolute.is_dir():
|
||||
raise FileNotFoundError(f"required symbol root is missing: {root}")
|
||||
for path in sorted(path for path in absolute.rglob("*") if path.is_file() and path.suffix.lower() in EXTENSIONS):
|
||||
yield path
|
||||
|
||||
|
||||
def relative(repo_root: Path, path: Path) -> str:
|
||||
return path.resolve().relative_to(repo_root.resolve()).as_posix()
|
||||
|
||||
|
||||
def add_symbol(rows: list[dict[str, object]], repo_root: Path, path: Path, language: str, symbol: str,
|
||||
symbol_kind: str, line: int, source_sha: str, **extra: object) -> None:
|
||||
rows.append({
|
||||
"symbol_source_id": f"symbol:{relative(repo_root, path)}:{line}:{symbol_kind}:{symbol}",
|
||||
"path": relative(repo_root, path),
|
||||
"language": language,
|
||||
"symbol": symbol,
|
||||
"symbol_kind": symbol_kind,
|
||||
"line": line,
|
||||
"source_sha": source_sha,
|
||||
"parse_status": "parsed",
|
||||
**extra,
|
||||
})
|
||||
|
||||
|
||||
def parse_file(repo_root: Path, path: Path) -> tuple[list[dict[str, object]], dict[str, object]]:
|
||||
language = EXTENSIONS[path.suffix.lower()]
|
||||
source_sha = sha256(path)
|
||||
rows: list[dict[str, object]] = []
|
||||
report: dict[str, object] = {"path": relative(repo_root, path), "language": language, "symbols": 0, "parse_status": "parsed"}
|
||||
try:
|
||||
lines = path.read_text(encoding="utf-8", errors="strict").splitlines()
|
||||
except UnicodeDecodeError as exc:
|
||||
report.update({"parse_status": "decode_error", "error": str(exc)})
|
||||
return rows, report
|
||||
for line_number, line in enumerate(lines, 1):
|
||||
match = DEFINE_RE.match(line) if language in {"c", "cpp"} else None
|
||||
if match:
|
||||
add_symbol(rows, repo_root, path, language, match.group(1), "macro", line_number, source_sha)
|
||||
continue
|
||||
if language == "python":
|
||||
match = PY_DEF_RE.match(line) or PY_CLASS_RE.match(line)
|
||||
if match:
|
||||
symbol = match.group(2) if match.re is PY_DEF_RE else match.group(1)
|
||||
kind = "async_function" if match.re is PY_DEF_RE and match.group(1) else ("function" if match.re is PY_DEF_RE else "class")
|
||||
add_symbol(rows, repo_root, path, language, symbol, kind, line_number, source_sha)
|
||||
continue
|
||||
if language == "hal_comp":
|
||||
for kind, pattern in COMP_PATTERNS:
|
||||
match = pattern.match(line)
|
||||
if match:
|
||||
add_symbol(rows, repo_root, path, language, match.group(1), kind, line_number, source_sha)
|
||||
break
|
||||
continue
|
||||
match = C_TYPE_RE.match(line)
|
||||
if match:
|
||||
add_symbol(rows, repo_root, path, language, match.group(1), "type", line_number, source_sha)
|
||||
continue
|
||||
match = C_FUNCTION_RE.match(line)
|
||||
if match:
|
||||
symbol = match.group(1)
|
||||
if symbol not in KEYWORDS:
|
||||
add_symbol(rows, repo_root, path, language, symbol, "function", line_number, source_sha)
|
||||
report["symbols"] = len(rows)
|
||||
return rows, report
|
||||
|
||||
|
||||
def generate(repo_root: Path, output: Path, report_path: Path) -> tuple[int, str]:
|
||||
rows: list[dict[str, object]] = []
|
||||
file_reports: list[dict[str, object]] = []
|
||||
for path in files(repo_root):
|
||||
parsed, report = parse_file(repo_root, path)
|
||||
rows.extend(parsed)
|
||||
file_reports.append(report)
|
||||
rows.sort(key=lambda row: str(row["symbol_source_id"]))
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with output.open("w", encoding="utf-8", newline="\n") as handle:
|
||||
for row in rows:
|
||||
handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n")
|
||||
digest = sha256(output)
|
||||
report = {
|
||||
"schema": "source-symbols-report.v1",
|
||||
"files": len(file_reports),
|
||||
"symbols": len(rows),
|
||||
"files_without_symbols": sorted(report["path"] for report in file_reports if report["symbols"] == 0),
|
||||
"decode_errors": [report for report in file_reports if report["parse_status"] != "parsed"],
|
||||
"output_sha256": digest,
|
||||
"file_reports": file_reports,
|
||||
}
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path.write_text(json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8")
|
||||
return len(rows), digest
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repo-root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||
parser.add_argument("--output", type=Path, default=Path("coverage/source-symbols.jsonl"))
|
||||
parser.add_argument("--report", type=Path, default=Path("coverage/source-symbols.report.json"))
|
||||
args = parser.parse_args()
|
||||
count, digest = generate(args.repo_root.resolve(), args.repo_root.resolve() / args.output, args.repo_root.resolve() / args.report)
|
||||
print(f"source_symbols={count} sha256={digest}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
236
scripts/generate_test_entries.py
Normal file
236
scripts/generate_test_entries.py
Normal file
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a deterministic index of LinuxCNC test and unit-test entrypoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
TEST_ROOT = Path("linuxcnc/tests")
|
||||
UNIT_ROOT = Path("linuxcnc/unit_tests")
|
||||
MESON_PATHS = (Path("linuxcnc/meson.build"),)
|
||||
ENTRY_NAMES = {"test.sh", "test.py", "runstreamer", "checkresult"}
|
||||
HARDWARE_TOKENS = (
|
||||
"/dev/", "hm2", "hostmot2", "mesa", "parport", "gpio", "pci", "modbus",
|
||||
"serial", "rtai", "xenomai", "latency", "linuxcncrsh", "mqtt", "tcp", "usb",
|
||||
)
|
||||
REALTIME_TOKENS = ("$realtime", "realtime", "rtapi", "halcmd", "halrun", "loadrt")
|
||||
CONFIG_SUFFIXES = {".ini", ".hal", ".tbl", ".var", ".ngc", ".nc", ".tap", ".py", ".tcl"}
|
||||
CONFIG_TOKEN_RE = re.compile(r"(?<![A-Za-z0-9_./~-])([^\s\"'=;,]+)")
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def rel(root: Path, path: Path) -> str:
|
||||
# Keep symlinked/shared test paths distinct; hashing still follows the link.
|
||||
return path.absolute().relative_to(root.absolute()).as_posix()
|
||||
|
||||
|
||||
def read_text(path: Path) -> tuple[str | None, str | None]:
|
||||
try:
|
||||
return path.read_text(encoding="utf-8", errors="strict"), None
|
||||
except (OSError, UnicodeError) as exc:
|
||||
return None, f"{type(exc).__name__}: {exc}"
|
||||
|
||||
|
||||
def classify(path: Path, text: str) -> tuple[str, str]:
|
||||
haystack = f"{path.as_posix()}\n{text}".lower()
|
||||
if any(token in haystack for token in HARDWARE_TOKENS):
|
||||
return "REAL-EXCLUDED", "hardware_or_external_transport"
|
||||
if any(token in haystack for token in REALTIME_TOKENS):
|
||||
return "needs_review", "realtime_or_linuxcnc_process_dependency"
|
||||
return "portable_candidate", "simulated_or_library_only"
|
||||
|
||||
|
||||
def directory_context(root: Path, path: Path) -> tuple[list[str], list[str]]:
|
||||
directory = path.parent
|
||||
files = sorted(candidate for candidate in directory.iterdir() if candidate.is_file())
|
||||
inputs = [rel(root, candidate) for candidate in files if candidate != path]
|
||||
expected = [
|
||||
rel(root, candidate) for candidate in files
|
||||
if candidate.name.lower().startswith(("expected", "result", "golden"))
|
||||
or candidate.name.lower() in {"expected", "expected.txt", "result", "golden"}
|
||||
]
|
||||
return inputs, expected
|
||||
|
||||
|
||||
def config_refs(root: Path, path: Path, text: str) -> list[str]:
|
||||
refs: set[str] = set()
|
||||
for token in CONFIG_TOKEN_RE.findall(text):
|
||||
if Path(token).suffix.lower() not in CONFIG_SUFFIXES:
|
||||
continue
|
||||
candidate = (path.parent / token).resolve()
|
||||
try:
|
||||
candidate.relative_to(root.resolve())
|
||||
except ValueError:
|
||||
continue
|
||||
refs.add(rel(root, candidate))
|
||||
return sorted(refs)
|
||||
|
||||
|
||||
def entry(root: Path, path: Path, test_kind: str, command: str) -> dict[str, object]:
|
||||
text, error = read_text(path)
|
||||
if text is None:
|
||||
text = ""
|
||||
portability, reason = classify(path, text)
|
||||
inputs, expected = directory_context(root, path)
|
||||
status = "blocked" if error else "parsed"
|
||||
row: dict[str, object] = {
|
||||
"test_source_id": f"test:{rel(root, path)}",
|
||||
"path": rel(root, path),
|
||||
"test_kind": test_kind,
|
||||
"command": command,
|
||||
"inputs": inputs,
|
||||
"expected": expected,
|
||||
"config_refs": config_refs(root, path, text),
|
||||
"portability": portability,
|
||||
"portability_reason": reason,
|
||||
"source_sha": sha256(path) if error is None else None,
|
||||
"parse_status": status,
|
||||
}
|
||||
if error:
|
||||
row["parse_error"] = error
|
||||
return row
|
||||
|
||||
|
||||
def script_command(root: Path, path: Path) -> tuple[str, str]:
|
||||
name = path.name
|
||||
if name == "test.py":
|
||||
return "python_test", f"python3 {rel(root, path)}"
|
||||
if name == "runstreamer":
|
||||
return "stream_test", f"bash {rel(root, path)}"
|
||||
if name == "checkresult":
|
||||
text, _ = read_text(path)
|
||||
interpreter = "python3" if text and "python" in text.splitlines()[0].lower() else "bash"
|
||||
return "oracle_check", f"{interpreter} {rel(root, path)}"
|
||||
return "shell_test", f"bash {rel(root, path)}"
|
||||
|
||||
|
||||
def meson_entries(root: Path) -> list[dict[str, object]]:
|
||||
rows: list[dict[str, object]] = []
|
||||
paths = [root / path for path in MESON_PATHS]
|
||||
paths.extend(sorted((root / UNIT_ROOT).rglob("meson.build")))
|
||||
for path in sorted(set(paths)):
|
||||
if not path.is_file():
|
||||
continue
|
||||
text, error = read_text(path)
|
||||
if text is None:
|
||||
text = ""
|
||||
portability, reason = classify(path, text)
|
||||
source_path = rel(root, path)
|
||||
targets = sorted(set(re.findall(r"\btest\s*\(\s*['\"]([^'\"]+)", text)))
|
||||
targets.extend(sorted(set(re.findall(r"executable\s*\(\s*['\"](test_[^'\"]+)", text))))
|
||||
target_names = sorted(set(targets))
|
||||
command = "meson test -C build-native --suite unit_tests"
|
||||
row: dict[str, object] = {
|
||||
"test_source_id": f"test:{source_path}",
|
||||
"path": source_path,
|
||||
"test_kind": "unit_test_build",
|
||||
"command": command,
|
||||
"inputs": sorted(rel(root, child) for child in path.parent.iterdir() if child.is_file()),
|
||||
"expected": [],
|
||||
"config_refs": [],
|
||||
"meson_targets": target_names,
|
||||
"portability": portability,
|
||||
"portability_reason": reason,
|
||||
"source_sha": sha256(path) if error is None else None,
|
||||
"parse_status": "blocked" if error else ("parsed" if target_names else "needs_review"),
|
||||
}
|
||||
if error:
|
||||
row["parse_error"] = error
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def unit_source_entries(root: Path) -> list[dict[str, object]]:
|
||||
rows: list[dict[str, object]] = []
|
||||
if not (root / UNIT_ROOT).is_dir():
|
||||
return rows
|
||||
for path in sorted((root / UNIT_ROOT).rglob("*")):
|
||||
if not path.is_file() or path.suffix.lower() not in {".c", ".cc", ".cpp", ".cxx"}:
|
||||
continue
|
||||
if not (path.name.startswith("test_") or path.name.startswith("tests_")):
|
||||
continue
|
||||
source_path = rel(root, path)
|
||||
portability, reason = classify(path, read_text(path)[0] or "")
|
||||
rows.append({
|
||||
"test_source_id": f"test:{source_path}",
|
||||
"path": source_path,
|
||||
"test_kind": "unit_test_source",
|
||||
"command": f"meson test -C build-native {path.stem.removeprefix('test_')}",
|
||||
"inputs": [source_path],
|
||||
"expected": [],
|
||||
"config_refs": [],
|
||||
"portability": portability,
|
||||
"portability_reason": reason,
|
||||
"source_sha": sha256(path),
|
||||
"parse_status": "parsed",
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def generate(root: Path, output: Path, report_path: Path) -> tuple[int, str]:
|
||||
roots = [root / TEST_ROOT, root / UNIT_ROOT]
|
||||
rows: list[dict[str, object]] = []
|
||||
parse_errors: list[dict[str, str]] = []
|
||||
for scan_root in roots:
|
||||
if not scan_root.is_dir():
|
||||
continue
|
||||
for path in sorted(scan_root.rglob("*")):
|
||||
if not path.is_file() or path.name not in ENTRY_NAMES:
|
||||
continue
|
||||
kind, command = script_command(root, path)
|
||||
row = entry(root, path, kind, command)
|
||||
rows.append(row)
|
||||
if row["parse_status"] == "blocked":
|
||||
parse_errors.append({"path": str(row["path"]), "error": str(row.get("parse_error", ""))})
|
||||
rows.extend(meson_entries(root))
|
||||
rows.extend(unit_source_entries(root))
|
||||
rows.sort(key=lambda row: str(row["test_source_id"]))
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with output.open("w", encoding="utf-8", newline="\n") as handle:
|
||||
for row in rows:
|
||||
handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n")
|
||||
digest = sha256(output)
|
||||
portability = Counter(str(row["portability"]) for row in rows)
|
||||
kinds = Counter(str(row["test_kind"]) for row in rows)
|
||||
report = {
|
||||
"schema": "test-entries-report.v1",
|
||||
"entries": len(rows),
|
||||
"test_directories": len({str(Path(str(row["path"])).parent) for row in rows}),
|
||||
"test_kinds": dict(sorted(kinds.items())),
|
||||
"portability": dict(sorted(portability.items())),
|
||||
"parse_errors": parse_errors,
|
||||
"blocked": sum(1 for row in rows if row["parse_status"] == "blocked"),
|
||||
"output_sha256": digest,
|
||||
}
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path.write_text(json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8")
|
||||
return len(rows), digest
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repo-root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||
parser.add_argument("--output", type=Path, default=Path("coverage/test-entries.jsonl"))
|
||||
parser.add_argument("--report", type=Path, default=Path("coverage/test-entries.report.json"))
|
||||
args = parser.parse_args()
|
||||
root = args.repo_root.resolve()
|
||||
count, digest = generate(root, root / args.output, root / args.report)
|
||||
print(f"test_entries={count} sha256={digest}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
243
scripts/native_runner.py
Normal file
243
scripts/native_runner.py
Normal file
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run one indexed test in a fixed native environment and emit an oracle record."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
|
||||
SCHEMA = "native-runner-result.v1"
|
||||
GOLDEN_SCHEMA = "native-golden.v1"
|
||||
DEFAULT_INDEX = Path("coverage/test-entries.jsonl")
|
||||
DEFAULT_REPORT = Path("coverage/native-runner.report.json")
|
||||
DEFAULT_ARTIFACTS = Path("build-native/native-runner-artifacts")
|
||||
MAX_OUTPUT_BYTES = 4 * 1024 * 1024
|
||||
PORTABLE_ENV = {
|
||||
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"HOME": ".runner-home",
|
||||
"LANG": "C",
|
||||
"LC_ALL": "C",
|
||||
"TZ": "UTC",
|
||||
"PYTHONHASHSEED": "0",
|
||||
}
|
||||
|
||||
|
||||
def sha256_bytes(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def read_entries(path: Path) -> list[dict[str, object]]:
|
||||
entries: list[dict[str, object]] = []
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
for line_number, line in enumerate(handle, 1):
|
||||
if not line.strip():
|
||||
continue
|
||||
row = json.loads(line)
|
||||
if not isinstance(row, dict):
|
||||
raise ValueError(f"{path}:{line_number}: expected object")
|
||||
entries.append(row)
|
||||
return entries
|
||||
|
||||
|
||||
def environment(root: Path) -> dict[str, str]:
|
||||
result = dict(PORTABLE_ENV)
|
||||
result["PWD"] = str(root)
|
||||
result["LINUXCNC_RUNNER_ROOT"] = str(root)
|
||||
return result
|
||||
|
||||
|
||||
def command_for_workdir(root: Path, entry: dict[str, object], workdir: Path) -> list[str]:
|
||||
command = entry.get("command")
|
||||
if not isinstance(command, str) or not command.strip():
|
||||
raise ValueError("entry has no executable command")
|
||||
tokens = shlex.split(command)
|
||||
entry_path = root / str(entry.get("path", ""))
|
||||
for index, token in enumerate(tokens):
|
||||
candidate = (root / token).resolve() if not token.startswith("/") else Path(token)
|
||||
try:
|
||||
candidate.relative_to(workdir.resolve())
|
||||
except ValueError:
|
||||
continue
|
||||
if candidate.is_file():
|
||||
tokens[index] = os.path.relpath(candidate, workdir)
|
||||
if entry_path.is_file() and tokens and tokens[-1] == str(entry.get("path")):
|
||||
tokens[-1] = entry_path.name
|
||||
return tokens
|
||||
|
||||
|
||||
def result_digest(result: dict[str, object]) -> str:
|
||||
payload = {key: value for key, value in result.items() if key not in {"duration_ms", "stdout", "stderr"}}
|
||||
return sha256_bytes(canonical_json(payload).encode("utf-8"))
|
||||
|
||||
|
||||
def compare_golden(actual: dict[str, object], golden: dict[str, object]) -> dict[str, object]:
|
||||
if golden.get("schema") != GOLDEN_SCHEMA:
|
||||
return {"status": "blocked", "reason": "unsupported_golden_schema"}
|
||||
expected = golden.get("expected", {})
|
||||
if not isinstance(expected, dict):
|
||||
return {"status": "blocked", "reason": "golden_expected_must_be_object"}
|
||||
differences: list[dict[str, object]] = []
|
||||
for field, expected_value in sorted(expected.items()):
|
||||
if actual.get(field) != expected_value:
|
||||
differences.append({"field": field, "expected": expected_value, "actual": actual.get(field)})
|
||||
if differences:
|
||||
return {"status": "different", "differences": differences}
|
||||
return {"status": "passed", "differences": []}
|
||||
|
||||
|
||||
def run_entry(root: Path, entry: dict[str, object], artifacts_dir: Path, timeout: float,
|
||||
allow_real_excluded: bool = False, allow_needs_review: bool = False,
|
||||
golden: dict[str, object] | None = None) -> dict[str, object]:
|
||||
portability = str(entry.get("portability", "needs_review"))
|
||||
if portability == "REAL-EXCLUDED" and not allow_real_excluded:
|
||||
return {
|
||||
"schema": SCHEMA, "status": "REAL-EXCLUDED", "test_source_id": entry.get("test_source_id"),
|
||||
"path": entry.get("path"), "portability": portability, "reason": "hardware_or_external_transport_requires_substitute",
|
||||
}
|
||||
if portability == "needs_review" and not allow_needs_review:
|
||||
return {
|
||||
"schema": SCHEMA, "status": "blocked", "test_source_id": entry.get("test_source_id"),
|
||||
"path": entry.get("path"), "portability": portability, "reason": "needs_review_requires_explicit_override",
|
||||
}
|
||||
if entry.get("parse_status") == "blocked":
|
||||
return {"schema": SCHEMA, "status": "blocked", "test_source_id": entry.get("test_source_id"), "path": entry.get("path"), "reason": "entry_parse_blocked"}
|
||||
entry_path = root / str(entry.get("path", ""))
|
||||
workdir = entry_path.parent if entry_path.is_file() else root
|
||||
try:
|
||||
command = command_for_workdir(root, entry, workdir)
|
||||
except (ValueError, OSError) as exc:
|
||||
return {"schema": SCHEMA, "status": "blocked", "test_source_id": entry.get("test_source_id"), "path": entry.get("path"), "reason": str(exc)}
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
cwd=workdir,
|
||||
env=environment(root),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
timed_out = False
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
completed = subprocess.CompletedProcess(command, 124, exc.stdout or b"", exc.stderr or b"")
|
||||
timed_out = True
|
||||
stdout = bytes(completed.stdout or b"")
|
||||
stderr = bytes(completed.stderr or b"")
|
||||
artifact_root = artifacts_dir / str(entry.get("test_source_id", "unknown")).replace("/", "_").replace(":", "_")
|
||||
artifact_root.mkdir(parents=True, exist_ok=True)
|
||||
stdout_path = artifact_root / "stdout.bin"
|
||||
stderr_path = artifact_root / "stderr.bin"
|
||||
stdout_path.write_bytes(stdout[:MAX_OUTPUT_BYTES])
|
||||
stderr_path.write_bytes(stderr[:MAX_OUTPUT_BYTES])
|
||||
status = "timeout" if timed_out else ("passed" if completed.returncode == 0 else "failed")
|
||||
result: dict[str, object] = {
|
||||
"schema": SCHEMA,
|
||||
"status": status,
|
||||
"test_source_id": entry.get("test_source_id"),
|
||||
"path": entry.get("path"),
|
||||
"command": command,
|
||||
"cwd": str(workdir.relative_to(root)),
|
||||
"portability": portability,
|
||||
"exit_code": completed.returncode,
|
||||
"stdout_bytes": len(stdout),
|
||||
"stderr_bytes": len(stderr),
|
||||
"stdout_sha256": sha256_bytes(stdout),
|
||||
"stderr_sha256": sha256_bytes(stderr),
|
||||
"stdout_artifact": str(stdout_path.relative_to(root)),
|
||||
"stderr_artifact": str(stderr_path.relative_to(root)),
|
||||
"source_sha": entry.get("source_sha"),
|
||||
}
|
||||
if len(stdout) <= MAX_OUTPUT_BYTES:
|
||||
result["stdout"] = stdout.decode("utf-8", errors="replace")
|
||||
if len(stderr) <= MAX_OUTPUT_BYTES:
|
||||
result["stderr"] = stderr.decode("utf-8", errors="replace")
|
||||
result["result_sha256"] = result_digest(result)
|
||||
if golden is not None:
|
||||
result["oracle"] = compare_golden(result, golden)
|
||||
return result
|
||||
|
||||
|
||||
def self_test(root: Path, report_path: Path) -> int:
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-c", "print('native-runner-self-test')"],
|
||||
cwd=root,
|
||||
env=environment(root),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
passed = completed.returncode == 0 and completed.stdout == b"native-runner-self-test\n" and completed.stderr == b""
|
||||
report = {
|
||||
"schema": "native-runner-report.v1",
|
||||
"status": "passed" if passed else "failed",
|
||||
"self_test": True,
|
||||
"result": {
|
||||
"exit_code": completed.returncode,
|
||||
"stdout_sha256": sha256_bytes(completed.stdout),
|
||||
"stderr_sha256": sha256_bytes(completed.stderr),
|
||||
"expected_stdout_sha256": sha256_bytes(b"native-runner-self-test\n"),
|
||||
},
|
||||
}
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path.write_text(json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"native_runner=self-test status={report['status']}")
|
||||
return 0 if passed else 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repo-root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||
parser.add_argument("--index", type=Path, default=DEFAULT_INDEX)
|
||||
parser.add_argument("--test-source-id")
|
||||
parser.add_argument("--path")
|
||||
parser.add_argument("--timeout", type=float, default=60.0)
|
||||
parser.add_argument("--allow-real-excluded", action="store_true")
|
||||
parser.add_argument("--allow-needs-review", action="store_true")
|
||||
parser.add_argument("--golden", type=Path)
|
||||
parser.add_argument("--artifacts-dir", type=Path, default=DEFAULT_ARTIFACTS)
|
||||
parser.add_argument("--report", type=Path, default=DEFAULT_REPORT)
|
||||
parser.add_argument("--self-test", action="store_true")
|
||||
args = parser.parse_args()
|
||||
root = args.repo_root.resolve()
|
||||
if args.self_test:
|
||||
return self_test(root, root / args.report)
|
||||
entries = read_entries(root / args.index)
|
||||
matches = [entry for entry in entries if (not args.test_source_id or entry.get("test_source_id") == args.test_source_id) and (not args.path or entry.get("path") == args.path)]
|
||||
if len(matches) != 1:
|
||||
parser.error(f"expected exactly one test entry, found {len(matches)}")
|
||||
golden = None
|
||||
if args.golden:
|
||||
golden = json.loads((root / args.golden).read_text(encoding="utf-8"))
|
||||
result = run_entry(root, matches[0], root / args.artifacts_dir, args.timeout, args.allow_real_excluded, args.allow_needs_review, golden)
|
||||
report = {"schema": "native-runner-report.v1", "status": result.get("status"), "result": result}
|
||||
(root / args.report).parent.mkdir(parents=True, exist_ok=True)
|
||||
(root / args.report).write_text(json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8")
|
||||
print(canonical_json(result))
|
||||
return 0 if result.get("status") == "passed" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
239
scripts/normalize_sources.py
Normal file
239
scripts/normalize_sources.py
Normal file
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Normalize the independent coverage indexes into stable cross-index records."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
INDEXES = (
|
||||
("source", Path("coverage/source-index.jsonl")),
|
||||
("document", Path("coverage/document-capabilities.jsonl")),
|
||||
("symbol", Path("coverage/source-symbols.jsonl")),
|
||||
("resource", Path("coverage/config-resources.jsonl")),
|
||||
("test", Path("coverage/test-entries.jsonl")),
|
||||
)
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def read_jsonl(path: Path) -> list[dict[str, object]]:
|
||||
rows: list[dict[str, object]] = []
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
for line_number, line in enumerate(handle, 1):
|
||||
if not line.strip():
|
||||
continue
|
||||
value = json.loads(line)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{path}:{line_number}: expected object")
|
||||
rows.append(value)
|
||||
return rows
|
||||
|
||||
|
||||
def canonical_path(value: object) -> str | None:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
raw = value.replace("\\", "/").strip()
|
||||
if raw.startswith("/"):
|
||||
return None
|
||||
parts: list[str] = []
|
||||
for part in PurePosixPath(raw).parts:
|
||||
if part in ("", "."):
|
||||
continue
|
||||
if part == "..":
|
||||
if not parts:
|
||||
return None
|
||||
parts.pop()
|
||||
continue
|
||||
parts.append(part)
|
||||
return "/".join(parts) or None
|
||||
|
||||
|
||||
def as_text(value: object) -> str:
|
||||
return str(value) if value is not None else ""
|
||||
|
||||
|
||||
def source_key(index_name: str, row: dict[str, object], path: str | None) -> str:
|
||||
if index_name == "source":
|
||||
return f"src:file:{path or as_text(row.get('source_id'))}"
|
||||
if index_name == "document":
|
||||
return f"src:doc:{path or 'unknown'}#{as_text(row.get('anchor')) or 'root'}"
|
||||
if index_name == "symbol":
|
||||
return "src:symbol:{}:{}:{}:{}".format(
|
||||
path or "unknown", as_text(row.get("line")), as_text(row.get("symbol_kind")), as_text(row.get("symbol")),
|
||||
)
|
||||
if index_name == "resource":
|
||||
entrypoint = canonical_path(row.get("entrypoint")) or "unknown"
|
||||
return f"src:resource:{entrypoint}:{as_text(row.get('line'))}:{as_text(row.get('edge_kind'))}:{path or as_text(row.get('raw_value'))}"
|
||||
return f"src:test:{path or as_text(row.get('test_source_id'))}"
|
||||
|
||||
|
||||
def row_path(index_name: str, row: dict[str, object]) -> str | None:
|
||||
if index_name == "document":
|
||||
return canonical_path(row.get("document"))
|
||||
if index_name == "resource":
|
||||
return canonical_path(row.get("path"))
|
||||
return canonical_path(row.get("path"))
|
||||
|
||||
|
||||
def hash_path(index_name: str, row: dict[str, object]) -> str | None:
|
||||
if index_name == "resource":
|
||||
return canonical_path(row.get("origin_path")) or canonical_path(row.get("entrypoint"))
|
||||
return row_path(index_name, row)
|
||||
|
||||
|
||||
def original_id(index_name: str, row: dict[str, object]) -> str:
|
||||
keys = {
|
||||
"source": "source_id",
|
||||
"document": "capability_source_id",
|
||||
"symbol": "symbol_source_id",
|
||||
"resource": "resource_source_id",
|
||||
"test": "test_source_id",
|
||||
}
|
||||
return as_text(row.get(keys[index_name]))
|
||||
|
||||
|
||||
def normalize_row(index_name: str, row: dict[str, object], root: Path, path_to_file_id: dict[str, str]) -> dict[str, object]:
|
||||
path = row_path(index_name, row)
|
||||
source_id = source_key(index_name, row, path)
|
||||
source_hash = row.get("source_sha", row.get("sha256"))
|
||||
if source_hash is not None:
|
||||
source_hash = as_text(source_hash)
|
||||
related: set[str] = set()
|
||||
if path in path_to_file_id:
|
||||
related.add(path_to_file_id[path])
|
||||
if index_name == "resource":
|
||||
origin = canonical_path(row.get("origin_path"))
|
||||
target = canonical_path(row.get("path"))
|
||||
if origin in path_to_file_id:
|
||||
related.add(path_to_file_id[origin])
|
||||
if target in path_to_file_id:
|
||||
related.add(path_to_file_id[target])
|
||||
elif index_name == "test":
|
||||
for ref in row.get("config_refs", []) if isinstance(row.get("config_refs"), list) else []:
|
||||
ref_path = canonical_path(ref)
|
||||
if ref_path in path_to_file_id:
|
||||
related.add(path_to_file_id[ref_path])
|
||||
record: dict[str, object] = {
|
||||
"source_id": source_id,
|
||||
"origin_index": index_name,
|
||||
"origin_source_id": original_id(index_name, row),
|
||||
"path": path,
|
||||
"hash_path": hash_path(index_name, row),
|
||||
"source_sha": source_hash,
|
||||
"domain": as_text(row.get("domain")) or ({"source": "source", "document": "documentation", "symbol": "code", "resource": "configuration", "test": "test"}[index_name]),
|
||||
"kind": as_text(row.get("kind")) or as_text(row.get("symbol_kind")) or as_text(row.get("resource_kind")) or as_text(row.get("test_kind")) or index_name,
|
||||
"line": row.get("line"),
|
||||
"parse_status": as_text(row.get("parse_status")) or as_text(row.get("mapping_status")) or "parsed",
|
||||
"related_source_ids": sorted(related),
|
||||
}
|
||||
if index_name == "resource":
|
||||
record.update({
|
||||
"edge_kind": row.get("edge_kind"),
|
||||
"raw_value": row.get("raw_value"),
|
||||
"resource_kind": row.get("resource_kind"),
|
||||
"target_status": row.get("parse_status"),
|
||||
})
|
||||
elif index_name == "test":
|
||||
record.update({
|
||||
"command": row.get("command"),
|
||||
"inputs": row.get("inputs", []),
|
||||
"expected": row.get("expected", []),
|
||||
"config_refs": row.get("config_refs", []),
|
||||
"test_kind": row.get("test_kind"),
|
||||
})
|
||||
return record
|
||||
|
||||
|
||||
def generate(root: Path, output: Path, report_path: Path) -> tuple[int, str]:
|
||||
loaded: dict[str, list[dict[str, object]]] = {}
|
||||
input_summary: dict[str, dict[str, object]] = {}
|
||||
for index_name, relative_path in INDEXES:
|
||||
path = root / relative_path
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"required index is missing: {relative_path}")
|
||||
loaded[index_name] = read_jsonl(path)
|
||||
input_summary[index_name] = {
|
||||
"path": relative_path.as_posix(),
|
||||
"rows": len(loaded[index_name]),
|
||||
"sha256": sha256(path),
|
||||
}
|
||||
|
||||
path_to_file_id: dict[str, str] = {}
|
||||
for row in loaded["source"]:
|
||||
path = canonical_path(row.get("path"))
|
||||
if path:
|
||||
path_to_file_id.setdefault(path, f"src:file:{path}")
|
||||
|
||||
records: list[dict[str, object]] = []
|
||||
source_ids: set[str] = set()
|
||||
source_id_counts: dict[str, int] = defaultdict(int)
|
||||
collisions: list[dict[str, object]] = []
|
||||
path_hashes: dict[str, set[str]] = defaultdict(set)
|
||||
missing_paths: set[str] = set()
|
||||
for index_name, _ in INDEXES:
|
||||
for row in loaded[index_name]:
|
||||
record = normalize_row(index_name, row, root, path_to_file_id)
|
||||
base_source_id = str(record["source_id"])
|
||||
source_id_counts[base_source_id] += 1
|
||||
if base_source_id in source_ids:
|
||||
collisions.append({"source_id": base_source_id, "occurrence": source_id_counts[base_source_id], "origin_index": index_name, "origin_source_id": record["origin_source_id"]})
|
||||
record["source_id"] = f"{base_source_id}~dup{source_id_counts[base_source_id]}"
|
||||
source_ids.add(str(record["source_id"]))
|
||||
hash_value = record.get("source_sha")
|
||||
hash_path_value = record.get("hash_path")
|
||||
if hash_value and hash_path_value:
|
||||
path_hashes[str(hash_path_value)].add(str(hash_value))
|
||||
primary_path = record.get("path")
|
||||
if primary_path and not (root / str(primary_path)).exists():
|
||||
if not (index_name == "resource" and as_text(row.get("parse_status")) in {"missing", "unresolved", "host_or_variable_path", "outside_repo"}):
|
||||
missing_paths.add(str(primary_path))
|
||||
records.append(record)
|
||||
|
||||
records.sort(key=lambda row: str(row["source_id"]))
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with output.open("w", encoding="utf-8", newline="\n") as handle:
|
||||
for record in records:
|
||||
handle.write(json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n")
|
||||
digest = sha256(output)
|
||||
report = {
|
||||
"schema": "normalized-sources-report.v1",
|
||||
"records": len(records),
|
||||
"unique_source_ids": len(source_ids),
|
||||
"input_summary": input_summary,
|
||||
"collisions": collisions,
|
||||
"hash_conflicts": {path: sorted(values) for path, values in sorted(path_hashes.items()) if len(values) > 1},
|
||||
"missing_paths": sorted(missing_paths),
|
||||
"orphan_related_ids": sum(1 for row in records if not row["related_source_ids"] and row["origin_index"] != "source"),
|
||||
"output_sha256": digest,
|
||||
}
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path.write_text(json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8")
|
||||
return len(records), digest
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repo-root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||
parser.add_argument("--output", type=Path, default=Path("coverage/normalized-sources.jsonl"))
|
||||
parser.add_argument("--report", type=Path, default=Path("coverage/normalized-sources.report.json"))
|
||||
args = parser.parse_args()
|
||||
root = args.repo_root.resolve()
|
||||
count, digest = generate(root, root / args.output, root / args.report)
|
||||
print(f"normalized_sources={count} sha256={digest}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
65
scripts/test_config_resources.py
Normal file
65
scripts/test_config_resources.py
Normal file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from generate_config_resources import generate, scan_entrypoint
|
||||
|
||||
|
||||
class ConfigResourcesTests(unittest.TestCase):
|
||||
def create_tree(self):
|
||||
directory = TemporaryDirectory()
|
||||
root = Path(directory.name)
|
||||
config = root / "linuxcnc/configs/sim/demo"
|
||||
config.mkdir(parents=True)
|
||||
(config / "main.ini").write_text(
|
||||
"[HAL]\nHALFILE = core.hal\nPOSTGUI_HALFILE = post.hal\n"
|
||||
"[RS274NGC]\nPARAMETER_FILE = vars.var\nSUBROUTINE_PATH = macros\n"
|
||||
"[EMCIO]\nTOOL_TABLE = tools.tbl\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(config / "core.hal").write_text("source extra.hal\nloadusr helper.py\n", encoding="utf-8")
|
||||
(config / "post.hal").write_text("loadrt demo\n", encoding="utf-8")
|
||||
(config / "extra.hal").write_text("setp demo.value 1\n", encoding="utf-8")
|
||||
(config / "helper.py").write_text("print('ok')\n", encoding="utf-8")
|
||||
(config / "tools.tbl").write_text("T1 P1\n", encoding="utf-8")
|
||||
(config / "vars.var").write_text("#1 = 2\n", encoding="utf-8")
|
||||
(config / "macros").mkdir()
|
||||
return directory, root, config / "main.ini"
|
||||
|
||||
def test_resolves_ini_and_hal_edges(self):
|
||||
directory, root, entrypoint = self.create_tree()
|
||||
self.addCleanup(directory.cleanup)
|
||||
rows = scan_entrypoint(root, root / "linuxcnc/configs/sim", entrypoint)
|
||||
paths = {row["path"] for row in rows}
|
||||
self.assertIn("linuxcnc/configs/sim/demo/core.hal", paths)
|
||||
self.assertIn("linuxcnc/configs/sim/demo/extra.hal", paths)
|
||||
self.assertIn("linuxcnc/configs/sim/demo/helper.py", paths)
|
||||
self.assertIn("linuxcnc/configs/sim/demo/tools.tbl", paths)
|
||||
|
||||
def test_missing_resource_is_retained(self):
|
||||
directory, root, entrypoint = self.create_tree()
|
||||
self.addCleanup(directory.cleanup)
|
||||
entrypoint.write_text("[HAL]\nHALFILE = missing.hal\n", encoding="utf-8")
|
||||
rows = scan_entrypoint(root, root / "linuxcnc/configs/sim", entrypoint)
|
||||
missing = [row for row in rows if row["path"].endswith("missing.hal")]
|
||||
self.assertEqual(len(missing), 1)
|
||||
self.assertEqual(missing[0]["parse_status"], "missing")
|
||||
|
||||
def test_generation_is_deterministic(self):
|
||||
directory, root, _ = self.create_tree()
|
||||
self.addCleanup(directory.cleanup)
|
||||
first = root / "one.jsonl"
|
||||
second = root / "two.jsonl"
|
||||
report = root / "report.json"
|
||||
_, digest_one = generate(root, first, report)
|
||||
_, digest_two = generate(root, second, report)
|
||||
self.assertEqual(digest_one, digest_two)
|
||||
self.assertEqual(first.read_bytes(), second.read_bytes())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
153
scripts/test_context_packet.py
Normal file
153
scripts/test_context_packet.py
Normal file
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from context_packet import PacketError, generate_packet
|
||||
|
||||
|
||||
class ContextPacketTests(unittest.TestCase):
|
||||
def base(self, candidates):
|
||||
return {
|
||||
"task": "bounded task",
|
||||
"header": {"milestone": "M1"},
|
||||
"baseline": {"source_sha": "abc"},
|
||||
"scope": {"include": ["feature/1"]},
|
||||
"contracts": ["protocol.v1"],
|
||||
"risks": [],
|
||||
"next": ["run test"],
|
||||
"forbidden": ["real hardware"],
|
||||
"candidates": candidates,
|
||||
}
|
||||
|
||||
def test_filters_by_evidence_bytes_before_final_write(self):
|
||||
packet = generate_packet(
|
||||
self.base([
|
||||
{"path": "small.log", "size": 6, "priority": 2},
|
||||
{"path": "large.log", "size": 7, "priority": 1},
|
||||
]),
|
||||
context_id="c1",
|
||||
packet_id="p1",
|
||||
total_bytes=4096,
|
||||
evidence_bytes=10,
|
||||
)
|
||||
self.assertEqual([item["path"] for item in packet["evidence"]["selected"]], ["small.log"])
|
||||
self.assertEqual(packet["evidence"]["excluded"][0]["reason_code"], "E4")
|
||||
self.assertLessEqual(packet["budget"]["packet_bytes"], 4096)
|
||||
|
||||
def test_filters_by_remaining_context_space(self):
|
||||
packet = generate_packet(
|
||||
self.base([{"path": "evidence.log", "size": 1, "priority": 1}]),
|
||||
context_id="c1",
|
||||
packet_id="p1",
|
||||
total_bytes=550,
|
||||
evidence_bytes=100,
|
||||
)
|
||||
self.assertEqual(packet["evidence"]["selected"], [])
|
||||
self.assertEqual(packet["evidence"]["excluded"][0]["reason_code"], "E5")
|
||||
self.assertLessEqual(packet["budget"]["packet_bytes"], 550)
|
||||
|
||||
def test_evidence_payload_counts_against_total_context_budget(self):
|
||||
packet = generate_packet(
|
||||
self.base([{"path": "payload.bin", "size": 40, "priority": 1}]),
|
||||
context_id="c1",
|
||||
packet_id="p1",
|
||||
total_bytes=570,
|
||||
evidence_bytes=100,
|
||||
)
|
||||
self.assertEqual(packet["evidence"]["selected"], [])
|
||||
self.assertEqual(packet["evidence"]["excluded"][0]["reason_code"], "E5")
|
||||
self.assertLessEqual(
|
||||
packet["budget"]["packet_bytes"]
|
||||
+ packet["budget"]["selected_evidence_bytes"],
|
||||
570,
|
||||
)
|
||||
self.assertEqual(
|
||||
packet["budget"]["packet_bytes"],
|
||||
len(json.dumps(packet, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")),
|
||||
)
|
||||
|
||||
def test_skeleton_over_budget_fails_before_selection(self):
|
||||
with self.assertRaisesRegex(PacketError, "context skeleton exceeds byte budget"):
|
||||
generate_packet(
|
||||
self.base([]),
|
||||
context_id="c1",
|
||||
packet_id="p1",
|
||||
total_bytes=100,
|
||||
evidence_bytes=100,
|
||||
)
|
||||
|
||||
def test_required_item_fails_without_writing_an_over_budget_packet(self):
|
||||
with self.assertRaisesRegex(PacketError, "required evidence cannot fit"):
|
||||
generate_packet(
|
||||
self.base([{"path": "required.log", "size": 101, "required": True}]),
|
||||
context_id="c1",
|
||||
packet_id="p1",
|
||||
total_bytes=4096,
|
||||
evidence_bytes=100,
|
||||
)
|
||||
|
||||
def test_required_item_fails_when_payload_consumes_remaining_context(self):
|
||||
with self.assertRaisesRegex(PacketError, r"required evidence cannot fit \(E5\)"):
|
||||
generate_packet(
|
||||
self.base([{"path": "required.log", "size": 40, "required": True}]),
|
||||
context_id="c1",
|
||||
packet_id="p1",
|
||||
total_bytes=570,
|
||||
evidence_bytes=100,
|
||||
)
|
||||
|
||||
def test_selection_is_deterministic_and_count_is_enforced(self):
|
||||
packet = generate_packet(
|
||||
self.base([
|
||||
{"path": "z.log", "size": 1},
|
||||
{"path": "a.log", "size": 1},
|
||||
{"path": "required.log", "size": 1, "required": True},
|
||||
]),
|
||||
context_id="c1",
|
||||
packet_id="p1",
|
||||
total_bytes=4096,
|
||||
evidence_bytes=100,
|
||||
max_files=2,
|
||||
)
|
||||
self.assertEqual([item["path"] for item in packet["evidence"]["selected"]], ["required.log", "a.log"])
|
||||
self.assertEqual(packet["evidence"]["excluded"][0]["reason_code"], "E3")
|
||||
|
||||
def test_cli_does_not_replace_existing_output_on_generation_failure(self):
|
||||
with TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
input_path = root / "input.json"
|
||||
output_path = root / "packet.json"
|
||||
input_path.write_text(
|
||||
json.dumps(self.base([{"path": "required.log", "size": 101, "required": True}]))
|
||||
)
|
||||
output_path.write_text("previous packet")
|
||||
result = subprocess.run(
|
||||
[
|
||||
"python3",
|
||||
"scripts/context_packet.py",
|
||||
"--input",
|
||||
str(input_path),
|
||||
"--output",
|
||||
str(output_path),
|
||||
"--context-id",
|
||||
"c1",
|
||||
"--packet-id",
|
||||
"p1",
|
||||
"--total-bytes",
|
||||
"4096",
|
||||
"--evidence-bytes",
|
||||
"100",
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertEqual(output_path.read_text(), "previous packet")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
63
scripts/test_dependency_closure.py
Normal file
63
scripts/test_dependency_closure.py
Normal file
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from generate_dependency_closure import generate
|
||||
|
||||
|
||||
class DependencyClosureTests(unittest.TestCase):
|
||||
def create_tree(self):
|
||||
directory = TemporaryDirectory()
|
||||
root = Path(directory.name)
|
||||
(root / "coverage").mkdir()
|
||||
(root / "linuxcnc").mkdir()
|
||||
(root / "linuxcnc/src").mkdir()
|
||||
(root / "linuxcnc/configs").mkdir()
|
||||
(root / "linuxcnc/tests/demo").mkdir(parents=True)
|
||||
(root / "linuxcnc/src/a.c").write_text("int a;\n", encoding="utf-8")
|
||||
(root / "linuxcnc/configs/demo.ini").write_text("[EMC]\n", encoding="utf-8")
|
||||
(root / "linuxcnc/tests/demo/test.sh").write_text("#!/bin/sh\n", encoding="utf-8")
|
||||
rows = [
|
||||
{"source_id": "src:file:linuxcnc/src/a.c", "origin_index": "source", "origin_source_id": "build:a", "path": "linuxcnc/src/a.c", "hash_path": "linuxcnc/src/a.c", "source_sha": "a", "domain": "source", "kind": "source", "line": None, "parse_status": "parsed", "related_source_ids": []},
|
||||
{"source_id": "src:file:linuxcnc/configs/demo.ini", "origin_index": "source", "origin_source_id": "build:ini", "path": "linuxcnc/configs/demo.ini", "hash_path": "linuxcnc/configs/demo.ini", "source_sha": "b", "domain": "source", "kind": "source", "line": None, "parse_status": "parsed", "related_source_ids": []},
|
||||
{"source_id": "src:test:linuxcnc/tests/demo/test.sh", "origin_index": "test", "origin_source_id": "test:demo", "path": "linuxcnc/tests/demo/test.sh", "hash_path": "linuxcnc/tests/demo/test.sh", "source_sha": "c", "domain": "test", "kind": "shell_test", "line": None, "parse_status": "parsed", "related_source_ids": [], "command": "bash", "inputs": ["linuxcnc/src/a.c"], "expected": [], "config_refs": ["linuxcnc/configs/missing.ini"], "test_kind": "shell_test"},
|
||||
]
|
||||
(root / "coverage/normalized-sources.jsonl").write_text("".join(json.dumps(row) + "\n" for row in rows), encoding="utf-8")
|
||||
return directory, root
|
||||
|
||||
def test_resolved_and_unresolved_edges_are_retained(self):
|
||||
directory, root = self.create_tree()
|
||||
self.addCleanup(directory.cleanup)
|
||||
count, _ = generate(root, root / "out.jsonl", root / "report.json")
|
||||
rows = [json.loads(line) for line in (root / "out.jsonl").read_text(encoding="utf-8").splitlines()]
|
||||
self.assertEqual(count, 2)
|
||||
self.assertTrue(any(row["parse_status"] == "resolved" for row in rows))
|
||||
self.assertTrue(any(row["to_source_id"].startswith("unresolved:") for row in rows))
|
||||
|
||||
def test_deterministic_output(self):
|
||||
directory, root = self.create_tree()
|
||||
self.addCleanup(directory.cleanup)
|
||||
_, first = generate(root, root / "one.jsonl", root / "report.json")
|
||||
_, second = generate(root, root / "two.jsonl", root / "report.json")
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual((root / "one.jsonl").read_bytes(), (root / "two.jsonl").read_bytes())
|
||||
|
||||
def test_duplicate_edges_are_reported(self):
|
||||
directory, root = self.create_tree()
|
||||
self.addCleanup(directory.cleanup)
|
||||
path = root / "coverage/normalized-sources.jsonl"
|
||||
rows = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
|
||||
rows[-1]["inputs"] = ["linuxcnc/src/a.c", "linuxcnc/src/a.c"]
|
||||
path.write_text("".join(json.dumps(row) + "\n" for row in rows), encoding="utf-8")
|
||||
generate(root, root / "out.jsonl", root / "report.json")
|
||||
report = json.loads((root / "report.json").read_text(encoding="utf-8"))
|
||||
self.assertTrue(report["duplicate_edges"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
62
scripts/test_document_capabilities.py
Normal file
62
scripts/test_document_capabilities.py
Normal file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from generate_document_capabilities import make_entries, parse_headings, slugify
|
||||
|
||||
|
||||
class DocumentCapabilitiesTests(unittest.TestCase):
|
||||
def test_slugify_is_ascii_and_deterministic(self):
|
||||
self.assertEqual(slugify("G-Code & Motion"), "g-code-and-motion")
|
||||
self.assertEqual(slugify("!!!"), "section")
|
||||
|
||||
def test_explicit_anchor_and_duplicate_heading_are_stable(self):
|
||||
with TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "sample.adoc"
|
||||
path.write_text("[[explicit]]\n== Same\n== Same\n", encoding="utf-8")
|
||||
headings = parse_headings(path)
|
||||
self.assertEqual(headings[0].anchor, "explicit")
|
||||
self.assertEqual(headings[1].anchor, None)
|
||||
|
||||
def test_include_graph_and_project_documents_are_indexed(self):
|
||||
with TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / "linuxcnc/docs/src/sub").mkdir(parents=True)
|
||||
(root / "docs").mkdir()
|
||||
(root / "linuxcnc/docs/src/Master_Documentation.adoc").write_text(
|
||||
"include::sub/a.adoc[]\n", encoding="utf-8"
|
||||
)
|
||||
(root / "linuxcnc/docs/src/sub/a.adoc").write_text("[[a]]\n= HAL\n", encoding="utf-8")
|
||||
(root / "docs/linuxcnc-web-wasm-source-map.md").write_text("# Source Map\n", encoding="utf-8")
|
||||
(root / "docs/linuxcnc-web-wasm-simulation-coverage.md").write_text("# Coverage\n", encoding="utf-8")
|
||||
entries, report = make_entries(root)
|
||||
ids = {str(entry["capability_source_id"]) for entry in entries}
|
||||
self.assertIn("doc:linuxcnc/docs/src/sub/a.adoc#a", ids)
|
||||
self.assertIn("doc:docs/linuxcnc-web-wasm-source-map.md#source-map", ids)
|
||||
self.assertEqual(report["duplicate_ids"], [])
|
||||
|
||||
def test_generated_entries_have_required_fields(self):
|
||||
with TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / "linuxcnc/docs/src").mkdir(parents=True)
|
||||
(root / "docs").mkdir()
|
||||
(root / "linuxcnc/docs/src/Master_Documentation.adoc").write_text("= G-Code\n", encoding="utf-8")
|
||||
for name in ("linuxcnc-web-wasm-source-map.md", "linuxcnc-web-wasm-simulation-coverage.md"):
|
||||
(root / "docs" / name).write_text("# Project\n", encoding="utf-8")
|
||||
entries, _ = make_entries(root)
|
||||
for entry in entries:
|
||||
self.assertTrue(entry["capability_source_id"])
|
||||
self.assertTrue(entry["document"])
|
||||
self.assertTrue(entry["anchor"])
|
||||
self.assertTrue(entry["title"])
|
||||
self.assertTrue(entry["source_sha"])
|
||||
self.assertIn(entry["mapping_status"], {"mapped", "needs_review"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
51
scripts/test_download_index.py
Normal file
51
scripts/test_download_index.py
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from validate_download_index import validate
|
||||
|
||||
|
||||
class DownloadIndexTests(unittest.TestCase):
|
||||
def write(self, row):
|
||||
directory = TemporaryDirectory()
|
||||
path = Path(directory.name) / "index.jsonl"
|
||||
path.write_text(json.dumps(row) + "\n", encoding="utf-8")
|
||||
return directory, path
|
||||
|
||||
def test_empty_project_index_is_valid(self):
|
||||
self.assertEqual(validate(Path("vendor/downloads/index.jsonl")), [])
|
||||
|
||||
def test_china_mirror_requires_origin(self):
|
||||
directory, path = self.write({
|
||||
"record_type": "artifact",
|
||||
"name": "demo",
|
||||
"version": "1",
|
||||
"source_url": "https://mirror.example/demo",
|
||||
"sha256": "0" * 64,
|
||||
"bytes": 1,
|
||||
"license": "MIT",
|
||||
"mirror_kind": "china-mirror",
|
||||
})
|
||||
self.addCleanup(directory.cleanup)
|
||||
errors = validate(path)
|
||||
self.assertTrue(any("origin_url" in error for error in errors))
|
||||
|
||||
def test_valid_local_cache_record(self):
|
||||
directory, path = self.write({
|
||||
"record_type": "artifact",
|
||||
"name": "demo",
|
||||
"version": "1",
|
||||
"source_url": "demo.tar",
|
||||
"sha256": "0" * 64,
|
||||
"bytes": 1,
|
||||
"license": "MIT",
|
||||
"mirror_kind": "local-cache",
|
||||
})
|
||||
self.addCleanup(directory.cleanup)
|
||||
self.assertEqual(validate(path), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
33
scripts/test_hal_runtime.py
Normal file
33
scripts/test_hal_runtime.py
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class HalRuntimeTests(unittest.TestCase):
|
||||
def test_native_hal_binary(self):
|
||||
binary = ROOT / "build-native/sim_hal_test"
|
||||
if not binary.is_file():
|
||||
self.skipTest("native build has not been generated")
|
||||
result = subprocess.run([str(binary)], cwd=ROOT, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
self.assertEqual(result.returncode, 0, result.stderr.decode(errors="replace"))
|
||||
|
||||
def test_header_declares_error_boundary(self):
|
||||
header = (ROOT / "wasm-core/include/sim_hal.h").read_text(encoding="utf-8")
|
||||
for token in ("SIM_HAL_ERR_TYPE", "SIM_HAL_ERR_FUNCTION", "SIM_HAL_ERR_REAL_EXCLUDED", "sim_hal_step"):
|
||||
self.assertIn(token, header)
|
||||
|
||||
def test_wasm_exports_hal_api(self):
|
||||
module = ROOT / "build-wasm/linuxcnc-sim-core.js"
|
||||
if not module.is_file():
|
||||
self.skipTest("WASM build has not been generated")
|
||||
text = module.read_text(encoding="utf-8")
|
||||
for symbol in ("sim_hal_register_pin", "sim_hal_register_thread", "sim_hal_step"):
|
||||
self.assertIn(symbol, text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
54
scripts/test_native_runner.py
Normal file
54
scripts/test_native_runner.py
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from native_runner import compare_golden, run_entry
|
||||
|
||||
|
||||
class NativeRunnerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = TemporaryDirectory()
|
||||
self.root = Path(self.directory.name)
|
||||
(self.root / "linuxcnc/tests/demo").mkdir(parents=True)
|
||||
(self.root / "linuxcnc/tests/demo/test.sh").write_text("#!/bin/sh\nprintf 'ok\\n'\n", encoding="utf-8")
|
||||
(self.root / "artifacts").mkdir()
|
||||
|
||||
def tearDown(self):
|
||||
self.directory.cleanup()
|
||||
|
||||
def entry(self, portability="portable_candidate"):
|
||||
return {
|
||||
"test_source_id": "test:linuxcnc/tests/demo/test.sh",
|
||||
"path": "linuxcnc/tests/demo/test.sh",
|
||||
"command": "bash linuxcnc/tests/demo/test.sh",
|
||||
"portability": portability,
|
||||
"parse_status": "parsed",
|
||||
"source_sha": "source",
|
||||
}
|
||||
|
||||
def test_fixed_runner_emits_stable_content_hashes(self):
|
||||
result = run_entry(self.root, self.entry(), self.root / "artifacts", 5)
|
||||
self.assertEqual(result["status"], "passed")
|
||||
self.assertEqual(result["stdout"], "ok\n")
|
||||
self.assertEqual(result["cwd"], "linuxcnc/tests/demo")
|
||||
self.assertRegex(result["result_sha256"], r"^[0-9a-f]{64}$")
|
||||
|
||||
def test_real_excluded_is_not_executed_without_override(self):
|
||||
result = run_entry(self.root, self.entry("REAL-EXCLUDED"), self.root / "artifacts", 5)
|
||||
self.assertEqual(result["status"], "REAL-EXCLUDED")
|
||||
self.assertNotIn("exit_code", result)
|
||||
|
||||
def test_golden_comparison_is_explicit(self):
|
||||
actual = {"status": "passed", "exit_code": 0}
|
||||
self.assertEqual(compare_golden(actual, {"schema": "native-golden.v1", "expected": actual})["status"], "passed")
|
||||
self.assertEqual(compare_golden(actual, {"schema": "native-golden.v1", "expected": {"exit_code": 1}})["status"], "different")
|
||||
self.assertEqual(compare_golden(actual, {"schema": "unknown", "expected": actual})["status"], "blocked")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
69
scripts/test_normalize_sources.py
Normal file
69
scripts/test_normalize_sources.py
Normal file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from normalize_sources import generate
|
||||
|
||||
|
||||
class NormalizeSourcesTests(unittest.TestCase):
|
||||
def create_tree(self):
|
||||
directory = TemporaryDirectory()
|
||||
root = Path(directory.name)
|
||||
(root / "coverage").mkdir()
|
||||
(root / "linuxcnc/src").mkdir(parents=True)
|
||||
(root / "docs").mkdir()
|
||||
(root / "linuxcnc/configs/sim/demo").mkdir(parents=True)
|
||||
(root / "linuxcnc/tests/demo").mkdir(parents=True)
|
||||
(root / "linuxcnc/src/demo.c").write_text("int main(void) { return 0; }\n", encoding="utf-8")
|
||||
(root / "docs/demo.md").write_text("# Demo\n", encoding="utf-8")
|
||||
(root / "linuxcnc/configs/sim/demo/main.ini").write_text("[EMC]\n", encoding="utf-8")
|
||||
(root / "linuxcnc/tests/demo/test.sh").write_text("#!/bin/sh\n", encoding="utf-8")
|
||||
indexes = {
|
||||
"source-index.jsonl": [{"bytes": 1, "kind": "source", "path": "linuxcnc/src/demo.c", "sha256": "a", "source_id": "build:demo"}],
|
||||
"document-capabilities.jsonl": [{"anchor": "demo", "capability_source_id": "doc:demo#demo", "document": "docs/demo.md", "domain": "demo", "source_sha": "b", "title": "Demo"}],
|
||||
"source-symbols.jsonl": [{"language": "c", "line": 1, "parse_status": "parsed", "path": "linuxcnc/src/demo.c", "source_sha": "a", "symbol": "main", "symbol_kind": "function", "symbol_source_id": "symbol:demo"}],
|
||||
"config-resources.jsonl": [{"edge_kind": "ini:EMC", "entrypoint": "linuxcnc/configs/sim/demo/main.ini", "line": 1, "origin_path": "linuxcnc/configs/sim/demo/main.ini", "parse_status": "resolved_file", "path": "linuxcnc/configs/sim/demo/main.ini", "resource_kind": "ini", "resource_source_id": "resource:demo", "source_sha": "c", "target_sha256": "c"}],
|
||||
"test-entries.jsonl": [{"command": "bash linuxcnc/tests/demo/test.sh", "config_refs": [], "expected": [], "inputs": [], "parse_status": "parsed", "path": "linuxcnc/tests/demo/test.sh", "portability": "portable_candidate", "source_sha": "d", "test_kind": "shell_test", "test_source_id": "test:demo"}],
|
||||
}
|
||||
for name, rows in indexes.items():
|
||||
(root / "coverage" / name).write_text("".join(json.dumps(row) + "\n" for row in rows), encoding="utf-8")
|
||||
return directory, root
|
||||
|
||||
def test_normalized_ids_are_unique_and_link_files(self):
|
||||
directory, root = self.create_tree()
|
||||
self.addCleanup(directory.cleanup)
|
||||
output = root / "coverage/normalized.jsonl"
|
||||
count, _ = generate(root, output, root / "coverage/report.json")
|
||||
rows = [json.loads(line) for line in output.read_text(encoding="utf-8").splitlines()]
|
||||
self.assertEqual(count, 5)
|
||||
self.assertEqual(len(rows), len({row["source_id"] for row in rows}))
|
||||
symbol = next(row for row in rows if row["origin_index"] == "symbol")
|
||||
self.assertIn("src:file:linuxcnc/src/demo.c", symbol["related_source_ids"])
|
||||
|
||||
def test_missing_and_hash_conflicts_are_reported(self):
|
||||
directory, root = self.create_tree()
|
||||
self.addCleanup(directory.cleanup)
|
||||
source = root / "coverage/source-index.jsonl"
|
||||
source.write_text(json.dumps({"bytes": 1, "kind": "source", "path": "linuxcnc/src/demo.c", "sha256": "different", "source_id": "build:demo"}) + "\n", encoding="utf-8")
|
||||
report_path = root / "coverage/report.json"
|
||||
generate(root, root / "out.jsonl", report_path)
|
||||
report = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
self.assertTrue(report["hash_conflicts"])
|
||||
self.assertFalse(report["missing_paths"])
|
||||
|
||||
def test_generation_is_deterministic(self):
|
||||
directory, root = self.create_tree()
|
||||
self.addCleanup(directory.cleanup)
|
||||
_, first = generate(root, root / "one.jsonl", root / "report.json")
|
||||
_, second = generate(root, root / "two.jsonl", root / "report.json")
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual((root / "one.jsonl").read_bytes(), (root / "two.jsonl").read_bytes())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
32
scripts/test_posemath_runtime.py
Normal file
32
scripts/test_posemath_runtime.py
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class PosemathRuntimeTests(unittest.TestCase):
|
||||
def test_native_posemath_binary(self):
|
||||
binary = ROOT / "build-native/sim_posemath_test"
|
||||
if not binary.is_file():
|
||||
self.skipTest("native build has not been generated")
|
||||
result = subprocess.run([str(binary)], cwd=ROOT, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
self.assertEqual(result.returncode, 0, result.stderr.decode(errors="replace"))
|
||||
|
||||
def test_wasm_exports_posemath_api(self):
|
||||
module = ROOT / "build-wasm/linuxcnc-sim-core.js"
|
||||
if not module.is_file():
|
||||
self.skipTest("WASM build has not been generated")
|
||||
text = module.read_text(encoding="utf-8")
|
||||
for symbol in ("sim_posemath_normalize", "sim_posemath_dot", "sim_posemath_line_point"):
|
||||
self.assertIn(symbol, text)
|
||||
|
||||
def test_posemath_source_is_linked(self):
|
||||
cmake = (ROOT / "wasm-core/CMakeLists.txt").read_text(encoding="utf-8")
|
||||
self.assertIn("posemath.cc", cmake)
|
||||
self.assertIn("target_link_libraries(sim_core PUBLIC sim_posemath)", cmake)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
68
scripts/test_protocol.py
Normal file
68
scripts/test_protocol.py
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Smoke tests for the fixed protocol ABI and its generated WASM exports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class ProtocolTests(unittest.TestCase):
|
||||
def test_contract_declares_versions_and_errors(self):
|
||||
header = (ROOT / "wasm-core/include/sim_protocol.h").read_text(encoding="utf-8")
|
||||
for token in ("SIM_PROTOCOL_ERR_SCHEMA", "SIM_PROTOCOL_ERR_SEQUENCE", "sim_protocol_accept", "sim_protocol_last_seq"):
|
||||
self.assertIn(token, header)
|
||||
|
||||
def test_native_protocol_binary_passes(self):
|
||||
binary = ROOT / "build-native/sim_protocol_test"
|
||||
if not binary.is_file():
|
||||
self.skipTest("native build has not been generated")
|
||||
completed = subprocess.run([str(binary)], cwd=ROOT, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
self.assertEqual(completed.returncode, 0, completed.stderr.decode(errors="replace"))
|
||||
|
||||
def test_native_event_binary_passes(self):
|
||||
binary = ROOT / "build-native/sim_event_test"
|
||||
if not binary.is_file():
|
||||
self.skipTest("native build has not been generated")
|
||||
completed = subprocess.run([str(binary)], cwd=ROOT, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
self.assertEqual(completed.returncode, 0, completed.stderr.decode(errors="replace"))
|
||||
|
||||
def test_wasm_exports_protocol_functions(self):
|
||||
module = ROOT / "build-wasm/linuxcnc-sim-core.js"
|
||||
if not module.is_file():
|
||||
self.skipTest("WASM build has not been generated")
|
||||
text = module.read_text(encoding="utf-8")
|
||||
for symbol in ("sim_protocol_reset", "sim_protocol_accept", "sim_protocol_last_seq", "sim_event_append", "sim_event_export"):
|
||||
self.assertIn(symbol, text)
|
||||
|
||||
def test_worker_session_smoke(self):
|
||||
completed = subprocess.run(
|
||||
["node", "web/worker/test_sim_session.mjs"],
|
||||
cwd=ROOT,
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(completed.returncode, 0, completed.stderr)
|
||||
self.assertIn("sim_session=passed", completed.stdout)
|
||||
|
||||
def test_real_wasm_session_smoke(self):
|
||||
completed = subprocess.run(
|
||||
["node", "web/worker/test_wasm_session.mjs"],
|
||||
cwd=ROOT,
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(completed.returncode, 0, completed.stderr)
|
||||
self.assertIn("wasm_session=passed", completed.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
59
scripts/test_source_symbols.py
Normal file
59
scripts/test_source_symbols.py
Normal file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from generate_source_symbols import generate, parse_file
|
||||
|
||||
|
||||
class SourceSymbolsTests(unittest.TestCase):
|
||||
def test_parses_c_python_and_comp_entries(self):
|
||||
with TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
for sub in ("linuxcnc/src/emc", "linuxcnc/src/hal", "linuxcnc/lib/python"):
|
||||
(root / sub).mkdir(parents=True, exist_ok=True)
|
||||
c = root / "linuxcnc/src/emc/demo.c"
|
||||
c.write_text("#define DEMO 1\nint demo(int x) { return x; }\n", encoding="utf-8")
|
||||
py = root / "linuxcnc/lib/python/demo.py"
|
||||
py.write_text("class Demo:\n async def run(self):\n pass\n", encoding="utf-8")
|
||||
comp = root / "linuxcnc/src/hal/demo.comp"
|
||||
comp.write_text("component demo;\npin in bit enable;\nfunction update;\n", encoding="utf-8")
|
||||
rows, report = parse_file(root, c)
|
||||
self.assertEqual({row["symbol"] for row in rows}, {"DEMO", "demo"})
|
||||
self.assertEqual(report["parse_status"], "parsed")
|
||||
rows, _ = parse_file(root, py)
|
||||
self.assertEqual({row["symbol_kind"] for row in rows}, {"class", "async_function"})
|
||||
rows, _ = parse_file(root, comp)
|
||||
self.assertEqual({row["symbol_kind"] for row in rows}, {"component", "pin", "function"})
|
||||
|
||||
def test_generation_is_deterministic(self):
|
||||
with TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
for sub in ("linuxcnc/src/emc", "linuxcnc/src/hal", "linuxcnc/lib/python"):
|
||||
(root / sub).mkdir(parents=True, exist_ok=True)
|
||||
(root / "linuxcnc/src/emc/demo.c").write_text("void z(void) {}\n", encoding="utf-8")
|
||||
first = root / "one.jsonl"
|
||||
second = root / "two.jsonl"
|
||||
report = root / "report.json"
|
||||
_, digest_one = generate(root, first, report)
|
||||
_, digest_two = generate(root, second, report)
|
||||
self.assertEqual(digest_one, digest_two)
|
||||
self.assertEqual(first.read_bytes(), second.read_bytes())
|
||||
|
||||
def test_decode_error_is_reported(self):
|
||||
with TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
path = root / "linuxcnc/src/emc"
|
||||
path.mkdir(parents=True)
|
||||
bad = path / "bad.c"
|
||||
bad.write_bytes(b"\xff\xfe")
|
||||
rows, report = parse_file(root, bad)
|
||||
self.assertEqual(rows, [])
|
||||
self.assertEqual(report["parse_status"], "decode_error")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
65
scripts/test_test_entries.py
Normal file
65
scripts/test_test_entries.py
Normal file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from generate_test_entries import generate
|
||||
|
||||
|
||||
class TestEntriesTests(unittest.TestCase):
|
||||
def create_tree(self):
|
||||
directory = TemporaryDirectory()
|
||||
root = Path(directory.name)
|
||||
test_dir = root / "linuxcnc/tests/demo"
|
||||
unit_dir = root / "linuxcnc/unit_tests/tp"
|
||||
test_dir.mkdir(parents=True)
|
||||
unit_dir.mkdir(parents=True)
|
||||
(test_dir / "test.sh").write_text("#!/bin/sh\nlinuxcnc demo.ini\n", encoding="utf-8")
|
||||
(test_dir / "demo.ini").write_text("[EMC]\nMACHINE = demo\n", encoding="utf-8")
|
||||
(test_dir / "expected.txt").write_text("ok\n", encoding="utf-8")
|
||||
(test_dir / "checkresult").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||
(unit_dir / "meson.build").write_text("test('blendmath', executable('test_blendmath', 'test_blendmath.c'))\n", encoding="utf-8")
|
||||
(unit_dir / "test_blendmath.c").write_text("int main(void) { return 0; }\n", encoding="utf-8")
|
||||
(root / "linuxcnc/meson.build").write_text("test('top', executable('test_top', 'x.c'))\n", encoding="utf-8")
|
||||
return directory, root
|
||||
|
||||
def test_entries_contain_contract_fields(self):
|
||||
directory, root = self.create_tree()
|
||||
self.addCleanup(directory.cleanup)
|
||||
output = root / "coverage/test-entries.jsonl"
|
||||
count, _ = generate(root, output, root / "coverage/report.json")
|
||||
self.assertGreaterEqual(count, 4)
|
||||
rows = [json.loads(line) for line in output.read_text(encoding="utf-8").splitlines()]
|
||||
shell = next(row for row in rows if row["path"].endswith("/test.sh"))
|
||||
self.assertEqual(shell["parse_status"], "parsed")
|
||||
self.assertIn("linuxcnc/tests/demo/demo.ini", shell["inputs"])
|
||||
self.assertIn("linuxcnc/tests/demo/expected.txt", shell["expected"])
|
||||
self.assertEqual(shell["portability"], "portable_candidate")
|
||||
|
||||
def test_hardware_is_real_excluded(self):
|
||||
directory, root = self.create_tree()
|
||||
self.addCleanup(directory.cleanup)
|
||||
test_path = root / "linuxcnc/tests/demo/test.sh"
|
||||
test_path.write_text("#!/bin/sh\nloadrt hostmot2\n", encoding="utf-8")
|
||||
generate(root, root / "out.jsonl", root / "report.json")
|
||||
rows = [json.loads(line) for line in (root / "out.jsonl").read_text(encoding="utf-8").splitlines()]
|
||||
shell = next(row for row in rows if row["path"].endswith("/test.sh"))
|
||||
self.assertEqual(shell["portability"], "REAL-EXCLUDED")
|
||||
|
||||
def test_generation_is_deterministic(self):
|
||||
directory, root = self.create_tree()
|
||||
self.addCleanup(directory.cleanup)
|
||||
first, digest_one = root / "one.jsonl", None
|
||||
_, digest_one = generate(root, first, root / "report.json")
|
||||
second, digest_two = root / "two.jsonl", None
|
||||
_, digest_two = generate(root, second, root / "report.json")
|
||||
self.assertEqual(digest_one, digest_two)
|
||||
self.assertEqual(first.read_bytes(), second.read_bytes())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
240
scripts/validate_baseline.py
Executable file
240
scripts/validate_baseline.py
Executable file
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate the M0 baseline and fail closed on unassigned coverage entries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
|
||||
REQUIRED_MANIFEST_FIELDS = {
|
||||
"id", "source", "source_sha", "domain", "kind", "dependencies", "inputs",
|
||||
"outputs", "errors", "sim_status", "web_status", "test_id", "oracle", "golden",
|
||||
"difference", "coverage_dimensions", "context_id", "exclusion_reason",
|
||||
}
|
||||
ALLOWED_STATUS = {"planned", "in_progress", "implemented", "verified", "accepted", "blocked", "REAL-EXCLUDED"}
|
||||
|
||||
|
||||
def read_jsonl(path: Path) -> list[dict[str, object]]:
|
||||
rows: list[dict[str, object]] = []
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
for line_number, line in enumerate(handle, 1):
|
||||
if not line.strip():
|
||||
continue
|
||||
value = json.loads(line)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{path}:{line_number}: expected object")
|
||||
rows.append(value)
|
||||
return rows
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def validate(repo_root: Path, lock_path: Path, source_path: Path, manifest_path: Path) -> list[str]:
|
||||
errors: list[str] = []
|
||||
lock = json.loads(lock_path.read_text(encoding="utf-8"))
|
||||
for field in ("context_id", "linuxcnc_version", "linuxcnc_source_sha", "emscripten_version", "schema_versions"):
|
||||
if not lock.get(field):
|
||||
errors.append(f"context lock missing {field}")
|
||||
sources = read_jsonl(source_path)
|
||||
manifests = read_jsonl(manifest_path)
|
||||
if lock.get("source_index_sha256") != sha256(source_path):
|
||||
errors.append("source index hash does not match context lock")
|
||||
if lock.get("manifest_seed_sha256") != sha256(manifest_path):
|
||||
errors.append("manifest seed hash does not match context lock")
|
||||
document_index_path = repo_root / "coverage/document-capabilities.jsonl"
|
||||
document_index_hash = lock.get("document_capabilities_sha256")
|
||||
if document_index_hash:
|
||||
if not document_index_path.is_file():
|
||||
errors.append("document capabilities index is missing")
|
||||
elif document_index_hash != sha256(document_index_path):
|
||||
errors.append("document capabilities index hash does not match context lock")
|
||||
version_path = repo_root / "linuxcnc/VERSION"
|
||||
if lock.get("linuxcnc_version") != version_path.read_text(encoding="utf-8").strip():
|
||||
errors.append("linuxcnc version does not match context lock")
|
||||
try:
|
||||
source_sha = subprocess.check_output(
|
||||
["git", "-C", str(repo_root / "linuxcnc"), "rev-parse", "HEAD"],
|
||||
text=True,
|
||||
).strip()
|
||||
except (OSError, subprocess.CalledProcessError):
|
||||
source_sha = None
|
||||
if source_sha and lock.get("linuxcnc_source_sha") != source_sha:
|
||||
errors.append("linuxcnc source commit does not match context lock")
|
||||
inventory_count = lock.get("inventory_counts", {})
|
||||
if isinstance(inventory_count, dict):
|
||||
if inventory_count.get("total") != len(sources):
|
||||
errors.append("source index count does not match context lock")
|
||||
if inventory_count.get("source_index") != len(sources):
|
||||
errors.append("source_index count does not match context lock")
|
||||
if inventory_count.get("manifest_seed") != len(manifests):
|
||||
errors.append("manifest_seed count does not match context lock")
|
||||
if document_index_hash and inventory_count.get("document_capabilities") is not None:
|
||||
if inventory_count.get("document_capabilities") != sum(
|
||||
1 for line in document_index_path.read_text(encoding="utf-8").splitlines() if line.strip()
|
||||
):
|
||||
errors.append("document_capabilities count does not match context lock")
|
||||
symbols_path = repo_root / "coverage/source-symbols.jsonl"
|
||||
symbols_hash = lock.get("source_symbols_sha256")
|
||||
if symbols_hash:
|
||||
if not symbols_path.is_file():
|
||||
errors.append("source symbols index is missing")
|
||||
elif symbols_hash != sha256(symbols_path):
|
||||
errors.append("source symbols index hash does not match context lock")
|
||||
if isinstance(inventory_count, dict) and symbols_hash and inventory_count.get("source_symbols") is not None:
|
||||
if inventory_count.get("source_symbols") != sum(1 for line in symbols_path.read_text(encoding="utf-8").splitlines() if line.strip()):
|
||||
errors.append("source_symbols count does not match context lock")
|
||||
resources_path = repo_root / "coverage/config-resources.jsonl"
|
||||
resources_hash = lock.get("config_resources_sha256")
|
||||
if resources_hash:
|
||||
if not resources_path.is_file():
|
||||
errors.append("config resources index is missing")
|
||||
elif resources_hash != sha256(resources_path):
|
||||
errors.append("config resources index hash does not match context lock")
|
||||
if isinstance(inventory_count, dict) and resources_hash and inventory_count.get("config_resources") is not None:
|
||||
if inventory_count.get("config_resources") != sum(1 for line in resources_path.read_text(encoding="utf-8").splitlines() if line.strip()):
|
||||
errors.append("config_resources count does not match context lock")
|
||||
test_entries_path = repo_root / "coverage/test-entries.jsonl"
|
||||
test_entries_hash = lock.get("test_entries_sha256")
|
||||
if test_entries_hash:
|
||||
if not test_entries_path.is_file():
|
||||
errors.append("test entries index is missing")
|
||||
elif test_entries_hash != sha256(test_entries_path):
|
||||
errors.append("test entries index hash does not match context lock")
|
||||
if isinstance(inventory_count, dict) and test_entries_hash and inventory_count.get("test_entries") is not None:
|
||||
if inventory_count.get("test_entries") != sum(1 for line in test_entries_path.read_text(encoding="utf-8").splitlines() if line.strip()):
|
||||
errors.append("test_entries count does not match context lock")
|
||||
normalized_path = repo_root / "coverage/normalized-sources.jsonl"
|
||||
normalized_hash = lock.get("normalized_sources_sha256")
|
||||
if normalized_hash:
|
||||
if not normalized_path.is_file():
|
||||
errors.append("normalized sources index is missing")
|
||||
elif normalized_hash != sha256(normalized_path):
|
||||
errors.append("normalized sources index hash does not match context lock")
|
||||
if isinstance(inventory_count, dict) and normalized_hash and inventory_count.get("normalized_sources") is not None:
|
||||
if inventory_count.get("normalized_sources") != sum(1 for line in normalized_path.read_text(encoding="utf-8").splitlines() if line.strip()):
|
||||
errors.append("normalized_sources count does not match context lock")
|
||||
closure_path = repo_root / "coverage/dependency-closure.jsonl"
|
||||
closure_hash = lock.get("dependency_closure_sha256")
|
||||
if closure_hash:
|
||||
if not closure_path.is_file():
|
||||
errors.append("dependency closure index is missing")
|
||||
elif closure_hash != sha256(closure_path):
|
||||
errors.append("dependency closure index hash does not match context lock")
|
||||
if isinstance(inventory_count, dict) and closure_hash and inventory_count.get("dependency_edges") is not None:
|
||||
if inventory_count.get("dependency_edges") != sum(1 for line in closure_path.read_text(encoding="utf-8").splitlines() if line.strip()):
|
||||
errors.append("dependency_edges count does not match context lock")
|
||||
golden_schema_path = repo_root / "coverage/golden-schema.json"
|
||||
golden_schema_hash = lock.get("golden_schema_sha256")
|
||||
if golden_schema_hash:
|
||||
if not golden_schema_path.is_file():
|
||||
errors.append("golden schema is missing")
|
||||
elif golden_schema_hash != sha256(golden_schema_path):
|
||||
errors.append("golden schema hash does not match context lock")
|
||||
runner_report_path = repo_root / "coverage/native-runner.report.json"
|
||||
runner_report_hash = lock.get("native_runner_report_sha256")
|
||||
if runner_report_hash:
|
||||
if not runner_report_path.is_file():
|
||||
errors.append("native runner report is missing")
|
||||
elif runner_report_hash != sha256(runner_report_path):
|
||||
errors.append("native runner report hash does not match context lock")
|
||||
recovery_report_path = repo_root / "coverage/workspace-recovery.report.json"
|
||||
recovery_report_hash = lock.get("workspace_recovery_report_sha256")
|
||||
if recovery_report_hash:
|
||||
if not recovery_report_path.is_file():
|
||||
errors.append("workspace recovery report is missing")
|
||||
elif recovery_report_hash != sha256(recovery_report_path):
|
||||
errors.append("workspace recovery report hash does not match context lock")
|
||||
runtime_report_path = repo_root / "coverage/runtime-mapping.report.json"
|
||||
runtime_report_hash = lock.get("runtime_mapping_report_sha256")
|
||||
if runtime_report_hash:
|
||||
if not runtime_report_path.is_file():
|
||||
errors.append("runtime mapping report is missing")
|
||||
elif runtime_report_hash != sha256(runtime_report_path):
|
||||
errors.append("runtime mapping report hash does not match context lock")
|
||||
python_manifest_path = repo_root / "python-runtime/manifest.json"
|
||||
python_manifest_hash = lock.get("python_runtime_manifest_sha256")
|
||||
if python_manifest_hash:
|
||||
if not python_manifest_path.is_file():
|
||||
errors.append("python runtime manifest is missing")
|
||||
elif python_manifest_hash != sha256(python_manifest_path):
|
||||
errors.append("python runtime manifest hash does not match context lock")
|
||||
python_report_path = repo_root / "coverage/python-runtime.report.json"
|
||||
python_report_hash = lock.get("python_runtime_report_sha256")
|
||||
if python_report_hash:
|
||||
if not python_report_path.is_file():
|
||||
errors.append("python runtime report is missing")
|
||||
elif python_report_hash != sha256(python_report_path):
|
||||
errors.append("python runtime report hash does not match context lock")
|
||||
hal_report_path = repo_root / "coverage/hal-runtime.report.json"
|
||||
hal_report_hash = lock.get("hal_runtime_report_sha256")
|
||||
if hal_report_hash:
|
||||
if not hal_report_path.is_file():
|
||||
errors.append("hal runtime report is missing")
|
||||
elif hal_report_hash != sha256(hal_report_path):
|
||||
errors.append("hal runtime report hash does not match context lock")
|
||||
posemath_report_path = repo_root / "coverage/posemath-runtime.report.json"
|
||||
posemath_report_hash = lock.get("posemath_runtime_report_sha256")
|
||||
if posemath_report_hash:
|
||||
if not posemath_report_path.is_file():
|
||||
errors.append("posemath runtime report is missing")
|
||||
elif posemath_report_hash != sha256(posemath_report_path):
|
||||
errors.append("posemath runtime report hash does not match context lock")
|
||||
source_ids = [str(row.get("source_id", "")) for row in sources]
|
||||
if len(source_ids) != len(set(source_ids)):
|
||||
errors.append("source index contains duplicate source_id")
|
||||
manifest_ids = [str(row.get("id", "")) for row in manifests]
|
||||
if len(manifest_ids) != len(set(manifest_ids)):
|
||||
errors.append("manifest contains duplicate id")
|
||||
source_sha_by_id = {str(row["source_id"]): str(row["sha256"]) for row in sources}
|
||||
manifest_sources = {str(row.get("source", "")) for row in manifests}
|
||||
missing = sorted(set(source_ids) - manifest_sources)
|
||||
if missing:
|
||||
errors.append(f"manifest has {len(missing)} orphan sources; first={missing[0]}")
|
||||
extra = sorted(manifest_sources - set(source_ids))
|
||||
if extra:
|
||||
errors.append(f"manifest has {len(extra)} unknown sources; first={extra[0]}")
|
||||
for row in manifests:
|
||||
missing_fields = REQUIRED_MANIFEST_FIELDS - row.keys()
|
||||
if missing_fields:
|
||||
errors.append(f"{row.get('id')}: missing fields {sorted(missing_fields)}")
|
||||
source_id = str(row.get("source", ""))
|
||||
if source_id in source_sha_by_id and row.get("source_sha") != source_sha_by_id[source_id]:
|
||||
errors.append(f"{row.get('id')}: source_sha does not match source index")
|
||||
for field in ("sim_status", "web_status"):
|
||||
if row.get(field) not in ALLOWED_STATUS:
|
||||
errors.append(f"{row.get('id')}: invalid {field}={row.get(field)!r}")
|
||||
if row.get("sim_status") == "accepted" or row.get("web_status") == "accepted":
|
||||
if not row.get("feature_id") or not row.get("test_id") or not row.get("oracle") or not row.get("context_id"):
|
||||
errors.append(f"{row.get('id')}: accepted entry lacks feature/test/oracle/context evidence")
|
||||
return errors
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repo-root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||
parser.add_argument("--lock", type=Path, default=Path("context-lock.json"))
|
||||
parser.add_argument("--source-index", type=Path, default=Path("coverage/source-index.jsonl"))
|
||||
parser.add_argument("--manifest", type=Path, default=Path("coverage/manifest.jsonl"))
|
||||
args = parser.parse_args()
|
||||
root = args.repo_root.resolve()
|
||||
errors = validate(root, root / args.lock, root / args.source_index, root / args.manifest)
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(f"ERROR: {error}")
|
||||
return 1
|
||||
print("baseline=valid")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
68
scripts/validate_download_index.py
Executable file
68
scripts/validate_download_index.py
Executable file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate external artifact provenance before it enters a build context."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
||||
KINDS = {"official", "china-mirror", "local-cache"}
|
||||
|
||||
|
||||
def validate(path: Path) -> list[str]:
|
||||
errors: list[str] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
for line_number, line in enumerate(handle, 1):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
errors.append(f"{path}:{line_number}: invalid JSON: {exc.msg}")
|
||||
continue
|
||||
if row.get("record_type") != "artifact":
|
||||
continue
|
||||
required = {"name", "version", "source_url", "sha256", "bytes", "license", "mirror_kind"}
|
||||
missing = sorted(required - row.keys())
|
||||
if missing:
|
||||
errors.append(f"{path}:{line_number}: missing {missing}")
|
||||
continue
|
||||
key = (str(row["name"]), str(row["version"]))
|
||||
if key in seen:
|
||||
errors.append(f"{path}:{line_number}: duplicate artifact {key[0]}@{key[1]}")
|
||||
seen.add(key)
|
||||
if not SHA256.fullmatch(str(row["sha256"])):
|
||||
errors.append(f"{path}:{line_number}: sha256 must be 64 lowercase hex characters")
|
||||
if not isinstance(row["bytes"], int) or row["bytes"] <= 0:
|
||||
errors.append(f"{path}:{line_number}: bytes must be a positive integer")
|
||||
if row["mirror_kind"] not in KINDS:
|
||||
errors.append(f"{path}:{line_number}: invalid mirror_kind")
|
||||
parsed = urlparse(str(row["source_url"]))
|
||||
if row["mirror_kind"] != "local-cache" and parsed.scheme not in {"http", "https"}:
|
||||
errors.append(f"{path}:{line_number}: source_url must be HTTP(S) for downloaded artifacts")
|
||||
if row["mirror_kind"] == "china-mirror" and not row.get("origin_url"):
|
||||
errors.append(f"{path}:{line_number}: china-mirror requires origin_url")
|
||||
return errors
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--index", type=Path, default=Path("vendor/downloads/index.jsonl"))
|
||||
args = parser.parse_args()
|
||||
errors = validate(args.index)
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(f"ERROR: {error}")
|
||||
return 1
|
||||
print(f"download_index=valid path={args.index}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user