diff --git a/.gitignore b/.gitignore index 79e3e5bf..94182016 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ mempalace/__pycache__/ # Prevent agents from writing to wrong path (see issue #1145) public/nexus/ +test-screenshots/ diff --git a/BROWSER_CONTRACT.md b/BROWSER_CONTRACT.md new file mode 100644 index 00000000..ecc18e64 --- /dev/null +++ b/BROWSER_CONTRACT.md @@ -0,0 +1,83 @@ +# Browser Contract — The Nexus + +The minimal set of guarantees a working Nexus browser surface must satisfy. +This is the target the smoke suite validates against. + +## 1. Static Assets + +The following files MUST exist at the repo root and be serveable: + +| File | Purpose | +|-------------------|----------------------------------| +| `index.html` | Entry point HTML shell | +| `app.js` | Main Three.js application | +| `style.css` | Visual styling | +| `portals.json` | Portal registry data | +| `vision.json` | Vision points data | +| `manifest.json` | PWA manifest | +| `gofai_worker.js` | GOFAI web worker | +| `server.py` | WebSocket bridge | + +## 2. DOM Contract + +The following elements MUST exist after the page loads: + +| ID | Type | Purpose | +|-----------------------|----------|------------------------------------| +| `nexus-canvas` | canvas | Three.js render target | +| `loading-screen` | div | Initial loading overlay | +| `hud` | div | Main HUD container | +| `chat-panel` | div | Chat interface panel | +| `chat-input` | input | Chat text input | +| `chat-messages` | div | Chat message history | +| `chat-send` | button | Send message button | +| `chat-toggle` | button | Collapse/expand chat | +| `debug-overlay` | div | Debug info overlay | +| `nav-mode-label` | span | Current navigation mode display | +| `ws-status-dot` | span | Hermes WS connection indicator | +| `hud-location-text` | span | Current location label | +| `portal-hint` | div | Portal proximity hint | +| `spatial-search` | div | Spatial memory search overlay | +| `enter-prompt` | div | Click-to-enter overlay (transient) | + +## 3. Three.js Contract + +After initialization completes: + +- `window` has a THREE renderer created from `#nexus-canvas` +- The canvas has a WebGL rendering context +- `scene` is a `THREE.Scene` with fog +- `camera` is a `THREE.PerspectiveCamera` +- `portals` array is populated from `portals.json` +- At least one portal mesh exists in the scene +- The render loop is running (`requestAnimationFrame` active) + +## 4. Loading Contract + +1. Page loads → loading screen visible +2. Progress bar fills to 100% +3. Loading screen fades out +4. Enter prompt appears +5. User clicks → enter prompt fades → HUD appears + +## 5. Provenance Contract + +A validation run MUST prove: + +- The served files match a known hash manifest from `Timmy_Foundation/the-nexus` main +- No file is served from `/Users/apayne/the-matrix` or other stale source +- The hash manifest is generated from a clean git checkout +- Screenshot evidence is captured and timestamped + +## 6. Data Contract + +- `portals.json` MUST parse as valid JSON array +- Each portal MUST have: `id`, `name`, `status`, `destination` +- `vision.json` MUST parse as valid JSON +- `manifest.json` MUST have `name`, `start_url`, `theme_color` + +## 7. WebSocket Contract + +- `server.py` starts without error on port 8765 +- A browser client can connect to `ws://localhost:8765` +- The connection status indicator reflects connected state diff --git a/bin/__pycache__/generate_provenance.cpython-312.pyc b/bin/__pycache__/generate_provenance.cpython-312.pyc new file mode 100644 index 00000000..f06cdcea Binary files /dev/null and b/bin/__pycache__/generate_provenance.cpython-312.pyc differ diff --git a/bin/browser_smoke.sh b/bin/browser_smoke.sh new file mode 100755 index 00000000..434d3352 --- /dev/null +++ b/bin/browser_smoke.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Browser smoke validation runner for The Nexus. +# Runs provenance checks + Playwright browser tests + screenshot capture. +# +# Usage: bash bin/browser_smoke.sh +# Env: NEXUS_TEST_PORT=9876 (default) +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +PORT="${NEXUS_TEST_PORT:-9876}" +SCREENSHOT_DIR="$REPO_ROOT/test-screenshots" +mkdir -p "$SCREENSHOT_DIR" + +echo "═══════════════════════════════════════════" +echo " Nexus Browser Smoke Validation" +echo "═══════════════════════════════════════════" + +# Step 1: Provenance check +echo "" +echo "[1/4] Provenance check..." +if python3 bin/generate_provenance.py --check; then + echo " ✓ Provenance verified" +else + echo " ✗ Provenance mismatch — files have changed since manifest was generated" + echo " Run: python3 bin/generate_provenance.py to regenerate" + exit 1 +fi + +# Step 2: Static file contract +echo "" +echo "[2/4] Static file contract..." +MISSING=0 +for f in index.html app.js style.css portals.json vision.json manifest.json gofai_worker.js; do + if [ -f "$f" ]; then + echo " ✓ $f" + else + echo " ✗ $f MISSING" + MISSING=1 + fi +done +if [ "$MISSING" -eq 1 ]; then + echo " Static file contract FAILED" + exit 1 +fi + +# Step 3: Browser tests via pytest + Playwright +echo "" +echo "[3/4] Browser tests (Playwright)..." +NEXUS_TEST_PORT=$PORT python3 -m pytest tests/test_browser_smoke.py \ + -v --tb=short -x \ + -k "not test_screenshot" \ + 2>&1 | tail -30 + +# Step 4: Screenshot capture +echo "" +echo "[4/4] Screenshot capture..." +NEXUS_TEST_PORT=$PORT python3 -m pytest tests/test_browser_smoke.py \ + -v --tb=short \ + -k "test_screenshot" \ + 2>&1 | tail -15 + +echo "" +echo "═══════════════════════════════════════════" +echo " Screenshots saved to: $SCREENSHOT_DIR/" +ls -la "$SCREENSHOT_DIR/" 2>/dev/null || echo " (none captured)" +echo "═══════════════════════════════════════════" +echo " Smoke validation complete." diff --git a/bin/generate_provenance.py b/bin/generate_provenance.py new file mode 100755 index 00000000..87aad49b --- /dev/null +++ b/bin/generate_provenance.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +""" +Generate a provenance manifest for the Nexus browser surface. +Hashes all frontend files so smoke tests can verify the app comes +from a clean Timmy_Foundation/the-nexus checkout, not stale sources. + +Usage: + python bin/generate_provenance.py # writes provenance.json + python bin/generate_provenance.py --check # verify existing manifest matches +""" +import hashlib +import json +import subprocess +import sys +import os +from datetime import datetime, timezone +from pathlib import Path + +# Files that constitute the browser-facing contract +CONTRACT_FILES = [ + "index.html", + "app.js", + "style.css", + "gofai_worker.js", + "server.py", + "portals.json", + "vision.json", + "manifest.json", +] + +# Component files imported by app.js +COMPONENT_FILES = [ + "nexus/components/spatial-memory.js", + "nexus/components/session-rooms.js", + "nexus/components/timeline-scrubber.js", + "nexus/components/memory-particles.js", +] + +ALL_FILES = CONTRACT_FILES + COMPONENT_FILES + + +def sha256_file(path: Path) -> str: + h = hashlib.sha256() + h.update(path.read_bytes()) + return h.hexdigest() + + +def get_git_info(repo_root: Path) -> dict: + """Capture git state for provenance.""" + def git(*args): + try: + r = subprocess.run( + ["git", *args], + cwd=repo_root, + capture_output=True, text=True, timeout=10, + ) + return r.stdout.strip() if r.returncode == 0 else None + except Exception: + return None + + return { + "commit": git("rev-parse", "HEAD"), + "branch": git("rev-parse", "--abbrev-ref", "HEAD"), + "remote": git("remote", "get-url", "origin"), + "dirty": git("status", "--porcelain") != "", + } + + +def generate_manifest(repo_root: Path) -> dict: + files = {} + missing = [] + for rel in ALL_FILES: + p = repo_root / rel + if p.exists(): + files[rel] = { + "sha256": sha256_file(p), + "size": p.stat().st_size, + } + else: + missing.append(rel) + + return { + "generated_at": datetime.now(timezone.utc).isoformat(), + "repo": "Timmy_Foundation/the-nexus", + "git": get_git_info(repo_root), + "files": files, + "missing": missing, + "file_count": len(files), + } + + +def check_manifest(repo_root: Path, existing: dict) -> tuple[bool, list[str]]: + """Check if current files match the stored manifest. Returns (ok, mismatches).""" + mismatches = [] + for rel, expected in existing.get("files", {}).items(): + p = repo_root / rel + if not p.exists(): + mismatches.append(f"MISSING: {rel}") + elif sha256_file(p) != expected["sha256"]: + mismatches.append(f"CHANGED: {rel}") + return (len(mismatches) == 0, mismatches) + + +def main(): + repo_root = Path(__file__).resolve().parent.parent + manifest_path = repo_root / "provenance.json" + + if "--check" in sys.argv: + if not manifest_path.exists(): + print("FAIL: provenance.json does not exist") + sys.exit(1) + existing = json.loads(manifest_path.read_text()) + ok, mismatches = check_manifest(repo_root, existing) + if ok: + print(f"OK: All {len(existing['files'])} files match provenance manifest") + sys.exit(0) + else: + print(f"FAIL: {len(mismatches)} file(s) differ:") + for m in mismatches: + print(f" {m}") + sys.exit(1) + + manifest = generate_manifest(repo_root) + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n") + print(f"Wrote provenance.json: {manifest['file_count']} files hashed") + if manifest["missing"]: + print(f" Missing (not yet created): {', '.join(manifest['missing'])}") + + +if __name__ == "__main__": + main() diff --git a/provenance.json b/provenance.json new file mode 100644 index 00000000..3db69be4 --- /dev/null +++ b/provenance.json @@ -0,0 +1,62 @@ +{ + "generated_at": "2026-04-11T01:14:54.632326+00:00", + "repo": "Timmy_Foundation/the-nexus", + "git": { + "commit": "d408d2c365a9efc0c1e3a9b38b9cc4eed75695c5", + "branch": "mimo/build/issue-686", + "remote": "https://forge.alexanderwhitestone.com/Timmy_Foundation/the-nexus.git", + "dirty": true + }, + "files": { + "index.html": { + "sha256": "71ba27afe8b6b42a09efe09d2b3017599392ddc3bc02543b31c2277dfb0b82cc", + "size": 25933 + }, + "app.js": { + "sha256": "2b765a724a0fcda29abd40ba921bc621d2699f11d0ba14cf1579cbbdafdc5cd5", + "size": 132902 + }, + "style.css": { + "sha256": "cd3068d03eed6f52a00bbc32cfae8fba4739b8b3cb194b3ec09fd747a075056d", + "size": 44198 + }, + "gofai_worker.js": { + "sha256": "d292f110aa12a8aa2b16b0c2d48e5b4ce24ee15b1cffb409ab846b1a05a91de2", + "size": 969 + }, + "server.py": { + "sha256": "e963cc9715accfc8814e3fe5c44af836185d66740d5a65fd0365e9c629d38e05", + "size": 4185 + }, + "portals.json": { + "sha256": "889a5e0f724eb73a95f960bca44bca232150bddff7c1b11f253bd056f3683a08", + "size": 3442 + }, + "vision.json": { + "sha256": "0e3b5c06af98486bbcb2fc2dc627dc8b7b08aed4c3a4f9e10b57f91e1e8ca6ad", + "size": 1658 + }, + "manifest.json": { + "sha256": "352304c4f7746f5d31cbc223636769969dd263c52800645c01024a3a8489d8c9", + "size": 495 + }, + "nexus/components/spatial-memory.js": { + "sha256": "60170f6490ddd743acd6d285d3a1af6cad61fbf8aaef3f679ff4049108eac160", + "size": 32782 + }, + "nexus/components/session-rooms.js": { + "sha256": "9997a60dda256e38cb4645508bf9e98c15c3d963b696e0080e3170a9a7fa7cf1", + "size": 15113 + }, + "nexus/components/timeline-scrubber.js": { + "sha256": "f8a17762c2735be283dc5074b13eb00e1e3b2b04feb15996c2cf0323b46b6014", + "size": 7177 + }, + "nexus/components/memory-particles.js": { + "sha256": "1be5567a3ebb229f9e1a072c08a25387ade87cb4a1df6a624e5c5254d3bef8fa", + "size": 14216 + } + }, + "missing": [], + "file_count": 12 +} diff --git a/tests/test_browser_smoke.py b/tests/test_browser_smoke.py new file mode 100644 index 00000000..642675c3 --- /dev/null +++ b/tests/test_browser_smoke.py @@ -0,0 +1,293 @@ +""" +Browser smoke tests for the Nexus 3D world. +Uses Playwright to verify the DOM contract, Three.js initialization, +portal loading, and loading screen flow. + +Refs: #686 +""" +import json +import os +import subprocess +import time +from pathlib import Path + +import pytest +from playwright.sync_api import sync_playwright, expect + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCREENSHOT_DIR = REPO_ROOT / "test-screenshots" + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def http_server(): + """Start a simple HTTP server for the Nexus static files.""" + import http.server + import threading + + port = int(os.environ.get("NEXUS_TEST_PORT", "9876")) + handler = http.server.SimpleHTTPRequestHandler + server = http.server.HTTPServer(("127.0.0.1", port), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + time.sleep(0.3) + yield f"http://127.0.0.1:{port}" + server.shutdown() + + +@pytest.fixture(scope="module") +def browser_page(http_server): + """Launch a headless browser and navigate to the Nexus.""" + SCREENSHOT_DIR.mkdir(exist_ok=True) + with sync_playwright() as pw: + browser = pw.chromium.launch( + headless=True, + args=["--no-sandbox", "--disable-gpu"], + ) + context = browser.new_context( + viewport={"width": 1280, "height": 720}, + ignore_https_errors=True, + ) + page = context.new_page() + # Collect console errors + console_errors = [] + page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None) + page.goto(http_server, wait_until="domcontentloaded", timeout=30000) + page._console_errors = console_errors + yield page + browser.close() + + +# --------------------------------------------------------------------------- +# Static asset tests +# --------------------------------------------------------------------------- + +class TestStaticAssets: + """Verify all contract files are serveable.""" + + REQUIRED_FILES = [ + "index.html", + "app.js", + "style.css", + "portals.json", + "vision.json", + "manifest.json", + "gofai_worker.js", + ] + + def test_index_html_served(self, http_server): + """index.html must return 200.""" + import urllib.request + resp = urllib.request.urlopen(f"{http_server}/index.html") + assert resp.status == 200 + + @pytest.mark.parametrize("filename", REQUIRED_FILES) + def test_contract_file_served(self, http_server, filename): + """Each contract file must return 200.""" + import urllib.request + try: + resp = urllib.request.urlopen(f"{http_server}/{filename}") + assert resp.status == 200 + except Exception as e: + pytest.fail(f"{filename} not serveable: {e}") + + +# --------------------------------------------------------------------------- +# DOM contract tests +# --------------------------------------------------------------------------- + +class TestDOMContract: + """Verify required DOM elements exist after page load.""" + + REQUIRED_ELEMENTS = { + "nexus-canvas": "canvas", + "hud": "div", + "chat-panel": "div", + "chat-input": "input", + "chat-messages": "div", + "chat-send": "button", + "chat-toggle": "button", + "debug-overlay": "div", + "nav-mode-label": "span", + "ws-status-dot": "span", + "hud-location-text": "span", + "portal-hint": "div", + "spatial-search": "div", + } + + @pytest.mark.parametrize("element_id,tag", list(REQUIRED_ELEMENTS.items())) + def test_element_exists(self, browser_page, element_id, tag): + """Element with given ID must exist in the DOM.""" + el = browser_page.query_selector(f"#{element_id}") + assert el is not None, f"#{element_id} ({tag}) missing from DOM" + + def test_canvas_has_webgl(self, browser_page): + """The nexus-canvas must have a WebGL rendering context.""" + has_webgl = browser_page.evaluate(""" + () => { + const c = document.getElementById('nexus-canvas'); + if (!c) return false; + const ctx = c.getContext('webgl2') || c.getContext('webgl'); + return ctx !== null; + } + """) + assert has_webgl, "nexus-canvas has no WebGL context" + + def test_title_contains_nexus(self, browser_page): + """Page title should reference The Nexus.""" + title = browser_page.title() + assert "nexus" in title.lower() or "timmy" in title.lower(), f"Unexpected title: {title}" + + +# --------------------------------------------------------------------------- +# Loading flow tests +# --------------------------------------------------------------------------- + +class TestLoadingFlow: + """Verify the loading screen → enter prompt → HUD flow.""" + + def test_loading_screen_transitions(self, browser_page): + """Loading screen should fade out and HUD should become visible.""" + # Wait for loading to complete and enter prompt to appear + try: + browser_page.wait_for_selector("#enter-prompt", state="visible", timeout=15000) + except Exception: + # Enter prompt may have already appeared and been clicked + pass + + # Try clicking the enter prompt if it exists + enter = browser_page.query_selector("#enter-prompt") + if enter and enter.is_visible(): + enter.click() + time.sleep(1) + + # HUD should now be visible + hud = browser_page.query_selector("#hud") + assert hud is not None, "HUD element missing" + # After enter, HUD display should not be 'none' + display = browser_page.evaluate("() => document.getElementById('hud').style.display") + assert display != "none", "HUD should be visible after entering" + + +# --------------------------------------------------------------------------- +# Three.js initialization tests +# --------------------------------------------------------------------------- + +class TestThreeJSInit: + """Verify Three.js initialized properly.""" + + def test_three_loaded(self, browser_page): + """THREE namespace should be available (via import map).""" + # Three.js is loaded as ES module, check for canvas context instead + has_canvas = browser_page.evaluate(""" + () => { + const c = document.getElementById('nexus-canvas'); + return c && c.width > 0 && c.height > 0; + } + """) + assert has_canvas, "Canvas not properly initialized" + + def test_canvas_dimensions(self, browser_page): + """Canvas should fill the viewport.""" + dims = browser_page.evaluate(""" + () => { + const c = document.getElementById('nexus-canvas'); + return { width: c.width, height: c.height, ww: window.innerWidth, wh: window.innerHeight }; + } + """) + assert dims["width"] > 0, "Canvas width is 0" + assert dims["height"] > 0, "Canvas height is 0" + + +# --------------------------------------------------------------------------- +# Data contract tests +# --------------------------------------------------------------------------- + +class TestDataContract: + """Verify JSON data files are valid and well-formed.""" + + def test_portals_json_valid(self): + """portals.json must parse as a non-empty JSON array.""" + data = json.loads((REPO_ROOT / "portals.json").read_text()) + assert isinstance(data, list), "portals.json must be an array" + assert len(data) > 0, "portals.json must have at least one portal" + + def test_portals_have_required_fields(self): + """Each portal must have id, name, status, destination.""" + data = json.loads((REPO_ROOT / "portals.json").read_text()) + required = {"id", "name", "status", "destination"} + for i, portal in enumerate(data): + missing = required - set(portal.keys()) + assert not missing, f"Portal {i} missing fields: {missing}" + + def test_vision_json_valid(self): + """vision.json must parse as valid JSON.""" + data = json.loads((REPO_ROOT / "vision.json").read_text()) + assert data is not None + + def test_manifest_json_valid(self): + """manifest.json must have required PWA fields.""" + data = json.loads((REPO_ROOT / "manifest.json").read_text()) + for key in ["name", "start_url", "theme_color"]: + assert key in data, f"manifest.json missing '{key}'" + + +# --------------------------------------------------------------------------- +# Screenshot / visual proof +# --------------------------------------------------------------------------- + +class TestVisualProof: + """Capture screenshots as visual validation evidence.""" + + def test_screenshot_initial_state(self, browser_page): + """Take a screenshot of the initial page state.""" + path = SCREENSHOT_DIR / "smoke-initial.png" + browser_page.screenshot(path=str(path)) + assert path.exists(), "Screenshot was not saved" + assert path.stat().st_size > 1000, "Screenshot seems empty" + + def test_screenshot_after_enter(self, browser_page): + """Take a screenshot after clicking through the enter prompt.""" + enter = browser_page.query_selector("#enter-prompt") + if enter and enter.is_visible(): + enter.click() + time.sleep(2) + else: + time.sleep(1) + + path = SCREENSHOT_DIR / "smoke-post-enter.png" + browser_page.screenshot(path=str(path)) + assert path.exists() + + def test_screenshot_fullscreen(self, browser_page): + """Full-page screenshot for visual regression baseline.""" + path = SCREENSHOT_DIR / "smoke-fullscreen.png" + browser_page.screenshot(path=str(path), full_page=True) + assert path.exists() + + +# --------------------------------------------------------------------------- +# Provenance in browser context +# --------------------------------------------------------------------------- + +class TestBrowserProvenance: + """Verify provenance from within the browser context.""" + + def test_page_served_from_correct_origin(self, http_server): + """The page must be served from localhost, not a stale remote.""" + import urllib.request + resp = urllib.request.urlopen(f"{http_server}/index.html") + content = resp.read().decode("utf-8", errors="replace") + # Must not contain references to legacy matrix path + assert "/Users/apayne/the-matrix" not in content, \ + "index.html references legacy matrix path — provenance violation" + + def test_index_html_has_nexus_title(self, http_server): + """index.html title must reference The Nexus.""" + import urllib.request + resp = urllib.request.urlopen(f"{http_server}/index.html") + content = resp.read().decode("utf-8", errors="replace") + assert "The Nexus" in content or "Timmy" in content, \ + "index.html title does not reference The Nexus" diff --git a/tests/test_provenance.py b/tests/test_provenance.py new file mode 100644 index 00000000..c666e1ea --- /dev/null +++ b/tests/test_provenance.py @@ -0,0 +1,73 @@ +""" +Provenance tests — verify the Nexus browser surface comes from +a clean Timmy_Foundation/the-nexus checkout, not stale sources. + +Refs: #686 +""" +import json +import hashlib +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def test_provenance_manifest_exists() -> None: + """provenance.json must exist and be valid JSON.""" + p = REPO_ROOT / "provenance.json" + assert p.exists(), "provenance.json missing — run bin/generate_provenance.py" + data = json.loads(p.read_text()) + assert "files" in data + assert "repo" in data + + +def test_provenance_repo_identity() -> None: + """Manifest must claim Timmy_Foundation/the-nexus.""" + data = json.loads((REPO_ROOT / "provenance.json").read_text()) + assert data["repo"] == "Timmy_Foundation/the-nexus" + + +def test_provenance_all_contract_files_present() -> None: + """Every file listed in the provenance manifest must exist on disk.""" + data = json.loads((REPO_ROOT / "provenance.json").read_text()) + missing = [] + for rel in data["files"]: + if not (REPO_ROOT / rel).exists(): + missing.append(rel) + assert not missing, f"Contract files missing: {missing}" + + +def test_provenance_hashes_match() -> None: + """File hashes must match the stored manifest (no stale/modified files).""" + data = json.loads((REPO_ROOT / "provenance.json").read_text()) + mismatches = [] + for rel, meta in data["files"].items(): + p = REPO_ROOT / rel + if not p.exists(): + mismatches.append(f"MISSING: {rel}") + continue + actual = hashlib.sha256(p.read_bytes()).hexdigest() + if actual != meta["sha256"]: + mismatches.append(f"CHANGED: {rel}") + assert not mismatches, f"Provenance mismatch:\n" + "\n".join(mismatches) + + +def test_no_legacy_matrix_references_in_frontend() -> None: + """Frontend files must not reference /Users/apayne/the-matrix as a source.""" + forbidden_paths = ["/Users/apayne/the-matrix"] + offenders = [] + for rel in ["index.html", "app.js", "style.css"]: + p = REPO_ROOT / rel + if p.exists(): + content = p.read_text() + for bad in forbidden_paths: + if bad in content: + offenders.append(f"{rel} references {bad}") + assert not offenders, f"Legacy matrix references found: {offenders}" + + +def test_no_stale_perplexity_computer_references_in_critical_files() -> None: + """Verify the provenance generator script itself is canonical.""" + script = REPO_ROOT / "bin" / "generate_provenance.py" + assert script.exists(), "bin/generate_provenance.py must exist" + content = script.read_text() + assert "Timmy_Foundation/the-nexus" in content