Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
67 changes: 67 additions & 0 deletions scripts/check-type-sync.sh
Original file line number Diff line number Diff line change
@@ -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
;;
Comment on lines +33 to +42

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n scripts/check-type-sync.sh

Repository: OmniNode-ai/omnidash-v2

Length of output: 2571


Use arrays for argument passing to avoid word-splitting and enable explicit validation.

The current string-based approach ("--omnibase-core $2") combined with unquoted expansion at line 66-67 will fail with paths containing spaces or special characters. Additionally, the script lacks validation that the second argument exists before accessing $2 (lines 36, 40, 52, 55).

Converting to arrays with explicit argument validation is the correct approach, especially since SC2086 is already being suppressed (line 63), indicating the original implementation acknowledged this concern.

Suggested fix
-OMNIBASE_CORE_ARG=""
-OMNIINTELLIGENCE_ARG=""
+OMNIBASE_CORE_ARG=()
+OMNIINTELLIGENCE_ARG=()

 while [[ $# -gt 0 ]]; do
   case "$1" in
     --omnibase-core)
-      OMNIBASE_CORE_ARG="--omnibase-core $2"
+      [[ $# -lt 2 ]] && { echo "--omnibase-core requires a PATH" >&2; exit 1; }
+      OMNIBASE_CORE_ARG=(--omnibase-core "$2")
       shift 2
       ;;
     --omniintelligence)
-      OMNIINTELLIGENCE_ARG="--omniintelligence $2"
+      [[ $# -lt 2 ]] && { echo "--omniintelligence requires a PATH" >&2; exit 1; }
+      OMNIINTELLIGENCE_ARG=(--omniintelligence "$2")
       shift 2
       ;;
@@
-if [[ -z "$OMNIBASE_CORE_ARG" && -n "${OMNIBASE_CORE_PATH:-}" ]]; then
-  OMNIBASE_CORE_ARG="--omnibase-core $OMNIBASE_CORE_PATH"
+if [[ ${`#OMNIBASE_CORE_ARG`[@]} -eq 0 && -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"
+if [[ ${`#OMNIINTELLIGENCE_ARG`[@]} -eq 0 && -n "${OMNIINTELLIGENCE_PATH:-}" ]]; then
+  OMNIINTELLIGENCE_ARG=(--omniintelligence "$OMNIINTELLIGENCE_PATH")
 fi
@@
-# shellcheck disable=SC2086
 python3 "$OMNIDASH_ROOT/scripts/check_type_sync.py" \
   --omnidash "$OMNIDASH_ROOT" \
-  $OMNIBASE_CORE_ARG \
-  $OMNIINTELLIGENCE_ARG
+  "${OMNIBASE_CORE_ARG[@]}" \
+  "${OMNIINTELLIGENCE_ARG[@]}"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/check-type-sync.sh` around lines 33 - 42, Replace the string-based
argument accumulation for OMNIBASE_CORE_ARG and OMNIINTELLIGENCE_ARG with arrays
and validate the presence of the next parameter before consuming it: in the
while/case handling where OMNIBASE_CORE_ARG="--omnibase-core $2" and
OMNIINTELLIGENCE_ARG="--omniintelligence $2", check that $2 is not empty (return
an error or usage message if missing) and assign like
OMNIBASE_CORE_ARG=(--omnibase-core "$2") and
OMNIINTELLIGENCE_ARG=(--omniintelligence "$2") so later expansions use quoted
array expansion (e.g. "${OMNIBASE_CORE_ARG[@]}") to avoid word-splitting and
handle spaces/special characters correctly; update any later usages (lines
referenced as 66-67 in the comment) to expand the arrays instead of unquoted
strings.

*)
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
283 changes: 283 additions & 0 deletions scripts/check_type_sync.py
Original file line number Diff line number Diff line change
@@ -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.)
Comment on lines +42 to +46

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Descriptor docs and runtime keys are out of sync.

Lines 42-46 document python_path/ts_name, but Lines 225-231 read name/python_rel/python_repo/ts_path. This mismatch can lead to misconfigured TARGETS and future KeyErrors when targets are added.

Suggested doc fix
 # Each entry maps:
-#   python_path  — path relative to the repo root (passed via args)
+#   python_repo  — one of: "omnibase_core" | "omniintelligence"
+#   python_rel   — path relative to the selected python repo root
 #   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
+#   name         — human-readable name for log output
 #   exclude_fields — fields intentionally absent in TS (base-class internals, etc.)

Also applies to: 225-231

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/check_type_sync.py` around lines 42 - 46, The docstring describing
target dict keys is out of sync with the runtime keys used by TARGETS: update
the documentation block that lists
python_path/ts_path/model_class/ts_name/exclude_fields to instead document the
actual keys the script expects (name, python_rel, python_repo, ts_path,
model_class, ts_name or exclude_fields as applicable), or alternatively change
the runtime to accept the documented keys; locate the TARGETS variable and the
code that reads name/python_rel/python_repo/ts_path to ensure the docs and key
names match and adjust whichever side (doc or code) is easier to maintain.



# 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

Comment on lines +235 to +242

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Do not silently skip when model extraction returns no fields.

On Line 235, treating extraction failure as a warning and continuing can produce false-green CI when python_rel/model_class is wrong. This should fail the check.

Suggested fix
-        if not all_fields:
-            print(
-                f"  WARN: No fields extracted from {target['model_class']} — "
-                "skipping (file missing or class not found)"
-            )
-            print()
-            continue
+        if not all_fields:
+            print(
+                f"  FAIL: No fields extracted from {target['model_class']} "
+                "(file missing, parse error, or class not found)"
+            )
+            overall_pass = False
+            print()
+            continue
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if not all_fields:
print(
f" WARN: No fields extracted from {target['model_class']} — "
"skipping (file missing or class not found)"
)
print()
continue
if not all_fields:
print(
f" FAIL: No fields extracted from {target['model_class']} "
"(file missing, parse error, or class not found)"
)
overall_pass = False
print()
continue
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/check_type_sync.py` around lines 235 - 242, The check currently
treats missing extracted fields as a warning and continues, which masks config
errors; change the behavior so missing fields from extraction for
target['model_class'] causes the script to fail: replace the "print warning +
continue" branch (the if not all_fields block) to log an explicit error (to
stderr) and exit with a non-zero status (e.g., call sys.exit(1) or raise a
RuntimeError). Ensure you reference and preserve the same variables (all_fields
and target['model_class']), and add an import for sys if using sys.exit.

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())

Comment on lines +248 to +252

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check if the file exists and examine the relevant section
head -260 scripts/check_type_sync.py | tail -20

Repository: OmniNode-ai/omnidash-v2

Length of output: 820


🏁 Script executed:

# Get more context around line 248-252
sed -n '240,260p' scripts/check_type_sync.py

Repository: OmniNode-ai/omnidash-v2

Length of output: 840


🏁 Script executed:

# Search for where exclude_fields is used to understand its type
rg "exclude_fields" scripts/check_type_sync.py -B 2 -A 2

Repository: OmniNode-ai/omnidash-v2

Length of output: 490


🏁 Script executed:

# Search for where target is populated to understand the structure
rg "target\[.*exclude_fields" scripts/check_type_sync.py

Repository: OmniNode-ai/omnidash-v2

Length of output: 49


🏁 Script executed:

# Look for where target is constructed/populated with exclude_fields
rg "exclude_fields" scripts/check_type_sync.py -B 5 -A 5

Repository: OmniNode-ai/omnidash-v2

Length of output: 1160


🏁 Script executed:

# Look at the beginning of the function to understand target structure
sed -n '1,100p' scripts/check_type_sync.py | head -80

Repository: OmniNode-ai/omnidash-v2

Length of output: 3000


🏁 Script executed:

# Search for type hints or schema validation on target
rg "target" scripts/check_type_sync.py | head -20

Repository: OmniNode-ai/omnidash-v2

Length of output: 942


🏁 Script executed:

# Look for where target comes from - search for the main function/loop
sed -n '200,240p' scripts/check_type_sync.py

Repository: OmniNode-ai/omnidash-v2

Length of output: 1496


🏁 Script executed:

# Check if TARGETS is populated elsewhere or has any examples
rg "TARGETS" scripts/check_type_sync.py -B 2 -A 2

Repository: OmniNode-ai/omnidash-v2

Length of output: 592


🏁 Script executed:

# Look for any test files or configuration that might populate TARGETS
fd -type f -name "*.py" | xargs rg "exclude_fields" 2>/dev/null | head -20

Repository: OmniNode-ai/omnidash-v2

Length of output: 466


🏁 Script executed:

# Check the full TARGETS definition more carefully
sed -n '50,80p' scripts/check_type_sync.py

Repository: OmniNode-ai/omnidash-v2

Length of output: 1236


🏁 Script executed:

# Look for type hints on target dict structure
sed -n '1,250p' scripts/check_type_sync.py | grep -A 20 "^TARGETS"

Repository: OmniNode-ai/omnidash-v2

Length of output: 722


🏁 Script executed:

# Check if there's any TypedDict or type hint for target structure
rg "TypedDict|dict\[" scripts/check_type_sync.py

Repository: OmniNode-ai/omnidash-v2

Length of output: 49


🏁 Script executed:

# Look at how required_fields is handled to see if similar defensive coding is used
sed -n '245,265p' scripts/check_type_sync.py

Repository: OmniNode-ai/omnidash-v2

Length of output: 907


🏁 Script executed:

# Test what happens when we subtract a list from a set in Python
python3 << 'EOF'
# Test the actual behavior
all_fields = {"a", "b", "c"}

# Current code behavior - with set default
try:
    result = all_fields - {"b"}
    print(f"Set subtraction works: {result}")
except TypeError as e:
    print(f"Set subtraction failed: {e}")

# What happens if exclude_fields is a list
try:
    result = all_fields - ["b"]
    print(f"List subtraction works: {result}")
except TypeError as e:
    print(f"List subtraction failed: {e}")

# What happens if exclude_fields is a tuple
try:
    result = all_fields - ("b",)
    print(f"Tuple subtraction works: {result}")
except TypeError as e:
    print(f"Tuple subtraction failed: {e}")

# Proposed fix - convert to set first
try:
    excluded = set(["b", "c"])
    result = all_fields - excluded
    print(f"Defensive set conversion works: {result}")
except TypeError as e:
    print(f"Defensive conversion failed: {e}")
EOF

Repository: OmniNode-ai/omnidash-v2

Length of output: 295


Coerce exclude_fields to set before subtraction to handle list/tuple values.

Line 251 will fail with TypeError if exclude_fields is configured as a list or tuple instead of a set. The get() default of set() only applies if the key is missing; it does not coerce the actual value. For consistency with how required_fields is handled (wrapped in set() on line 250), normalize exclude_fields defensively.

Suggested fix
         if target.get("required_fields"):
             required = set(target["required_fields"])
         else:
-            required = all_fields - target.get("exclude_fields", set())
+            excluded = set(target.get("exclude_fields", []))
+            required = all_fields - excluded
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/check_type_sync.py` around lines 248 - 252, The subtraction
all_fields - target.get("exclude_fields", set()) can raise TypeError if
exclude_fields is a list/tuple; normalize the value to a set like you do for
required_fields. Update the logic that sets required (the branch using
target.get("exclude_fields")) to coerce exclude_fields into a set before
subtracting (e.g., use set(target.get("exclude_fields", set()))), ensuring
required_fields handling (set(target["required_fields"])) remains unchanged.

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())
9 changes: 7 additions & 2 deletions tsconfig.node.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
Loading
Loading