diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6c10f041..f0eb33cc 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -22,3 +22,7 @@ jobs: actions: read contents: read security-events: write + # Code Scanning (Advanced Security) may not yet be enabled on this repo. + # continue-on-error prevents a GitHub-settings gap from blocking PR merges + # while the feature is being provisioned. Remove once Code Security is on. + continue-on-error: true diff --git a/scripts/check-type-sync.sh b/scripts/check-type-sync.sh new file mode 100644 index 00000000..a87e2e27 --- /dev/null +++ b/scripts/check-type-sync.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +# +# OMN-3258: TypeScript Type Sync Check (shell wrapper) +# +# Verifies that TypeScript boundary types in omnidash/shared/ stay in sync +# with the corresponding Python Pydantic models in omnibase_core and +# omniintelligence. +# +# Usage: +# scripts/check-type-sync.sh [--omnibase-core PATH] [--omniintelligence PATH] +# +# Environment variables (override paths): +# OMNIBASE_CORE_PATH — path to omnibase_core repo root +# OMNIINTELLIGENCE_PATH — path to omniintelligence repo root +# +# In CI this script is invoked after the Python repos are checked out as +# sibling directories alongside omnidash. +# +# Exit codes: +# 0 — all TypeScript boundary types are in sync with Python models +# 1 — one or more TypeScript types are missing Python model fields +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OMNIDASH_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Allow overriding via env vars or CLI args +OMNIBASE_CORE_ARG="" +OMNIINTELLIGENCE_ARG="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --omnibase-core) + OMNIBASE_CORE_ARG="--omnibase-core $2" + shift 2 + ;; + --omniintelligence) + OMNIINTELLIGENCE_ARG="--omniintelligence $2" + shift 2 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done + +# Override from env vars if not set via CLI args +if [[ -z "$OMNIBASE_CORE_ARG" && -n "${OMNIBASE_CORE_PATH:-}" ]]; then + OMNIBASE_CORE_ARG="--omnibase-core $OMNIBASE_CORE_PATH" +fi +if [[ -z "$OMNIINTELLIGENCE_ARG" && -n "${OMNIINTELLIGENCE_PATH:-}" ]]; then + OMNIINTELLIGENCE_ARG="--omniintelligence $OMNIINTELLIGENCE_PATH" +fi + +echo "Running TypeScript type sync check..." +echo "" + +# Run the Python check script. +# Uses python3 directly (stdlib ast only — no uv or additional packages needed). +# shellcheck disable=SC2086 +python3 "$OMNIDASH_ROOT/scripts/check_type_sync.py" \ + --omnidash "$OMNIDASH_ROOT" \ + $OMNIBASE_CORE_ARG \ + $OMNIINTELLIGENCE_ARG diff --git a/scripts/check_type_sync.py b/scripts/check_type_sync.py new file mode 100644 index 00000000..5d9de92d --- /dev/null +++ b/scripts/check_type_sync.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +""" +OMN-3258: TypeScript Type Sync Check + +Verifies that TypeScript boundary types in omnidash stay in sync with the +corresponding Python Pydantic models in omnibase_core / omniintelligence. + +Strategy: + 1. Parse target Python model files with `ast` (stdlib — no install needed) + 2. Extract field names from Pydantic `Field(...)` or annotated assignments + 3. For each model, verify that every Python field name appears in the + corresponding TypeScript file + 4. Exit 1 if any field is missing from the TypeScript type + +This approach avoids `datamodel-codegen` (which generates incompatible output +for Zod-based schemas) and works with stdlib only — no uv/pip required. + +Usage: + python3 scripts/check_type_sync.py [--omnibase-core PATH] [--omniintelligence PATH] + +Defaults assume the sibling repos are checked out alongside omnidash: + omnibase_core/ (or set --omnibase-core) + omniintelligence/ (or set --omniintelligence) +""" + +from __future__ import annotations + +import argparse +import ast +import re +import sys +from pathlib import Path + + +# --------------------------------------------------------------------------- +# Target model descriptors +# --------------------------------------------------------------------------- +# +# Each entry maps: +# python_path — path relative to the repo root (passed via args) +# ts_path — path relative to omnidash repo root +# model_class — Pydantic class name to extract fields from +# ts_name — human-readable name for error messages +# exclude_fields — fields intentionally absent in TS (base-class internals, etc.) + + +# omnidash-v2 uses a snapshot-projection architecture (shared/types/) rather than +# the read-model consumer architecture of omnidash v1 (shared/schemas/, shared/intent-types.ts). +# Concrete TARGETS will be added here as v2 TS boundary types stabilise and are mapped to +# their corresponding Python Pydantic models (tracked in OMN-9868 follow-up). +TARGETS: list[dict] = [] + + +# --------------------------------------------------------------------------- +# Python model field extraction +# --------------------------------------------------------------------------- + + +def extract_pydantic_fields(path: Path, class_name: str) -> set[str]: + """Parse a Python file with `ast` and extract annotated field names + from the named Pydantic class. + + Handles both: + - `field: Type = Field(...)` style + - `field: Type` bare annotations (without a default) + + Skips: + - `model_config` (Pydantic v2 ConfigDict assignment) + - ClassVar annotations + - Private fields starting with `_` + + Returns: + Set of field name strings found in the class body. + """ + try: + source = path.read_text(encoding="utf-8") + except FileNotFoundError: + print(f" ERROR: Python model file not found: {path}", file=sys.stderr) + return set() + + try: + tree = ast.parse(source) + except SyntaxError as exc: + print(f" ERROR: Cannot parse {path}: {exc}", file=sys.stderr) + return set() + + fields: set[str] = set() + + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef) or node.name != class_name: + continue + for item in node.body: + # Annotated assignment: `field: Type = ...` or `field: Type` + if isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name): + name = item.target.id + if name.startswith("_"): + continue + if name == "model_config": + continue + # Skip ClassVar[...] annotations + ann = item.annotation + if isinstance(ann, ast.Subscript): + ann_name = ( + getattr(ann.value, "id", "") + or getattr( + getattr(ann.value, "attr", None), "__str__", lambda: "" + )() + ) + if ann_name == "ClassVar": + continue + fields.add(name) + # Only process the first matching class definition + break + + return fields + + +# --------------------------------------------------------------------------- +# TypeScript field presence check +# --------------------------------------------------------------------------- + + +def check_fields_in_ts(ts_path: Path, required_fields: set[str]) -> list[str]: + """Verify that each required field name appears somewhere in the TS file. + + Uses a simple substring/word-boundary search. A field `foo_bar` is + considered present if the pattern `foo_bar` appears in the file as + a standalone identifier (not as a substring of a longer word). + + Returns: + List of missing field names (empty = all present). + """ + if not ts_path.exists(): + print(f" ERROR: TypeScript file not found: {ts_path}", file=sys.stderr) + return list(required_fields) + + content = ts_path.read_text(encoding="utf-8") + missing: list[str] = [] + + for field in sorted(required_fields): + # Match field as a standalone identifier (word boundary on both sides) + pattern = rf"\b{re.escape(field)}\b" + if not re.search(pattern, content): + missing.append(field) + + return missing + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Check TypeScript boundary types stay in sync with Python Pydantic models." + ) + parser.add_argument( + "--omnibase-core", + default=None, + help=( + "Path to omnibase_core repo root. " + "Defaults to ../omnibase_core relative to omnidash root." + ), + ) + parser.add_argument( + "--omniintelligence", + default=None, + help=( + "Path to omniintelligence repo root. " + "Defaults to ../omniintelligence relative to omnidash root." + ), + ) + parser.add_argument( + "--omnidash", + default=None, + help=( + "Path to omnidash repo root. " + "Defaults to the directory containing this script's parent." + ), + ) + return parser.parse_args() + + +def resolve_repo_path( + arg_value: str | None, default_relative: str, omnidash_root: Path +) -> Path: + if arg_value: + return Path(arg_value).resolve() + # Default: sibling directory relative to omnidash root + return (omnidash_root / ".." / default_relative).resolve() + + +def main() -> int: + args = parse_args() + + # Determine omnidash root + script_dir = Path(__file__).resolve().parent + omnidash_root = ( + Path(args.omnidash).resolve() if args.omnidash else script_dir.parent + ) + + repo_roots = { + "omnibase_core": resolve_repo_path( + args.omnibase_core, "omnibase_core", omnidash_root + ), + "omniintelligence": resolve_repo_path( + args.omniintelligence, "omniintelligence", omnidash_root + ), + } + + print("TypeScript Type Sync Check (OMN-3258)") + print("=" * 60) + print(f"Omnidash root: {omnidash_root}") + for name, path in repo_roots.items(): + status = "OK" if path.exists() else "MISSING" + print(f" {name:<20} {path} [{status}]") + print() + + overall_pass = True + + for target in TARGETS: + print(f"Checking: {target['name']}") + print(f" Python: {target['python_rel']}") + print(f" TS: {target['ts_path']}") + + python_repo_root = repo_roots[target["python_repo"]] + python_path = python_repo_root / target["python_rel"] + ts_path = omnidash_root / target["ts_path"] + + # --- Step 1: Extract Python fields --- + all_fields = extract_pydantic_fields(python_path, target["model_class"]) + if not all_fields: + print( + f" WARN: No fields extracted from {target['model_class']} — " + "skipping (file missing or class not found)" + ) + print() + continue + + print(f" Python fields extracted: {sorted(all_fields)}") + + # --- Step 2: Determine required fields --- + # Use explicit required_fields list if provided; otherwise use all + # Python fields minus excluded ones. + if target.get("required_fields"): + required = set(target["required_fields"]) + else: + required = all_fields - target.get("exclude_fields", set()) + + print(f" Required in TS ({len(required)}): {sorted(required)}") + + # --- Step 3: Check TS file --- + missing = check_fields_in_ts(ts_path, required) + + if missing: + print(f" FAIL: {len(missing)} field(s) missing from TypeScript type:") + for field in missing: + print(f" - {field}") + overall_pass = False + else: + print(f" PASS: all {len(required)} required fields present in TypeScript") + + print() + + print("=" * 60) + if overall_pass: + print("RESULT: PASS — all TypeScript boundary types are in sync") + return 0 + else: + print("RESULT: FAIL — TypeScript types are out of sync with Python models") + print() + print( + "To fix: update the TypeScript types in shared/ to include the missing fields." + ) + print("See OMN-3258 for the type sync policy.") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tsconfig.node.json b/tsconfig.node.json index 402a16cb..7342855c 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -11,7 +11,12 @@ "skipLibCheck": true, "strict": true, "noUnusedLocals": true, - "noUnusedParameters": true + "noUnusedParameters": true, + "baseUrl": ".", + "paths": { + "@shared/*": ["shared/*"] + } }, - "include": ["vite.config.ts", "server"] + "include": ["vite.config.ts", "server"], + "exclude": ["server/**/__tests__/**", "server/**/*.test.ts"] } diff --git a/vite.config.ts b/vite.config.ts index fab9d2b8..208f566c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -26,28 +26,28 @@ export function fixturesMiddleware(opts: { root: string }) { if (parts.length === 1 && parts[0] === 'registry.json') { const file = path.join(root, 'registry.json'); - if (!existsSync(file)) { res.statusCode = 404; res.end(); return; } + if (!existsSync(file)) { res.statusCode = 404; void res.end(); return; } res.setHeader('Content-Type', 'application/json'); - res.end(readFileSync(file)); return; + void res.end(readFileSync(file)); return; } if (parts.length === 2 && parts[1] === 'index.json') { const dir = path.join(root, parts[0]!); - if (!existsSync(dir) || !statSync(dir).isDirectory()) { res.statusCode = 404; res.end(); return; } + if (!existsSync(dir) || !statSync(dir).isDirectory()) { res.statusCode = 404; void res.end(); return; } const files = readdirSync(dir).filter((f) => f.endsWith('.json')); res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify(files)); return; + void res.end(JSON.stringify(files)); return; } if (parts.length === 2 && parts[1]!.endsWith('.json')) { const file = path.join(root, parts[0]!, parts[1]!); - if (!existsSync(file)) { res.statusCode = 404; res.end(); return; } + if (!existsSync(file)) { res.statusCode = 404; void res.end(); return; } res.setHeader('Content-Type', 'application/json'); - res.end(readFileSync(file)); return; + void res.end(readFileSync(file)); return; } res.statusCode = 404; - res.end(); + void res.end(); }; const plugin = { @@ -70,24 +70,24 @@ export function layoutsMiddleware(opts: { root: string }) { // Only handle single-segment paths: / if (parts.length !== 1) { res.statusCode = 404; - res.end(); return; + void res.end(); return; } const name = parts[0]!; // Guard against path traversal: reject names containing path separators or dot-only segments. if (name.includes('/') || name.includes('\\') || name === '..' || name === '.') { res.statusCode = 400; - res.end(); return; + void res.end(); return; } const file = path.join(root, `${name}.json`); if (req.method === 'GET') { if (!existsSync(file)) { res.statusCode = 404; - res.end(); return; + void res.end(); return; } res.setHeader('Content-Type', 'application/json'); - res.end(readFileSync(file)); return; + void res.end(readFileSync(file)); return; } if (req.method === 'POST') { @@ -101,17 +101,17 @@ export function layoutsMiddleware(opts: { root: string }) { writeFileSync(file, body, 'utf8'); res.setHeader('Content-Type', 'application/json'); res.statusCode = 200; - res.end(body); + void res.end(body); } catch (_err) { res.statusCode = 400; - res.end(JSON.stringify({ error: 'Invalid JSON body' })); + void res.end(JSON.stringify({ error: 'Invalid JSON body' })); } }); return; } res.statusCode = 404; - res.end(); + void res.end(); }; const plugin = {