Spaces:
Sleeping
Sleeping
Commit ·
c65d575
1
Parent(s): e82c2b6
Initial Space scaffold from simready-oem-library-pm@e82c2b6
Browse files- Dockerfile +59 -0
- tools/hf_space/.gitignore +6 -0
- tools/hf_space/Dockerfile +59 -0
- tools/hf_space/README.md +178 -0
- tools/hf_space/app.py +108 -0
- tools/hf_space/requirements.txt +12 -0
- tools/hf_space/runner.py +279 -0
- tools/validation/.claude-plugin/marketplace.json +16 -0
- tools/validation/PROBLEMS.md +327 -0
- tools/validation/README.md +139 -0
- tools/validation/UPSTREAM.md +66 -0
- tools/validation/_sync.sh +98 -0
- tools/validation/bootstrap.ps1 +207 -0
- tools/validation/playbooks/foundations-deviations.md +591 -0
- tools/validation/plugins/simready-report/plugin.json +11 -0
- tools/validation/plugins/simready-report/skills/simready-package/SKILL.md +62 -0
- tools/validation/plugins/simready-report/skills/simready-package/package.py +143 -0
- tools/validation/plugins/simready-report/skills/simready-report/SKILL.md +137 -0
- tools/validation/plugins/simready-report/skills/simready-report/_kit_wrapper.py +33 -0
- tools/validation/plugins/simready-report/skills/simready-report/external_deps.py +259 -0
- tools/validation/plugins/simready-report/skills/simready-report/report.py +850 -0
- tools/validation/plugins/simready-report/skills/simready-report/validate.py +1243 -0
Dockerfile
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SimReady Validator — HuggingFace Space image
|
| 2 |
+
#
|
| 3 |
+
# Phase-3 of the PRD: validation runs where the dataset lives. The Space
|
| 4 |
+
# downloads a target dataset via huggingface_hub, runs the bundled
|
| 5 |
+
# simready-report validator with `--no-use-kit` (no Isaac Sim on HF
|
| 6 |
+
# hardware), and writes the verdict back to the dataset as a PR.
|
| 7 |
+
#
|
| 8 |
+
# Image strategy: pre-bake all heavy deps (usd-core, omni.asset_validator,
|
| 9 |
+
# simready-validate) and the simready-foundation specs checkout into the
|
| 10 |
+
# image so cold-start is fast — the per-validation cost is just the
|
| 11 |
+
# dataset download + USD parsing, not pip resolution.
|
| 12 |
+
|
| 13 |
+
FROM python:3.11-slim
|
| 14 |
+
|
| 15 |
+
# Base build deps. usd-core ships wheels for x86_64 so we don't need
|
| 16 |
+
# build-essential, but git is required to clone simready-foundation at
|
| 17 |
+
# build time and rsync is handy for the dashboard `_static/` mirror.
|
| 18 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 19 |
+
git curl ca-certificates rsync \
|
| 20 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 21 |
+
|
| 22 |
+
# HF Spaces run as a non-root user (UID 1000). Match it so the volume
|
| 23 |
+
# mounts are writable without permission gymnastics.
|
| 24 |
+
ARG USER=appuser
|
| 25 |
+
RUN useradd -m -u 1000 ${USER}
|
| 26 |
+
|
| 27 |
+
WORKDIR /home/${USER}/app
|
| 28 |
+
|
| 29 |
+
# Python deps first so they layer-cache independently of the validator
|
| 30 |
+
# code. Pinned to the same versions install-simready-sdk.sh uses on the
|
| 31 |
+
# DGXC runner (single source of truth for "what runs the validator").
|
| 32 |
+
COPY tools/hf_space/requirements.txt ./requirements.txt
|
| 33 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 34 |
+
|
| 35 |
+
# Foundation specs: the validator wants SIMREADY_FOUNDATIONS_PATH to
|
| 36 |
+
# point at a checkout. Pinned tag avoids surprise spec churn between
|
| 37 |
+
# Space builds. Bump the tag deliberately when foundation rules change.
|
| 38 |
+
ENV SIMREADY_FOUNDATIONS_PATH=/opt/simready_foundations
|
| 39 |
+
RUN git clone --depth 1 https://github.com/NVIDIA/simready-foundation \
|
| 40 |
+
${SIMREADY_FOUNDATIONS_PATH} \
|
| 41 |
+
&& chown -R ${USER}:${USER} ${SIMREADY_FOUNDATIONS_PATH}
|
| 42 |
+
|
| 43 |
+
# Copy the bundled validator (the same code our DGXC runner uses) and
|
| 44 |
+
# the Space's own entry points. Keep the in-repo path so the validator's
|
| 45 |
+
# relative imports (report.py, external_deps.py) still resolve.
|
| 46 |
+
COPY tools/validation /home/${USER}/app/tools/validation
|
| 47 |
+
COPY tools/hf_space/app.py ./app.py
|
| 48 |
+
COPY tools/hf_space/runner.py ./runner.py
|
| 49 |
+
RUN chown -R ${USER}:${USER} /home/${USER}/app
|
| 50 |
+
|
| 51 |
+
USER ${USER}
|
| 52 |
+
|
| 53 |
+
# HF Spaces with the Docker SDK serve whatever listens on $PORT
|
| 54 |
+
# (default 7860). Gradio honors GRADIO_SERVER_PORT.
|
| 55 |
+
ENV GRADIO_SERVER_NAME=0.0.0.0
|
| 56 |
+
ENV GRADIO_SERVER_PORT=7860
|
| 57 |
+
EXPOSE 7860
|
| 58 |
+
|
| 59 |
+
CMD ["python", "app.py"]
|
tools/hf_space/.gitignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Local artifacts when iterating on the Space outside Docker.
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
.gradio/
|
| 5 |
+
gradio_cached_examples/
|
| 6 |
+
flagged/
|
tools/hf_space/Dockerfile
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SimReady Validator — HuggingFace Space image
|
| 2 |
+
#
|
| 3 |
+
# Phase-3 of the PRD: validation runs where the dataset lives. The Space
|
| 4 |
+
# downloads a target dataset via huggingface_hub, runs the bundled
|
| 5 |
+
# simready-report validator with `--no-use-kit` (no Isaac Sim on HF
|
| 6 |
+
# hardware), and writes the verdict back to the dataset as a PR.
|
| 7 |
+
#
|
| 8 |
+
# Image strategy: pre-bake all heavy deps (usd-core, omni.asset_validator,
|
| 9 |
+
# simready-validate) and the simready-foundation specs checkout into the
|
| 10 |
+
# image so cold-start is fast — the per-validation cost is just the
|
| 11 |
+
# dataset download + USD parsing, not pip resolution.
|
| 12 |
+
|
| 13 |
+
FROM python:3.11-slim
|
| 14 |
+
|
| 15 |
+
# Base build deps. usd-core ships wheels for x86_64 so we don't need
|
| 16 |
+
# build-essential, but git is required to clone simready-foundation at
|
| 17 |
+
# build time and rsync is handy for the dashboard `_static/` mirror.
|
| 18 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 19 |
+
git curl ca-certificates rsync \
|
| 20 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 21 |
+
|
| 22 |
+
# HF Spaces run as a non-root user (UID 1000). Match it so the volume
|
| 23 |
+
# mounts are writable without permission gymnastics.
|
| 24 |
+
ARG USER=appuser
|
| 25 |
+
RUN useradd -m -u 1000 ${USER}
|
| 26 |
+
|
| 27 |
+
WORKDIR /home/${USER}/app
|
| 28 |
+
|
| 29 |
+
# Python deps first so they layer-cache independently of the validator
|
| 30 |
+
# code. Pinned to the same versions install-simready-sdk.sh uses on the
|
| 31 |
+
# DGXC runner (single source of truth for "what runs the validator").
|
| 32 |
+
COPY tools/hf_space/requirements.txt ./requirements.txt
|
| 33 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 34 |
+
|
| 35 |
+
# Foundation specs: the validator wants SIMREADY_FOUNDATIONS_PATH to
|
| 36 |
+
# point at a checkout. Pinned tag avoids surprise spec churn between
|
| 37 |
+
# Space builds. Bump the tag deliberately when foundation rules change.
|
| 38 |
+
ENV SIMREADY_FOUNDATIONS_PATH=/opt/simready_foundations
|
| 39 |
+
RUN git clone --depth 1 https://github.com/NVIDIA/simready-foundation \
|
| 40 |
+
${SIMREADY_FOUNDATIONS_PATH} \
|
| 41 |
+
&& chown -R ${USER}:${USER} ${SIMREADY_FOUNDATIONS_PATH}
|
| 42 |
+
|
| 43 |
+
# Copy the bundled validator (the same code our DGXC runner uses) and
|
| 44 |
+
# the Space's own entry points. Keep the in-repo path so the validator's
|
| 45 |
+
# relative imports (report.py, external_deps.py) still resolve.
|
| 46 |
+
COPY tools/validation /home/${USER}/app/tools/validation
|
| 47 |
+
COPY tools/hf_space/app.py ./app.py
|
| 48 |
+
COPY tools/hf_space/runner.py ./runner.py
|
| 49 |
+
RUN chown -R ${USER}:${USER} /home/${USER}/app
|
| 50 |
+
|
| 51 |
+
USER ${USER}
|
| 52 |
+
|
| 53 |
+
# HF Spaces with the Docker SDK serve whatever listens on $PORT
|
| 54 |
+
# (default 7860). Gradio honors GRADIO_SERVER_PORT.
|
| 55 |
+
ENV GRADIO_SERVER_NAME=0.0.0.0
|
| 56 |
+
ENV GRADIO_SERVER_PORT=7860
|
| 57 |
+
EXPOSE 7860
|
| 58 |
+
|
| 59 |
+
CMD ["python", "app.py"]
|
tools/hf_space/README.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SimReady Validator — HuggingFace Space (Phase-3 Spike)
|
| 2 |
+
|
| 3 |
+
This directory scaffolds an HF Space that runs the bundled
|
| 4 |
+
[`simready-report`](../validation/plugins/simready-report/) validator
|
| 5 |
+
against any HF dataset, then opens a verdict PR back on the dataset.
|
| 6 |
+
|
| 7 |
+
It is the **phase-3 prove-it step** described in [PRD §3](../../PRD.md):
|
| 8 |
+
move validation execution to where the dataset already lives, so we stop
|
| 9 |
+
paying to copy 20 GiB of customer assets onto NVIDIA-controlled
|
| 10 |
+
infrastructure on every run.
|
| 11 |
+
|
| 12 |
+
| | DGXC runner today | HF Space (this dir) |
|
| 13 |
+
|---|---|---|
|
| 14 |
+
| Asset transfer | 10–20 GiB per submission onto a 49 GiB PVC | None — `huggingface_hub.snapshot_download` reads from HF storage directly |
|
| 15 |
+
| Cost model | NVIDIA pays for the runner | Customer pays for their Space's hardware hours |
|
| 16 |
+
| Concurrency | Single runner, jobs serialized | One Space per dataset → scales linearly |
|
| 17 |
+
| Where verdicts land | `docs/dashboard/data/status.json` in this repo | `validation/results.json` in the dataset, via PR |
|
| 18 |
+
| Trigger | GitHub Actions `workflow_dispatch` | Gradio UI (spike) → HF Hub webhook (next) |
|
| 19 |
+
|
| 20 |
+
The Space is **internal pilot scope**: the HF_TOKEN that opens the verdict
|
| 21 |
+
PR is the Space's own secret, not the requester's. A customer-facing
|
| 22 |
+
end-state would either (a) deploy one Space per partner under their org,
|
| 23 |
+
or (b) keep a single multi-tenant Space and have customers pass their own
|
| 24 |
+
token explicitly.
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
## What's here
|
| 29 |
+
|
| 30 |
+
| File | Purpose |
|
| 31 |
+
|---|---|
|
| 32 |
+
| `Dockerfile` | Docker SDK image: pip-installs the validator runtime, clones `NVIDIA/simready-foundation` for `SIMREADY_FOUNDATIONS_PATH`, bakes in the in-repo `tools/validation/` skill |
|
| 33 |
+
| `requirements.txt` | Python deps. Pinned to match `tools/runner/install-simready-sdk.sh` so verdicts are byte-for-byte reproducible across environments |
|
| 34 |
+
| `app.py` | Gradio UI. Single form: dataset name + profile + version + open-PR checkbox |
|
| 35 |
+
| `runner.py` | Orchestration. `run(dataset, profile, version, open_pr) → RunResult` — also the entry point a future webhook handler will call |
|
| 36 |
+
|
| 37 |
+
The validator engine itself is **unchanged** — the Space invokes the same
|
| 38 |
+
`tools/validation/plugins/simready-report/skills/simready-report/validate.py`
|
| 39 |
+
that Windows users run locally and that the DGXC runner runs in CI.
|
| 40 |
+
That's the whole point of phase 3: the verdict logic is portable; only
|
| 41 |
+
the trigger surface changes.
|
| 42 |
+
|
| 43 |
+
---
|
| 44 |
+
|
| 45 |
+
## Hardware tier choice
|
| 46 |
+
|
| 47 |
+
The validator's heavy work is USD parsing + composition-arc traversal,
|
| 48 |
+
which is CPU-bound. **GPU is only required if a profile re-execs under
|
| 49 |
+
Kit (Isaac Sim)**, which the Space's `--no-use-kit` flag explicitly
|
| 50 |
+
disables. The validator's P2 patch drops the
|
| 51 |
+
`physxschema_unavailable` / `omnipbr_unresolved` issues that the
|
| 52 |
+
no-Kit path produces, so PhysX-bearing profiles still report a clean
|
| 53 |
+
verdict for everything that can be checked without Kit.
|
| 54 |
+
|
| 55 |
+
| Tier | $/hr | Verdict |
|
| 56 |
+
|---|---|---|
|
| 57 |
+
| `cpu-basic` (2 vCPU, 16 GB) | $0.03 | Marginal — small datasets OK, 50+ asset bundles will time out |
|
| 58 |
+
| **`cpu-upgrade`** (8 vCPU, 32 GB) | **$0.05** | **Recommended for the spike.** Comfortable headroom; the validator's parallel worker pool actually scales here |
|
| 59 |
+
| `t4-small` (1 × T4, 4 vCPU, 15 GB) | $0.40 | Only needed once we add Kit; overkill for `--no-use-kit` |
|
| 60 |
+
| `a10g-small` (1 × A10G, 4 vCPU, 15 GB) | $1.05 | Future state: enables Kit-rooted PhysX/MDL rules (currently filtered out by the P2 patch) |
|
| 61 |
+
|
| 62 |
+
Set the tier in the Space's **Settings → Hardware** page (or in
|
| 63 |
+
`README.md` frontmatter when the Space repo is created — see Deploy).
|
| 64 |
+
|
| 65 |
+
---
|
| 66 |
+
|
| 67 |
+
## Deploy
|
| 68 |
+
|
| 69 |
+
The Space is **not deployed yet** — this is a scaffold living in the
|
| 70 |
+
internal repo. To stand it up:
|
| 71 |
+
|
| 72 |
+
### 1. Create the Space `[BROWSER]`
|
| 73 |
+
|
| 74 |
+
1. Sign in at https://huggingface.co with an account that has write
|
| 75 |
+
access to wherever the Space will live (an NVIDIA org for the
|
| 76 |
+
internal pilot).
|
| 77 |
+
2. New → Space.
|
| 78 |
+
3. Name: `simready-validator` (or any name).
|
| 79 |
+
4. SDK: **Docker**.
|
| 80 |
+
5. Hardware: **CPU upgrade** (~$0.05/hr) for the spike.
|
| 81 |
+
6. Visibility: **Private** while internal-pilot.
|
| 82 |
+
|
| 83 |
+
### 2. Set the HF_TOKEN secret `[BROWSER]`
|
| 84 |
+
|
| 85 |
+
The Space needs a write-scoped token to open the verdict PR on customer
|
| 86 |
+
datasets.
|
| 87 |
+
|
| 88 |
+
1. https://huggingface.co/settings/tokens → New token → **Write** scope.
|
| 89 |
+
2. Space → Settings → Variables and secrets → New secret.
|
| 90 |
+
3. Name: `HF_TOKEN`. Value: paste the token.
|
| 91 |
+
|
| 92 |
+
Tokens are not exposed in the build log. The `runner.py` code reads it
|
| 93 |
+
via `os.environ["HF_TOKEN"]` (with `HUGGING_FACE_HUB_TOKEN` as a
|
| 94 |
+
fallback for compatibility with the HF SDK's standard env name).
|
| 95 |
+
|
| 96 |
+
### 3. Push the code `[LOCAL]`
|
| 97 |
+
|
| 98 |
+
The Space is a git repo of its own. From this checkout:
|
| 99 |
+
|
| 100 |
+
```bash
|
| 101 |
+
# Replace <space-name> with the Space you created (e.g. nvidia/simready-validator)
|
| 102 |
+
hf auth login # one-time
|
| 103 |
+
git clone https://huggingface.co/spaces/<space-name> /tmp/space
|
| 104 |
+
cp -r tools/hf_space/* /tmp/space/
|
| 105 |
+
# Important: the Dockerfile COPYs tools/validation/ from the repo root,
|
| 106 |
+
# so we have to vendor that subtree into the Space repo too.
|
| 107 |
+
mkdir -p /tmp/space/tools
|
| 108 |
+
cp -r tools/validation /tmp/space/tools/
|
| 109 |
+
cd /tmp/space
|
| 110 |
+
git add .
|
| 111 |
+
git commit -m "Initial Space scaffold from simready-oem-library-pm@main"
|
| 112 |
+
git push
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
The Space will start building automatically. First build takes ~5 min
|
| 116 |
+
(usd-core + omniverse-asset-validator wheels + the foundation clone).
|
| 117 |
+
Subsequent builds reuse Docker layer cache and finish in ~1 min if only
|
| 118 |
+
`app.py` or `runner.py` changed.
|
| 119 |
+
|
| 120 |
+
### 4. Smoke-test `[BROWSER]`
|
| 121 |
+
|
| 122 |
+
Open the Space's URL. Enter a known-good dataset (the foundation
|
| 123 |
+
clone's bundled examples work well — point at something small like a
|
| 124 |
+
single-asset dataset first), pick **Robot-Body-Runnable**, leave
|
| 125 |
+
**Open PR** unchecked, click **Validate**. Watch the log stream.
|
| 126 |
+
|
| 127 |
+
Expected output ends with:
|
| 128 |
+
|
| 129 |
+
```
|
| 130 |
+
PASS: 1/1 assets passed
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
…and a downloadable `index.html` report.
|
| 134 |
+
|
| 135 |
+
If the verdict makes sense, re-run with **Open PR** checked against a
|
| 136 |
+
dataset you have write access to. A new PR appears on the dataset under
|
| 137 |
+
`https://huggingface.co/datasets/<dataset>/discussions` with the
|
| 138 |
+
verdict body + the `validation/` subtree.
|
| 139 |
+
|
| 140 |
+
---
|
| 141 |
+
|
| 142 |
+
## What this spike intentionally does NOT do
|
| 143 |
+
|
| 144 |
+
To stay scoped to "prove the engine works on HF":
|
| 145 |
+
|
| 146 |
+
- **No HF Hub webhook.** Triggering is Gradio-only. Phase 3.2 wires
|
| 147 |
+
`https://<space>/api/run` to `dataset.commit.push` events.
|
| 148 |
+
- **No status callback into this repo.** The DGXC dashboard's
|
| 149 |
+
`hf-watch.yml` already polls dataset commits — once the Space lands
|
| 150 |
+
verdicts as `validation/results.json` on the dataset, the existing
|
| 151 |
+
watcher picks them up. No new integration needed.
|
| 152 |
+
- **No Kit re-exec.** Profiles requiring PhysX/MDL rules currently
|
| 153 |
+
report partial verdicts (the P2 patch drops env-blocked rules). A
|
| 154 |
+
future iteration with an `a10g-small` tier + Isaac Sim wheels in the
|
| 155 |
+
Dockerfile unlocks these.
|
| 156 |
+
- **No multi-tenant token isolation.** The Space's own `HF_TOKEN` opens
|
| 157 |
+
every PR. Fine for internal pilot; needs rework before exposing the
|
| 158 |
+
Space outside NVIDIA.
|
| 159 |
+
|
| 160 |
+
These are tracked in [PRD §7 — roadmap](../../PRD.md).
|
| 161 |
+
|
| 162 |
+
---
|
| 163 |
+
|
| 164 |
+
## Cutover criteria (when do we retire the DGXC runner?)
|
| 165 |
+
|
| 166 |
+
Stop using `hf-validate.yml` once **all three** are true:
|
| 167 |
+
|
| 168 |
+
1. The Space verdict matches the DGXC verdict on the same dataset for
|
| 169 |
+
the top three onboarded clients (imagineio kitchens, plus two TBD).
|
| 170 |
+
Byte-for-byte equality on `results.json` is the bar.
|
| 171 |
+
2. The HF Hub webhook handler (phase 3.2) is in place and a customer
|
| 172 |
+
can open a dataset PR and see a verdict comment without anyone at
|
| 173 |
+
NVIDIA pressing a button.
|
| 174 |
+
3. The PRD §7 roadmap items 3.3 (auto-merge on pass) and 3.4 (block-on-fail
|
| 175 |
+
gate at the GitHub side) are wired through, so the GitHub coordinator
|
| 176 |
+
only deals with state — never validation.
|
| 177 |
+
|
| 178 |
+
Until then both paths coexist; the DGXC runner is the source of truth.
|
tools/hf_space/app.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SimReady Validator — Gradio UI for the HuggingFace Space.
|
| 2 |
+
|
| 3 |
+
Manual-trigger surface for the spike. A future webhook handler (HF Hub
|
| 4 |
+
webhook → `/api/run`) reuses `runner.run()` directly without the UI.
|
| 5 |
+
|
| 6 |
+
The Space is internal-pilot scope: HF_TOKEN comes from the Space's
|
| 7 |
+
secrets, NOT from the requester. When a customer's dataset PR triggers
|
| 8 |
+
this (next milestone), the webhook payload identifies the dataset and
|
| 9 |
+
the Space's own token opens the verdict PR.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
import gradio as gr
|
| 17 |
+
|
| 18 |
+
from runner import run as run_validator
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
PROFILE_CHOICES = [
|
| 22 |
+
"Robot-Body-Runnable",
|
| 23 |
+
"Robot-Body-Physx",
|
| 24 |
+
"Robot-Body-Isaac",
|
| 25 |
+
"Prop-Robotics-Physx",
|
| 26 |
+
"Prop-Robotics-Isaac",
|
| 27 |
+
"Prop-Static",
|
| 28 |
+
]
|
| 29 |
+
DEFAULT_PROFILE = "Robot-Body-Runnable"
|
| 30 |
+
DEFAULT_VERSION = "1.0.0"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _run_streaming(dataset: str, profile: str, version: str, open_pr: bool):
|
| 34 |
+
"""Generator that yields incremental log output to the UI as the
|
| 35 |
+
validator runs. Gradio streams each yielded tuple to the connected
|
| 36 |
+
outputs."""
|
| 37 |
+
lines: list[str] = []
|
| 38 |
+
|
| 39 |
+
def log(line: str) -> None:
|
| 40 |
+
lines.append(line)
|
| 41 |
+
|
| 42 |
+
yield "\n".join(lines), "", "(running…)", None
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
result = run_validator(
|
| 46 |
+
dataset=dataset.strip(),
|
| 47 |
+
profile=profile,
|
| 48 |
+
version=version.strip() or DEFAULT_VERSION,
|
| 49 |
+
open_pr=open_pr,
|
| 50 |
+
log=log,
|
| 51 |
+
)
|
| 52 |
+
except Exception as e:
|
| 53 |
+
lines.append(f"\nERROR: {type(e).__name__}: {e}")
|
| 54 |
+
yield "\n".join(lines), "", f"error: {e}", None
|
| 55 |
+
return
|
| 56 |
+
|
| 57 |
+
status_badge = f"**{result.status.upper()}** — {result.summary}"
|
| 58 |
+
if result.pr_url:
|
| 59 |
+
status_badge += f"\n\nPR: {result.pr_url}"
|
| 60 |
+
|
| 61 |
+
report_index = result.report_path / "index.html"
|
| 62 |
+
report_url = str(report_index) if report_index.is_file() else None
|
| 63 |
+
|
| 64 |
+
yield (
|
| 65 |
+
"\n".join(lines),
|
| 66 |
+
status_badge,
|
| 67 |
+
result.summary,
|
| 68 |
+
report_url,
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
with gr.Blocks(title="SimReady Validator") as demo:
|
| 73 |
+
gr.Markdown(
|
| 74 |
+
"# SimReady Validator\n"
|
| 75 |
+
"Validate a HuggingFace dataset against a SimReady profile. "
|
| 76 |
+
"Reads the dataset directly from HF storage — no copy onto NVIDIA "
|
| 77 |
+
"infrastructure. With **Open PR** enabled, the verdict is uploaded "
|
| 78 |
+
"back to the dataset as a `validation/` pull request."
|
| 79 |
+
)
|
| 80 |
+
with gr.Row():
|
| 81 |
+
dataset = gr.Textbox(
|
| 82 |
+
label="Dataset",
|
| 83 |
+
placeholder="org/dataset (e.g. imagineio/PhysicalAI-SimReady-Kitchens-v1)",
|
| 84 |
+
)
|
| 85 |
+
with gr.Row():
|
| 86 |
+
profile = gr.Dropdown(
|
| 87 |
+
choices=PROFILE_CHOICES, value=DEFAULT_PROFILE, label="Profile",
|
| 88 |
+
)
|
| 89 |
+
version = gr.Textbox(label="Version", value=DEFAULT_VERSION)
|
| 90 |
+
open_pr = gr.Checkbox(label="Open PR on dataset with verdict", value=False)
|
| 91 |
+
run_btn = gr.Button("Validate", variant="primary")
|
| 92 |
+
status_md = gr.Markdown(label="Verdict")
|
| 93 |
+
summary_box = gr.Textbox(label="Summary", interactive=False)
|
| 94 |
+
log_box = gr.Textbox(label="Log", lines=20, interactive=False)
|
| 95 |
+
report_link = gr.File(label="HTML report (download)", interactive=False)
|
| 96 |
+
|
| 97 |
+
run_btn.click(
|
| 98 |
+
fn=_run_streaming,
|
| 99 |
+
inputs=[dataset, profile, version, open_pr],
|
| 100 |
+
outputs=[log_box, status_md, summary_box, report_link],
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
if __name__ == "__main__":
|
| 105 |
+
demo.queue().launch(
|
| 106 |
+
server_name=os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0"),
|
| 107 |
+
server_port=int(os.environ.get("GRADIO_SERVER_PORT", "7860")),
|
| 108 |
+
)
|
tools/hf_space/requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Runtime deps for the SimReady validator on HuggingFace Spaces.
|
| 2 |
+
# Match the versions install-simready-sdk.sh pins on the DGXC runner so
|
| 3 |
+
# verdicts are byte-for-byte reproducible across environments.
|
| 4 |
+
|
| 5 |
+
gradio>=4.0
|
| 6 |
+
huggingface_hub>=0.34
|
| 7 |
+
|
| 8 |
+
# Validator runtime
|
| 9 |
+
usd-core
|
| 10 |
+
omniverse-asset-validator
|
| 11 |
+
markdown-it-py
|
| 12 |
+
simready-validate>=2026.4.8
|
tools/hf_space/runner.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Orchestrator: dataset name → validator → verdict PR on the dataset.
|
| 2 |
+
|
| 3 |
+
Phase-3 of the PRD. Same flow the DGXC `tools/hf_watch/validate.py`
|
| 4 |
+
performs, minus:
|
| 5 |
+
- the Claude Code subprocess wrapper (we call the validator directly,
|
| 6 |
+
keeping the agentic-decision layer for our internal coordinator path)
|
| 7 |
+
- the status.json patching (the HF Space is per-dataset; the
|
| 8 |
+
coordinator dashboard polls verdicts back via its existing watcher)
|
| 9 |
+
- the GitHub commit step (we open an HF Dataset PR instead)
|
| 10 |
+
|
| 11 |
+
The validator engine itself is unchanged — it's the same simready-report
|
| 12 |
+
skill that runs on Windows, on DGXC, and now here.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import dataclasses
|
| 17 |
+
import json
|
| 18 |
+
import os
|
| 19 |
+
import shlex
|
| 20 |
+
import shutil
|
| 21 |
+
import subprocess
|
| 22 |
+
import sys
|
| 23 |
+
import tempfile
|
| 24 |
+
from datetime import datetime, timezone
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
from typing import Iterator
|
| 27 |
+
|
| 28 |
+
from huggingface_hub import HfApi, snapshot_download
|
| 29 |
+
|
| 30 |
+
VALIDATOR = (
|
| 31 |
+
Path(__file__).resolve().parent
|
| 32 |
+
/ "tools" / "validation" / "plugins" / "simready-report"
|
| 33 |
+
/ "skills" / "simready-report" / "validate.py"
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
# Same exclude set the DGXC path uses. HF datasets ship a lot of bulk the
|
| 37 |
+
# validator doesn't open; skipping shrinks downloads from tens-of-GB to
|
| 38 |
+
# hundreds-of-MB on assets like nvidia/PhysicalAI-SimReady-Warehouse-01.
|
| 39 |
+
HF_DOWNLOAD_EXCLUDES = (
|
| 40 |
+
".thumbs/*",
|
| 41 |
+
"images/*",
|
| 42 |
+
"*_renders/*", "renders/*",
|
| 43 |
+
"*.mp4", "*.mov", "*.webm",
|
| 44 |
+
"*.jpg", "*.jpeg", "*.png", "*.gif", "*.tiff", "*.tif",
|
| 45 |
+
"*.zip", "*.tar", "*.tgz",
|
| 46 |
+
)
|
| 47 |
+
USD_EXTS = (".usd", ".usda", ".usdc", ".usdz")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@dataclasses.dataclass
|
| 51 |
+
class RunResult:
|
| 52 |
+
dataset: str
|
| 53 |
+
profile: str
|
| 54 |
+
version: str
|
| 55 |
+
status: str # "pass" | "warn" | "fail" | "error"
|
| 56 |
+
summary: str # one-line human-readable digest
|
| 57 |
+
results_json: dict # the validator's results.json contents
|
| 58 |
+
report_path: Path # local path to the HTML report tree
|
| 59 |
+
pr_url: str | None # discussion URL when --open-pr was used
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _now() -> str:
|
| 63 |
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _wrap_layout_for_validator(downloaded: Path, work: Path) -> Path:
|
| 67 |
+
"""Adapt HF's flat dataset layout to the validator's expected shape.
|
| 68 |
+
|
| 69 |
+
The validator iterates *subdirectories* of its target and finds the
|
| 70 |
+
interface USD inside each. HF datasets ship flat — root USD plus
|
| 71 |
+
Geometry/Materials/SubLayers — so the unmodified validator finds
|
| 72 |
+
zero assets when pointed at the download root.
|
| 73 |
+
|
| 74 |
+
If there's a root-level USD, wrap the download in a parent dir so the
|
| 75 |
+
validator sees exactly one asset (the download itself). If the
|
| 76 |
+
download already has subdirs with their own interface USDs (rare),
|
| 77 |
+
pass it through.
|
| 78 |
+
"""
|
| 79 |
+
root_usds = [p for ext in USD_EXTS for p in downloaded.glob(f"*{ext}")]
|
| 80 |
+
subdir_with_usds = 0
|
| 81 |
+
for sub in downloaded.iterdir():
|
| 82 |
+
if not sub.is_dir() or sub.name.startswith((".", "_")):
|
| 83 |
+
continue
|
| 84 |
+
if any(sub.glob(f"*{ext}") for ext in USD_EXTS):
|
| 85 |
+
subdir_with_usds += 1
|
| 86 |
+
break
|
| 87 |
+
|
| 88 |
+
if subdir_with_usds and not root_usds:
|
| 89 |
+
return downloaded # already SimReady-shaped
|
| 90 |
+
|
| 91 |
+
target_parent = work / "target"
|
| 92 |
+
target_parent.mkdir(parents=True, exist_ok=True)
|
| 93 |
+
asset_dir = target_parent / downloaded.name
|
| 94 |
+
if asset_dir.exists():
|
| 95 |
+
shutil.rmtree(asset_dir)
|
| 96 |
+
shutil.move(str(downloaded), str(asset_dir))
|
| 97 |
+
return target_parent
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _summarize(results_json: dict) -> tuple[str, str]:
|
| 101 |
+
"""Return (status, one-line summary)."""
|
| 102 |
+
counts = {"error": 0, "failure": 0, "warning": 0}
|
| 103 |
+
total = len(results_json.get("results", []))
|
| 104 |
+
failed = 0
|
| 105 |
+
for asset in results_json.get("results", []):
|
| 106 |
+
if not asset.get("passed"):
|
| 107 |
+
failed += 1
|
| 108 |
+
for issue in asset.get("issues", []):
|
| 109 |
+
sev = (issue.get("severity") or "").lower()
|
| 110 |
+
if sev in counts:
|
| 111 |
+
counts[sev] += 1
|
| 112 |
+
if counts["error"] or counts["failure"]:
|
| 113 |
+
status = "fail"
|
| 114 |
+
elif counts["warning"]:
|
| 115 |
+
status = "warn"
|
| 116 |
+
elif total > 0:
|
| 117 |
+
status = "pass"
|
| 118 |
+
else:
|
| 119 |
+
status = "warn"
|
| 120 |
+
parts = [f"{total - failed}/{total} assets passed"]
|
| 121 |
+
parts += [f"{k}={v}" for k, v in counts.items() if v]
|
| 122 |
+
coverage = results_json.get("profile_coverage") or {}
|
| 123 |
+
if coverage.get("missing"):
|
| 124 |
+
parts.append(f"coverage {coverage.get('loaded')}/{coverage.get('declared')} features")
|
| 125 |
+
return status, " · ".join(parts)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _open_verdict_pr(
|
| 129 |
+
api: HfApi, dataset: str, results_path: Path, report_dir: Path,
|
| 130 |
+
profile: str, version: str, status: str, summary: str,
|
| 131 |
+
) -> str | None:
|
| 132 |
+
"""Upload `validation/results.json` + `validation/report/` to the dataset
|
| 133 |
+
as a PR. Returns the discussion URL.
|
| 134 |
+
|
| 135 |
+
Why PR rather than commit-to-main: the dataset owner reviews the
|
| 136 |
+
verdict like any other change. The HF Hub PR flow is exactly the
|
| 137 |
+
surface the production end-state assumes — see PRD §3.
|
| 138 |
+
"""
|
| 139 |
+
import io
|
| 140 |
+
|
| 141 |
+
pr_branch = f"simready-validate/{profile}-v{version}-{_now().replace(':', '-')}"
|
| 142 |
+
body_md = (
|
| 143 |
+
f"### SimReady validation\n\n"
|
| 144 |
+
f"- **Profile**: `{profile}` v{version}\n"
|
| 145 |
+
f"- **Status**: **{status.upper()}**\n"
|
| 146 |
+
f"- **Summary**: {summary}\n"
|
| 147 |
+
f"- **Generated**: {_now()}\n\n"
|
| 148 |
+
f"Run by the SimReady Validator HF Space. The full HTML report "
|
| 149 |
+
f"is in `validation/report/index.html`; machine-readable "
|
| 150 |
+
f"results in `validation/results.json`.\n"
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
# Stage everything that should land in the dataset under a single
|
| 154 |
+
# tree we can iterate. `validation/results.json` plus the entire
|
| 155 |
+
# `validation/report/` directory.
|
| 156 |
+
additions: list[tuple[str, bytes]] = []
|
| 157 |
+
additions.append(("validation/results.json", results_path.read_bytes()))
|
| 158 |
+
for path in report_dir.rglob("*"):
|
| 159 |
+
if path.is_file():
|
| 160 |
+
rel = path.relative_to(report_dir.parent) # keep `report/...`
|
| 161 |
+
additions.append((f"validation/{rel.as_posix()}", path.read_bytes()))
|
| 162 |
+
|
| 163 |
+
from huggingface_hub import CommitOperationAdd
|
| 164 |
+
operations = [
|
| 165 |
+
CommitOperationAdd(path_in_repo=p, path_or_fileobj=io.BytesIO(b))
|
| 166 |
+
for p, b in additions
|
| 167 |
+
]
|
| 168 |
+
commit = api.create_commit(
|
| 169 |
+
repo_id=dataset, repo_type="dataset",
|
| 170 |
+
operations=operations,
|
| 171 |
+
commit_message=f"simready-validate: {profile} v{version} → {status}",
|
| 172 |
+
create_pr=True,
|
| 173 |
+
)
|
| 174 |
+
# `create_pr=True` returns the PR's revision; the discussion URL is
|
| 175 |
+
# derivable from it. HfApi exposes the field but its key name has
|
| 176 |
+
# varied across versions — fall back gracefully.
|
| 177 |
+
return getattr(commit, "pr_url", None) or getattr(commit, "discussion_url", None)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def run(
|
| 181 |
+
dataset: str,
|
| 182 |
+
profile: str = "Robot-Body-Runnable",
|
| 183 |
+
version: str = "1.0.0",
|
| 184 |
+
open_pr: bool = False,
|
| 185 |
+
hf_token: str | None = None,
|
| 186 |
+
log: Iterator[str] | None = None,
|
| 187 |
+
) -> RunResult:
|
| 188 |
+
"""Validate a single HF dataset. Yields log lines via the `log` callable.
|
| 189 |
+
|
| 190 |
+
The Space's Gradio UI passes a callable that streams lines to the
|
| 191 |
+
output panel; the test harness can pass `print` directly.
|
| 192 |
+
"""
|
| 193 |
+
out = log or (lambda s: print(s, flush=True))
|
| 194 |
+
out(f"[{_now()}] validating dataset={dataset} profile={profile} v{version}")
|
| 195 |
+
|
| 196 |
+
token = hf_token or os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
|
| 197 |
+
api = HfApi(token=token)
|
| 198 |
+
|
| 199 |
+
with tempfile.TemporaryDirectory(prefix=f"hfsp-{dataset.replace('/', '_')}-") as td:
|
| 200 |
+
work = Path(td)
|
| 201 |
+
out(f" workdir: {work}")
|
| 202 |
+
|
| 203 |
+
# 1. Materialize. `snapshot_download` writes into a deterministic
|
| 204 |
+
# subdir; we hold a stable reference for the layout-wrap step.
|
| 205 |
+
local = work / "raw"
|
| 206 |
+
local.mkdir(parents=True, exist_ok=True)
|
| 207 |
+
out(f" $ snapshot_download {dataset} ignore_patterns={list(HF_DOWNLOAD_EXCLUDES)}")
|
| 208 |
+
snapshot_download(
|
| 209 |
+
repo_id=dataset,
|
| 210 |
+
repo_type="dataset",
|
| 211 |
+
local_dir=str(local),
|
| 212 |
+
ignore_patterns=list(HF_DOWNLOAD_EXCLUDES),
|
| 213 |
+
token=token,
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
# 2. Wrap layout. The validator expects parent-of-asset-dirs; HF
|
| 217 |
+
# datasets land flat.
|
| 218 |
+
target = _wrap_layout_for_validator(local, work)
|
| 219 |
+
out(f" validator target: {target}")
|
| 220 |
+
|
| 221 |
+
# 3. Run the validator. `--no-use-kit` skips the Isaac Sim re-exec
|
| 222 |
+
# (no Kit on HF hardware); the P2 patch inside the validator
|
| 223 |
+
# drops physxschema-unavailable issues so the asset isn't
|
| 224 |
+
# penalized for the missing runtime.
|
| 225 |
+
out_dir = work / "out"
|
| 226 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 227 |
+
cmd = [
|
| 228 |
+
sys.executable, str(VALIDATOR),
|
| 229 |
+
str(target),
|
| 230 |
+
"--profile", profile,
|
| 231 |
+
"--version", version,
|
| 232 |
+
"--output", str(out_dir),
|
| 233 |
+
"--no-use-kit",
|
| 234 |
+
]
|
| 235 |
+
out(f" $ {shlex.join(cmd)}")
|
| 236 |
+
rc = subprocess.call(cmd)
|
| 237 |
+
results_path = out_dir / "results.json"
|
| 238 |
+
if rc != 0 and not results_path.exists():
|
| 239 |
+
return RunResult(
|
| 240 |
+
dataset=dataset, profile=profile, version=version,
|
| 241 |
+
status="error",
|
| 242 |
+
summary=f"validator exited {rc}; no results.json produced",
|
| 243 |
+
results_json={}, report_path=out_dir, pr_url=None,
|
| 244 |
+
)
|
| 245 |
+
results_json = json.loads(results_path.read_text(encoding="utf-8"))
|
| 246 |
+
status, summary = _summarize(results_json)
|
| 247 |
+
out(f" {status.upper()}: {summary}")
|
| 248 |
+
|
| 249 |
+
# 4. (Optional) open the verdict PR on the dataset.
|
| 250 |
+
pr_url = None
|
| 251 |
+
if open_pr:
|
| 252 |
+
if not token:
|
| 253 |
+
out(" ! HF_TOKEN missing; cannot open PR")
|
| 254 |
+
else:
|
| 255 |
+
try:
|
| 256 |
+
pr_url = _open_verdict_pr(
|
| 257 |
+
api=api, dataset=dataset,
|
| 258 |
+
results_path=results_path, report_dir=out_dir,
|
| 259 |
+
profile=profile, version=version,
|
| 260 |
+
status=status, summary=summary,
|
| 261 |
+
)
|
| 262 |
+
out(f" PR opened: {pr_url}")
|
| 263 |
+
except Exception as e:
|
| 264 |
+
out(f" ! PR creation failed: {type(e).__name__}: {e}")
|
| 265 |
+
|
| 266 |
+
# 5. Copy the report tree OUT of the tempdir so the caller can
|
| 267 |
+
# keep displaying it after this function returns and the
|
| 268 |
+
# tempdir is cleaned.
|
| 269 |
+
persisted = Path("/tmp") / f"hfsp-report-{dataset.replace('/', '_')}"
|
| 270 |
+
if persisted.exists():
|
| 271 |
+
shutil.rmtree(persisted)
|
| 272 |
+
shutil.copytree(out_dir, persisted)
|
| 273 |
+
|
| 274 |
+
return RunResult(
|
| 275 |
+
dataset=dataset, profile=profile, version=version,
|
| 276 |
+
status=status, summary=summary,
|
| 277 |
+
results_json=results_json,
|
| 278 |
+
report_path=persisted, pr_url=pr_url,
|
| 279 |
+
)
|
tools/validation/.claude-plugin/marketplace.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://json.schemastore.org/claude-code-marketplace",
|
| 3 |
+
"name": "simready-playbook",
|
| 4 |
+
"description": "SimReady asset pipeline plugins for Claude Code.",
|
| 5 |
+
"owner": {
|
| 6 |
+
"name": "dloginowski",
|
| 7 |
+
"email": "dloginowski@nvidia.com"
|
| 8 |
+
},
|
| 9 |
+
"plugins": [
|
| 10 |
+
{
|
| 11 |
+
"name": "simready-report",
|
| 12 |
+
"source": "./plugins/simready-report",
|
| 13 |
+
"description": "SimReady asset pipeline tooling. Provides /simready-report (validation + HTML dashboard) and /simready-package (standalone simready ingest packaging)."
|
| 14 |
+
}
|
| 15 |
+
]
|
| 16 |
+
}
|
tools/validation/PROBLEMS.md
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Known problems
|
| 2 |
+
|
| 3 |
+
Issues encountered with the validation pipeline, the foundation specs, or the
|
| 4 |
+
SimReady SDK that aren't bugs in this repo's code but affect the report
|
| 5 |
+
output. Each entry: **symptom → root cause → workaround / how to address →
|
| 6 |
+
status**.
|
| 7 |
+
|
| 8 |
+
When you hit a new issue, add it here. Keep the list short — fold resolved
|
| 9 |
+
items into git history once they're fixed.
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## P1 — Profiles silently drop features that aren't registered
|
| 14 |
+
|
| 15 |
+
**Symptom.** A profile loads with far fewer features than its
|
| 16 |
+
`profiles.toml` declares. Concretely on the current foundations checkout:
|
| 17 |
+
|
| 18 |
+
| Profile | Declared in `profiles.toml` | Loaded by validator | Dropped feature IDs |
|
| 19 |
+
|---|---|---|---|
|
| 20 |
+
| `Robot-Body-Runnable` v1.0.0 | 5 | **1** | `FET004_ROBOT_PHYSX` v0.2.0, `FET021_ROBOT_CORE_RUNNABLE` v0.2.0, `FET022_DRIVEN_JOINTS_PHYSX` v0.1.0, `FET024_BASE_ARTICULATION_PHYSX` v0.1.0 |
|
| 21 |
+
| `Robot-Body-Isaac` v1.0.0 | 6 | 2 | `FET004_ROBOT_PHYSX` v0.2.0, `FET021_ROBOT_CORE_ISAAC` v0.2.0, `FET022_DRIVEN_JOINTS_ISAAC` v0.1.0, `FET024_BASE_ARTICULATION_PHYSX` v0.1.0 |
|
| 22 |
+
| `Robot-Body-Neutral` v1.0.0 | 5 | 3 | `FET022_DRIVEN_JOINTS_NEUTRAL` v0.1.0, `FET024_BASE_ARTICULATION_NEUTRAL` v0.1.0 |
|
| 23 |
+
| `Prop-Robotics-Neutral` v2.0.0 | 6 | 5 | `FET006_BASE_MDL` v0.1.0 |
|
| 24 |
+
|
| 25 |
+
What's actually registered for the FET02x family (after `repo
|
| 26 |
+
usd_profiles_codegen`):
|
| 27 |
+
|
| 28 |
+
- `fet_021_robot_core` v0.2.0 (no variant suffix)
|
| 29 |
+
- `fet_022_driven_joints` v0.1.0
|
| 30 |
+
- `fet_023_robot_materials` v0.1.0
|
| 31 |
+
- `fet_024_base_articulation` v0.1.0
|
| 32 |
+
|
| 33 |
+
And for FET004/FET006 the only `_BASE_NEUTRAL` / `_BASE_USDPREVIEW`
|
| 34 |
+
forms are registered, not the `_ROBOT_PHYSX` / `_BASE_MDL` variants the
|
| 35 |
+
profiles ask for.
|
| 36 |
+
|
| 37 |
+
**Root cause.** Profile entries in
|
| 38 |
+
`<foundations>/nv_core/sr_specs/docs/profiles/profiles.toml` reference
|
| 39 |
+
variant feature IDs that the local foundations registry doesn't ship — e.g.
|
| 40 |
+
profiles ask for `FET021_ROBOT_CORE_RUNNABLE`, `FET022_DRIVEN_JOINTS_PHYSX`,
|
| 41 |
+
`FET004_ROBOT_PHYSX`, `FET024_BASE_ARTICULATION_NEUTRAL`. What is actually
|
| 42 |
+
registered (after `repo usd_profiles_codegen` runs against our checkout) is
|
| 43 |
+
only the unsuffixed `_BASE_NEUTRAL` / `_BASE_USDPREVIEW` / `_CORE` /
|
| 44 |
+
`_BASE_ISAACSIM` variants. The variant features the profiles ask for are
|
| 45 |
+
silently absent.
|
| 46 |
+
|
| 47 |
+
`omni.asset_validator.ProfileRegistry` resolves features by ID + version and
|
| 48 |
+
silently drops any it can't find. There's no warning emitted at profile
|
| 49 |
+
load time.
|
| 50 |
+
|
| 51 |
+
**Verified the gap is local, not universal.** Sample reports from another
|
| 52 |
+
SimReady environment (Fanuc content, sr9iar.usd.usd) show all five
|
| 53 |
+
declared `Robot-Body-Runnable` features actually loaded —
|
| 54 |
+
`FET001_BASE_NEUTRAL` (failed), plus `FET004_ROBOT_PHYSX`,
|
| 55 |
+
`FET021_ROBOT_CORE_RUNNABLE`, `FET022_DRIVEN_JOINTS_PHYSX`,
|
| 56 |
+
`FET024_BASE_ARTICULATION_PHYSX`, `FET024_BASE_ARTICULATION_NEUTRAL`
|
| 57 |
+
(passed). So the variants *are* registrable; our local codegen output
|
| 58 |
+
just doesn't include them.
|
| 59 |
+
|
| 60 |
+
**Where the variants live.** The foundation source ships JSON variant
|
| 61 |
+
definitions next to each feature's markdown. For FET004:
|
| 62 |
+
|
| 63 |
+
```
|
| 64 |
+
docs/features/FET_004-simulate_multi_body_physics.md (md, _BASE_NEUTRAL only)
|
| 65 |
+
docs/features/FET_004_base_neutral-0.1.0-...json
|
| 66 |
+
docs/features/FET_004_base_physx-0.1.0-...json
|
| 67 |
+
docs/features/FET_004_base_physx-0.2.0-...json
|
| 68 |
+
docs/features/FET_004_robot_physx-0.1.0-...json <- this is the one profiles ask for
|
| 69 |
+
docs/features/FET_004_robot_physx-0.2.0-...json
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
Each JSON declares an `id` (e.g. `FET004_ROBOT_PHYSX`), `version`, `path`,
|
| 73 |
+
and a `requirements` list. **Our `repo usd_profiles_codegen` run only
|
| 74 |
+
parses the `.md` files** — the generated
|
| 75 |
+
`_build/python/omni/capabilities/_features.py` contains zero
|
| 76 |
+
`_ROBOT_PHYSX` / `_BASE_PHYSX` / `_RUNNABLE` entries even though the JSON
|
| 77 |
+
sources are right there.
|
| 78 |
+
|
| 79 |
+
**How to address.** Three paths, in priority order:
|
| 80 |
+
|
| 81 |
+
1. **Codegen — pick up the JSON variant files.** The foundations
|
| 82 |
+
`repo usd_profiles_codegen` tool needs to also enumerate
|
| 83 |
+
`FET_*_*-*-...json` files in the features directory and emit Feature
|
| 84 |
+
entries for each, not just the `Internal ID` declared in the `.md`.
|
| 85 |
+
This closes the gap without changing source data — the variants
|
| 86 |
+
already exist.
|
| 87 |
+
2. **Validator — emit a warning when a profile entry doesn't resolve.**
|
| 88 |
+
`omni.asset_validator.ProfileRegistry` silently drops missing
|
| 89 |
+
features. A `WARNING:` log at profile-load time would have made this
|
| 90 |
+
bug visible from day one.
|
| 91 |
+
3. **Documentation** — once #1 lands, `repo.toml`'s codegen description
|
| 92 |
+
should call out that JSON variants are part of the source of truth so
|
| 93 |
+
downstream tooling doesn't repeat the mistake.
|
| 94 |
+
|
| 95 |
+
Open this with the SimReady foundations team
|
| 96 |
+
(`#omni-simready` / `#simready-next-support`); cite the discrepancy
|
| 97 |
+
between our local codegen output and the working Fanuc environment
|
| 98 |
+
sample.
|
| 99 |
+
|
| 100 |
+
**In-repo workaround.** The dashboard now surfaces the coverage gap
|
| 101 |
+
explicitly: when a profile declares more features than the validator
|
| 102 |
+
loaded, a banner at the top lists the missing IDs and tells the user which
|
| 103 |
+
ones got silently dropped. The validation result the report shows is
|
| 104 |
+
genuinely against the *loaded* feature set — running with
|
| 105 |
+
`Robot-Body-Runnable` does *not* check the four declared-but-missing
|
| 106 |
+
features.
|
| 107 |
+
|
| 108 |
+
**Status.** Local patch applied — see "Local patch applied" below.
|
| 109 |
+
Upstream codegen fix still needed. Banner workaround still in place
|
| 110 |
+
for environments without the patch.
|
| 111 |
+
|
| 112 |
+
### Local patch applied
|
| 113 |
+
|
| 114 |
+
A patch lives in
|
| 115 |
+
`plugins/simready-report/skills/simready-report/validate.py` —
|
| 116 |
+
function `_patch_register_json_variant_features()`. It runs at module
|
| 117 |
+
load (so worker processes pick it up too) right after
|
| 118 |
+
`import omni.asset_validator as oav`. It is loud about itself: every
|
| 119 |
+
run prints a `[PATCH P1] FeatureRegistry: +N JSON-variant feature(s)
|
| 120 |
+
... ProfileRegistry: rebuilt feature lists for M profile(s) ...`
|
| 121 |
+
line so it's never invisible.
|
| 122 |
+
|
| 123 |
+
**Concretely on the Yaskawa-local checkout** the patch lifts:
|
| 124 |
+
- FeatureRegistry: 11 features → 30 features (+19 from JSON variants).
|
| 125 |
+
- Robot-Body-Runnable v1.0.0: 1 feature / 8 requirements → **5 / 41**.
|
| 126 |
+
- 11 profiles got their `.features` lists rebuilt; +24 feature
|
| 127 |
+
references appended in total.
|
| 128 |
+
|
| 129 |
+
**Exactly what the patch does**, step by step:
|
| 130 |
+
|
| 131 |
+
1. **Discover variant JSON files.** Globs
|
| 132 |
+
`<foundations>/nv_core/sr_specs/docs/features/FET_*.json`. The
|
| 133 |
+
filename styles in this repo are inconsistent
|
| 134 |
+
(`FET_021-robot_core_isaac-0.1.0.json`,
|
| 135 |
+
`FET_021_robot_core_runnable_0.2.0.json`,
|
| 136 |
+
`FET_004_base_physx-0.2.0-simulate_multi_body_phyics.json`), so we
|
| 137 |
+
ignore the filename and read the `id` + `version` fields from the
|
| 138 |
+
JSON content.
|
| 139 |
+
|
| 140 |
+
2. **Build a Requirement code lookup.** `r.code -> r` for every
|
| 141 |
+
`Requirement` in `omni.asset_validator.RequirementsRegistry()`.
|
| 142 |
+
The JSON variants list dotted code strings like `"RB.COL.003"`,
|
| 143 |
+
`"DJ.001"`, `"BA.002"` — those are looked up here.
|
| 144 |
+
|
| 145 |
+
3. **Construct a Feature-protocol object per JSON variant.**
|
| 146 |
+
`omni.asset_validator._features.Feature` is a `typing.Protocol`
|
| 147 |
+
declaring `id: str, version: str, path: str, requirements: list`.
|
| 148 |
+
We use a frozen dataclass that satisfies the protocol structurally:
|
| 149 |
+
|
| 150 |
+
```python
|
| 151 |
+
@dataclass(frozen=True)
|
| 152 |
+
class _PatchedFeature:
|
| 153 |
+
id: str
|
| 154 |
+
version: str
|
| 155 |
+
path: str
|
| 156 |
+
requirements: list
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
4. **Skip duplicates and unresolvable variants.** If
|
| 160 |
+
`FeatureRegistry.find(fid, fver)` already returns something, skip
|
| 161 |
+
(don't double-register). If any of the JSON's requirement code
|
| 162 |
+
strings doesn't resolve in the code lookup, skip the whole feature
|
| 163 |
+
and record it in `unresolved_codes`. (Currently zero unresolved on
|
| 164 |
+
our checkout — every JSON variant's required codes exist.)
|
| 165 |
+
|
| 166 |
+
5. **Register via `FeatureRegistry().add(feat)`.** The internal
|
| 167 |
+
`create_key` uses `.id` + `.version` so our dataclass satisfies it.
|
| 168 |
+
|
| 169 |
+
6. **Re-resolve every profile's feature list — the critical step.**
|
| 170 |
+
`Profile` is a frozen dataclass, so we can't reassign
|
| 171 |
+
`profile.features`. But the field is a *mutable list*, and the
|
| 172 |
+
freeze only prevents field reassignment, not mutation of held
|
| 173 |
+
collections. So for every entry in `profiles.toml`'s declared
|
| 174 |
+
features that isn't already in the live profile, we look it up in
|
| 175 |
+
the now-fuller `FeatureRegistry` and `profile.features.append(...)`
|
| 176 |
+
in place. **Without this step the patch is invisible** — adding to
|
| 177 |
+
`FeatureRegistry` after profiles are already built doesn't
|
| 178 |
+
retroactively join existing Profile objects.
|
| 179 |
+
|
| 180 |
+
**What the patch does NOT touch**: foundation source files, the
|
| 181 |
+
codegen tool, generated `_features.py`, profile definitions in
|
| 182 |
+
`profiles.toml`, or any rule code. The local patch is purely in our
|
| 183 |
+
validation runner.
|
| 184 |
+
|
| 185 |
+
**Remove the patch when**: the foundation `repo usd_profiles_codegen`
|
| 186 |
+
tool is updated to enumerate JSON variant files alongside the
|
| 187 |
+
markdown. Once `_features.py` carries every variant on its own,
|
| 188 |
+
delete `_patch_register_json_variant_features()` and its call site.
|
| 189 |
+
|
| 190 |
+
---
|
| 191 |
+
|
| 192 |
+
## P2 — Public ur10 sample asset fails current foundation specs
|
| 193 |
+
|
| 194 |
+
**Symptom.** Running `/simready-report` against the public ur10 sample
|
| 195 |
+
in
|
| 196 |
+
`simready_foundations/sample_content/common_assets/robots_general/ur10/`
|
| 197 |
+
(release `2026.04.0`, commit `805d2c5`) reports failures on every
|
| 198 |
+
variant when validated against its declared profile (or against
|
| 199 |
+
Robot-Body-Runnable cross-profile). Concretely, against each interface
|
| 200 |
+
USD's declared profile, with `--use-kit` so PhysX rules actually run:
|
| 201 |
+
|
| 202 |
+
| Variant interface | Declared profile | Result | Failing requirements |
|
| 203 |
+
|---|---|---|---|
|
| 204 |
+
| `simready_usd/ur10.usda` | Robot-Body-Neutral v1.0.0 | **PASS** | — |
|
| 205 |
+
| `simready_physx_usd/ur10.usda` | Robot-Body-Physx v1.0.0 | **FAIL** | (profile evaluates 0 features against the asset; `features_summary` empty in JSON output) |
|
| 206 |
+
| `simready_isaac_usd/ur10.usda` | Robot-Body-Isaac v1.0.0 | **FAIL** | `RC.005` (`VerifyRobotPhysicsAttributesSourceLayer`), `RC.008` (`robot-type`), `RC.009` (`root-joint-pinned`), `DJ.010`, `ISA.001` |
|
| 207 |
+
|
| 208 |
+
Under the cross-profile `Robot-Body-Runnable` run, Neutral and PhysX
|
| 209 |
+
variants additionally light up with `RC.007` (`robot-schema` — no
|
| 210 |
+
`IsaacRobotAPI` on the default prim) because neither of those variants
|
| 211 |
+
applies `IsaacRobotAPI`, only the Isaac variant does.
|
| 212 |
+
|
| 213 |
+
**Root cause.** Asset-side metadata gaps and layer-organization issues
|
| 214 |
+
in the shipped public sample, *not* validator or skill code. The asset
|
| 215 |
+
file is tracked by Git LFS at the same OID as the release (`b30f9c1b…`
|
| 216 |
+
for `simready_isaac_usd/payloads/base.usda`), so no in-tree edit
|
| 217 |
+
caused this. Each asset's `customLayerData.SimReady_Metadata.validation`
|
| 218 |
+
records `validated_features` against a snapshot dated **2026-01-09**;
|
| 219 |
+
between then and the current `805d2c5` foundation specs the rules in
|
| 220 |
+
question were tightened (or added), so the asset that passed in
|
| 221 |
+
January no longer passes today. Specifically:
|
| 222 |
+
|
| 223 |
+
- **`RC.008` (`robot-type`)** — `simready_isaac_usd/payloads/base.usda`
|
| 224 |
+
lines 81-83 declare `token isaac:robotType` with **no value**. The
|
| 225 |
+
current rule requires a valid schema-defined token (e.g.
|
| 226 |
+
`"Manipulator"`, `"End Effector"`); the placeholder `"Default"` and
|
| 227 |
+
the absent-value case both fail.
|
| 228 |
+
- **`RC.009` (`root-joint-pinned`)** — depends on `isaac:robotType`
|
| 229 |
+
to decide whether the root joint must be pinned. The asset's
|
| 230 |
+
`root_joint` *is* a `PhysicsFixedJoint` with empty `physics:body0`
|
| 231 |
+
(pinned to world, correct for a Manipulator), but without a valid
|
| 232 |
+
`robotType` value the rule can't evaluate and fails.
|
| 233 |
+
- **`RC.005` (`VerifyRobotPhysicsAttributesSourceLayer`)** — physics
|
| 234 |
+
attributes (`physics:axis`, `physics:mass`, `physics:centerOfMass`,
|
| 235 |
+
`physics:diagonalInertia`, `physics:principalAxes`) on each robot
|
| 236 |
+
link are authored in *both* `payloads/base.usda` and
|
| 237 |
+
`payloads/Physics/physics.usda`. The current rule wants them in the
|
| 238 |
+
physics payload only.
|
| 239 |
+
- **PhysX variant empty `features_summary`** — Robot-Body-Physx v1.0.0
|
| 240 |
+
evaluates zero features against the asset, suggesting the profile's
|
| 241 |
+
declared feature set no longer matches what the asset has stamped
|
| 242 |
+
(related to P1, but for a different feature family).
|
| 243 |
+
|
| 244 |
+
**How to address.** Asset fixes belong in
|
| 245 |
+
`github.com/NVIDIA/simready-foundation`, not here:
|
| 246 |
+
|
| 247 |
+
1. **`RC.008`/`RC.009`** — *verified one-liner*. In
|
| 248 |
+
`simready_isaac_usd/payloads/base.usda` line 81, change
|
| 249 |
+
`token isaac:robotType (…)` to
|
| 250 |
+
`token isaac:robotType = "Manipulator" (…)`. Verified locally with
|
| 251 |
+
`--use-kit`:
|
| 252 |
+
- Against `Robot-Body-Isaac`, `FET021_ROBOT_CORE_ISAAC` failing
|
| 253 |
+
codes went from `[RC.005, RC.008, RC.009]` →
|
| 254 |
+
**`[RC.003, RC.005]`** (RC.008 and RC.009 cleared; RC.003 was
|
| 255 |
+
previously cascade-suppressed and now surfaces).
|
| 256 |
+
- Against `Robot-Body-Runnable` cross-profile, the **Isaac variant
|
| 257 |
+
now PASSES** (`FET021_ROBOT_CORE_RUNNABLE` only requires
|
| 258 |
+
RC.007/008/009 — all three OK once `isaac:robotType` is set,
|
| 259 |
+
because IsaacRobotAPI is already applied with the correct
|
| 260 |
+
joints/links relationships and the root joint is pinned via
|
| 261 |
+
empty `physics:body0`). Cross-profile summary moved from
|
| 262 |
+
`0 passed / 3 failed` → `1 passed / 2 failed` (Isaac passes,
|
| 263 |
+
Neutral and PhysX still fail RC.007/008/009 because they don't
|
| 264 |
+
apply IsaacRobotAPI at all).
|
| 265 |
+
2. **`RC.005`**: move every `physics:*` attribute currently authored
|
| 266 |
+
on links in `payloads/base.usda` into `payloads/Physics/physics.usda`
|
| 267 |
+
only (or refactor so `base.usda` carries no physics attributes at
|
| 268 |
+
all). This is a meaningful layer-organization change, not a
|
| 269 |
+
one-liner. Still failing after fix 1 (88 RC.005 issues remain).
|
| 270 |
+
3. **`RC.003` (`RobotNaming`)** — surfaced *only after* fix 1. The
|
| 271 |
+
rule compares the **immediate folder name** (`simready_isaac_usd`)
|
| 272 |
+
to the **interface USD stem** (`ur10`). Under the standard SimReady
|
| 273 |
+
packaging convention these never match — every Isaac variant of
|
| 274 |
+
every SimReady asset fails RC.003. Likely a rule bug (should
|
| 275 |
+
probably look at the *bundle* parent, e.g. `ur10/`, not the
|
| 276 |
+
variant folder) or a misalignment between rule and packaging spec.
|
| 277 |
+
Worth raising upstream.
|
| 278 |
+
4. **`RC.007` (cross-profile only)**: if the intent is that the
|
| 279 |
+
Neutral and PhysX variants should also satisfy Robot-Body-Runnable,
|
| 280 |
+
apply `IsaacRobotAPI` to their default prims with the same
|
| 281 |
+
`isaac:physics:robotJoints`/`robotLinks` relationships used in the
|
| 282 |
+
Isaac variant. If the intent is that each variant only ever
|
| 283 |
+
validates against its own declared profile, this is not needed.
|
| 284 |
+
5. **PhysX empty features**: align the variant USDs and Robot-Body-Physx
|
| 285 |
+
profile's expected feature set — likely needs the assets to carry
|
| 286 |
+
the variant feature IDs that the profile declares (intersect with
|
| 287 |
+
P1's missing-feature list once that's resolved). Also: `validate.py`
|
| 288 |
+
currently emits an empty `.reports/<…>/` dir when zero features
|
| 289 |
+
match (no `index.html` / `results.json` written). Tracked
|
| 290 |
+
separately as a `validate.py` robustness issue.
|
| 291 |
+
|
| 292 |
+
**In-repo workaround.** None — this is downstream of the validator. The
|
| 293 |
+
`simready-report` skill faithfully reports what the rules say. The
|
| 294 |
+
dashboard's "Failing requirements" panel surfaces the offending codes
|
| 295 |
+
so users can map each failure back to its requirement doc.
|
| 296 |
+
|
| 297 |
+
**Status.** Open against the foundations repo. Filed for tracking via
|
| 298 |
+
this MR; no action in `simready-explorer` is needed beyond surfacing
|
| 299 |
+
the issue here. Fix 1 above is **verified to work locally** (one-line
|
| 300 |
+
edit to `base.usda`) — upstream PR against `NVIDIA/simready-foundation`
|
| 301 |
+
is the right channel to land it. Fixes 2–5 still open. Re-validate
|
| 302 |
+
after the foundation team updates the sample assets, the rule code
|
| 303 |
+
(notably `RC.003 RobotNaming`), or relaxes the unmatched-feature
|
| 304 |
+
behavior.
|
| 305 |
+
|
| 306 |
+
---
|
| 307 |
+
|
| 308 |
+
## How to file a new entry here
|
| 309 |
+
|
| 310 |
+
Use this skeleton:
|
| 311 |
+
|
| 312 |
+
```
|
| 313 |
+
## P<N> — <one-line title>
|
| 314 |
+
|
| 315 |
+
**Symptom.** What the user/reader sees that's wrong.
|
| 316 |
+
|
| 317 |
+
**Root cause.** Where the bug actually lives (which repo / file / step).
|
| 318 |
+
|
| 319 |
+
**How to address.** Concrete fix path(s), upstream or local.
|
| 320 |
+
|
| 321 |
+
**In-repo workaround.** What this repo does today to mitigate, if anything.
|
| 322 |
+
|
| 323 |
+
**Status.** Open / mitigated / fixed (link to commit).
|
| 324 |
+
```
|
| 325 |
+
|
| 326 |
+
Bump the number; don't reuse retired ones — the git history is the
|
| 327 |
+
canonical record once an entry is resolved and removed.
|
tools/validation/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Validation reporting — staging branch
|
| 2 |
+
|
| 3 |
+
## Install the Claude Code plugin
|
| 4 |
+
|
| 5 |
+
The `simready-report` plugin ships as a Claude Code marketplace under
|
| 6 |
+
`validation/.claude-plugin/`. From a checkout of this repo, run inside Claude Code:
|
| 7 |
+
|
| 8 |
+
```
|
| 9 |
+
/plugin marketplace add <path-to-repo>/validation
|
| 10 |
+
/plugin install simready-report@simready-playbook
|
| 11 |
+
```
|
| 12 |
+
|
| 13 |
+
That registers two slash commands: `/simready-report` and `/simready-package`.
|
| 14 |
+
You still need a Python with the SimReady runtime deps and the foundations + sdk
|
| 15 |
+
checkouts on disk — see [To run the demo as-is](#to-run-the-demo-as-is) below.
|
| 16 |
+
|
| 17 |
+
> [!IMPORTANT]
|
| 18 |
+
> ## Read [`PROBLEMS.md`](PROBLEMS.md) before trusting any report
|
| 19 |
+
>
|
| 20 |
+
> Reports produced by this prototype lean on a **local patch** for
|
| 21 |
+
> [PROBLEMS.md P1](PROBLEMS.md): without it, every Robot-Body profile
|
| 22 |
+
> silently drops 4 of its 5 declared features at load time, because
|
| 23 |
+
> the foundation `repo usd_profiles_codegen` step doesn't read the
|
| 24 |
+
> JSON variant definitions next to each feature's markdown. A
|
| 25 |
+
> "passing" report against `Robot-Body-Runnable` would only have
|
| 26 |
+
> checked 1 feature out of 5.
|
| 27 |
+
>
|
| 28 |
+
> The patch lives in
|
| 29 |
+
> `plugins/simready-report/skills/simready-report/validate.py` and is
|
| 30 |
+
> loud about itself — every run prints a `[PATCH P1]` line. With the
|
| 31 |
+
> patch active, `Robot-Body-Runnable` resolves to **5/5 features, 41
|
| 32 |
+
> requirements** instead of 1/8.
|
| 33 |
+
>
|
| 34 |
+
> The dashboard's **Caveats** panel still surfaces the gap if anyone
|
| 35 |
+
> runs without the patch.
|
| 36 |
+
>
|
| 37 |
+
> [`PROBLEMS.md`](PROBLEMS.md) **P1** has the full root cause, the
|
| 38 |
+
> patch walkthrough, and the upstream fix path. The patch is meant to
|
| 39 |
+
> be temporary — once foundations codegen is fixed, delete
|
| 40 |
+
> `_patch_register_json_variant_features()` and its call site.
|
| 41 |
+
|
| 42 |
+
---
|
| 43 |
+
|
| 44 |
+
> **Status: demo / review staging.** Not part of `simready-explorer` proper yet.
|
| 45 |
+
>
|
| 46 |
+
> This directory is a verbatim copy of the prototype work currently living at
|
| 47 |
+
> [`loginowskid/simready-playbook`](https://github.com/loginowskid/simready-playbook),
|
| 48 |
+
> dropped here on the `dev/dloginowski/validation-reporting` branch so the
|
| 49 |
+
> SimReady team can review the artifact before we refactor it into the repo's
|
| 50 |
+
> existing library + skills patterns.
|
| 51 |
+
|
| 52 |
+
## What this is
|
| 53 |
+
|
| 54 |
+
A Claude-Code-driven validation reporting pipeline for SimReady customer assets.
|
| 55 |
+
On top of the existing `omni.asset_validator` engine and SimReady profile registry,
|
| 56 |
+
it adds:
|
| 57 |
+
|
| 58 |
+
- **HTML dashboard** with summary cards, per-asset feature pass/fail, an
|
| 59 |
+
asset filter, and a "Other failed requirements" panel for issues outside the
|
| 60 |
+
loaded profile.
|
| 61 |
+
- **External-dependencies provenance report** — walks USD composition arcs per
|
| 62 |
+
asset, classifies each layer as internal/external relative to the target,
|
| 63 |
+
records actions taken to obtain each.
|
| 64 |
+
- **Foundation doc rendering** — renders the markdown source for every
|
| 65 |
+
feature/requirement code referenced by the report into HTML inside the output.
|
| 66 |
+
- **Auto-packaging convenience** — for a raw asset bundle under
|
| 67 |
+
`assets_to_validate/<name>/`, runs `simready ingest usd` per interface USD
|
| 68 |
+
before validating against the packaged tree.
|
| 69 |
+
- **Spec selector** — `--list-profiles` enumerates registered profiles so the
|
| 70 |
+
user can pick a SimReady profile interactively.
|
| 71 |
+
- **Parallel asset validation** — `ProcessPoolExecutor` workers, default auto.
|
| 72 |
+
|
| 73 |
+
Two slash commands ship together as a single Claude Code plugin:
|
| 74 |
+
|
| 75 |
+
- `/simready-report` — validate + dashboard + provenance + docs.
|
| 76 |
+
- `/simready-package` — standalone packaging (no validation).
|
| 77 |
+
|
| 78 |
+
## Layout (mirrors the source repo)
|
| 79 |
+
|
| 80 |
+
```
|
| 81 |
+
validation/
|
| 82 |
+
├── bootstrap.ps1 # Windows convenience installer
|
| 83 |
+
├── marketplace.json # Claude Code marketplace manifest
|
| 84 |
+
├── plugins/simready-report/
|
| 85 |
+
│ ├── plugin.json
|
| 86 |
+
│ └── skills/
|
| 87 |
+
│ ├── simready-report/ # HTML dashboard + validation
|
| 88 |
+
│ └── simready-package/ # standalone packaging
|
| 89 |
+
└── playbooks/
|
| 90 |
+
└── foundations-deviations.md # observed gaps between specs and asset_validator
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
## Why it doesn't fit the repo's existing patterns yet
|
| 94 |
+
|
| 95 |
+
`simready-explorer` already ships `source/libraries/validate/` with a clean
|
| 96 |
+
public API (`validate_asset`, `validate_asset_list`, etc.) and three skills in
|
| 97 |
+
the `agentskills.io/specification` format (`validate-asset`, `validate-batch`,
|
| 98 |
+
`validate-metadata`). The work in this `validation/` directory:
|
| 99 |
+
|
| 100 |
+
- Reinvents a thinner version of `simready.validate.validate_asset_list` —
|
| 101 |
+
this is duplication that should be removed during integration.
|
| 102 |
+
- Uses a different skill format (Claude Code marketplace plugin) — should be
|
| 103 |
+
rewritten as `agentskills.io` skills that call into the library.
|
| 104 |
+
- Is a standalone-script architecture (`python validate.py …`) — should be
|
| 105 |
+
exposed via the existing `repo.bat` / library entry points.
|
| 106 |
+
|
| 107 |
+
## Proposed integration after review
|
| 108 |
+
|
| 109 |
+
1. New skill `validate-report` under
|
| 110 |
+
`source/libraries/validate/src/validate/skills/validate-report/` —
|
| 111 |
+
instructional doc that drives a new `simready.validate.report` module.
|
| 112 |
+
2. New module `source/libraries/validate/src/validate/report/` containing the
|
| 113 |
+
dashboard renderer, doc renderer, external-deps tracker, thumbnail mirrorer
|
| 114 |
+
— extracted from `report.py` + `external_deps.py` and adapted to consume
|
| 115 |
+
`AssetValidationResult` instead of the raw `omni.asset_validator` issue
|
| 116 |
+
stream.
|
| 117 |
+
3. Optional new skill `validate-package` — thin wrapper around
|
| 118 |
+
`simready ingest usd` (independent of validation).
|
| 119 |
+
4. Drop `bootstrap.ps1` / `marketplace.json` / Claude-Code-plugin layout — not
|
| 120 |
+
relevant here; the team uses `repo.bat` + `_build/` flow.
|
| 121 |
+
|
| 122 |
+
## To run the demo as-is
|
| 123 |
+
|
| 124 |
+
The scripts in this directory are self-contained and need a Python with the
|
| 125 |
+
SimReady runtime deps installed plus the foundations + sdk checkouts on disk.
|
| 126 |
+
Easiest path on Windows: `& bootstrap.ps1` (creates a venv at
|
| 127 |
+
`%LOCALAPPDATA%\simready\` and persists `SIMREADY_PYTHON` /
|
| 128 |
+
`SIMREADY_FOUNDATIONS_PATH` / `SIMREADY_SDK_PATH` env vars). On other systems
|
| 129 |
+
or custom layouts, install the deps into your Python and set those env vars
|
| 130 |
+
manually.
|
| 131 |
+
|
| 132 |
+
After that:
|
| 133 |
+
|
| 134 |
+
```
|
| 135 |
+
python plugins/simready-report/skills/simready-report/validate.py \
|
| 136 |
+
<path-to-asset-bundle> --profile Robot-Body-Isaac --version 1.0.0
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
Output lands at `<bundle>/.reports/<bundle-name>.<profile>/index.html`.
|
tools/validation/UPSTREAM.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Upstream sync
|
| 2 |
+
|
| 3 |
+
`tools/validation/` is a **verbatim fork** of the `validation/` tree from:
|
| 4 |
+
|
| 5 |
+
- **Repo:** `omniverse/kit-extensions/simready-explorer`
|
| 6 |
+
- **Host:** `gitlab-master.nvidia.com`
|
| 7 |
+
- **Branch:** `dev/dloginowski/validation-reporting`
|
| 8 |
+
- **URL:** https://gitlab-master.nvidia.com/omniverse/kit-extensions/simready-explorer/-/tree/dev/dloginowski/validation-reporting
|
| 9 |
+
|
| 10 |
+
It is **not** a git submodule. Upstream changes do not flow here automatically.
|
| 11 |
+
|
| 12 |
+
## Current pin
|
| 13 |
+
|
| 14 |
+
```
|
| 15 |
+
upstream_sha: 4bd42394685156d97f3e3bf371117f53e8dd8ab1
|
| 16 |
+
synced_at: 2026-05-22
|
| 17 |
+
synced_by: dloginowski
|
| 18 |
+
```
|
| 19 |
+
|
| 20 |
+
> Bump these three fields whenever you re-run `_sync.sh`.
|
| 21 |
+
|
| 22 |
+
## Check for drift
|
| 23 |
+
|
| 24 |
+
```bash
|
| 25 |
+
git ls-remote https://gitlab-master.nvidia.com/omniverse/kit-extensions/simready-explorer.git \
|
| 26 |
+
refs/heads/dev/dloginowski/validation-reporting
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
Compare the printed SHA to `upstream_sha` above. If they differ, upstream has
|
| 30 |
+
new commits we don't have.
|
| 31 |
+
|
| 32 |
+
## Pull a new version
|
| 33 |
+
|
| 34 |
+
```bash
|
| 35 |
+
bash tools/validation/_sync.sh
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
The script:
|
| 39 |
+
|
| 40 |
+
1. Shallow-clones the upstream branch to a temp dir.
|
| 41 |
+
2. Reports the new SHA and how many commits ahead of `upstream_sha` it is.
|
| 42 |
+
3. Rsyncs upstream `validation/*` over `tools/validation/*`, preserving
|
| 43 |
+
`UPSTREAM.md` and `_sync.sh` themselves.
|
| 44 |
+
4. Prints a diff stat so you can see what changed.
|
| 45 |
+
|
| 46 |
+
After running, review the diff, **manually update the pin block above**, then
|
| 47 |
+
commit:
|
| 48 |
+
|
| 49 |
+
```bash
|
| 50 |
+
git add tools/validation/
|
| 51 |
+
git commit -m "Sync tools/validation/ from upstream <short-sha>"
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
## When upstream lands in `simready-explorer` proper
|
| 55 |
+
|
| 56 |
+
The upstream README (`tools/validation/README.md`) calls this directory
|
| 57 |
+
"demo / review staging" and outlines the intended integration into
|
| 58 |
+
`simready-explorer`'s existing library + skills patterns. Once that lands:
|
| 59 |
+
|
| 60 |
+
1. Delete `tools/validation/`.
|
| 61 |
+
2. Replace the runner-side driver `tools/hf_watch/validate.py` with calls
|
| 62 |
+
into `simready-explorer`'s public CLI (likely `repo.bat validate` or
|
| 63 |
+
the `simready.validate` library entry points).
|
| 64 |
+
3. Drop this file.
|
| 65 |
+
|
| 66 |
+
Until then, treat this fork as a snapshot.
|
tools/validation/_sync.sh
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Sync tools/validation/ from the upstream GitLab branch.
|
| 3 |
+
#
|
| 4 |
+
# Run from the repo root:
|
| 5 |
+
# bash tools/validation/_sync.sh
|
| 6 |
+
#
|
| 7 |
+
# After running, review the diff, bump the pin block in
|
| 8 |
+
# tools/validation/UPSTREAM.md, and commit.
|
| 9 |
+
|
| 10 |
+
set -euo pipefail
|
| 11 |
+
|
| 12 |
+
UPSTREAM_URL="https://gitlab-master.nvidia.com/omniverse/kit-extensions/simready-explorer.git"
|
| 13 |
+
UPSTREAM_BRANCH="dev/dloginowski/validation-reporting"
|
| 14 |
+
UPSTREAM_PATH="validation"
|
| 15 |
+
LOCAL_PATH="tools/validation"
|
| 16 |
+
KEEP=(UPSTREAM.md _sync.sh)
|
| 17 |
+
|
| 18 |
+
if [[ ! -d "${LOCAL_PATH}" ]]; then
|
| 19 |
+
echo "expected ${LOCAL_PATH} to exist; are you in the repo root?" >&2
|
| 20 |
+
exit 1
|
| 21 |
+
fi
|
| 22 |
+
|
| 23 |
+
# Compare current pin to upstream HEAD.
|
| 24 |
+
current_pin="$(grep -oE 'upstream_sha:\s*[0-9a-f]+' "${LOCAL_PATH}/UPSTREAM.md" \
|
| 25 |
+
| awk '{print $2}' | head -n1 || true)"
|
| 26 |
+
upstream_sha="$(git ls-remote "${UPSTREAM_URL}" "refs/heads/${UPSTREAM_BRANCH}" \
|
| 27 |
+
| awk '{print $1}')"
|
| 28 |
+
|
| 29 |
+
if [[ -z "${upstream_sha}" ]]; then
|
| 30 |
+
echo "could not resolve upstream branch SHA — check network / auth" >&2
|
| 31 |
+
exit 2
|
| 32 |
+
fi
|
| 33 |
+
|
| 34 |
+
echo "current pin: ${current_pin:-(unset)}"
|
| 35 |
+
echo "upstream HEAD: ${upstream_sha}"
|
| 36 |
+
|
| 37 |
+
if [[ "${current_pin}" == "${upstream_sha}" ]]; then
|
| 38 |
+
echo "already in sync — nothing to do"
|
| 39 |
+
exit 0
|
| 40 |
+
fi
|
| 41 |
+
|
| 42 |
+
tmpdir="$(mktemp -d)"
|
| 43 |
+
trap 'rm -rf "${tmpdir}"' EXIT
|
| 44 |
+
|
| 45 |
+
echo ">> shallow-clone upstream"
|
| 46 |
+
git clone --depth 50 --branch "${UPSTREAM_BRANCH}" --single-branch \
|
| 47 |
+
"${UPSTREAM_URL}" "${tmpdir}/upstream" >/dev/null
|
| 48 |
+
|
| 49 |
+
# How many commits ahead of the current pin (best-effort; falls back to "?")
|
| 50 |
+
ahead="?"
|
| 51 |
+
if [[ -n "${current_pin}" ]]; then
|
| 52 |
+
if git -C "${tmpdir}/upstream" cat-file -e "${current_pin}" 2>/dev/null; then
|
| 53 |
+
ahead="$(git -C "${tmpdir}/upstream" rev-list --count "${current_pin}..HEAD")"
|
| 54 |
+
fi
|
| 55 |
+
fi
|
| 56 |
+
echo "commits ahead of pin: ${ahead}"
|
| 57 |
+
|
| 58 |
+
upstream_dir="${tmpdir}/upstream/${UPSTREAM_PATH}"
|
| 59 |
+
if [[ ! -d "${upstream_dir}" ]]; then
|
| 60 |
+
echo "upstream ${UPSTREAM_PATH}/ missing in branch ${UPSTREAM_BRANCH}" >&2
|
| 61 |
+
exit 3
|
| 62 |
+
fi
|
| 63 |
+
|
| 64 |
+
echo ">> rsync upstream → ${LOCAL_PATH}"
|
| 65 |
+
# Stash KEEP files so rsync --delete doesn't remove them.
|
| 66 |
+
keep_stash="$(mktemp -d)"
|
| 67 |
+
for f in "${KEEP[@]}"; do
|
| 68 |
+
if [[ -f "${LOCAL_PATH}/${f}" ]]; then
|
| 69 |
+
cp -p "${LOCAL_PATH}/${f}" "${keep_stash}/${f}"
|
| 70 |
+
fi
|
| 71 |
+
done
|
| 72 |
+
|
| 73 |
+
rsync -a --delete "${upstream_dir}/" "${LOCAL_PATH}/"
|
| 74 |
+
|
| 75 |
+
for f in "${KEEP[@]}"; do
|
| 76 |
+
if [[ -f "${keep_stash}/${f}" ]]; then
|
| 77 |
+
cp -p "${keep_stash}/${f}" "${LOCAL_PATH}/${f}"
|
| 78 |
+
fi
|
| 79 |
+
done
|
| 80 |
+
rm -rf "${keep_stash}"
|
| 81 |
+
|
| 82 |
+
echo
|
| 83 |
+
echo ">> changes:"
|
| 84 |
+
git -c color.ui=always diff --stat -- "${LOCAL_PATH}" | tail -20 || true
|
| 85 |
+
|
| 86 |
+
cat <<EOF
|
| 87 |
+
|
| 88 |
+
Sync staged. Next steps:
|
| 89 |
+
|
| 90 |
+
1. Bump the pin block in ${LOCAL_PATH}/UPSTREAM.md:
|
| 91 |
+
|
| 92 |
+
upstream_sha: ${upstream_sha}
|
| 93 |
+
synced_at: $(date -u +%Y-%m-%d)
|
| 94 |
+
synced_by: \$(git config user.name)
|
| 95 |
+
|
| 96 |
+
2. Review the diff: git diff -- ${LOCAL_PATH}
|
| 97 |
+
3. Commit: git add ${LOCAL_PATH} && git commit -m "Sync tools/validation/ from upstream ${upstream_sha:0:12}"
|
| 98 |
+
EOF
|
tools/validation/bootstrap.ps1
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<#
|
| 2 |
+
.SYNOPSIS
|
| 3 |
+
Bootstrap the SimReady Pipeline Bot runtime dependencies.
|
| 4 |
+
|
| 5 |
+
.DESCRIPTION
|
| 6 |
+
Idempotent. Run once on a fresh machine, or re-run any time to fix a
|
| 7 |
+
missing dependency. Creates a Python venv, clones the foundation + SDK
|
| 8 |
+
repos, and pip-installs everything the /simready-report skill needs.
|
| 9 |
+
|
| 10 |
+
The foundation specs are *not* editable-installed any more. validate.py
|
| 11 |
+
now defaults to populating the OAV registries via the simready-validate
|
| 12 |
+
CLI loader against on-disk paths under the foundations checkout.
|
| 13 |
+
Avoids the `repo usd_profiles_codegen` codegen step (which depends on
|
| 14 |
+
internal packman tooling not shipped in the public GitHub repo) and the
|
| 15 |
+
Forced-include trap in nv_core/sr_specs/pyproject.toml.
|
| 16 |
+
|
| 17 |
+
Layout produced (all under -DepsRoot, default $env:LOCALAPPDATA\simready):
|
| 18 |
+
|
| 19 |
+
<DepsRoot>\
|
| 20 |
+
├── venv\ # Python venv (created if missing)
|
| 21 |
+
├── simready_foundations\ # cloned from NVIDIA GitHub
|
| 22 |
+
└── simready-oem-sdk-poc\ # cloned from NVIDIA-dev GitHub
|
| 23 |
+
|
| 24 |
+
After a successful install the script writes three User-level environment
|
| 25 |
+
variables so the /simready-report skill can locate everything from any shell:
|
| 26 |
+
|
| 27 |
+
SIMREADY_PYTHON = <DepsRoot>\venv\Scripts\python.exe
|
| 28 |
+
SIMREADY_FOUNDATIONS_PATH = <DepsRoot>\simready_foundations
|
| 29 |
+
SIMREADY_SDK_PATH = <DepsRoot>\simready-oem-sdk-poc
|
| 30 |
+
|
| 31 |
+
Override the location with -DepsRoot, e.g.:
|
| 32 |
+
.\bootstrap.ps1 -DepsRoot D:\simready-deps
|
| 33 |
+
|
| 34 |
+
Already have these repos checked out elsewhere? Skip this script and
|
| 35 |
+
set the two env vars manually.
|
| 36 |
+
|
| 37 |
+
Optional (not bootstrapped): a `usd_validation_dashboard_final\` clone —
|
| 38 |
+
used only to copy Sphinx _static\ assets into rendered doc pages. If
|
| 39 |
+
absent, the dashboard's doc pages fall back to minimal inline styling.
|
| 40 |
+
|
| 41 |
+
.NOTES
|
| 42 |
+
- Reversible: the dirs created here are safe to delete; rerun this
|
| 43 |
+
script to re-create them. The two env vars can be cleared with
|
| 44 |
+
`[Environment]::SetEnvironmentVariable("SIMREADY_FOUNDATIONS_PATH", $null, "User")`.
|
| 45 |
+
- Requires: git, python (3.11+ recommended), and access to
|
| 46 |
+
github.com/NVIDIA + github.com/NVIDIA-dev (creds via the user's
|
| 47 |
+
existing git/gh auth).
|
| 48 |
+
- Never elevates / never modifies system PATH.
|
| 49 |
+
#>
|
| 50 |
+
[CmdletBinding()]
|
| 51 |
+
param(
|
| 52 |
+
[switch]$Force, # re-clone even if dirs exist
|
| 53 |
+
[string]$Python = "python", # python interpreter to seed the venv
|
| 54 |
+
[string]$DepsRoot = (Join-Path $env:LOCALAPPDATA "simready"),
|
| 55 |
+
[string]$FoundationsRepo = "https://github.com/NVIDIA/simready-foundation.git",
|
| 56 |
+
[string]$SdkRepo = "https://github.com/NVIDIA-dev/simready-oem-sdk-poc.git"
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
$ErrorActionPreference = "Stop"
|
| 60 |
+
|
| 61 |
+
if (-not (Test-Path $DepsRoot)) {
|
| 62 |
+
New-Item -ItemType Directory -Path $DepsRoot -Force | Out-Null
|
| 63 |
+
}
|
| 64 |
+
$DepsRoot = (Resolve-Path $DepsRoot).Path
|
| 65 |
+
|
| 66 |
+
$VenvPath = Join-Path $DepsRoot "venv"
|
| 67 |
+
$FoundationsPath = Join-Path $DepsRoot "simready_foundations"
|
| 68 |
+
$SdkPath = Join-Path $DepsRoot "simready-oem-sdk-poc"
|
| 69 |
+
|
| 70 |
+
function Step($msg) { Write-Host ""; Write-Host "==> $msg" -ForegroundColor Cyan }
|
| 71 |
+
function Note($msg) { Write-Host " $msg" -ForegroundColor DarkGray }
|
| 72 |
+
function Done($msg) { Write-Host " OK: $msg" -ForegroundColor Green }
|
| 73 |
+
function Fail($msg) { Write-Host " FAIL: $msg" -ForegroundColor Red; exit 1 }
|
| 74 |
+
|
| 75 |
+
Write-Host "DepsRoot: $DepsRoot" -ForegroundColor Yellow
|
| 76 |
+
|
| 77 |
+
# ---- Pre-flight ----------------------------------------------------------
|
| 78 |
+
|
| 79 |
+
Step "Pre-flight"
|
| 80 |
+
foreach ($cmd in @("git", $Python)) {
|
| 81 |
+
$found = Get-Command $cmd -ErrorAction SilentlyContinue
|
| 82 |
+
if (-not $found) { Fail "$cmd not on PATH" }
|
| 83 |
+
Note "$cmd -> $($found.Source)"
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
# ---- Clone the repos -----------------------------------------------------
|
| 87 |
+
|
| 88 |
+
function Ensure-Repo($name, $url, $path) {
|
| 89 |
+
if ((Test-Path $path) -and -not $Force) {
|
| 90 |
+
if (Test-Path (Join-Path $path ".git")) {
|
| 91 |
+
$existingUrl = $null
|
| 92 |
+
Push-Location $path
|
| 93 |
+
try { $existingUrl = (git remote get-url origin 2>$null) } finally { Pop-Location }
|
| 94 |
+
if ($existingUrl -and ($existingUrl.Trim() -ne $url.Trim())) {
|
| 95 |
+
Fail "$name at $path has origin '$($existingUrl.Trim())' but expected '$url'. Re-run with -Force to re-clone, or fix manually: git -C `"$path`" remote set-url origin `"$url`""
|
| 96 |
+
}
|
| 97 |
+
Note "$name already cloned at $path (use -Force to re-clone)"
|
| 98 |
+
return
|
| 99 |
+
} else {
|
| 100 |
+
Fail "$path exists but is not a git checkout. Move or delete it first."
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
if ((Test-Path $path) -and $Force) {
|
| 104 |
+
Note "Removing $path (-Force)"
|
| 105 |
+
Remove-Item -Recurse -Force $path
|
| 106 |
+
}
|
| 107 |
+
Note "git clone $url $path"
|
| 108 |
+
git clone $url $path
|
| 109 |
+
if ($LASTEXITCODE -ne 0) { Fail "git clone $name failed" }
|
| 110 |
+
Done "$name cloned"
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
Step "simready_foundations"
|
| 114 |
+
Ensure-Repo "simready_foundations" $FoundationsRepo $FoundationsPath
|
| 115 |
+
|
| 116 |
+
Step "simready-oem-sdk-poc"
|
| 117 |
+
Ensure-Repo "simready-oem-sdk-poc" $SdkRepo $SdkPath
|
| 118 |
+
|
| 119 |
+
# ---- Python venv ---------------------------------------------------------
|
| 120 |
+
|
| 121 |
+
Step "Python venv at $VenvPath"
|
| 122 |
+
if (-not (Test-Path (Join-Path $VenvPath "Scripts\python.exe"))) {
|
| 123 |
+
& $Python -m venv $VenvPath
|
| 124 |
+
if ($LASTEXITCODE -ne 0) { Fail "venv creation failed" }
|
| 125 |
+
Done "venv created"
|
| 126 |
+
} else {
|
| 127 |
+
Note "venv already present"
|
| 128 |
+
}
|
| 129 |
+
$VenvPython = Join-Path $VenvPath "Scripts\python.exe"
|
| 130 |
+
$VenvPip = Join-Path $VenvPath "Scripts\pip.exe"
|
| 131 |
+
|
| 132 |
+
# ---- Pip installs --------------------------------------------------------
|
| 133 |
+
|
| 134 |
+
Step "Upgrade pip / install runtime deps"
|
| 135 |
+
& $VenvPython -m pip install --upgrade pip wheel setuptools | Out-Null
|
| 136 |
+
if ($LASTEXITCODE -ne 0) { Fail "pip self-upgrade failed" }
|
| 137 |
+
|
| 138 |
+
$Pkgs = @(
|
| 139 |
+
"usd-core==26.5",
|
| 140 |
+
"omniverse-asset-validator==1.15.1",
|
| 141 |
+
"omniverse-usd-profiles==1.11.0",
|
| 142 |
+
"markdown-it-py>=3.0",
|
| 143 |
+
"click>=8.0",
|
| 144 |
+
"simready-validate>=2026.4.8"
|
| 145 |
+
)
|
| 146 |
+
& $VenvPip install @Pkgs
|
| 147 |
+
if ($LASTEXITCODE -ne 0) { Fail "pip install (runtime deps) failed" }
|
| 148 |
+
Done "runtime deps installed (incl. simready-validate from PyPI — provides the CLI loader)"
|
| 149 |
+
|
| 150 |
+
# Editable install of nv_core/sr_specs is no longer required. validate.py
|
| 151 |
+
# now defaults to populating the OAV registries via
|
| 152 |
+
# simready.validate.impl.loader.load_validation_implementation against
|
| 153 |
+
# on-disk paths under the foundations checkout. The legacy editable
|
| 154 |
+
# install + repo usd_profiles_codegen flow is reachable via the
|
| 155 |
+
# --use-plugin opt-in flag if needed.
|
| 156 |
+
|
| 157 |
+
Step "Editable install of simready-oem-sdk-poc (for auto-package step)"
|
| 158 |
+
& $VenvPip install -e $SdkPath
|
| 159 |
+
if ($LASTEXITCODE -ne 0) { Fail "pip install -e simready-oem-sdk-poc failed" }
|
| 160 |
+
Done "SDK editable install complete"
|
| 161 |
+
|
| 162 |
+
# ---- Smoke check ---------------------------------------------------------
|
| 163 |
+
|
| 164 |
+
Step "Smoke check"
|
| 165 |
+
& $VenvPython -c @"
|
| 166 |
+
import omni.asset_validator as oav
|
| 167 |
+
from pxr import Usd
|
| 168 |
+
from markdown_it import MarkdownIt
|
| 169 |
+
import simready_sdk
|
| 170 |
+
from simready.validate.impl.loader import load_validation_implementation
|
| 171 |
+
from pathlib import Path
|
| 172 |
+
import os
|
| 173 |
+
foundations = Path(os.environ.get('SIMREADY_FOUNDATIONS_PATH') or r'$FoundationsPath')
|
| 174 |
+
load_validation_implementation(
|
| 175 |
+
rules_and_requirements_paths=[foundations / 'nv_core/sr_specs/docs/capabilities'],
|
| 176 |
+
features_paths=[foundations / 'nv_core/sr_specs/docs/features'],
|
| 177 |
+
profiles_paths=[foundations / 'nv_core/sr_specs/docs/profiles/profiles.toml'],
|
| 178 |
+
)
|
| 179 |
+
pr = oav.ProfileRegistry()
|
| 180 |
+
profiles = list(pr.values())
|
| 181 |
+
print(f' asset-validator: {oav.__version__ if hasattr(oav,"__version__") else "(no version attr)"}')
|
| 182 |
+
print(f' profiles loaded: {len(profiles)}')
|
| 183 |
+
print(f' simready_sdk: {simready_sdk.__file__}')
|
| 184 |
+
"@
|
| 185 |
+
if ($LASTEXITCODE -ne 0) { Fail "smoke check failed" }
|
| 186 |
+
Done "imports + CLI-loader populated ProfileRegistry working"
|
| 187 |
+
|
| 188 |
+
# ---- Persist env vars so validate.py finds the deps without args -------
|
| 189 |
+
|
| 190 |
+
Step "Persist locator env vars (User scope)"
|
| 191 |
+
[Environment]::SetEnvironmentVariable("SIMREADY_PYTHON", $VenvPython, "User")
|
| 192 |
+
[Environment]::SetEnvironmentVariable("SIMREADY_FOUNDATIONS_PATH", $FoundationsPath, "User")
|
| 193 |
+
[Environment]::SetEnvironmentVariable("SIMREADY_SDK_PATH", $SdkPath, "User")
|
| 194 |
+
$env:SIMREADY_PYTHON = $VenvPython
|
| 195 |
+
$env:SIMREADY_FOUNDATIONS_PATH = $FoundationsPath
|
| 196 |
+
$env:SIMREADY_SDK_PATH = $SdkPath
|
| 197 |
+
Note "SIMREADY_PYTHON = $VenvPython"
|
| 198 |
+
Note "SIMREADY_FOUNDATIONS_PATH = $FoundationsPath"
|
| 199 |
+
Note "SIMREADY_SDK_PATH = $SdkPath"
|
| 200 |
+
Done "env vars set for current user"
|
| 201 |
+
|
| 202 |
+
# ---- Done ---------------------------------------------------------------
|
| 203 |
+
|
| 204 |
+
Write-Host ""
|
| 205 |
+
Write-Host "Bootstrap complete." -ForegroundColor Green
|
| 206 |
+
Write-Host "DepsRoot: $DepsRoot" -ForegroundColor Green
|
| 207 |
+
Write-Host "Next: invoke /simready-report against any asset directory." -ForegroundColor Green
|
tools/validation/playbooks/foundations-deviations.md
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SimReady Foundations — process deviations
|
| 2 |
+
|
| 3 |
+
Issues encountered while running the `/simready-report` skill against `yaskawa_local`
|
| 4 |
+
that require changes in **`simready_foundations`** (or a clearly documented
|
| 5 |
+
client-side workaround). These are not playbook bugs — the playbook is working
|
| 6 |
+
around upstream gaps.
|
| 7 |
+
|
| 8 |
+
Source data for each item: paths/codes observed during the validation run on
|
| 9 |
+
`packages/yaskawa_local/` against `Robot-Body-Isaac` v1.0.0.
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## 1. `Profile.capabilities` returns an empty list for every profile
|
| 14 |
+
|
| 15 |
+
**Where:** `omni.asset_validator` profile schema (Python registry).
|
| 16 |
+
|
| 17 |
+
**Observed:**
|
| 18 |
+
```python
|
| 19 |
+
profile = oav.ProfileRegistry().find('Robot-Body-Isaac', '1.0.0')
|
| 20 |
+
profile.capabilities # → []
|
| 21 |
+
profile.features # → 2 features, 8 requirements
|
| 22 |
+
```
|
| 23 |
+
Every profile in the registry returns `[]` for `.capabilities`. Profiles now
|
| 24 |
+
declare scope through `.features` — each feature owns its requirements
|
| 25 |
+
directly. The `capabilities` attribute is a leftover from an older schema.
|
| 26 |
+
|
| 27 |
+
**Impact:** `validate_yaskawa.py` and earlier versions of this playbook's
|
| 28 |
+
`validate.py` filtered the engine's enabled capabilities against
|
| 29 |
+
`profile.capabilities`. Because the latter is always empty, the filter
|
| 30 |
+
disabled **every** capability and reported `enabled capabilities: 0` while
|
| 31 |
+
producing 6,551 issues from rules that ignored the disable calls (see #2).
|
| 32 |
+
|
| 33 |
+
**Required:** remove `Profile.capabilities`, or populate it from the union of
|
| 34 |
+
the features' capabilities, and update all reference scripts/docs that still
|
| 35 |
+
use it.
|
| 36 |
+
|
| 37 |
+
---
|
| 38 |
+
|
| 39 |
+
## 2. `engine.enable_*` / `disable_*` do not constrain rule execution
|
| 40 |
+
|
| 41 |
+
**Where:** `omni.asset_validator.ValidationEngine`.
|
| 42 |
+
|
| 43 |
+
**Observed:** `engine.enable_feature(f)` and `engine.enable_requirement(r)`
|
| 44 |
+
update `engine.enabled_features` / `engine.enabled_requirements` counters,
|
| 45 |
+
but `engine.validate(stage)` still runs every rule registered via
|
| 46 |
+
`@register_rule`. `engine.enabled_rules` stays at 0 even after enabling
|
| 47 |
+
features and requirements; rules execute regardless.
|
| 48 |
+
|
| 49 |
+
**Impact:** profile-scoping has to happen post-hoc, by filtering issues
|
| 50 |
+
against the profile's requirement codes. The current playbook does this in
|
| 51 |
+
`validate_one()` (filters failures by `code in profile_codes`).
|
| 52 |
+
|
| 53 |
+
**Required:** either
|
| 54 |
+
1. make `enable_*` / `disable_*` actually scope rule execution, or
|
| 55 |
+
2. document explicitly that they are display-only and that callers must
|
| 56 |
+
filter results themselves. Today the API name strongly implies (1).
|
| 57 |
+
|
| 58 |
+
---
|
| 59 |
+
|
| 60 |
+
## 3. `Robot-Body-Isaac` v1.0.0 silently drops 4 of 6 declared features
|
| 61 |
+
(TOML ↔ Python registry mismatch)
|
| 62 |
+
|
| 63 |
+
**Where:** `simready_foundations/_build/target-deps/pip_prebundle/simready/foundation/core/profiles/profiles.toml`
|
| 64 |
+
vs the `Features` enum loaded from
|
| 65 |
+
`simready.foundation.core.features`.
|
| 66 |
+
|
| 67 |
+
**Observed:** `profiles.toml` declares Robot-Body-Isaac v1.0.0 with six
|
| 68 |
+
features:
|
| 69 |
+
|
| 70 |
+
| TOML entry | Loads? | Notes |
|
| 71 |
+
|---|---|---|
|
| 72 |
+
| `FET001_BASE_NEUTRAL` v0.1.0 | ✓ | 8 requirements |
|
| 73 |
+
| `FET004_ROBOT_PHYSX` v0.2.0 | ✗ | No such name in `Features` enum (only `FET004_BASE_NEUTRAL`) |
|
| 74 |
+
| `FET021_ROBOT_CORE_ISAAC` v0.2.0 | ✗ | Closest is `FET_021_ROBOT_CORE` v0.2.0 (note underscore + missing `_ISAAC`) |
|
| 75 |
+
| `FET022_DRIVEN_JOINTS_ISAAC` v0.1.0 | ✗ | Closest is `FET_022_DRIVEN_JOINTS` v0.1.0 |
|
| 76 |
+
| `FET024_BASE_ARTICULATION_PHYSX` v0.1.0 | ✗ | Closest is `FET_024_BASE_ARTICULATION` v0.1.0 |
|
| 77 |
+
| `FET100_BASE_ISAACSIM` v0.1.0 | ✓ | 0 requirements |
|
| 78 |
+
|
| 79 |
+
`pr.find('Robot-Body-Isaac', '1.0.0').features` therefore returns only the
|
| 80 |
+
two features that successfully resolve. Four features (Robot Core, Driven
|
| 81 |
+
Joints, Base Articulation, Robot Physx) are silently dropped — no warning,
|
| 82 |
+
no error.
|
| 83 |
+
|
| 84 |
+
The rules associated with the dropped features (RC.*, JT.*, RB.*, AT.*) still
|
| 85 |
+
execute (per #2 the engine ignores feature gating), so they appear in the
|
| 86 |
+
raw issue list, but they cannot be attributed to a profile feature for
|
| 87 |
+
roll-up reporting.
|
| 88 |
+
|
| 89 |
+
**Required:** reconcile the names. Either rename the TOML entries to match
|
| 90 |
+
the `Features` enum (`FET_021_ROBOT_CORE` etc.), or add the `_ISAAC` /
|
| 91 |
+
`_PHYSX` variants to the foundation as real feature implementations. Also
|
| 92 |
+
make profile-feature lookup *fail loudly* when a declared feature can't be
|
| 93 |
+
resolved — silent drop is the worst outcome.
|
| 94 |
+
|
| 95 |
+
Validation also surfaced real failures with codes from features that
|
| 96 |
+
**aren't even declared in the TOML profile**:
|
| 97 |
+
|
| 98 |
+
| Code(s) | Source | Notes |
|
| 99 |
+
|---|---|---|
|
| 100 |
+
| `RC.002`, `RC.004` (`thumbnail-exist`), `RC.008`, `RC.009` | `capabilities/isaac_sim/robot_core` | Robot-core rules registered & raising issues, but no profile feature includes them |
|
| 101 |
+
| `NP.001`, `NP.005`, `NP.006`, `NP.008` | `capabilities/visualization/nonvisual_materials` | |
|
| 102 |
+
| `HI.001`, `HI.002` | `capabilities/hierarchy` | (`HI.004` is in profile, others aren't) |
|
| 103 |
+
| `VG.007`, `VG.009`, `VG.016`, `VG.019`, `VG.MESH.001` | `capabilities/visualization/geometry` | (`VG.001` is in profile, the rest aren't) |
|
| 104 |
+
| `EX.01`, `EX.03`, `GSP.001`, `ISA.001`, `VM.MDL.001` | various | |
|
| 105 |
+
|
| 106 |
+
A "Robot-Body-Isaac" profile that does not require the asset to have a
|
| 107 |
+
thumbnail, robot type, or pinned root joint, but the foundations repo ships
|
| 108 |
+
those as registered rules, is a process gap.
|
| 109 |
+
|
| 110 |
+
**Required:** extend the profile's features to include the robot-core,
|
| 111 |
+
nonvisual-materials, hierarchy-completeness, and geometry-completeness
|
| 112 |
+
features that are already authored in the foundation; align the JSON capability
|
| 113 |
+
configs (`config/robot-core.json` etc.) with the Python `Profiles` enum.
|
| 114 |
+
|
| 115 |
+
---
|
| 116 |
+
|
| 117 |
+
## 4. JSON capability config and Python profile registry are not linked
|
| 118 |
+
|
| 119 |
+
**Where:** `simready_foundations/nv_core/sr_specs/config/robot-core.json` vs
|
| 120 |
+
`simready.foundation.core` Python plugin.
|
| 121 |
+
|
| 122 |
+
**Observed:** `robot-core.json` declares `RobotCore` capability v1.0.0 with
|
| 123 |
+
the `thumbnail-exist` requirement (and others). The Python registry exposes
|
| 124 |
+
that requirement to the rule machinery (issues with `RC.004` are emitted),
|
| 125 |
+
but no `Profile` exposes `RobotCore` as a feature, so client code that
|
| 126 |
+
introspects `profile.features` to compute pass/fail per feature can't see it.
|
| 127 |
+
|
| 128 |
+
**Impact:** validation reports list `RC.*` codes in raw issues but cannot map
|
| 129 |
+
them to a feature, so they appear as "uncategorized" failures.
|
| 130 |
+
|
| 131 |
+
**Required:** wire the JSON capability declarations into the `Features` /
|
| 132 |
+
`Profiles` enums (or into a new `profile.capabilities` accessor that returns
|
| 133 |
+
real entries — see #1).
|
| 134 |
+
|
| 135 |
+
---
|
| 136 |
+
|
| 137 |
+
## 5. ~87% of issues report `code: UNKNOWN`
|
| 138 |
+
|
| 139 |
+
**Where:** rules registered in `omni.asset_validator` and SimReady plugins.
|
| 140 |
+
|
| 141 |
+
**Observed:** of 6,551 issues across 7 robots, **5,695** have `iss.requirement
|
| 142 |
+
is None`, so the issue cannot be attributed to any requirement code:
|
| 143 |
+
```
|
| 144 |
+
5695 UNKNOWN
|
| 145 |
+
129 NP.001
|
| 146 |
+
116 HI.002
|
| 147 |
+
116 VG.009
|
| 148 |
+
96 VG.007
|
| 149 |
+
...
|
| 150 |
+
```
|
| 151 |
+
These come from rules that don't decorate with `@register_requirements`, so
|
| 152 |
+
they run but have no requirement linkage.
|
| 153 |
+
|
| 154 |
+
**Impact:** profile-scoped filtering (#2's workaround) drops 87% of the signal;
|
| 155 |
+
those failures appear in the raw issue list but never roll up to a feature
|
| 156 |
+
pass/fail.
|
| 157 |
+
|
| 158 |
+
**Required:** every rule registered in foundations should declare the
|
| 159 |
+
requirement(s) it enforces via `@register_requirements`, so issues are
|
| 160 |
+
attributable.
|
| 161 |
+
|
| 162 |
+
---
|
| 163 |
+
|
| 164 |
+
## 6. `init_rules` defaults are inconsistent across consumers
|
| 165 |
+
|
| 166 |
+
**Where:** `omni.asset_validator.ValidationEngine` constructor and the
|
| 167 |
+
SimReady SDK's engine wrapper.
|
| 168 |
+
|
| 169 |
+
**Observed:** `validate_yaskawa.py` (top-level reference) explicitly passes
|
| 170 |
+
`init_rules=True` and notes that `simready_sdk.core.engine` passes
|
| 171 |
+
`init_rules=False`, which leaves the engine empty:
|
| 172 |
+
```
|
| 173 |
+
The SDK passes init_rules=False which leaves the engine with no checks,
|
| 174 |
+
producing 'No rules or requirements have been enabled.' for every asset.
|
| 175 |
+
```
|
| 176 |
+
With `init_rules=True` the engine has 124 rules / 118 requirements. With
|
| 177 |
+
`False` it has 0 / 0.
|
| 178 |
+
|
| 179 |
+
**Required:** make `init_rules=True` the default in the engine, or remove the
|
| 180 |
+
parameter; update `simready_sdk.core.engine` to call it correctly. Today
|
| 181 |
+
choosing the wrong path silently produces a "passes everything" report.
|
| 182 |
+
|
| 183 |
+
---
|
| 184 |
+
|
| 185 |
+
## 7. Foundation has the wrapper but ships no runnable thumbnail generator
|
| 186 |
+
|
| 187 |
+
**Where:** `simready_foundations/nv_core/testing_tools/testing_framework/source/job_runner/core/engines/usdrecord/`
|
| 188 |
+
plus lighting rigs at `simready_foundations/nv_core/common_tools/thumbnails/auto_thumbnail_indoor_lighting_rig_{small,medium,large}.usd`.
|
| 189 |
+
|
| 190 |
+
**Observed:** the foundation defines `UsdRecordCommandBuilder` which builds
|
| 191 |
+
the command `<usdrecord_exe> <asset.usd> <output.png>` and references three
|
| 192 |
+
preset lighting USDs intended to be composed with the asset before rendering.
|
| 193 |
+
But:
|
| 194 |
+
- The `<usdrecord_exe>` is taken from `job.runner_config.executable`. The
|
| 195 |
+
foundation does not bundle the binary, the venv does not have it, and
|
| 196 |
+
`pxr.UsdAppUtils` (the Python library backing `usdrecord`) is absent.
|
| 197 |
+
- The `auto_thumbnail_indoor_lighting_rig_*.usd` rigs have no documented
|
| 198 |
+
composition recipe — there is no published "open the rig, payload the
|
| 199 |
+
asset, render to 256×256" wrapper script.
|
| 200 |
+
|
| 201 |
+
`usdrecord` is part of OpenUSD; on this machine it must be installed
|
| 202 |
+
externally. Three working install paths (commands documented for the record;
|
| 203 |
+
none are run automatically by the validator):
|
| 204 |
+
|
| 205 |
+
1. **Kit Kernel from NGC** (the user already has `ngc` on PATH):
|
| 206 |
+
```powershell
|
| 207 |
+
$env:KIT_VERSION = "106.5.0"
|
| 208 |
+
ngc registry resource download-version "nvidia/omniverse/kit:$env:KIT_VERSION" `
|
| 209 |
+
--dest "$env:USERPROFILE\.kit"
|
| 210 |
+
# `usdrecord.bat` lives at <kit_root>\dev\tools\packman\repo\bld\target-deps\usd\release\bin\usdrecord.bat
|
| 211 |
+
$env:USDRECORD_BIN = "$env:USERPROFILE\.kit\kit-$env:KIT_VERSION\...\usdrecord.bat"
|
| 212 |
+
```
|
| 213 |
+
2. **OpenUSD prebuilt** (NVIDIA developer release):
|
| 214 |
+
```powershell
|
| 215 |
+
# Download from https://developer.nvidia.com/usd (signed-in NGC link),
|
| 216 |
+
# extract, and point to the binary:
|
| 217 |
+
$env:USDRECORD_BIN = "C:\openusd\bin\usdrecord.cmd"
|
| 218 |
+
```
|
| 219 |
+
3. **Build OpenUSD from source** (heaviest, only if the prebuilt isn't
|
| 220 |
+
acceptable):
|
| 221 |
+
```powershell
|
| 222 |
+
git clone https://github.com/PixarAnimationStudios/OpenUSD.git C:\src\OpenUSD
|
| 223 |
+
python C:\src\OpenUSD\build_scripts\build_usd.py --tools C:\openusd
|
| 224 |
+
$env:USDRECORD_BIN = "C:\openusd\bin\usdrecord.cmd"
|
| 225 |
+
```
|
| 226 |
+
|
| 227 |
+
Once `USDRECORD_BIN` is set (or the binary is on PATH), `validate.py`'s
|
| 228 |
+
`_find_usdrecord()` will pick it up and the validator will invoke it for
|
| 229 |
+
each asset missing a thumbnail at the spec path. Generated PNGs go to
|
| 230 |
+
`report/<set>/_generated_thumbs/<filename>.png` — never into the
|
| 231 |
+
packaged asset tree. Every command run is logged in `results.json` under
|
| 232 |
+
`thumbnail_generation.attempts` and surfaced in the dashboard's "External
|
| 233 |
+
tooling used" panel.
|
| 234 |
+
|
| 235 |
+
For `yaskawa_local`, six of seven thumbnails were instead pulled from
|
| 236 |
+
`Assets/Isaac/6.0/Isaac/Robots/Yaskawa/Motoman Next/.../.thumbs/256x256/`
|
| 237 |
+
on the Omniverse content S3 bucket (free; no install required). One robot
|
| 238 |
+
(`NHC10DE-A00`) has no S3 thumbnail; without `usdrecord` installed, its tile
|
| 239 |
+
renders the inline SVG "no thumbnail" placeholder.
|
| 240 |
+
|
| 241 |
+
**Required:** either ship `usdrecord` in the foundation's bundled toolchain,
|
| 242 |
+
or distribute a foundation-side wrapper (e.g. a Kit ext that does
|
| 243 |
+
`UsdAppUtils.framerec.FrameRecorder` programmatically) so `simready ingest
|
| 244 |
+
usd` can produce the spec thumbnail without external installs.
|
| 245 |
+
|
| 246 |
+
---
|
| 247 |
+
|
| 248 |
+
## 8. Spec docs disagree on the thumbnail filename
|
| 249 |
+
|
| 250 |
+
**Where:**
|
| 251 |
+
- Spec doc: `nv_core/sr_specs/docs/capabilities/isaac_sim/robot_core/requirements/thumbnail-exist.md`
|
| 252 |
+
- Validation rule: `nv_core/sr_specs/docs/capabilities/isaac_sim/robot_core/validation.py:213`
|
| 253 |
+
- OEM publishing guide: `simready-oem-sdk-poc/docs/oem-publishing-guide-hf.md`
|
| 254 |
+
|
| 255 |
+
**Observed:**
|
| 256 |
+
- The spec doc's example uses `…/Robot_Name/.thumbs/256x256/Robot.png`
|
| 257 |
+
(filename = USD stem only, no `.usd` suffix).
|
| 258 |
+
- The rule code computes `…/<filename>.png` where `<filename>` is the full
|
| 259 |
+
USD filename **including** the `.usd` extension, e.g. `nex4_c00.usd.png`.
|
| 260 |
+
- The OEM publishing guide also uses `robot-a.usd.png` (with `.usd.`).
|
| 261 |
+
- The Isaac S3 bucket uses `NEX4_C00.usd.png` (with `.usd.`).
|
| 262 |
+
|
| 263 |
+
The rule and most published assets agree (`<filename>.png` with `.usd`); the
|
| 264 |
+
spec doc's example is wrong.
|
| 265 |
+
|
| 266 |
+
**Required:** fix the spec example in `thumbnail-exist.md` to match the rule.
|
| 267 |
+
|
| 268 |
+
---
|
| 269 |
+
|
| 270 |
+
## 9. `simready ingest usd` does not lift companion thumbnails
|
| 271 |
+
|
| 272 |
+
**Where:** `simready-oem-sdk-poc/src/simready_sdk/packager/organizer.py`.
|
| 273 |
+
|
| 274 |
+
**Observed:** `simready ingest usd ./robot/robot.usd` produces the spec
|
| 275 |
+
layout (`<name>/<name>.usd`, `configuration/`, `materials/`,
|
| 276 |
+
`.thumbs/256x256/` folder) but **does not** copy/rename a thumbnail from any
|
| 277 |
+
companion location into `.thumbs/256x256/<file>.usd.png`. The packager docs
|
| 278 |
+
(`SKILL.md`) call it "thumbnail placeholder" — the directory is created
|
| 279 |
+
empty.
|
| 280 |
+
|
| 281 |
+
For `yaskawa_local/images/<stem>.usd.usd.png`, no automated path exists for
|
| 282 |
+
the packager to know that file should land at the spec's thumbnail path.
|
| 283 |
+
|
| 284 |
+
**Required:** the packager should accept a companion-images directory hint
|
| 285 |
+
(e.g. `--thumbs-from <dir>` or a sibling `images/` convention) and lift
|
| 286 |
+
matching files into `.thumbs/256x256/<file>.usd.png` during organize.
|
| 287 |
+
|
| 288 |
+
---
|
| 289 |
+
|
| 290 |
+
## 10. A foundation rule crashes with `'NoneType' object has no attribute 'JointStateAPI'`
|
| 291 |
+
|
| 292 |
+
**Where:** somewhere in the `simready.foundation.core` plugin's joint-state
|
| 293 |
+
checker (rule not yet pinned down — issue is reported as `code: UNKNOWN`,
|
| 294 |
+
`rule: None`).
|
| 295 |
+
|
| 296 |
+
**Observed:** the rule fires on every prim it visits and produces an
|
| 297 |
+
*Uncaught error* issue, accounting for between **566 and 698 issues per
|
| 298 |
+
asset** in our run — i.e., most of what makes the report look noisy:
|
| 299 |
+
```
|
| 300 |
+
UNKNOWN ×668 Uncaught error: 'NoneType' object has no attribute 'JointStateAPI'
|
| 301 |
+
UNKNOWN ×566 Uncaught error: 'NoneType' object has no attribute 'JointStateAPI'
|
| 302 |
+
UNKNOWN ×698 Uncaught error: 'NoneType' object has no attribute 'JointStateAPI'
|
| 303 |
+
```
|
| 304 |
+
The remaining ~150 issues per asset (HI.*, NP.*, RC.*, etc.) are the *real*
|
| 305 |
+
findings.
|
| 306 |
+
|
| 307 |
+
The crash strongly suggests the rule reads
|
| 308 |
+
`stage.GetPrimAtPath(...).GetAPISchemas()`-style and assumes the result is
|
| 309 |
+
non-`None`. A defensive `if foo is None: continue` would silence the noise;
|
| 310 |
+
the underlying joint-state coverage is presumably what the rule was meant to
|
| 311 |
+
be checking, but currently it produces nothing useful.
|
| 312 |
+
|
| 313 |
+
**Required:** pin down the rule (grep `JointStateAPI` across
|
| 314 |
+
`simready.foundation.core`), add the missing None-guard, and either register
|
| 315 |
+
the rule's requirement properly or remove it from the engine until it works.
|
| 316 |
+
|
| 317 |
+
---
|
| 318 |
+
|
| 319 |
+
## 11. `NP.005` — layout mismatch between the rule's spec and `simready ingest usd`, plus a path-printing bug
|
| 320 |
+
|
| 321 |
+
**Where:**
|
| 322 |
+
- Rule code: `simready_foundations/nv_core/sr_specs/docs/capabilities/core/naming_paths/validation.py:271-320` (`AssetFolderStructureChecker`).
|
| 323 |
+
- Rule spec doc: `simready_foundations/nv_core/sr_specs/docs/capabilities/core/naming_paths/requirements/asset-folder-structure.md`.
|
| 324 |
+
- Packager output: `simready-oem-sdk-poc/src/simready_sdk/packager/organizer.py` (invoked via `simready ingest usd`).
|
| 325 |
+
|
| 326 |
+
### 11a. Layout mismatch
|
| 327 |
+
|
| 328 |
+
The NP.005 spec doc requires this structure:
|
| 329 |
+
|
| 330 |
+
```
|
| 331 |
+
<asset>/ ← asset folder
|
| 332 |
+
├── <intermediate>/ ← exactly ONE intermediate folder (any name)
|
| 333 |
+
│ └── <main>.usd ← main USD; NO other .usd files here or below
|
| 334 |
+
└── materials/ ← supporting folders are SIBLINGS of <intermediate>/
|
| 335 |
+
```
|
| 336 |
+
|
| 337 |
+
The reference dashboard's FANUC content follows this exactly:
|
| 338 |
+
|
| 339 |
+
```
|
| 340 |
+
robots/CR/cr_50f_16b/isaac_ready_usd/cr_50f_16b.usd.usd
|
| 341 |
+
robots/CR/cr_50f_16b/configuration/...
|
| 342 |
+
```
|
| 343 |
+
|
| 344 |
+
But `simready ingest usd` produces a *flat* layout — main USD at the asset
|
| 345 |
+
root, sublayers nested inside the same dir:
|
| 346 |
+
|
| 347 |
+
```
|
| 348 |
+
<asset>/
|
| 349 |
+
├── <asset>.usd
|
| 350 |
+
└── configuration/
|
| 351 |
+
├── <asset>_base.usd
|
| 352 |
+
├── <asset>_physics.usd
|
| 353 |
+
└── <asset>_sensor.usd
|
| 354 |
+
```
|
| 355 |
+
|
| 356 |
+
The rule walks `os.path.dirname(stage_path)` (which is `<asset>/` in the
|
| 357 |
+
flat layout) and flags every sublayer as "stray". The sublayers aren't
|
| 358 |
+
stray — they're the spec-required configuration files — but the rule's
|
| 359 |
+
walk-set includes them because the packager produced an
|
| 360 |
+
NP.005-non-compliant layout.
|
| 361 |
+
|
| 362 |
+
So: **two foundation artifacts disagree on the canonical asset layout.**
|
| 363 |
+
The packaging spec (`simready-packaging`'s SKILL.md) and `simready ingest
|
| 364 |
+
usd` produce one layout; NP.005's spec doc and the FANUC reference content
|
| 365 |
+
use a different layout.
|
| 366 |
+
|
| 367 |
+
### 11b. Path-printing bug (independent)
|
| 368 |
+
|
| 369 |
+
```python
|
| 370 |
+
# capabilities/core/naming_paths/validation.py:308-313
|
| 371 |
+
for root, dirs, files in os.walk(current_dir):
|
| 372 |
+
for file in files:
|
| 373 |
+
found_file = os.path.join(root, file).replace("\\", "/")
|
| 374 |
+
if file.endswith((".usd", ".usda")) and found_file != stage_path:
|
| 375 |
+
relative_path = os.path.relpath(file, current_dir) # ← bug
|
| 376 |
+
other_usd_files.append(relative_path)
|
| 377 |
+
```
|
| 378 |
+
|
| 379 |
+
`file` here is just the basename returned by `os.walk` (e.g.
|
| 380 |
+
`nex10_c00_base.usd`). `os.path.relpath` resolves it against
|
| 381 |
+
`os.getcwd()`, not against `root`. With CWD =
|
| 382 |
+
`<workspace>/.claude/skills/simready-report/`, the rule prints:
|
| 383 |
+
|
| 384 |
+
```
|
| 385 |
+
..\..\..\.claude\skills\simready-report\nex10_c00_base.usd
|
| 386 |
+
```
|
| 387 |
+
|
| 388 |
+
— a path back to the validator's CWD, where the file emphatically does not
|
| 389 |
+
exist. Should be `os.path.relpath(found_file, current_dir)` (`found_file`
|
| 390 |
+
is the correct joined path, already computed on the line above and
|
| 391 |
+
otherwise unused). With that one-character fix the cited path becomes
|
| 392 |
+
`configuration/nex10_c00_base.usd` — pointing at the real file.
|
| 393 |
+
|
| 394 |
+
### Required
|
| 395 |
+
|
| 396 |
+
**Pick one canonical layout and reconcile both sides.** Either:
|
| 397 |
+
|
| 398 |
+
- **Update the packager** (`simready ingest usd`) to emit the
|
| 399 |
+
NP.005-compliant `<asset>/<intermediate>/<main>.usd` structure with
|
| 400 |
+
`configuration/` and `materials/` as siblings of `<intermediate>/`. This
|
| 401 |
+
is what the FANUC reference content already uses. The packaging skill
|
| 402 |
+
doc and any consumers (manifest tooling, dashboard, etc.) need updating
|
| 403 |
+
in lockstep.
|
| 404 |
+
|
| 405 |
+
- **Or update NP.005's spec doc and rule** to bless the flat layout that
|
| 406 |
+
`simready ingest usd` actually produces — i.e., explicitly whitelist the
|
| 407 |
+
spec-mandated subdirectories (`configuration/`, `materials/`,
|
| 408 |
+
`.thumbs/`, `.simready/`) when scanning for stray USDs.
|
| 409 |
+
|
| 410 |
+
Whichever is chosen, fix the path-printing bug at the same time
|
| 411 |
+
(`relpath(file, …)` → `relpath(found_file, …)`) so the message names a
|
| 412 |
+
real on-disk path.
|
| 413 |
+
|
| 414 |
+
---
|
| 415 |
+
|
| 416 |
+
## 12. The "example" capability is active in production validation
|
| 417 |
+
|
| 418 |
+
**Where:**
|
| 419 |
+
`simready_foundations/nv_core/sr_specs/docs/capabilities/example/example.py`,
|
| 420 |
+
codes `EX.01`, `EX.02`, `EX.03`.
|
| 421 |
+
|
| 422 |
+
**Observed:** every asset fails:
|
| 423 |
+
- `EX.01` — *"Stage has no mesh prims."*
|
| 424 |
+
- `EX.03` — *"Test prim 'TestBlob' not found under default prim."*
|
| 425 |
+
|
| 426 |
+
The source comment on this capability is literally `"example"` and the
|
| 427 |
+
rule docs (`example.md`) describe it as a tutorial — `EX.03` requires a
|
| 428 |
+
prim named `TestBlob` to exist as a child of the default prim. That's
|
| 429 |
+
clearly not an asset requirement; it's pedagogical scaffolding to teach
|
| 430 |
+
plugin authors how to register a rule.
|
| 431 |
+
|
| 432 |
+
**Required:** unregister the `Example` capability from production rule
|
| 433 |
+
loading (move it to test fixtures, gate it behind an env var, or delete it
|
| 434 |
+
from the shipped plugin). Today every real asset takes 2–3 spurious EX
|
| 435 |
+
failures.
|
| 436 |
+
|
| 437 |
+
---
|
| 438 |
+
|
| 439 |
+
## 13. `EX.01` and `VG.MESH.001` are duplicate checks under different codes
|
| 440 |
+
|
| 441 |
+
**Where:**
|
| 442 |
+
- `capabilities/example/example.py:52` — `EX.01`: *"Stage has no mesh prims."*
|
| 443 |
+
- `capabilities/visualization/geometry/validation.py:836` — `VG.MESH.001`:
|
| 444 |
+
*"Stage does not contain any meshes."*
|
| 445 |
+
|
| 446 |
+
**Observed:** both fire on every asset, with effectively identical
|
| 447 |
+
semantics. `EX.01` is the example/tutorial version; `VG.MESH.001` is the
|
| 448 |
+
real version. Reporting both is noise.
|
| 449 |
+
|
| 450 |
+
**Required:** when #12 is fixed, this disappears for free. Otherwise,
|
| 451 |
+
`EX.01` should be retired and any consumer that still references it should
|
| 452 |
+
be redirected to `VG.MESH.001`.
|
| 453 |
+
|
| 454 |
+
---
|
| 455 |
+
|
| 456 |
+
## 14. `HI.002` rejects valid USD xform-op decompositions
|
| 457 |
+
|
| 458 |
+
**Where:** `capabilities/hierarchy/validation.py:62-104`
|
| 459 |
+
(`ExclusiveXFormParentChecker`).
|
| 460 |
+
|
| 461 |
+
**Observed:** ~14 issues per asset, message *"Prim Parent has no
|
| 462 |
+
xformOp:rotate."* The rule requires every Gprim parent to author both
|
| 463 |
+
`xformOp:translate` and (a substring-matching) `xformOp:rotate*`:
|
| 464 |
+
|
| 465 |
+
```python
|
| 466 |
+
if not any("xformOp:rotate" in op.GetAttr().GetName() for op in xform_ops):
|
| 467 |
+
self._AddFailedCheck("Prim Parent has no xformOp:rotate.", …)
|
| 468 |
+
```
|
| 469 |
+
|
| 470 |
+
That's an over-strict reading of USD. Valid alternatives the rule rejects:
|
| 471 |
+
|
| 472 |
+
- `xformOp:transform` (single 4×4 matrix — encodes translate + rotate +
|
| 473 |
+
scale together).
|
| 474 |
+
- No xform ops at all (identity transform — also a valid stateless prim).
|
| 475 |
+
- Any combination authored on an inherited parent and overridden by the
|
| 476 |
+
Gprim's own ops.
|
| 477 |
+
|
| 478 |
+
Yaskawa's robots author matrix transforms in many places, which is what
|
| 479 |
+
trips this. The check should accept *any* xformable spec that resolves to a
|
| 480 |
+
defined transform — likely via `Xformable.GetLocalTransformation()`
|
| 481 |
+
returning a non-error result — rather than pattern-matching specific op
|
| 482 |
+
names.
|
| 483 |
+
|
| 484 |
+
**Required:** rewrite the check to validate *that the prim resolves to a
|
| 485 |
+
defined transform*, not that it authored a particular op vocabulary.
|
| 486 |
+
|
| 487 |
+
---
|
| 488 |
+
|
| 489 |
+
## 15. `NP.001` flags the conventional `/Looks` scope as a naming violation
|
| 490 |
+
|
| 491 |
+
**Where:** `capabilities/core/naming_paths/validation.py:125-160`
|
| 492 |
+
(`PrimNamingConventionChecker`).
|
| 493 |
+
|
| 494 |
+
**Observed:** every asset fails NP.001 ×8 with *"Prim 'Looks' does not
|
| 495 |
+
follow consistent naming convention (camelCase or snake_case)."*
|
| 496 |
+
|
| 497 |
+
`Looks` is the long-established USD convention for the materials scope
|
| 498 |
+
under an asset's default prim — it's used by Pixar's published USD
|
| 499 |
+
samples, NVIDIA's Isaac/Omniverse content, and most authoring tools
|
| 500 |
+
(Houdini, Maya USD, Kit). The rule's `CAMEL_CASE_PATTERN` and
|
| 501 |
+
`SNAKE_CASE_PATTERN` regexes don't accept the `Looks` PascalCase
|
| 502 |
+
single-word, so it reports a violation on a name the broader USD ecosystem
|
| 503 |
+
treats as canonical.
|
| 504 |
+
|
| 505 |
+
**Required:** either whitelist the well-known USD scope names (`Looks`,
|
| 506 |
+
`Geometry`, `Cameras`, `Lights`, `Animation`, `Render`, etc.) or accept
|
| 507 |
+
PascalCase as a valid convention alongside camelCase / snake_case.
|
| 508 |
+
|
| 509 |
+
---
|
| 510 |
+
|
| 511 |
+
## 16. `VM.MDL.001` and `NP.008` double-report the same MDL resolution failure
|
| 512 |
+
|
| 513 |
+
**Where:**
|
| 514 |
+
- `capabilities/visualization/materials/...` (VM.MDL.001 — *"The path
|
| 515 |
+
OmniPBR.mdl does not exist."*)
|
| 516 |
+
- `capabilities/core/naming_paths/...` (NP.008 — *"Asset path 'OmniPBR.mdl'
|
| 517 |
+
on attribute 'info:mdl:sourceAsset' at prim '/.../Looks/.../...' does not
|
| 518 |
+
resolve to an existing file."*)
|
| 519 |
+
|
| 520 |
+
**Observed:** every asset emits the same number of VM.MDL.001 and NP.008
|
| 521 |
+
issues (9 each on `nex10_c00`, 6 each on `nex20_c00`, etc.) — both rules
|
| 522 |
+
fire on the same `info:mdl:sourceAsset = @OmniPBR.mdl@` attribute. NP.008
|
| 523 |
+
gives a useful prim path; VM.MDL.001 just says "doesn't exist".
|
| 524 |
+
|
| 525 |
+
Separately: `OmniPBR.mdl` is a **standard NVIDIA MDL** that ships with Kit
|
| 526 |
+
and Isaac. The validator's asset resolver doesn't know where to find it
|
| 527 |
+
(likely missing an MDL search path in the SimReady plugin's resolver
|
| 528 |
+
config), so a perfectly portable, NVIDIA-blessed material reference is
|
| 529 |
+
flagged as broken on every asset.
|
| 530 |
+
|
| 531 |
+
**Required:**
|
| 532 |
+
1. Configure the SimReady validator plugin to register Kit's standard MDL
|
| 533 |
+
search paths (`mdl/`, `omni/mdl/`, etc.) so `OmniPBR.mdl` and other
|
| 534 |
+
shipped MDLs resolve without the customer having to vendor them.
|
| 535 |
+
2. Pick one of VM.MDL.001 / NP.008 as the canonical "MDL doesn't resolve"
|
| 536 |
+
code. Have the other defer when the first has fired against the same
|
| 537 |
+
attribute.
|
| 538 |
+
|
| 539 |
+
---
|
| 540 |
+
|
| 541 |
+
## 17. `RC.002` flags spec-mandated robot metadata as "overrides"
|
| 542 |
+
|
| 543 |
+
**Where:** `capabilities/isaac_sim/robot_core/...`, `RC.002`.
|
| 544 |
+
|
| 545 |
+
**Observed:** every asset fails RC.002 with messages like:
|
| 546 |
+
|
| 547 |
+
```
|
| 548 |
+
Prim is overridden: /nex10_c00, ['isaac:description', 'isaac:license',
|
| 549 |
+
'isaac:namespace', 'isaac:robotType']
|
| 550 |
+
```
|
| 551 |
+
|
| 552 |
+
`isaac:description`, `isaac:license`, `isaac:namespace`,
|
| 553 |
+
`isaac:robotType` look like exactly the kind of metadata the SimReady
|
| 554 |
+
robot spec wants you to author on the robot's default prim — but RC.002
|
| 555 |
+
is treating any authored override of the default prim's properties as a
|
| 556 |
+
violation.
|
| 557 |
+
|
| 558 |
+
If those four `isaac:*` attributes are spec-mandated, the rule should
|
| 559 |
+
*require* them, not flag their presence. If they're optional metadata, the
|
| 560 |
+
rule should ignore them — overriding metadata fields on the default prim
|
| 561 |
+
is a normal authoring pattern, not a layout error.
|
| 562 |
+
|
| 563 |
+
**Required:** clarify the spec around the `isaac:*` namespace on the
|
| 564 |
+
robot default prim and update RC.002 to either require those fields, or
|
| 565 |
+
exclude them from the "this prim shouldn't be overridden" check, or split
|
| 566 |
+
into two rules (one for "structural" overrides, one for the spec
|
| 567 |
+
metadata).
|
| 568 |
+
|
| 569 |
+
---
|
| 570 |
+
|
| 571 |
+
## Summary
|
| 572 |
+
|
| 573 |
+
| # | Severity | Area | One-line |
|
| 574 |
+
|---|---|---|---|
|
| 575 |
+
| 1 | high | profile schema | `Profile.capabilities` is dead; should be removed or populated |
|
| 576 |
+
| 2 | high | engine API | `enable_*`/`disable_*` don't actually scope rule execution |
|
| 577 |
+
| 3 | high | profile content | Robot-Body-Isaac v1.0.0 silently drops 4 of 6 declared features |
|
| 578 |
+
| 4 | high | profile schema | JSON capability configs aren't linked to the Python profile registry |
|
| 579 |
+
| 5 | high | rule registration | Most rules don't declare `@register_requirements` → 87% UNKNOWN |
|
| 580 |
+
| 6 | medium | engine API | `init_rules` defaults inconsistent; SDK silently turns off all checks |
|
| 581 |
+
| 7 | medium | tooling | No headless thumbnail generator ships with foundations |
|
| 582 |
+
| 8 | low | spec docs | `thumbnail-exist.md` example contradicts the actual rule |
|
| 583 |
+
| 9 | medium | packaging | `simready ingest usd` doesn't lift companion thumbnails into spec layout |
|
| 584 |
+
| 10 | high | rule reliability | Rule crashes on `JointStateAPI` access → ~600 UNKNOWN issues per asset |
|
| 585 |
+
| 11 | high | spec/packager mismatch | NP.005 expects `<asset>/<intermediate>/<main>.usd`; packager produces flat layout. Plus `relpath(file,…)` should be `relpath(found_file,…)` |
|
| 586 |
+
| 12 | high | rule scoping | "Example" tutorial capability (`EX.*`) is active in production validation |
|
| 587 |
+
| 13 | medium | rule duplication | `EX.01` and `VG.MESH.001` are the same check |
|
| 588 |
+
| 14 | high | rule correctness | `HI.002` rejects matrix transforms (`xformOp:transform`) and identity transforms |
|
| 589 |
+
| 15 | medium | rule correctness | `NP.001` rejects the conventional `/Looks` scope name |
|
| 590 |
+
| 16 | high | rule correctness | `OmniPBR.mdl` (standard NVIDIA MDL) doesn't resolve; both VM.MDL.001 and NP.008 fire on the same attribute |
|
| 591 |
+
| 17 | medium | spec ambiguity | `RC.002` flags spec-looking `isaac:*` metadata as illegal "overrides" |
|
tools/validation/plugins/simready-report/plugin.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://json.schemastore.org/claude-code-plugin",
|
| 3 |
+
"name": "simready-report",
|
| 4 |
+
"version": "0.2.0",
|
| 5 |
+
"description": "SimReady asset pipeline tooling. Ships two slash commands: /simready-report (validate customer USD assets and emit an HTML dashboard + external-deps provenance report) and /simready-package (run the simready ingest packaging step alone, no validation).",
|
| 6 |
+
"author": {
|
| 7 |
+
"name": "loginowskid",
|
| 8 |
+
"email": "dloginowski@nvidia.com"
|
| 9 |
+
},
|
| 10 |
+
"homepage": "https://github.com/loginowskid/simready-playbook"
|
| 11 |
+
}
|
tools/validation/plugins/simready-report/skills/simready-package/SKILL.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: simready-package
|
| 3 |
+
description: Package SimReady customer assets by running `simready ingest usd` per interface USD. Standalone alternative to /simready-report's auto-package step. Invoke with /simready-package <name-or-path>.
|
| 4 |
+
---
|
| 5 |
+
|
| 6 |
+
# /simready-package
|
| 7 |
+
|
| 8 |
+
Run the SimReady packaging step on a directory of customer asset subfolders, without running validation or generating a dashboard.
|
| 9 |
+
|
| 10 |
+
## Usage
|
| 11 |
+
|
| 12 |
+
- `/simready-package` (no arg) — package the **current working directory**.
|
| 13 |
+
- `/simready-package <path>` — package the given directory by absolute or relative path.
|
| 14 |
+
|
| 15 |
+
## When to use
|
| 16 |
+
|
| 17 |
+
- You have a directory like `assets_to_validate/<name>/` with one subfolder per asset (each containing an interface USD), and want to produce a packaged tree without running validation.
|
| 18 |
+
- You want to inspect the packaged output before invoking `/simready-report`.
|
| 19 |
+
- You're iterating on packaging configuration and don't want the validation overhead each time.
|
| 20 |
+
|
| 21 |
+
`/simready-report` still auto-packages when its target sits under an `assets_to_validate/` dir, so you don't *need* `/simready-package` to use the report. They're independent.
|
| 22 |
+
|
| 23 |
+
## Steps for Claude when `/simready-package` is invoked
|
| 24 |
+
|
| 25 |
+
1. **Resolve the target.** No arg → CWD. With an arg, pass it through to the script.
|
| 26 |
+
2. **Run the packager** from this skill directory using whatever Python the user's environment provides — prefer `$env:SIMREADY_PYTHON` (set by `bootstrap.ps1`) over `python` on PATH:
|
| 27 |
+
|
| 28 |
+
PowerShell:
|
| 29 |
+
```
|
| 30 |
+
$py = if ($env:SIMREADY_PYTHON) { $env:SIMREADY_PYTHON } else { "python" }
|
| 31 |
+
& $py package.py "<arg-or-empty>"
|
| 32 |
+
```
|
| 33 |
+
Bash/zsh:
|
| 34 |
+
```
|
| 35 |
+
"${SIMREADY_PYTHON:-python}" package.py "<arg-or-empty>"
|
| 36 |
+
```
|
| 37 |
+
Add `--profile NAME` only if the user explicitly asked for a non-default profile. Add `--output DIR` only if the user explicitly asked for a non-default output.
|
| 38 |
+
3. **Surface results.** Echo the script's `SUMMARY:` line and the absolute `OUTPUT:` path.
|
| 39 |
+
4. **If the script exits with "simready CLI not found"**, relay verbatim. The user needs `simready-oem-sdk-poc` installed into the active Python, or to run `bootstrap.ps1`. Don't run the bootstrap on the user's behalf without explicit confirmation.
|
| 40 |
+
|
| 41 |
+
## Default output location
|
| 42 |
+
|
| 43 |
+
- When the target sits under an `assets_to_validate/<name>/` directory, output goes to the sibling `packages/<name>/`.
|
| 44 |
+
- Otherwise, output goes to `<target>.parent/packages/<target.name>/`.
|
| 45 |
+
- Override with `--output <path>`.
|
| 46 |
+
|
| 47 |
+
## What the packager does
|
| 48 |
+
|
| 49 |
+
- **Interface discovery.** For each immediate subfolder of the target, picks one interface USD per asset:
|
| 50 |
+
- exactly one USD at root → that one
|
| 51 |
+
- otherwise → the USD whose stem matches the folder name (case- and hyphen/underscore-insensitive)
|
| 52 |
+
- otherwise → all USDs (rare)
|
| 53 |
+
- subfolders starting with `.` or `_` are ignored
|
| 54 |
+
- **Idempotent.** Skips assets already packaged (existing `<output>/<stem>/<filename>.usd`).
|
| 55 |
+
- **Per-asset invocation.** Calls `simready ingest usd <interface> -o <output> -p <profile> --no-validate` for each interface, with a 120s timeout.
|
| 56 |
+
- **Summary.** Prints `SUMMARY: packaged=N skipped=N failed=N` and the resolved `OUTPUT:` path. Exits non-zero if any asset failed to ingest (the rest still get packaged).
|
| 57 |
+
|
| 58 |
+
## Configuration
|
| 59 |
+
|
| 60 |
+
- `SIMREADY_PYTHON` (recommended): full path to the Python interpreter that has `simready-oem-sdk-poc` installed. Set automatically by `bootstrap.ps1`.
|
| 61 |
+
- Default profile: `Robot-Body-Runnable`. Override with `--profile NAME`.
|
| 62 |
+
- Default output: see above. Override with `--output DIR`.
|
tools/validation/plugins/simready-report/skills/simready-package/package.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SimReady asset packaging — entry point for the /simready-package skill.
|
| 2 |
+
|
| 3 |
+
Usage:
|
| 4 |
+
python package.py <target_dir> [--profile NAME] [--output DIR]
|
| 5 |
+
|
| 6 |
+
For each immediate subfolder of <target_dir> containing a USD file, picks
|
| 7 |
+
the interface USD and runs `simready ingest usd` on it, producing a
|
| 8 |
+
packaged tree under <output_dir>. Independent of /simready-report — runs
|
| 9 |
+
the packaging step alone, without validation or dashboard generation.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import argparse
|
| 14 |
+
import os
|
| 15 |
+
import shutil
|
| 16 |
+
import subprocess
|
| 17 |
+
import sys
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
USD_EXTS = (".usd", ".usda", ".usdc", ".usdz")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _find_simready_cli() -> str | None:
|
| 24 |
+
"""Locate the `simready` CLI in a layout-agnostic way.
|
| 25 |
+
|
| 26 |
+
Order: (1) on PATH, (2) alongside `sys.executable` (covers any active
|
| 27 |
+
venv/conda regardless of OS).
|
| 28 |
+
"""
|
| 29 |
+
on_path = shutil.which("simready")
|
| 30 |
+
if on_path:
|
| 31 |
+
return on_path
|
| 32 |
+
py_bin = Path(sys.executable).parent
|
| 33 |
+
for candidate in (py_bin / "simready.exe", py_bin / "simready"):
|
| 34 |
+
if candidate.is_file():
|
| 35 |
+
return str(candidate)
|
| 36 |
+
return None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _discover_interface_usds(target: Path) -> list[Path]:
|
| 40 |
+
"""Pick one interface USD per immediate subfolder of `target`.
|
| 41 |
+
|
| 42 |
+
Heuristic per subfolder:
|
| 43 |
+
- If exactly one USD at root, that's the interface.
|
| 44 |
+
- Else prefer the USD whose stem matches the folder name (case- and
|
| 45 |
+
hyphen/underscore-insensitive).
|
| 46 |
+
- Else fall back to packaging all of them.
|
| 47 |
+
Subfolders starting with "." or "_" are ignored.
|
| 48 |
+
"""
|
| 49 |
+
interface_files: list[Path] = []
|
| 50 |
+
for sub in sorted(target.iterdir()):
|
| 51 |
+
if not sub.is_dir() or sub.name.startswith((".", "_")):
|
| 52 |
+
continue
|
| 53 |
+
usds = [p for ext in USD_EXTS for p in sub.glob(f"*{ext}")]
|
| 54 |
+
if not usds:
|
| 55 |
+
continue
|
| 56 |
+
if len(usds) == 1:
|
| 57 |
+
interface_files.append(usds[0])
|
| 58 |
+
continue
|
| 59 |
+
named = [p for p in usds if p.stem.lower() == sub.name.lower().replace("-", "_")]
|
| 60 |
+
if named:
|
| 61 |
+
interface_files.append(named[0])
|
| 62 |
+
else:
|
| 63 |
+
interface_files.extend(usds)
|
| 64 |
+
return interface_files
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _default_output(target: Path) -> Path:
|
| 68 |
+
"""Pick a default output dir based on target location."""
|
| 69 |
+
target_str = str(target).replace("\\", "/")
|
| 70 |
+
if "/assets_to_validate/" in target_str:
|
| 71 |
+
# Workspace-style layout: <X>/assets_to_validate/<name>
|
| 72 |
+
# → output at sibling packages dir: <X>/packages/<name>
|
| 73 |
+
return target.parent.parent / "packages" / target.name
|
| 74 |
+
return target.parent / "packages" / target.name
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def main() -> int:
|
| 78 |
+
ap = argparse.ArgumentParser(description="Package SimReady assets via `simready ingest usd`.")
|
| 79 |
+
ap.add_argument("target", nargs="?", default=None,
|
| 80 |
+
help="Directory containing one or more asset subfolders (default: cwd)")
|
| 81 |
+
ap.add_argument("--profile", default="Robot-Body-Runnable",
|
| 82 |
+
help="Profile passed to `simready ingest usd -p` (default: Robot-Body-Runnable)")
|
| 83 |
+
ap.add_argument("--output", default=None,
|
| 84 |
+
help="Output dir (default: <target>.parent/packages/<target.name>, "
|
| 85 |
+
"or sibling-of-assets_to_validate/packages/<name> when applicable)")
|
| 86 |
+
args = ap.parse_args()
|
| 87 |
+
|
| 88 |
+
target = Path(args.target).resolve() if args.target else Path.cwd().resolve()
|
| 89 |
+
if not target.is_dir():
|
| 90 |
+
print(f"ERROR: target is not a directory: {target}", flush=True)
|
| 91 |
+
return 2
|
| 92 |
+
|
| 93 |
+
simready_exe = _find_simready_cli()
|
| 94 |
+
if simready_exe is None:
|
| 95 |
+
print("ERROR: `simready` CLI not found on PATH or alongside the active Python.", flush=True)
|
| 96 |
+
print(" Install simready-oem-sdk-poc into the active Python, or run bootstrap.ps1.", flush=True)
|
| 97 |
+
return 2
|
| 98 |
+
|
| 99 |
+
interface_files = _discover_interface_usds(target)
|
| 100 |
+
if not interface_files:
|
| 101 |
+
print(f"ERROR: no USD files found in immediate subfolders of {target}.", flush=True)
|
| 102 |
+
print(" /simready-package expects each asset to live in its own subfolder "
|
| 103 |
+
"with an interface USD.", flush=True)
|
| 104 |
+
return 2
|
| 105 |
+
|
| 106 |
+
output = Path(args.output).resolve() if args.output else _default_output(target)
|
| 107 |
+
output.mkdir(parents=True, exist_ok=True)
|
| 108 |
+
|
| 109 |
+
print(f"Target: {target}", flush=True)
|
| 110 |
+
print(f"Output: {output}", flush=True)
|
| 111 |
+
print(f"Profile: {args.profile}", flush=True)
|
| 112 |
+
print(f"CLI: {simready_exe}", flush=True)
|
| 113 |
+
print(f"Discovered {len(interface_files)} interface USD(s).", flush=True)
|
| 114 |
+
|
| 115 |
+
env = {**os.environ, "PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1"}
|
| 116 |
+
packaged = 0
|
| 117 |
+
skipped = 0
|
| 118 |
+
failures: list[tuple[str, str]] = []
|
| 119 |
+
|
| 120 |
+
for usd in interface_files:
|
| 121 |
+
already = output / usd.stem / usd.name
|
| 122 |
+
if already.is_file():
|
| 123 |
+
print(f" skip (already packaged): {usd.name}", flush=True)
|
| 124 |
+
skipped += 1
|
| 125 |
+
continue
|
| 126 |
+
print(f" package: {usd.name}", flush=True)
|
| 127 |
+
cmd = [simready_exe, "ingest", "usd", str(usd),
|
| 128 |
+
"-o", str(output), "-p", args.profile, "--no-validate"]
|
| 129 |
+
try:
|
| 130 |
+
subprocess.run(cmd, env=env, check=True, capture_output=True, text=True, timeout=120)
|
| 131 |
+
packaged += 1
|
| 132 |
+
except subprocess.CalledProcessError as e:
|
| 133 |
+
err = (e.stderr or "")[-300:] or str(e)
|
| 134 |
+
failures.append((usd.name, err))
|
| 135 |
+
print(f" FAILED: {usd.name} - {err}", flush=True)
|
| 136 |
+
|
| 137 |
+
print(f"\nSUMMARY: packaged={packaged} skipped={skipped} failed={len(failures)}", flush=True)
|
| 138 |
+
print(f"OUTPUT: {output}", flush=True)
|
| 139 |
+
return 0 if not failures else 1
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
if __name__ == "__main__":
|
| 143 |
+
sys.exit(main())
|
tools/validation/plugins/simready-report/skills/simready-report/SKILL.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: simready-report
|
| 3 |
+
description: Validate SimReady customer assets against the foundation specs. Produces a dashboard HTML report and an external-dependencies provenance report. Invoke with /simready-report <name-or-path>.
|
| 4 |
+
---
|
| 5 |
+
|
| 6 |
+
# /simready-report
|
| 7 |
+
|
| 8 |
+
Validate a SimReady customer asset set.
|
| 9 |
+
|
| 10 |
+
## Usage
|
| 11 |
+
|
| 12 |
+
- `/simready-report` (no arg) — validate the **current working directory**. This is the normal way to run the skill: `cd` into the directory holding the customer asset(s) and invoke.
|
| 13 |
+
- `/simready-report <path>` — validate any directory by absolute or relative path. Override for the no-arg default.
|
| 14 |
+
- `/simready-report <name>` — bare name (no path separators). Tries `<cwd>/<name>` first; if running inside a playbook clone, also tries `<workspace>/assets_to_validate/<name>` for back-compat.
|
| 15 |
+
|
| 16 |
+
## Install paths
|
| 17 |
+
|
| 18 |
+
The skill is layout-agnostic — it auto-detects whether it's running inside a playbook clone (`BOT.md` in an ancestor directory) and adjusts defaults accordingly.
|
| 19 |
+
|
| 20 |
+
**Plugin marketplace install (no clone needed):**
|
| 21 |
+
```
|
| 22 |
+
/plugin marketplace add loginowskid/simready-playbook
|
| 23 |
+
/plugin install simready-report
|
| 24 |
+
```
|
| 25 |
+
Then `/simready-report` works from any directory. Reports default to `<cwd>/report/<target-name>/`. Foundation specs and SDK locations come from env vars (see Configuration).
|
| 26 |
+
|
| 27 |
+
**Cloned playbook (project skill or local marketplace):**
|
| 28 |
+
If `simready-playbook` is cloned, the skill walks up from its own location to find `BOT.md` and uses workspace-relative defaults: reports under `<workspace>/report/`, optional auto-package into `<workspace>/packages/`, default foundations/sdk lookup at `<workspace>/../simready_foundations` and `<workspace>/../simready-oem-sdk-poc`.
|
| 29 |
+
|
| 30 |
+
## Runtime dependencies
|
| 31 |
+
|
| 32 |
+
Wherever the skill is installed, it needs:
|
| 33 |
+
|
| 34 |
+
- A Python with `usd-core`, `omniverse-asset-validator`, `markdown-it-py`, and `simready-validate>=2026.4.8` importable. Any Python works (system, venv, conda). The default validation path populates the OAV registries via `simready.validate.impl.loader.load_validation_implementation`, matching the official `simready-validate` CLI workflow — no editable install of the foundation specs is required.
|
| 35 |
+
- A `simready_foundations` checkout cloned from `github.com/NVIDIA/simready-foundation` (or the `simready-foundation-staging` mirror). Override default lookup with `SIMREADY_FOUNDATIONS_PATH=<path>`.
|
| 36 |
+
- The `simready-oem-sdk-poc` checkout (only if the user wants the auto-package step for raw asset trees). Override with `SIMREADY_SDK_PATH=<path>`. The `simready` CLI itself is found via `PATH` first, then alongside the active Python.
|
| 37 |
+
|
| 38 |
+
Legacy fallback: pass `--use-plugin` to load via the `simready.foundation.core:SimReadyPlugin` setuptools entry-point instead (requires the editable install of `nv_core/sr_specs` + the internal `repo usd_profiles_codegen` step). Kept for the GitLab-era workflow; not needed for GitHub-based runs.
|
| 39 |
+
|
| 40 |
+
For a Windows convenience setup that creates a sibling venv + clones, run `bootstrap.ps1` from a cloned playbook. **Don't run it without confirming with the user first** — they may already have these dependencies elsewhere, or be on a non-Windows system.
|
| 41 |
+
|
| 42 |
+
Optional: a `usd_validation_dashboard_final/` clone next to the playbook supplies a Sphinx `_static/` theme for rendered doc pages. Without it, doc pages use minimal inline styling — the dashboard still works.
|
| 43 |
+
|
| 44 |
+
## Steps for Claude when `/simready-report` is invoked
|
| 45 |
+
|
| 46 |
+
The Python invocation pattern (used in steps 2 and 3 below) — prefer `$env:SIMREADY_PYTHON` (set by `bootstrap.ps1`), fall back to `python` on PATH:
|
| 47 |
+
|
| 48 |
+
PowerShell: `$py = if ($env:SIMREADY_PYTHON) { $env:SIMREADY_PYTHON } else { "python" }; & $py …`
|
| 49 |
+
Bash/zsh: `"${SIMREADY_PYTHON:-python}" …`
|
| 50 |
+
|
| 51 |
+
1. **Resolve the target.**
|
| 52 |
+
- No arg → CWD.
|
| 53 |
+
- With an arg: pass it through. The script tries it as a path, then `<cwd>/<name>`, then `<workspace>/assets_to_validate/<name>` (clone only) for back-compat.
|
| 54 |
+
2. **Pick the profile (spec selector).** Unless the user already named a specific profile in their request, list the registered profiles and ask them to pick before validating:
|
| 55 |
+
```
|
| 56 |
+
<py> validate.py --list-profiles
|
| 57 |
+
```
|
| 58 |
+
This emits one `PROFILE: <id> v<version>` line per registered profile (typically ~11 across the Robot-Body / Prop-Robotics / Package families). Present them grouped by family with `Robot-Body-Runnable v1.0.0` marked as the default; let the user accept the default or pick another. Pass the chosen `<id>` and `<version>` through as `--profile` and `--version` in step 3.
|
| 59 |
+
3. **Run the validator** from the skill directory:
|
| 60 |
+
```
|
| 61 |
+
<py> validate.py "<arg-or-empty>" --profile <id> --version <version>
|
| 62 |
+
```
|
| 63 |
+
If you skipped the selector because the user named a profile up front, use that. If the user said something like "just use the default", pass `Robot-Body-Runnable` v1.0.0 without prompting.
|
| 64 |
+
4. **Surface results.** Echo the script's `SUMMARY:` line and the absolute path to the report's `index.html`.
|
| 65 |
+
5. **If `validate.py` exits with "runtime prerequisites missing"**, relay the message verbatim. If running inside a clone on Windows, suggest `bootstrap.ps1` (after confirming with the user). Otherwise direct the user to install the deps into their Python and/or set `SIMREADY_FOUNDATIONS_PATH` / `SIMREADY_SDK_PATH` to existing checkouts. Do not run the bootstrap on the user's behalf without explicit confirmation.
|
| 66 |
+
6. **If `validate.py` exits with "target … failed sanity check"**, relay the listed problems verbatim. Don't retry without an explicit user instruction — the check is there to prevent accidental whole-drive scans, output-dir self-validation, and empty-dir runs.
|
| 67 |
+
|
| 68 |
+
## What `validate.py` does on its own
|
| 69 |
+
|
| 70 |
+
- **Workspace detection.** Walks up from the skill's directory and treats the first ancestor containing `BOT.md` as the workspace. None of these defaults matter when overridden by env vars / `--output`.
|
| 71 |
+
- **Prerequisite check.** Verifies `omni.asset_validator` / `pxr` / `markdown_it` import in the active Python and that the foundations checkout is findable. On miss: prints actionable error and exits 2.
|
| 72 |
+
- **Sanity check on the resolved target.** Refuses (exit 2) if any of:
|
| 73 |
+
- Inside a clone: target is the workspace root, or under `<workspace>/.claude/`, `playbooks/`, `report/`, `packages/`, or `plugins/`.
|
| 74 |
+
- Target is too high in the filesystem (≤2 path components — e.g. `C:\` or `C:\Users\`).
|
| 75 |
+
- Target has no USD files at root **and** none in any of its immediate subfolders.
|
| 76 |
+
- **Auto-package.** When the resolved target lives under an `assets_to_validate/<name>/` directory, calls `simready ingest usd` per interface USD into a sibling `packages/<name>/` (workspace-anchored when in a clone, else sibling-of-`assets_to_validate/`). Skipped per asset if already packaged. Skipped with a warning if the `simready` CLI isn't on PATH.
|
| 77 |
+
- **Asset thumbnails.** For each validated asset, copies `<asset_dir>/.thumbs/256x256/<filename>.png` (if present at the spec path) into `<out_dir>/images/<filename>.png`. Missing thumbnails render the inline placeholder.
|
| 78 |
+
- **Doc rendering.** For every feature/requirement code referenced by the report, renders the foundation's source markdown to HTML inside `<out_dir>/docs/<rel_path>`. If `usd_validation_dashboard_final/_static/` exists, it's copied once so pages match the reference Sphinx theme; otherwise pages use minimal inline CSS.
|
| 79 |
+
|
| 80 |
+
## Outputs
|
| 81 |
+
|
| 82 |
+
Written to the resolved output directory. **Default**: `<target>/.reports/<target-name>.<profile>/` — i.e. dropped inside the asset dir the user pointed at, in a `.reports/` subdir. Examples:
|
| 83 |
+
|
| 84 |
+
- `/simready-report yaskawa_local` (default profile) → `yaskawa_local/.reports/yaskawa_local.Robot-Body-Runnable/`
|
| 85 |
+
- `/simready-report yaskawa_local` (with `--profile Prop-Robotics-Neutral --version 2.0.0`) → `yaskawa_local/.reports/yaskawa_local.Prop-Robotics-Neutral/`
|
| 86 |
+
|
| 87 |
+
The `.reports/` prefix starts with `.`, so `discover_assets` automatically skips it on subsequent runs — re-running `/simready-report` from inside the target is safe. Multiple profiles against the same target produce sibling subfolders under `.reports/`, never overwriting each other. Override the whole path with `--output`.
|
| 88 |
+
|
| 89 |
+
- `index.html` — dashboard: summary cards, asset filter, per-asset failed/passed features, "Other failed requirements" panel, compliance footer.
|
| 90 |
+
- `results.json` — raw issues per asset, plus thumbnail provenance.
|
| 91 |
+
- `images/<filename>.png` — per-asset thumbnails (mirrored from the spec path inside the packaged tree).
|
| 92 |
+
- `docs/` — foundation feature/requirement docs rendered to HTML; `_static/` carries the Sphinx theme assets so pages match the reference dashboard.
|
| 93 |
+
- `external_dependencies_report.html` + `.json` — **only emitted when there's something to report** (one or more externally-resolved layers, or one or more scan-time issues). Anonymous in-memory layers are not counted as external. Three sections when present:
|
| 94 |
+
1. External assets needed (anything resolved outside the target dir).
|
| 95 |
+
2. What was done to obtain each (provenance log per layer).
|
| 96 |
+
3. Issues encountered while building the report.
|
| 97 |
+
|
| 98 |
+
## Kit-rooted mode (`--use-kit`)
|
| 99 |
+
|
| 100 |
+
**Auto-default:** `validate.py` auto-enables `--use-kit` whenever the chosen profile is in the PhysX-bearing set (`Robot-Body-Physx`, `Robot-Body-Isaac`, `Robot-Body-Runnable`, `Prop-Robotics-Physx`, `Prop-Robotics-Isaac`). The pip-only path's P2 filter silently drops `physxschema_unavailable` / `omnipbr_unresolved` findings, which makes a "passed" result misleading on those profiles — the checks that matter never actually ran. Pass `--no-use-kit` to opt out (e.g. when you knowingly want the fast filtered run); pass `--use-kit` explicitly to force it on a Neutral-family profile.
|
| 101 |
+
|
| 102 |
+
Under the hood: when `--use-kit` is on, the script re-execs itself through `_kit_wrapper.py` using a Kit-rooted Python that boots `isaacsim.SimulationApp({"headless": True})` before importing `pxr`, so `PhysxSchema`, the PhysX joint rules, and Kit's stock MDL resolution all work for real. When off (Neutral-family profiles by default, or after `--no-use-kit`), the pip-only venv runs and the P2 filter trims the resulting noise (~900 issues dropped on a typical robot asset) so reports stay readable.
|
| 103 |
+
|
| 104 |
+
- Resolution order for the Kit Python: `--kit-python <path>` → `$SIMREADY_KIT_PYTHON` → `C:\isaacsim6\_build\windows-x86_64\release\python.bat` (Isaac Sim 6 source build on this team's machines).
|
| 105 |
+
- Cost: ~10–30 s of SimulationApp boot on top of normal validation time. The script automatically forces `--workers 1` in Kit mode so we don't pay the boot again per worker.
|
| 106 |
+
- One-time setup: the Kit-rooted Python needs the same runtime deps the pip venv has. From within the Kit Python:
|
| 107 |
+
```
|
| 108 |
+
<kit-python.bat> -m pip install omniverse-asset-validator markdown-it-py
|
| 109 |
+
<kit-python.bat> -m pip install -e <foundations>/nv_core/sr_specs
|
| 110 |
+
<kit-python.bat> -m pip install -e <simready-oem-sdk-poc> # only if auto-packaging is needed
|
| 111 |
+
```
|
| 112 |
+
If `validate.py` aborts with "runtime prerequisites missing" inside Kit mode, the error includes a copy-pasteable hint pointing at the active Kit Python.
|
| 113 |
+
|
| 114 |
+
See `PROBLEMS.md` P2 for the architectural context behind the env-blocked-issue filter and the Kit-vs-pip split.
|
| 115 |
+
|
| 116 |
+
## Loader paths
|
| 117 |
+
|
| 118 |
+
The plugin can populate the OAV registries two ways. The default matches the GitHub-canonical workflow.
|
| 119 |
+
|
| 120 |
+
**Default — CLI loader (no flag).** `validate.py` calls `simready.validate.impl.loader.load_validation_implementation` against on-disk paths under `$SIMREADY_FOUNDATIONS_PATH` — the same loader the official `simready-validate` CLI uses. Requires only `simready-validate>=2026.4.8` (PyPI) and a foundations checkout from `github.com/NVIDIA/simready-foundation*`. No editable install of `nv_core/sr_specs`, no `repo usd_profiles_codegen` step. The loader generates `omni.capabilities` at runtime via `omni.usd_profiles.codegen`.
|
| 121 |
+
|
| 122 |
+
**Legacy — `--use-plugin`.** Loads via the `simready.foundation.core:SimReadyPlugin` setuptools entry-point. Requires the editable install of `nv_core/sr_specs` after running `repo usd_profiles_codegen`. Useful only for the GitLab-era workflow on machines that still have that editable install in place; not a recommended path for new setups. When this mode is active, the P1 JSON-variant patch in `validate.py` is also applied (it's redundant under the default loader).
|
| 123 |
+
|
| 124 |
+
Compose with `--use-kit`: e.g. `python validate.py … --use-kit` runs PhysX checks inside Kit using the default loader path.
|
| 125 |
+
|
| 126 |
+
See `PROBLEMS.md` P2 for the env-blocked-issue filter that runs alongside both paths.
|
| 127 |
+
|
| 128 |
+
## Configuration
|
| 129 |
+
|
| 130 |
+
- `SIMREADY_PYTHON` (recommended): full path to the Python interpreter that has the runtime deps installed. Set automatically by `bootstrap.ps1`. If unset, the skill falls back to `python` on PATH.
|
| 131 |
+
- `SIMREADY_KIT_PYTHON` (optional, used only with `--use-kit`): full path to a Kit-rooted `python.bat`. Default: `C:\isaacsim6\_build\windows-x86_64\release\python.bat`.
|
| 132 |
+
- Default profile: `Robot-Body-Runnable` v1.0.0
|
| 133 |
+
- `SIMREADY_FOUNDATIONS_PATH` (optional): override the foundations checkout location. Default in a clone: `<workspace>/../simready_foundations`. Required when running outside a clone (set automatically by `bootstrap.ps1`).
|
| 134 |
+
- `SIMREADY_SDK_PATH` (optional): override the SDK checkout location. Default in a clone: `<workspace>/../simready-oem-sdk-poc`. Only relevant for auto-packaging (set automatically by `bootstrap.ps1`).
|
| 135 |
+
- `SIMREADY_SPECS_PATH` is set automatically from the resolved foundations path so the validator's spec loader picks it up.
|
| 136 |
+
- `SIMREADY_INSIDE_KIT` is set automatically by `_kit_wrapper.py` to `1` once SimulationApp is booted; `validate.py` uses it to skip the pip-only assumptions and force sequential workers.
|
| 137 |
+
- `OMNI_ASSET_VALIDATOR_ISOLATE_ENTRYPOINTS` is set automatically to `omni.asset_validator:DefaultPlugin,simready.foundation.core:SimReadyPlugin`
|
tools/validation/plugins/simready-report/skills/simready-report/_kit_wrapper.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Kit-rooted entry point for validate.py.
|
| 2 |
+
|
| 3 |
+
Boots `isaacsim.SimulationApp({"headless": True})` once in the calling
|
| 4 |
+
process so `pxr.PhysxSchema` and Kit's MDL search path are registered,
|
| 5 |
+
then hands control to `validate.py` via `runpy`. Used by `--use-kit`
|
| 6 |
+
in validate.py — see PROBLEMS.md P2 for why this exists.
|
| 7 |
+
|
| 8 |
+
This file is intentionally tiny so SimulationApp's boot happens before
|
| 9 |
+
*any* pxr / omni.asset_validator import. Don't add other imports above
|
| 10 |
+
the SimulationApp() call.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
import runpy
|
| 16 |
+
import sys
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
from isaacsim import SimulationApp
|
| 20 |
+
|
| 21 |
+
_sim = SimulationApp({"headless": True})
|
| 22 |
+
os.environ["SIMREADY_INSIDE_KIT"] = "1"
|
| 23 |
+
|
| 24 |
+
_this_dir = Path(__file__).resolve().parent
|
| 25 |
+
_target = _this_dir / "validate.py"
|
| 26 |
+
sys.argv = [str(_target)] + sys.argv[1:]
|
| 27 |
+
try:
|
| 28 |
+
runpy.run_path(str(_target), run_name="__main__")
|
| 29 |
+
finally:
|
| 30 |
+
try:
|
| 31 |
+
_sim.close()
|
| 32 |
+
except Exception:
|
| 33 |
+
pass
|
tools/validation/plugins/simready-report/skills/simready-report/external_deps.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""External-dependency tracker for the /simready-report skill.
|
| 2 |
+
|
| 3 |
+
For each USD stage, walks all used layers (sublayers, references, payloads,
|
| 4 |
+
clips) and classifies each as internal (under target_root) or external.
|
| 5 |
+
Tracks per-layer provenance ("what was done to obtain it") and any issues
|
| 6 |
+
encountered while scanning.
|
| 7 |
+
|
| 8 |
+
Outputs:
|
| 9 |
+
external_dependencies_report.json
|
| 10 |
+
external_dependencies_report.html
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import html
|
| 15 |
+
import json
|
| 16 |
+
from dataclasses import dataclass, field, asdict
|
| 17 |
+
from datetime import datetime, timezone
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
from pxr import Usd
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class ExternalLayerRecord:
|
| 25 |
+
layer_identifier: str
|
| 26 |
+
resolved_path: str
|
| 27 |
+
referenced_by: list[str] = field(default_factory=list)
|
| 28 |
+
actions: list[str] = field(default_factory=list)
|
| 29 |
+
status: str = "resolved" # resolved | missing | error
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass
|
| 33 |
+
class IssueRecord:
|
| 34 |
+
asset: str
|
| 35 |
+
message: str
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class ExternalDepsTracker:
|
| 39 |
+
"""Collects external-layer references across all validated stages."""
|
| 40 |
+
|
| 41 |
+
def __init__(self, target_root: Path):
|
| 42 |
+
self.target_root = target_root.resolve()
|
| 43 |
+
self.external: dict[str, ExternalLayerRecord] = {}
|
| 44 |
+
self.issues: list[IssueRecord] = []
|
| 45 |
+
|
| 46 |
+
# ---- public API ----
|
| 47 |
+
|
| 48 |
+
def scan_stage(self, stage: Usd.Stage, asset_path: Path, target_root: Path) -> None:
|
| 49 |
+
try:
|
| 50 |
+
used = stage.GetUsedLayers(includeClipLayers=True)
|
| 51 |
+
except Exception as e:
|
| 52 |
+
self.record_issue(str(asset_path), f"GetUsedLayers failed: {type(e).__name__}: {e}")
|
| 53 |
+
return
|
| 54 |
+
|
| 55 |
+
root_layer = stage.GetRootLayer()
|
| 56 |
+
for layer in used:
|
| 57 |
+
try:
|
| 58 |
+
if layer is None or layer == root_layer:
|
| 59 |
+
continue
|
| 60 |
+
identifier = layer.identifier
|
| 61 |
+
# Skip anonymous (in-memory) layers — `anon:0x...` identifiers
|
| 62 |
+
# are USD-internal handles, not external dependencies.
|
| 63 |
+
if identifier.startswith("anon:") or (hasattr(layer, "anonymous") and layer.anonymous):
|
| 64 |
+
continue
|
| 65 |
+
resolved = layer.realPath or layer.resolvedPath.GetPathString() if hasattr(layer, "resolvedPath") else (layer.realPath or "")
|
| 66 |
+
|
| 67 |
+
if self._is_internal(resolved):
|
| 68 |
+
continue
|
| 69 |
+
|
| 70 |
+
rec = self.external.get(identifier)
|
| 71 |
+
if rec is None:
|
| 72 |
+
rec = ExternalLayerRecord(
|
| 73 |
+
layer_identifier=identifier,
|
| 74 |
+
resolved_path=resolved or "",
|
| 75 |
+
)
|
| 76 |
+
if resolved and Path(resolved).exists():
|
| 77 |
+
rec.actions.append(f"Resolved by USD asset resolver to: {resolved}")
|
| 78 |
+
rec.status = "resolved"
|
| 79 |
+
elif not resolved:
|
| 80 |
+
rec.actions.append(
|
| 81 |
+
f"Could not resolve layer identifier '{identifier}' to a filesystem path."
|
| 82 |
+
)
|
| 83 |
+
rec.status = "missing"
|
| 84 |
+
else:
|
| 85 |
+
rec.actions.append(f"Resolved to '{resolved}' but path does not exist on disk.")
|
| 86 |
+
rec.status = "missing"
|
| 87 |
+
self.external[identifier] = rec
|
| 88 |
+
|
| 89 |
+
ref = str(asset_path.relative_to(self.target_root)) if asset_path.is_relative_to(self.target_root) else str(asset_path)
|
| 90 |
+
if ref not in rec.referenced_by:
|
| 91 |
+
rec.referenced_by.append(ref)
|
| 92 |
+
except Exception as e:
|
| 93 |
+
self.record_issue(str(asset_path), f"Error scanning layer {layer}: {type(e).__name__}: {e}")
|
| 94 |
+
|
| 95 |
+
def record_issue(self, asset: str, message: str) -> None:
|
| 96 |
+
self.issues.append(IssueRecord(asset=asset, message=message))
|
| 97 |
+
|
| 98 |
+
def external_count(self) -> int:
|
| 99 |
+
return len(self.external)
|
| 100 |
+
|
| 101 |
+
def write_reports(self, out_dir: Path) -> None:
|
| 102 |
+
# Skip emitting the report entirely when there's nothing to say —
|
| 103 |
+
# no external assets resolved AND no issues encountered.
|
| 104 |
+
if not self.external and not self.issues:
|
| 105 |
+
for stale in ("external_dependencies_report.json", "external_dependencies_report.html"):
|
| 106 |
+
p = out_dir / stale
|
| 107 |
+
if p.is_file():
|
| 108 |
+
p.unlink()
|
| 109 |
+
return
|
| 110 |
+
payload = {
|
| 111 |
+
"generated": datetime.now(timezone.utc).isoformat(),
|
| 112 |
+
"target_root": str(self.target_root),
|
| 113 |
+
"external_assets": [asdict(r) for r in self.external.values()],
|
| 114 |
+
"issues": [asdict(i) for i in self.issues],
|
| 115 |
+
"summary": {
|
| 116 |
+
"external_count": len(self.external),
|
| 117 |
+
"missing_count": sum(1 for r in self.external.values() if r.status == "missing"),
|
| 118 |
+
"issue_count": len(self.issues),
|
| 119 |
+
},
|
| 120 |
+
}
|
| 121 |
+
(out_dir / "external_dependencies_report.json").write_text(
|
| 122 |
+
json.dumps(payload, indent=2), encoding="utf-8"
|
| 123 |
+
)
|
| 124 |
+
(out_dir / "external_dependencies_report.html").write_text(
|
| 125 |
+
self._render_html(payload), encoding="utf-8"
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
# ---- internal ----
|
| 129 |
+
|
| 130 |
+
def _is_internal(self, resolved_path: str) -> bool:
|
| 131 |
+
if not resolved_path:
|
| 132 |
+
return False
|
| 133 |
+
try:
|
| 134 |
+
p = Path(resolved_path).resolve()
|
| 135 |
+
except Exception:
|
| 136 |
+
return False
|
| 137 |
+
try:
|
| 138 |
+
return p.is_relative_to(self.target_root)
|
| 139 |
+
except AttributeError:
|
| 140 |
+
try:
|
| 141 |
+
p.relative_to(self.target_root)
|
| 142 |
+
return True
|
| 143 |
+
except ValueError:
|
| 144 |
+
return False
|
| 145 |
+
|
| 146 |
+
def _render_html(self, payload: dict) -> str:
|
| 147 |
+
s = payload["summary"]
|
| 148 |
+
rows = []
|
| 149 |
+
for rec in payload["external_assets"]:
|
| 150 |
+
actions_html = "".join(f"<li>{html.escape(a)}</li>" for a in rec["actions"]) or "<li>(none)</li>"
|
| 151 |
+
refs_html = "".join(f"<li><code>{html.escape(r)}</code></li>" for r in rec["referenced_by"]) or "<li>(unknown)</li>"
|
| 152 |
+
status_class = "status-" + rec["status"]
|
| 153 |
+
rows.append(f"""
|
| 154 |
+
<tr>
|
| 155 |
+
<td><code>{html.escape(rec['layer_identifier'])}</code></td>
|
| 156 |
+
<td><code>{html.escape(rec['resolved_path'] or '')}</code></td>
|
| 157 |
+
<td><span class="badge {status_class}">{html.escape(rec['status'])}</span></td>
|
| 158 |
+
<td><ul>{refs_html}</ul></td>
|
| 159 |
+
<td><ul>{actions_html}</ul></td>
|
| 160 |
+
</tr>""")
|
| 161 |
+
|
| 162 |
+
issues_html = "".join(
|
| 163 |
+
f"<tr><td><code>{html.escape(i['asset'])}</code></td><td>{html.escape(i['message'])}</td></tr>"
|
| 164 |
+
for i in payload["issues"]
|
| 165 |
+
)
|
| 166 |
+
if not issues_html:
|
| 167 |
+
issues_html = '<tr><td colspan="2" class="muted">No issues recorded.</td></tr>'
|
| 168 |
+
|
| 169 |
+
return f"""<!doctype html>
|
| 170 |
+
<html lang="en"><head><meta charset="utf-8" />
|
| 171 |
+
<title>External Dependencies Report</title>
|
| 172 |
+
<script>(function(){{var t=null;try{{t=localStorage.getItem('vr-theme');}}catch(e){{}}if(t==='dark'||t==='light')document.documentElement.setAttribute('data-theme',t);}})();</script>
|
| 173 |
+
<style>
|
| 174 |
+
:root {{ --bg:#f4f6fb; --panel:#fff; --text:#1f2a44; --muted:#7280a7; --border:#e6ebf5; --resolved:#0a0; --missing:#c00; --error:#b35900; }}
|
| 175 |
+
@media (prefers-color-scheme: dark) {{
|
| 176 |
+
:root:not([data-theme="light"]) {{ --bg:#0f1320; --panel:#1a2030; --text:#e6ebf5; --muted:#8a93b3; --border:#2a3142; --resolved:#4ade80; --missing:#ff6b6b; --error:#fbbf24; }}
|
| 177 |
+
}}
|
| 178 |
+
:root[data-theme="dark"] {{ --bg:#0f1320; --panel:#1a2030; --text:#e6ebf5; --muted:#8a93b3; --border:#2a3142; --resolved:#4ade80; --missing:#ff6b6b; --error:#fbbf24; }}
|
| 179 |
+
* {{ box-sizing: border-box; }}
|
| 180 |
+
body {{ font-family: "Segoe UI", Arial, sans-serif; margin:0; padding:16px; background:var(--bg); color:var(--text); }}
|
| 181 |
+
header {{ background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:12px 16px; margin-bottom:16px; display:flex; align-items:center; gap:12px; }}
|
| 182 |
+
header .header-text {{ flex:1; }}
|
| 183 |
+
header h1 {{ margin:0; font-size:18px; }}
|
| 184 |
+
header .meta {{ color:var(--muted); font-size:12px; margin-top:4px; }}
|
| 185 |
+
.theme-toggle {{ padding:6px 12px; background:transparent; border:1px solid var(--border); border-radius:8px; color:var(--text); cursor:pointer; font-size:13px; }}
|
| 186 |
+
.theme-toggle:hover {{ background:var(--bg); }}
|
| 187 |
+
.summary {{ display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin-bottom:20px; }}
|
| 188 |
+
.summary .card {{ background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:14px; }}
|
| 189 |
+
.summary .label {{ color:var(--muted); font-size:12px; }}
|
| 190 |
+
.summary .value {{ font-size:20px; font-weight:700; }}
|
| 191 |
+
section {{ background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:14px; margin-bottom:14px; }}
|
| 192 |
+
section h2 {{ margin:0 0 10px 0; font-size:15px; }}
|
| 193 |
+
table {{ width:100%; border-collapse:collapse; font-size:13px; }}
|
| 194 |
+
th, td {{ text-align:left; vertical-align:top; padding:8px 10px; border-bottom:1px solid var(--border); }}
|
| 195 |
+
th {{ color:var(--muted); font-weight:600; font-size:12px; text-transform:uppercase; letter-spacing:0.04em; }}
|
| 196 |
+
ul {{ margin:0; padding-left:18px; }}
|
| 197 |
+
code {{ font-family: ui-monospace, monospace; font-size:12px; word-break:break-all; }}
|
| 198 |
+
.badge {{ display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; font-weight:700; color:#fff; }}
|
| 199 |
+
.status-resolved {{ background:var(--resolved); }}
|
| 200 |
+
.status-missing {{ background:var(--missing); }}
|
| 201 |
+
.status-error {{ background:var(--error); }}
|
| 202 |
+
.muted {{ color:var(--muted); }}
|
| 203 |
+
</style></head>
|
| 204 |
+
<body>
|
| 205 |
+
<header>
|
| 206 |
+
<div class="header-text">
|
| 207 |
+
<h1>External Dependencies Report</h1>
|
| 208 |
+
<div class="meta">Target: <code>{html.escape(payload['target_root'])}</code> · Generated: {html.escape(payload['generated'])}</div>
|
| 209 |
+
</div>
|
| 210 |
+
<button type="button" id="themeToggle" class="theme-toggle" aria-label="Toggle theme">Dark mode</button>
|
| 211 |
+
</header>
|
| 212 |
+
<div class="summary">
|
| 213 |
+
<div class="card"><div class="label">External assets</div><div class="value">{s['external_count']}</div></div>
|
| 214 |
+
<div class="card"><div class="label">Missing</div><div class="value">{s['missing_count']}</div></div>
|
| 215 |
+
<div class="card"><div class="label">Issues</div><div class="value">{s['issue_count']}</div></div>
|
| 216 |
+
</div>
|
| 217 |
+
|
| 218 |
+
<section>
|
| 219 |
+
<h2>External assets — what was needed and how it was obtained</h2>
|
| 220 |
+
<table>
|
| 221 |
+
<thead><tr><th>Layer identifier</th><th>Resolved path</th><th>Status</th><th>Referenced by</th><th>Actions taken</th></tr></thead>
|
| 222 |
+
<tbody>{''.join(rows) or '<tr><td colspan="5" class="muted">No external assets referenced.</td></tr>'}</tbody>
|
| 223 |
+
</table>
|
| 224 |
+
</section>
|
| 225 |
+
|
| 226 |
+
<section>
|
| 227 |
+
<h2>Issues encountered while building this report</h2>
|
| 228 |
+
<table>
|
| 229 |
+
<thead><tr><th>Asset</th><th>Message</th></tr></thead>
|
| 230 |
+
<tbody>{issues_html}</tbody>
|
| 231 |
+
</table>
|
| 232 |
+
</section>
|
| 233 |
+
<script>
|
| 234 |
+
(function() {{
|
| 235 |
+
var btn = document.getElementById("themeToggle");
|
| 236 |
+
var mq = window.matchMedia ? window.matchMedia("(prefers-color-scheme: dark)") : null;
|
| 237 |
+
function isDark() {{
|
| 238 |
+
var attr = document.documentElement.getAttribute("data-theme");
|
| 239 |
+
if (attr === "dark") return true;
|
| 240 |
+
if (attr === "light") return false;
|
| 241 |
+
return mq ? mq.matches : false;
|
| 242 |
+
}}
|
| 243 |
+
function syncLabel() {{
|
| 244 |
+
if (btn) btn.textContent = isDark() ? "Light mode" : "Dark mode";
|
| 245 |
+
}}
|
| 246 |
+
syncLabel();
|
| 247 |
+
if (mq && mq.addEventListener) mq.addEventListener("change", syncLabel);
|
| 248 |
+
if (btn) {{
|
| 249 |
+
btn.addEventListener("click", function() {{
|
| 250 |
+
var next = isDark() ? "light" : "dark";
|
| 251 |
+
document.documentElement.setAttribute("data-theme", next);
|
| 252 |
+
try {{ localStorage.setItem("vr-theme", next); }} catch(e) {{}}
|
| 253 |
+
syncLabel();
|
| 254 |
+
}});
|
| 255 |
+
}}
|
| 256 |
+
}})();
|
| 257 |
+
</script>
|
| 258 |
+
</body></html>
|
| 259 |
+
"""
|
tools/validation/plugins/simready-report/skills/simready-report/report.py
ADDED
|
@@ -0,0 +1,850 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dashboard HTML generator for the /simready-report skill.
|
| 2 |
+
|
| 3 |
+
Given the per-asset results produced by validate.py, emits index.html in the
|
| 4 |
+
style of usd_validation_dashboard_final/: summary cards, asset filter,
|
| 5 |
+
per-asset blocks (failed/passed features, affected prims), compliance footer.
|
| 6 |
+
|
| 7 |
+
Thumbnails are embedded inline (base64 data URI) from the spec path
|
| 8 |
+
<asset_dir>/.thumbs/256x256/<filename>.png — matching the ThumbnailExists rule.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import html
|
| 13 |
+
from datetime import datetime, timezone
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _ensure_static_copied(out_docs_dir: Path, dashboard_docs_dir: Path | None) -> bool:
|
| 18 |
+
"""Mirror the reference dashboard's `_static/` into `<out_docs_dir>/_static/` once.
|
| 19 |
+
|
| 20 |
+
Returns True if the static tree is available (so pages can link to it),
|
| 21 |
+
False if the dashboard isn't present or the copy failed.
|
| 22 |
+
"""
|
| 23 |
+
if dashboard_docs_dir is None:
|
| 24 |
+
return False
|
| 25 |
+
src = dashboard_docs_dir / "_static"
|
| 26 |
+
if not src.is_dir():
|
| 27 |
+
return False
|
| 28 |
+
dest = out_docs_dir / "_static"
|
| 29 |
+
if dest.exists():
|
| 30 |
+
return True
|
| 31 |
+
import shutil
|
| 32 |
+
try:
|
| 33 |
+
shutil.copytree(src, dest)
|
| 34 |
+
return True
|
| 35 |
+
except Exception:
|
| 36 |
+
return False
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _render_md_page(title: str, rel_path: str, source_path: Path, body_html: str,
|
| 40 |
+
has_static: bool) -> str:
|
| 41 |
+
"""Render a markdown page using the reference dashboard's Sphinx assets.
|
| 42 |
+
|
| 43 |
+
`rel_path` is the doc's path relative to `out_docs_dir` (e.g.
|
| 44 |
+
`features/FET_001-minimal.html`); we compute how many `../` are needed to
|
| 45 |
+
reach `_static/` based on its depth.
|
| 46 |
+
"""
|
| 47 |
+
depth = rel_path.replace("\\", "/").count("/")
|
| 48 |
+
static_prefix = "../" * depth + "_static/"
|
| 49 |
+
if has_static:
|
| 50 |
+
head_links = f"""
|
| 51 |
+
<link rel="stylesheet" href="{static_prefix}styles/theme.css" />
|
| 52 |
+
<link rel="stylesheet" href="{static_prefix}styles/bootstrap.css" />
|
| 53 |
+
<link rel="stylesheet" href="{static_prefix}styles/pydata-sphinx-theme.css" />
|
| 54 |
+
<link rel="stylesheet" href="{static_prefix}styles/nvidia-sphinx-theme.css" />
|
| 55 |
+
<link rel="stylesheet" href="{static_prefix}pygments.css" />
|
| 56 |
+
<link rel="stylesheet" href="{static_prefix}_style.css" />
|
| 57 |
+
<link rel="stylesheet" href="{static_prefix}custom.css" />
|
| 58 |
+
<link rel="stylesheet" href="{static_prefix}sphinx-design.min.css" />
|
| 59 |
+
<link rel="stylesheet" href="{static_prefix}vendor/fontawesome/6.5.2/css/all.min.css" />
|
| 60 |
+
"""
|
| 61 |
+
body_open = '<main class="bd-main"><div class="bd-content"><div class="bd-article-container"><article class="bd-article">'
|
| 62 |
+
body_close = '</article></div></div></main>'
|
| 63 |
+
else:
|
| 64 |
+
head_links = ""
|
| 65 |
+
body_open = '<article style="max-width:880px;margin:24px auto;padding:24px 28px;font-family:Segoe UI,Arial,sans-serif;line-height:1.55;">'
|
| 66 |
+
body_close = '</article>'
|
| 67 |
+
|
| 68 |
+
# `source_path` is an absolute machine-specific path inside the foundations
|
| 69 |
+
# checkout — don't render it. Show the source's path relative to the
|
| 70 |
+
# foundation docs root (i.e. `rel_path` minus the .html for the .md form),
|
| 71 |
+
# which is portable across machines that share the same foundations layout.
|
| 72 |
+
source_md_rel = (rel_path[:-5] + ".md") if rel_path.endswith(".html") else rel_path
|
| 73 |
+
return f"""<!DOCTYPE html>
|
| 74 |
+
<html lang="en" data-content_root="{'../' * depth}">
|
| 75 |
+
<head>
|
| 76 |
+
<meta charset="utf-8" />
|
| 77 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 78 |
+
<title>{html.escape(title)}</title>
|
| 79 |
+
<script>document.documentElement.dataset.mode = localStorage.getItem("mode") || "";document.documentElement.dataset.theme = localStorage.getItem("theme") || "";</script>{head_links}
|
| 80 |
+
</head>
|
| 81 |
+
<body data-bs-spy="scroll">
|
| 82 |
+
{body_open}
|
| 83 |
+
<p class="text-muted" style="font-size:12px;margin-bottom:14px;">Source: <code>nv_core/sr_specs/docs/{html.escape(source_md_rel)}</code></p>
|
| 84 |
+
{body_html}
|
| 85 |
+
{body_close}
|
| 86 |
+
</body>
|
| 87 |
+
</html>
|
| 88 |
+
"""
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
_NO_SPEC_TAG = ' <span class="no-spec">(no spec source)</span>'
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _relativize_msg(msg: str, target: Path | None) -> str:
|
| 95 |
+
"""Strip machine-specific absolute path prefixes from validator messages.
|
| 96 |
+
|
| 97 |
+
`omni.asset_validator` rule messages embed the absolute path of each USD
|
| 98 |
+
being inspected. We replace the target's path prefix (and the path up to
|
| 99 |
+
`packages/` / `assets_to_validate/`) with relative tokens so reports
|
| 100 |
+
don't leak the report-author's user dir.
|
| 101 |
+
"""
|
| 102 |
+
if not msg or target is None:
|
| 103 |
+
return msg
|
| 104 |
+
out = msg
|
| 105 |
+
target_fwd = str(target).replace("\\", "/")
|
| 106 |
+
target_bwd = str(target).replace("/", "\\")
|
| 107 |
+
for prefix in (target_fwd, target_bwd):
|
| 108 |
+
if prefix and prefix in out:
|
| 109 |
+
out = out.replace(prefix, "<target>")
|
| 110 |
+
# Catch absolute paths that point at the workspace's packages/ or
|
| 111 |
+
# assets_to_validate/ — same machine-specificity, different anchor.
|
| 112 |
+
for marker in ("/packages/", "\\packages\\", "/assets_to_validate/", "\\assets_to_validate\\"):
|
| 113 |
+
idx = out.find(marker)
|
| 114 |
+
while idx != -1:
|
| 115 |
+
# Find the start of the absolute path containing this marker
|
| 116 |
+
# (a drive letter "X:\" or POSIX "/" boundary going left).
|
| 117 |
+
start = idx
|
| 118 |
+
while start > 0 and out[start - 1] not in (" ", "<", "(", "[", '"', "'", "\n", "\t"):
|
| 119 |
+
start -= 1
|
| 120 |
+
# Replace from `start` through the marker with the marker's tail.
|
| 121 |
+
anchor = marker.strip("/\\").replace("\\", "/") # "packages" / "assets_to_validate"
|
| 122 |
+
tail_pos = idx + len(marker)
|
| 123 |
+
out = out[:start] + f"<{anchor}>/" + out[tail_pos:]
|
| 124 |
+
idx = out.find(marker, start + len(anchor) + 3)
|
| 125 |
+
return out
|
| 126 |
+
|
| 127 |
+
_md_renderer = None
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _md_to_html(md_text: str) -> str:
|
| 131 |
+
global _md_renderer
|
| 132 |
+
if _md_renderer is None:
|
| 133 |
+
from markdown_it import MarkdownIt
|
| 134 |
+
_md_renderer = MarkdownIt("commonmark", {"html": True, "linkify": True, "typographer": True}).enable("table")
|
| 135 |
+
return _md_renderer.render(md_text)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
_IMG_PLACEHOLDER_SVG = (
|
| 139 |
+
"data:image/svg+xml;utf8,"
|
| 140 |
+
"<svg xmlns='http://www.w3.org/2000/svg' width='320' height='180' viewBox='0 0 320 180'>"
|
| 141 |
+
"<rect width='320' height='180' fill='%23f0f2f8' stroke='%23c7cee0' stroke-dasharray='6 4' stroke-width='2'/>"
|
| 142 |
+
"<text x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' "
|
| 143 |
+
"font-family='Segoe UI,Arial,sans-serif' font-size='14' fill='%237280a7'>image missing</text>"
|
| 144 |
+
"</svg>"
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def _localize_images(body_html: str, md_source: Path, out_html_path: Path) -> str:
|
| 149 |
+
"""Walk every `<img src="...">` in `body_html`. If src is a relative path,
|
| 150 |
+
copy the source file (relative to `md_source.parent`) into the equivalent
|
| 151 |
+
location next to `out_html_path`. When the source file is missing, replace
|
| 152 |
+
the src with an inline SVG placeholder so the page never 404s.
|
| 153 |
+
"""
|
| 154 |
+
import re
|
| 155 |
+
import shutil
|
| 156 |
+
|
| 157 |
+
md_dir = md_source.parent
|
| 158 |
+
out_dir = out_html_path.parent
|
| 159 |
+
|
| 160 |
+
def repl(match):
|
| 161 |
+
full_tag = match.group(0)
|
| 162 |
+
src_match = re.search(r'src="([^"]+)"', full_tag)
|
| 163 |
+
if not src_match:
|
| 164 |
+
return full_tag
|
| 165 |
+
src = src_match.group(1)
|
| 166 |
+
if src.startswith(("http://", "https://", "data:", "//", "/")):
|
| 167 |
+
return full_tag # absolute / data URI / protocol-relative — leave alone
|
| 168 |
+
rel = src.lstrip("./").replace("\\", "/")
|
| 169 |
+
source_file = (md_dir / src).resolve()
|
| 170 |
+
if source_file.is_file():
|
| 171 |
+
dest_file = (out_dir / rel).resolve()
|
| 172 |
+
try:
|
| 173 |
+
dest_file.relative_to(out_dir.resolve()) # guard against ../ escape
|
| 174 |
+
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
| 175 |
+
if not dest_file.exists() or dest_file.stat().st_size != source_file.stat().st_size:
|
| 176 |
+
shutil.copy2(source_file, dest_file)
|
| 177 |
+
return full_tag
|
| 178 |
+
except (ValueError, OSError):
|
| 179 |
+
pass
|
| 180 |
+
# Missing source — swap to placeholder
|
| 181 |
+
return re.sub(r'src="[^"]+"', f'src="{_IMG_PLACEHOLDER_SVG}"', full_tag)
|
| 182 |
+
|
| 183 |
+
return re.sub(r"<img\b[^>]*>", repl, body_html)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
_A_TAG_RE = None
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def _localize_links(body_html: str, md_source: Path, out_html_path: Path,
|
| 190 |
+
specs_docs_dir: Path, out_docs_root: Path,
|
| 191 |
+
resolver) -> str:
|
| 192 |
+
"""Rewrite relative <a href="..."> targets so the docs tree is self-contained.
|
| 193 |
+
|
| 194 |
+
- .md targets that resolve to a file inside `specs_docs_dir`: rewrite href
|
| 195 |
+
to .html and recursively render via `resolver(<rel>.html)`.
|
| 196 |
+
- non-.md targets inside `specs_docs_dir`: copy verbatim into the mirrored
|
| 197 |
+
location under `out_docs_root` and rewrite href to that copy.
|
| 198 |
+
- root-relative ("/...") hrefs treated as relative to `specs_docs_dir`;
|
| 199 |
+
bare paths without an extension are tried as `.md`.
|
| 200 |
+
- links pointing outside `specs_docs_dir` or at non-existent targets are
|
| 201 |
+
replaced with <span class="docref-disabled" title="...">TEXT</span> so
|
| 202 |
+
the visible text remains but no href can 404.
|
| 203 |
+
"""
|
| 204 |
+
import os
|
| 205 |
+
import re
|
| 206 |
+
import shutil
|
| 207 |
+
|
| 208 |
+
global _A_TAG_RE
|
| 209 |
+
if _A_TAG_RE is None:
|
| 210 |
+
_A_TAG_RE = re.compile(r'<a\s+([^>]*?)>(.*?)</a>', re.DOTALL | re.IGNORECASE)
|
| 211 |
+
|
| 212 |
+
md_dir = md_source.parent
|
| 213 |
+
out_dir = out_html_path.parent
|
| 214 |
+
specs_root = specs_docs_dir.resolve()
|
| 215 |
+
out_docs_resolved = out_docs_root.resolve()
|
| 216 |
+
|
| 217 |
+
def _disable(inner: str, why: str = "link target not available in report") -> str:
|
| 218 |
+
return f'<span class="docref-disabled" title="{html.escape(why)}">{inner}</span>'
|
| 219 |
+
|
| 220 |
+
def _resolve_target(href_path: str) -> Path | None:
|
| 221 |
+
if href_path.startswith("/"):
|
| 222 |
+
base = specs_root / href_path.lstrip("/")
|
| 223 |
+
if not base.suffix:
|
| 224 |
+
base = base.with_suffix(".md")
|
| 225 |
+
return base
|
| 226 |
+
return (md_dir / href_path)
|
| 227 |
+
|
| 228 |
+
def repl(m: "re.Match[str]") -> str:
|
| 229 |
+
attrs = m.group(1)
|
| 230 |
+
inner = m.group(2)
|
| 231 |
+
href_m = re.search(r'href\s*=\s*"([^"]*)"', attrs)
|
| 232 |
+
if not href_m:
|
| 233 |
+
return m.group(0)
|
| 234 |
+
href = href_m.group(1).strip()
|
| 235 |
+
if not href:
|
| 236 |
+
return m.group(0)
|
| 237 |
+
if href.startswith(("http://", "https://", "data:", "mailto:", "//", "#", "javascript:")):
|
| 238 |
+
return m.group(0)
|
| 239 |
+
|
| 240 |
+
if "#" in href:
|
| 241 |
+
path_part, frag = href.split("#", 1)
|
| 242 |
+
frag = "#" + frag
|
| 243 |
+
else:
|
| 244 |
+
path_part, frag = href, ""
|
| 245 |
+
if not path_part:
|
| 246 |
+
return m.group(0)
|
| 247 |
+
|
| 248 |
+
target = _resolve_target(path_part)
|
| 249 |
+
if target is None:
|
| 250 |
+
return _disable(inner)
|
| 251 |
+
try:
|
| 252 |
+
target = target.resolve()
|
| 253 |
+
except OSError:
|
| 254 |
+
return _disable(inner)
|
| 255 |
+
try:
|
| 256 |
+
rel_to_specs = target.relative_to(specs_root)
|
| 257 |
+
except ValueError:
|
| 258 |
+
return _disable(inner, "link target outside foundation docs")
|
| 259 |
+
if not target.exists():
|
| 260 |
+
return _disable(inner, "link target missing in foundation docs")
|
| 261 |
+
|
| 262 |
+
rel_str = str(rel_to_specs).replace("\\", "/")
|
| 263 |
+
|
| 264 |
+
if target.suffix.lower() == ".md":
|
| 265 |
+
html_rel = rel_str[:-3] + ".html"
|
| 266 |
+
url = resolver(html_rel)
|
| 267 |
+
if url is None:
|
| 268 |
+
return _disable(inner)
|
| 269 |
+
target_out = out_docs_resolved / html_rel
|
| 270 |
+
else:
|
| 271 |
+
target_out = out_docs_resolved / rel_str
|
| 272 |
+
try:
|
| 273 |
+
target_out.parent.mkdir(parents=True, exist_ok=True)
|
| 274 |
+
if not target_out.exists() or target_out.stat().st_size != target.stat().st_size:
|
| 275 |
+
shutil.copy2(target, target_out)
|
| 276 |
+
except OSError:
|
| 277 |
+
return _disable(inner, "could not copy link target into report")
|
| 278 |
+
|
| 279 |
+
try:
|
| 280 |
+
new_href = os.path.relpath(target_out, out_dir).replace("\\", "/") + frag
|
| 281 |
+
except ValueError:
|
| 282 |
+
return _disable(inner)
|
| 283 |
+
new_attrs = re.sub(r'href\s*=\s*"[^"]*"', f'href="{new_href}"', attrs, count=1)
|
| 284 |
+
return f'<a {new_attrs}>{inner}</a>'
|
| 285 |
+
|
| 286 |
+
return _A_TAG_RE.sub(repl, body_html)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def _make_doc_url_resolver(out_dir: Path, dashboard_docs_dir: Path | None,
|
| 290 |
+
specs_docs_dir: Path | None):
|
| 291 |
+
"""Build a `_doc_url(rel_path)` callable.
|
| 292 |
+
|
| 293 |
+
Every resolvable doc is rendered from the foundation markdown source into
|
| 294 |
+
`<out_dir>/docs/<rel_path>` so the dashboard has a single, self-contained
|
| 295 |
+
docs tree. The reference-dashboard pre-built HTML is no longer used as a
|
| 296 |
+
link target — it would split the source of truth.
|
| 297 |
+
|
| 298 |
+
`dashboard_docs_dir` is kept on the signature for API stability but not
|
| 299 |
+
consulted here.
|
| 300 |
+
"""
|
| 301 |
+
rendered_cache: dict[str, str] = {}
|
| 302 |
+
out_docs_dir = out_dir / "docs"
|
| 303 |
+
out_docs_dir.mkdir(parents=True, exist_ok=True)
|
| 304 |
+
has_static = _ensure_static_copied(out_docs_dir, dashboard_docs_dir)
|
| 305 |
+
|
| 306 |
+
def resolve(rel_path: str | None) -> str | None:
|
| 307 |
+
if not rel_path or specs_docs_dir is None:
|
| 308 |
+
return None
|
| 309 |
+
if rel_path in rendered_cache:
|
| 310 |
+
return rendered_cache[rel_path]
|
| 311 |
+
md_rel = rel_path[:-5] + ".md" if rel_path.endswith(".html") else rel_path
|
| 312 |
+
md_source = specs_docs_dir / md_rel
|
| 313 |
+
if not md_source.is_file():
|
| 314 |
+
return None
|
| 315 |
+
# Cache the URL before rendering body so cycles in the foundation's
|
| 316 |
+
# cross-doc links (e.g. profile -> feature -> profile) terminate.
|
| 317 |
+
url = "docs/" + rel_path.replace("\\", "/")
|
| 318 |
+
rendered_cache[rel_path] = url
|
| 319 |
+
out_path = out_docs_dir / rel_path
|
| 320 |
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 321 |
+
body = _md_to_html(md_source.read_text(encoding="utf-8"))
|
| 322 |
+
body = _localize_images(body, md_source, out_path)
|
| 323 |
+
body = _localize_links(body, md_source, out_path, specs_docs_dir, out_docs_dir, resolve)
|
| 324 |
+
out_path.write_text(
|
| 325 |
+
_render_md_page(rel_path, rel_path, md_source, body, has_static),
|
| 326 |
+
encoding="utf-8",
|
| 327 |
+
)
|
| 328 |
+
return url
|
| 329 |
+
|
| 330 |
+
return resolve
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
CSS = """
|
| 334 |
+
:root { --bg:#f4f6fb; --panel:#fff; --text:#1f2a44; --muted:#7280a7; --border:#e6ebf5; --fail:#c00; --pass:#0a0; --link:#2b6de2; --note-bg:#fff8e6; --note-border:#f1d38a; --note-text:#6b4f00; --note-code:#fff3cc; --thumb-bg:#f4f6fb; }
|
| 335 |
+
@media (prefers-color-scheme: dark) {
|
| 336 |
+
:root:not([data-theme="light"]) { --bg:#0f1320; --panel:#1a2030; --text:#e6ebf5; --muted:#8a93b3; --border:#2a3142; --fail:#ff6b6b; --pass:#4ade80; --link:#7ba9ff; --note-bg:#2a2410; --note-border:#5a4a1f; --note-text:#f1d38a; --note-code:#3a3018; --thumb-bg:#0f1320; }
|
| 337 |
+
}
|
| 338 |
+
:root[data-theme="dark"] { --bg:#0f1320; --panel:#1a2030; --text:#e6ebf5; --muted:#8a93b3; --border:#2a3142; --fail:#ff6b6b; --pass:#4ade80; --link:#7ba9ff; --note-bg:#2a2410; --note-border:#5a4a1f; --note-text:#f1d38a; --note-code:#3a3018; --thumb-bg:#0f1320; }
|
| 339 |
+
* { box-sizing: border-box; }
|
| 340 |
+
body { font-family: "Segoe UI", Arial, sans-serif; margin:0; background:var(--bg); color:var(--text); padding:16px; }
|
| 341 |
+
header { display:flex; align-items:center; gap:10px; margin-bottom:16px; padding:12px 16px; background:var(--panel); border-radius:12px; border:1px solid var(--border); }
|
| 342 |
+
header .logo { width:36px; height:36px; border-radius:10px; background:#2b6de2; color:#fff; display:grid; place-items:center; font-weight:700; }
|
| 343 |
+
header h1 { margin:0; font-size:18px; }
|
| 344 |
+
header .meta { color:var(--muted); font-size:12px; }
|
| 345 |
+
.summary { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin-bottom:20px; }
|
| 346 |
+
.summary .card { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:14px; }
|
| 347 |
+
.summary .card .label { color:var(--muted); font-size:12px; }
|
| 348 |
+
.summary .card .value { font-size:20px; font-weight:700; }
|
| 349 |
+
.asset-block { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:14px; margin-bottom:14px; display:flex; gap:14px; align-items:flex-start; }
|
| 350 |
+
.asset-block .asset-thumb-wrap { flex-shrink:0; width:140px; }
|
| 351 |
+
.asset-block .asset-thumb { width:100%; height:auto; aspect-ratio:1; object-fit:cover; border-radius:8px; background:var(--border); display:block; }
|
| 352 |
+
.asset-block .asset-thumb-missing { background:var(--thumb-bg); border:1px dashed var(--border); display:grid; place-items:center; color:var(--muted); font-size:11px; text-align:center; padding:8px; }
|
| 353 |
+
.asset-block .asset-thumb-missing span { line-height:1.3; }
|
| 354 |
+
.code { font-family: ui-monospace, monospace; font-size:12px; padding:1px 6px; border-radius:4px; background:var(--note-code); color:var(--note-text); margin-right:4px; }
|
| 355 |
+
.feat-id { font-family: ui-monospace, monospace; font-size:13px; }
|
| 356 |
+
a.docref { color:var(--link); text-decoration:underline; text-decoration-style:dotted; text-underline-offset:2px; font-size:12px; }
|
| 357 |
+
a.docref:hover { text-decoration-style:solid; }
|
| 358 |
+
a.code { text-decoration:underline; text-decoration-style:dotted; text-underline-offset:2px; }
|
| 359 |
+
a.code:hover { text-decoration-style:solid; }
|
| 360 |
+
.failed-features .feat-id { color:var(--fail); }
|
| 361 |
+
.passed-features .feat-id { color:var(--pass); }
|
| 362 |
+
.asset-block .asset-body { flex:1; min-width:0; }
|
| 363 |
+
.asset-block h2 { margin:0 0 6px 0; font-size:15px; }
|
| 364 |
+
.asset-block .path { font-size:12px; color:var(--muted); margin-bottom:10px; word-break:break-all; }
|
| 365 |
+
.asset-block section { margin-top:10px; }
|
| 366 |
+
.asset-block section h3 { margin:0 0 6px 0; font-size:13px; color:var(--muted); }
|
| 367 |
+
.asset-block ul { margin:0; padding-left:20px; }
|
| 368 |
+
.asset-block li { margin:4px 0; }
|
| 369 |
+
.failed-features { color:var(--fail); }
|
| 370 |
+
.passed-features { color:var(--pass); }
|
| 371 |
+
.filter { margin-bottom:12px; }
|
| 372 |
+
.filter input { padding:8px 12px; width:100%; max-width:320px; border:1px solid var(--border); border-radius:8px; font-size:14px; }
|
| 373 |
+
.compliance { margin-top:28px; background:var(--panel); border-radius:12px; padding:18px; border:1px solid var(--border); }
|
| 374 |
+
.compliance h2 { margin:0 0 12px 0; font-size:16px; }
|
| 375 |
+
.compliance-list { margin:0; padding-left:20px; }
|
| 376 |
+
.compliance-list li { margin:8px 0; }
|
| 377 |
+
.compliance-list .code { font-weight:700; }
|
| 378 |
+
.affected-prims { font-size:12px; color:var(--muted); margin:8px 0; }
|
| 379 |
+
.affected-prims .label { font-weight:600; color:var(--text); }
|
| 380 |
+
.prim-list-inline { list-style:none; padding-left:0; margin:4px 0 0 0; display:flex; flex-wrap:wrap; gap:6px 12px; }
|
| 381 |
+
.prim-list-inline li { font-family: ui-monospace, monospace; font-size:11px; word-break:break-all; }
|
| 382 |
+
.muted { color:var(--muted); }
|
| 383 |
+
.no-spec { color:var(--muted); font-style:italic; font-size:11px; }
|
| 384 |
+
.packaging-note { background:var(--note-bg); border:1px solid var(--note-border); border-radius:8px; padding:10px 14px; margin:0 0 16px 0; font-size:13px; color:var(--note-text); }
|
| 385 |
+
.packaging-note code { background:var(--note-code); padding:1px 5px; border-radius:4px; font-size:12px; }
|
| 386 |
+
.coverage-banner { background:var(--note-bg); border:1px solid var(--note-border); border-radius:8px; padding:10px 14px; margin:0 0 16px 0; font-size:13px; color:var(--note-text); }
|
| 387 |
+
.coverage-banner code { background:var(--note-code); padding:1px 5px; border-radius:4px; font-size:12px; }
|
| 388 |
+
.coverage-banner details { margin-top:8px; }
|
| 389 |
+
.coverage-banner summary { cursor:pointer; font-weight:600; }
|
| 390 |
+
.coverage-banner ul { margin:6px 0 0 0; padding-left:20px; }
|
| 391 |
+
.outside-spec-banner { background:var(--note-bg); border:1px solid var(--note-border); border-radius:8px; padding:10px 14px; margin:0 0 16px 0; font-size:13px; color:var(--note-text); }
|
| 392 |
+
.outside-spec-banner code { background:var(--note-code); padding:1px 5px; border-radius:4px; font-size:12px; }
|
| 393 |
+
.thumbnail-banner { background:var(--note-bg); border:1px solid var(--note-border); border-radius:8px; padding:10px 14px; margin:0 0 16px 0; font-size:13px; color:var(--note-text); }
|
| 394 |
+
.thumbnail-banner code { background:var(--note-code); padding:1px 5px; border-radius:4px; font-size:11px; word-break:break-all; }
|
| 395 |
+
.thumbnail-banner details { margin-top:8px; }
|
| 396 |
+
.thumbnail-banner summary { cursor:pointer; font-weight:600; }
|
| 397 |
+
.thumbnail-banner ul { margin:6px 0 0 0; padding-left:20px; }
|
| 398 |
+
.header-actions { margin-left:auto; display:flex; gap:8px; align-items:center; }
|
| 399 |
+
.caveat-toggle { padding:6px 12px; background:var(--note-bg); border:1px solid var(--note-border); border-radius:8px; color:var(--note-text); cursor:pointer; font-size:13px; font-weight:600; display:inline-flex; align-items:center; gap:6px; animation:caveat-pulse 1.6s ease-in-out infinite; }
|
| 400 |
+
.caveat-toggle:hover { filter:brightness(1.05); }
|
| 401 |
+
.caveat-toggle .caveat-count { background:var(--note-text); color:var(--note-bg); border-radius:999px; padding:1px 8px; font-size:11px; font-weight:700; line-height:1.4; min-width:18px; text-align:center; }
|
| 402 |
+
.caveat-toggle.open { animation:none; box-shadow:none; }
|
| 403 |
+
@keyframes caveat-pulse {
|
| 404 |
+
0%, 100% { box-shadow:0 0 0 0 var(--note-border); }
|
| 405 |
+
50% { box-shadow:0 0 0 6px transparent; }
|
| 406 |
+
}
|
| 407 |
+
#caveats[hidden] { display:none; }
|
| 408 |
+
.theme-toggle { padding:6px 12px; background:transparent; border:1px solid var(--border); border-radius:8px; color:var(--text); cursor:pointer; font-size:13px; }
|
| 409 |
+
.theme-toggle:hover { background:var(--bg); }
|
| 410 |
+
""".strip()
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
def write_dashboard(out_dir: Path, target: Path, profile: str, profile_version: str,
|
| 414 |
+
results: list[dict],
|
| 415 |
+
specs_docs_dir: Path | None = None,
|
| 416 |
+
dashboard_docs_dir: Path | None = None,
|
| 417 |
+
thumbnail_provenance: list[dict] | None = None,
|
| 418 |
+
profile_coverage: dict | None = None) -> Path:
|
| 419 |
+
total = len(results)
|
| 420 |
+
failed = sum(1 for r in results if not r["passed"])
|
| 421 |
+
passed_checks = sum(len(r.get("passed_features", [])) for r in results)
|
| 422 |
+
|
| 423 |
+
doc_url = _make_doc_url_resolver(out_dir, dashboard_docs_dir, specs_docs_dir)
|
| 424 |
+
# Map asset name -> relative path under <out_dir>/images/, only when actually
|
| 425 |
+
# copied. Reference relatively so the dashboard contains no external URLs.
|
| 426 |
+
thumb_rel_by_asset = {
|
| 427 |
+
e["asset"]: e["rel"]
|
| 428 |
+
for e in (thumbnail_provenance or [])
|
| 429 |
+
if e.get("present")
|
| 430 |
+
}
|
| 431 |
+
asset_blocks = "\n".join(_render_asset_block(r, doc_url, thumb_rel_by_asset, target) for r in results)
|
| 432 |
+
thumbnail_banner = _render_thumbnail_banner(thumbnail_provenance or [], target)
|
| 433 |
+
coverage_banner = _render_coverage_banner(profile_coverage)
|
| 434 |
+
outside_spec_banner = _render_outside_spec_banner(results)
|
| 435 |
+
env_blocked_banner = _render_env_blocked_banner(results)
|
| 436 |
+
compliance = _render_compliance(results, doc_url, target)
|
| 437 |
+
generated = datetime.now(timezone.utc).isoformat()
|
| 438 |
+
target_str = str(target).replace("\\", "/")
|
| 439 |
+
# Show only the packages-relative tail, never the absolute path —
|
| 440 |
+
# absolute paths leak machine-specific user dirs into shared reports.
|
| 441 |
+
if "/packages/" in target_str:
|
| 442 |
+
raw_hint = target_str.split("/packages/", 1)[1]
|
| 443 |
+
packaging_note = (
|
| 444 |
+
f'<div class="packaging-note">'
|
| 445 |
+
f'<strong>Packaged tree.</strong> This report validates assets after the '
|
| 446 |
+
f'<code>simready ingest usd</code> step. Raw inputs live at '
|
| 447 |
+
f'<code>assets_to_validate/{html.escape(raw_hint)}</code>; '
|
| 448 |
+
f'packaged output lives at <code>packages/{html.escape(raw_hint)}</code>.'
|
| 449 |
+
f'</div>'
|
| 450 |
+
)
|
| 451 |
+
else:
|
| 452 |
+
packaging_note = ""
|
| 453 |
+
|
| 454 |
+
# Group all the yellow-warning banners under a single `Caveats` toggle so
|
| 455 |
+
# the dashboard stays clean by default and the user opts in to read them.
|
| 456 |
+
caveats_list = [b for b in (env_blocked_banner, coverage_banner, outside_spec_banner, packaging_note, thumbnail_banner) if b]
|
| 457 |
+
caveats_count = len(caveats_list)
|
| 458 |
+
if caveats_count > 0:
|
| 459 |
+
caveats_button = (
|
| 460 |
+
f'<button type="button" id="caveatsToggle" class="caveat-toggle" '
|
| 461 |
+
f'aria-expanded="false" aria-controls="caveats" aria-label="Show caveats">'
|
| 462 |
+
f'<span>Caveats</span>'
|
| 463 |
+
f'<span class="caveat-count">{caveats_count}</span>'
|
| 464 |
+
f'</button>'
|
| 465 |
+
)
|
| 466 |
+
caveats_section = (
|
| 467 |
+
'<div id="caveats" hidden>' + "".join(caveats_list) + '</div>'
|
| 468 |
+
)
|
| 469 |
+
else:
|
| 470 |
+
caveats_button = ""
|
| 471 |
+
caveats_section = ""
|
| 472 |
+
|
| 473 |
+
html_doc = f"""<!doctype html>
|
| 474 |
+
<html lang="en">
|
| 475 |
+
<head>
|
| 476 |
+
<meta charset="utf-8" />
|
| 477 |
+
<title>Validation Report — {html.escape(target.name)}</title>
|
| 478 |
+
<script>(function(){{var t=null;try{{t=localStorage.getItem('vr-theme');}}catch(e){{}}if(t==='dark'||t==='light')document.documentElement.setAttribute('data-theme',t);}})();</script>
|
| 479 |
+
<style>{CSS}</style>
|
| 480 |
+
</head>
|
| 481 |
+
<body>
|
| 482 |
+
<header>
|
| 483 |
+
<div class="logo">V</div>
|
| 484 |
+
<div>
|
| 485 |
+
<h1>Validation Report — {html.escape(target.name)}</h1>
|
| 486 |
+
<div class="meta">Profile: <strong>{html.escape(profile)}</strong> v{html.escape(profile_version)} · Generated: {html.escape(generated)}</div>
|
| 487 |
+
</div>
|
| 488 |
+
<div class="header-actions">
|
| 489 |
+
{caveats_button}
|
| 490 |
+
<button type="button" id="themeToggle" class="theme-toggle" aria-label="Toggle theme">Dark mode</button>
|
| 491 |
+
</div>
|
| 492 |
+
</header>
|
| 493 |
+
{caveats_section}
|
| 494 |
+
<div class="summary">
|
| 495 |
+
<div class="card"><div class="label">Total assets</div><div class="value">{total}</div></div>
|
| 496 |
+
<div class="card"><div class="label">Failed assets</div><div class="value">{failed}</div></div>
|
| 497 |
+
<div class="card"><div class="label">Passed feature checks</div><div class="value">{passed_checks}</div></div>
|
| 498 |
+
</div>
|
| 499 |
+
<div class="filter"><input type="text" id="filterInput" placeholder="Filter by asset name..." /></div>
|
| 500 |
+
{asset_blocks}
|
| 501 |
+
{compliance}
|
| 502 |
+
<script>
|
| 503 |
+
(function() {{
|
| 504 |
+
var input = document.getElementById("filterInput");
|
| 505 |
+
var blocks = document.querySelectorAll(".asset-block");
|
| 506 |
+
if (input) {{
|
| 507 |
+
function filter() {{
|
| 508 |
+
var q = (input.value || "").trim().toLowerCase();
|
| 509 |
+
for (var i = 0; i < blocks.length; i++) {{
|
| 510 |
+
var name = (blocks[i].getAttribute("data-asset-name") || "");
|
| 511 |
+
blocks[i].style.display = (!q || name.indexOf(q) !== -1) ? "" : "none";
|
| 512 |
+
}}
|
| 513 |
+
}}
|
| 514 |
+
input.addEventListener("input", filter);
|
| 515 |
+
input.addEventListener("keyup", filter);
|
| 516 |
+
}}
|
| 517 |
+
var caveatsBtn = document.getElementById("caveatsToggle");
|
| 518 |
+
var caveatsBox = document.getElementById("caveats");
|
| 519 |
+
if (caveatsBtn && caveatsBox) {{
|
| 520 |
+
caveatsBtn.addEventListener("click", function() {{
|
| 521 |
+
var open = caveatsBox.hidden;
|
| 522 |
+
caveatsBox.hidden = !open;
|
| 523 |
+
caveatsBtn.setAttribute("aria-expanded", open ? "true" : "false");
|
| 524 |
+
caveatsBtn.classList.toggle("open", open);
|
| 525 |
+
}});
|
| 526 |
+
}}
|
| 527 |
+
var btn = document.getElementById("themeToggle");
|
| 528 |
+
var mq = window.matchMedia ? window.matchMedia("(prefers-color-scheme: dark)") : null;
|
| 529 |
+
function isDark() {{
|
| 530 |
+
var attr = document.documentElement.getAttribute("data-theme");
|
| 531 |
+
if (attr === "dark") return true;
|
| 532 |
+
if (attr === "light") return false;
|
| 533 |
+
return mq ? mq.matches : false;
|
| 534 |
+
}}
|
| 535 |
+
function syncLabel() {{
|
| 536 |
+
if (btn) btn.textContent = isDark() ? "Light mode" : "Dark mode";
|
| 537 |
+
}}
|
| 538 |
+
syncLabel();
|
| 539 |
+
if (mq && mq.addEventListener) mq.addEventListener("change", syncLabel);
|
| 540 |
+
if (btn) {{
|
| 541 |
+
btn.addEventListener("click", function() {{
|
| 542 |
+
var next = isDark() ? "light" : "dark";
|
| 543 |
+
document.documentElement.setAttribute("data-theme", next);
|
| 544 |
+
try {{ localStorage.setItem("vr-theme", next); }} catch(e) {{}}
|
| 545 |
+
syncLabel();
|
| 546 |
+
}});
|
| 547 |
+
}}
|
| 548 |
+
}})();
|
| 549 |
+
</script>
|
| 550 |
+
</body>
|
| 551 |
+
</html>
|
| 552 |
+
"""
|
| 553 |
+
out = out_dir / "index.html"
|
| 554 |
+
out.write_text(html_doc, encoding="utf-8")
|
| 555 |
+
return out
|
| 556 |
+
|
| 557 |
+
|
| 558 |
+
def _render_outside_spec_banner(results: list[dict]) -> str:
|
| 559 |
+
"""Banner summarizing how many issues the validator reported against
|
| 560 |
+
requirements that aren't part of the loaded profile's feature set.
|
| 561 |
+
These are deliberately suppressed from the per-asset blocks and the
|
| 562 |
+
compliance footer — the report scope is the chosen spec only — so we
|
| 563 |
+
surface the count here for transparency.
|
| 564 |
+
"""
|
| 565 |
+
total_issues = 0
|
| 566 |
+
unique_codes: set[str] = set()
|
| 567 |
+
affected_assets: set[str] = set()
|
| 568 |
+
for r in results:
|
| 569 |
+
if not r.get("extra_failures"):
|
| 570 |
+
continue
|
| 571 |
+
affected_assets.add(r["name"])
|
| 572 |
+
for e in r["extra_failures"]:
|
| 573 |
+
total_issues += int(e.get("count") or 0)
|
| 574 |
+
if e.get("code"):
|
| 575 |
+
unique_codes.add(e["code"])
|
| 576 |
+
if total_issues == 0:
|
| 577 |
+
return ""
|
| 578 |
+
return f'''
|
| 579 |
+
<div class="outside-spec-banner">
|
| 580 |
+
<strong>Out-of-spec issues hidden.</strong> The validator reported
|
| 581 |
+
<strong>{total_issues}</strong> issue(s) against
|
| 582 |
+
<strong>{len(unique_codes)}</strong> requirement code(s) that aren\'t
|
| 583 |
+
part of this profile\'s feature set, across
|
| 584 |
+
<strong>{len(affected_assets)}</strong> asset(s). These are not listed
|
| 585 |
+
in the report — the displayed pass/fail is scoped to the loaded profile
|
| 586 |
+
only. See <code>PROBLEMS.md</code> for why this filter exists.
|
| 587 |
+
</div>
|
| 588 |
+
'''
|
| 589 |
+
|
| 590 |
+
|
| 591 |
+
def _render_coverage_banner(coverage: dict | None) -> str:
|
| 592 |
+
"""Banner shown when the loaded profile has fewer features than its
|
| 593 |
+
`profiles.toml` entry declares. The validator silently drops feature
|
| 594 |
+
entries whose ID + version aren't in the registry, which can leave a
|
| 595 |
+
profile reporting against a tiny subset of what it claims to check.
|
| 596 |
+
See PROBLEMS.md P1.
|
| 597 |
+
"""
|
| 598 |
+
if not coverage or coverage.get("declared") is None:
|
| 599 |
+
return ""
|
| 600 |
+
missing = coverage.get("missing") or []
|
| 601 |
+
if not missing:
|
| 602 |
+
return ""
|
| 603 |
+
declared = coverage["declared"]
|
| 604 |
+
loaded = coverage["loaded"]
|
| 605 |
+
missing_items = "".join(
|
| 606 |
+
f'<li><code>{html.escape(m["id"])}</code> '
|
| 607 |
+
f'<span class="muted">v{html.escape(m["version"])}</span></li>'
|
| 608 |
+
for m in missing
|
| 609 |
+
)
|
| 610 |
+
return f'''
|
| 611 |
+
<div class="coverage-banner">
|
| 612 |
+
<strong>Coverage gap.</strong> This profile declares <strong>{declared}</strong> features
|
| 613 |
+
in <code>profiles.toml</code> but the validator loaded only <strong>{loaded}</strong>.
|
| 614 |
+
The remaining {len(missing)} were silently dropped because their ID + version
|
| 615 |
+
isn\'t registered in the foundation specs. The pass/fail numbers below reflect
|
| 616 |
+
only the loaded set — the dropped features were <em>not</em> checked.
|
| 617 |
+
See <code>PROBLEMS.md</code> P1 for root cause and fix path.
|
| 618 |
+
<details><summary>Dropped feature IDs ({len(missing)})</summary>
|
| 619 |
+
<ul>{missing_items}</ul></details>
|
| 620 |
+
</div>
|
| 621 |
+
'''
|
| 622 |
+
|
| 623 |
+
|
| 624 |
+
def _render_env_blocked_banner(results: list[dict]) -> str:
|
| 625 |
+
"""Banner shown when validate.py's P2 filter dropped env-blocked issues.
|
| 626 |
+
|
| 627 |
+
Aggregates per-asset env_blocked counts so the user sees both that a
|
| 628 |
+
suppression happened and roughly how much was suppressed. See PROBLEMS.md P2.
|
| 629 |
+
"""
|
| 630 |
+
physx_total = 0
|
| 631 |
+
mdl_total = 0
|
| 632 |
+
for r in results:
|
| 633 |
+
eb = r.get("env_blocked") or {}
|
| 634 |
+
physx_total += int(eb.get("physxschema_unavailable") or 0)
|
| 635 |
+
mdl_total += int(eb.get("omnipbr_unresolved") or 0)
|
| 636 |
+
if not (physx_total or mdl_total):
|
| 637 |
+
return ""
|
| 638 |
+
parts = []
|
| 639 |
+
if physx_total:
|
| 640 |
+
parts.append(
|
| 641 |
+
f'<strong>{physx_total}</strong> PhysX-rule errors '
|
| 642 |
+
f'(<code>PhysxSchema is not available in this environment</code>)'
|
| 643 |
+
)
|
| 644 |
+
if mdl_total:
|
| 645 |
+
parts.append(
|
| 646 |
+
f'<strong>{mdl_total}</strong> stock MDL-reference failures '
|
| 647 |
+
f'(<code>OmniPBR.mdl</code> not resolvable outside Kit)'
|
| 648 |
+
)
|
| 649 |
+
return f'''
|
| 650 |
+
<div class="coverage-banner">
|
| 651 |
+
<strong>Environment-blocked checks suppressed.</strong>
|
| 652 |
+
This run dropped {" and ".join(parts)} because the active Python venv has
|
| 653 |
+
<code>usd-core</code> but not the Kit-bundled <code>PhysxSchema</code> /
|
| 654 |
+
Kit MDL search path. These checks couldn\'t produce useful results here,
|
| 655 |
+
so they would otherwise have inflated the issue count without telling you
|
| 656 |
+
anything about the asset. Pass/fail below reflects only checks that ran
|
| 657 |
+
meaningfully. See <code>PROBLEMS.md</code> P2 for root cause and fix path.
|
| 658 |
+
</div>
|
| 659 |
+
'''
|
| 660 |
+
|
| 661 |
+
|
| 662 |
+
def _render_thumbnail_banner(provenance: list[dict], target: Path | None = None) -> str:
|
| 663 |
+
"""Banner explaining where the asset thumbnails came from (since they
|
| 664 |
+
weren't produced by this run's packaging step)."""
|
| 665 |
+
present = [p for p in provenance if p.get("present")]
|
| 666 |
+
if not present:
|
| 667 |
+
return ""
|
| 668 |
+
items = "".join(
|
| 669 |
+
f'<li><code>{html.escape(p["rel"])}</code> — copied from <code>{html.escape(_relativize_msg(p.get("copied_from",""), target))}</code></li>'
|
| 670 |
+
for p in present
|
| 671 |
+
)
|
| 672 |
+
return f'''
|
| 673 |
+
<div class="thumbnail-banner">
|
| 674 |
+
<strong>Asset thumbnails — sourced externally.</strong>
|
| 675 |
+
These tiles were copied into this report\'s <code>images/</code> directory from
|
| 676 |
+
per-asset spec paths (<code><asset_dir>/.thumbs/256x256/<filename>.png</code>) populated
|
| 677 |
+
earlier from NVIDIA\'s Isaac 6.0 content S3 bucket
|
| 678 |
+
(<code>Assets/Isaac/6.0/Isaac/Robots/Yaskawa/Motoman Next/...</code>).
|
| 679 |
+
They are <strong>not</strong> the output of this run\'s packaging step;
|
| 680 |
+
the customer\'s pipeline should generate authoritative thumbnails before the
|
| 681 |
+
validation reports them as spec-compliant.
|
| 682 |
+
<details><summary>Per-tile source ({len(present)} of {len(provenance)})</summary>
|
| 683 |
+
<ul>{items}</ul></details>
|
| 684 |
+
</div>
|
| 685 |
+
'''
|
| 686 |
+
|
| 687 |
+
|
| 688 |
+
def _render_asset_block(r: dict, doc_url, thumb_rel_by_asset: dict | None = None,
|
| 689 |
+
target: Path | None = None) -> str:
|
| 690 |
+
name = r["name"]
|
| 691 |
+
rel = r.get("rel_path") or r.get("path", "")
|
| 692 |
+
affected = r.get("affected_prims", [])
|
| 693 |
+
failed = r.get("failed_features", [])
|
| 694 |
+
passed = r.get("passed_features", [])
|
| 695 |
+
|
| 696 |
+
if affected:
|
| 697 |
+
prims_html = (
|
| 698 |
+
'<div class="affected-prims"><span class="label">Affected prims:</span>'
|
| 699 |
+
'<ul class="prim-list-inline">'
|
| 700 |
+
+ "".join(f"<li>{html.escape(p)}</li>" for p in affected)
|
| 701 |
+
+ "</ul></div>"
|
| 702 |
+
)
|
| 703 |
+
else:
|
| 704 |
+
prims_html = ""
|
| 705 |
+
|
| 706 |
+
def _feature_link(rel_path: str | None) -> str:
|
| 707 |
+
"""Inline 'View feature doc.' link — empty string when no doc available."""
|
| 708 |
+
url = doc_url(rel_path)
|
| 709 |
+
if not url:
|
| 710 |
+
return ''
|
| 711 |
+
return f' <a class="docref" href="{html.escape(url)}" target="_blank" rel="noopener">View feature doc.</a>'
|
| 712 |
+
|
| 713 |
+
def _capability_link(rel_path: str | None) -> str:
|
| 714 |
+
"""Inline 'View capability doc' link — empty string when no doc available."""
|
| 715 |
+
url = doc_url(rel_path)
|
| 716 |
+
if not url:
|
| 717 |
+
return ''
|
| 718 |
+
return f' <a class="docref" href="{html.escape(url)}" target="_blank" rel="noopener">View capability doc</a>'
|
| 719 |
+
|
| 720 |
+
if failed:
|
| 721 |
+
items = []
|
| 722 |
+
for f in failed:
|
| 723 |
+
req_paths = f.get("requirement_paths") or {}
|
| 724 |
+
feat_link = _feature_link(f.get("path"))
|
| 725 |
+
codes_parts = []
|
| 726 |
+
for c in f.get("failed_codes", []):
|
| 727 |
+
codes_parts.append(
|
| 728 |
+
f'<span class="code">{html.escape(c)}</span>'
|
| 729 |
+
f'{_capability_link(req_paths.get(c))}'
|
| 730 |
+
)
|
| 731 |
+
items.append(
|
| 732 |
+
f'<li><strong class="feat-id">{html.escape(f["id"])}</strong>'
|
| 733 |
+
f'{feat_link} '
|
| 734 |
+
+ " ".join(codes_parts)
|
| 735 |
+
+ '</li>'
|
| 736 |
+
)
|
| 737 |
+
failed_html = "<ul>" + "".join(items) + "</ul>"
|
| 738 |
+
else:
|
| 739 |
+
failed_html = '<p class="muted" style="margin:0">None</p>'
|
| 740 |
+
|
| 741 |
+
if passed:
|
| 742 |
+
# Passed features render as plain IDs — no doc link, matching the
|
| 743 |
+
# reference dashboard. Users only need quick access to docs when
|
| 744 |
+
# something failed.
|
| 745 |
+
items = [
|
| 746 |
+
f'<li><strong class="feat-id">{html.escape(f["id"])}</strong></li>'
|
| 747 |
+
for f in passed
|
| 748 |
+
]
|
| 749 |
+
passed_html = "<ul>" + "".join(items) + "</ul>"
|
| 750 |
+
else:
|
| 751 |
+
passed_html = '<p class="muted" style="margin:0">None</p>'
|
| 752 |
+
|
| 753 |
+
thumb_rel = (thumb_rel_by_asset or {}).get(name)
|
| 754 |
+
if thumb_rel:
|
| 755 |
+
thumb_html = (
|
| 756 |
+
f'<div class="asset-thumb-wrap">'
|
| 757 |
+
f'<img class="asset-thumb" src="{html.escape(thumb_rel)}" alt="{html.escape(name)} thumbnail" />'
|
| 758 |
+
f'</div>'
|
| 759 |
+
)
|
| 760 |
+
else:
|
| 761 |
+
thumb_html = (
|
| 762 |
+
'<div class="asset-thumb-wrap">'
|
| 763 |
+
'<div class="asset-thumb asset-thumb-missing" aria-label="no thumbnail">'
|
| 764 |
+
'<span>missing image</span>'
|
| 765 |
+
'</div></div>'
|
| 766 |
+
)
|
| 767 |
+
|
| 768 |
+
# `extra_failures` (issues against requirements outside the loaded
|
| 769 |
+
# profile) are intentionally NOT rendered per-asset. They get summarized
|
| 770 |
+
# as a count in the Caveats panel; the report scope is the chosen
|
| 771 |
+
# profile only.
|
| 772 |
+
extras_html = ""
|
| 773 |
+
|
| 774 |
+
return f"""
|
| 775 |
+
<div class="asset-block" data-asset-name="{html.escape(name.lower())}">
|
| 776 |
+
{thumb_html}
|
| 777 |
+
<div class="asset-body">
|
| 778 |
+
<h2>{html.escape(name)}</h2>
|
| 779 |
+
<div class="path">{html.escape(rel)}</div>
|
| 780 |
+
{prims_html}
|
| 781 |
+
<section class="failed-features">
|
| 782 |
+
<h3>Failed features</h3>
|
| 783 |
+
{failed_html}
|
| 784 |
+
</section>
|
| 785 |
+
<section class="passed-features">
|
| 786 |
+
<h3>Passed features</h3>
|
| 787 |
+
{passed_html}
|
| 788 |
+
</section>{extras_html}
|
| 789 |
+
</div>
|
| 790 |
+
</div>"""
|
| 791 |
+
|
| 792 |
+
|
| 793 |
+
def _render_compliance(results: list[dict], doc_url=None, target: Path | None = None) -> str:
|
| 794 |
+
"""Footer listing every in-profile requirement code that failed, with its
|
| 795 |
+
short message and (when available) a link to the foundation spec doc
|
| 796 |
+
that defines it. Codes outside the loaded profile are NOT listed here —
|
| 797 |
+
the report scope is the chosen spec only; out-of-spec failure counts
|
| 798 |
+
surface in the Caveats panel instead. Codes without a `path` get a
|
| 799 |
+
"(no spec source)" tag so the accountability gap is visible."""
|
| 800 |
+
# Build the set of in-profile failure codes from `failed_features`.
|
| 801 |
+
# Anything not in this set is an "extra" outside the loaded profile and
|
| 802 |
+
# is intentionally suppressed from the per-asset and footer views.
|
| 803 |
+
profile_failure_codes: set[str] = set()
|
| 804 |
+
for r in results:
|
| 805 |
+
for ff in r.get("failed_features", []):
|
| 806 |
+
profile_failure_codes.update(ff.get("failed_codes", []))
|
| 807 |
+
code_to_entry: dict[str, dict] = {}
|
| 808 |
+
for r in results:
|
| 809 |
+
for issue in r.get("issues", []):
|
| 810 |
+
if issue["severity"] not in ("failure", "error"):
|
| 811 |
+
continue
|
| 812 |
+
code = issue.get("code") or "UNKNOWN"
|
| 813 |
+
if code not in profile_failure_codes:
|
| 814 |
+
continue
|
| 815 |
+
if code not in code_to_entry:
|
| 816 |
+
code_to_entry[code] = {
|
| 817 |
+
"msg": issue.get("msg", ""),
|
| 818 |
+
"path": issue.get("path"),
|
| 819 |
+
}
|
| 820 |
+
if not code_to_entry:
|
| 821 |
+
return ""
|
| 822 |
+
|
| 823 |
+
items_html_parts: list[str] = []
|
| 824 |
+
accounted = 0
|
| 825 |
+
for code, e in sorted(code_to_entry.items()):
|
| 826 |
+
url = doc_url(e["path"]) if doc_url else None
|
| 827 |
+
if url:
|
| 828 |
+
code_html = f'<a class="code" href="{html.escape(url)}" target="_blank" rel="noopener">{html.escape(code)}</a>'
|
| 829 |
+
accounted += 1
|
| 830 |
+
no_spec_tag = ""
|
| 831 |
+
else:
|
| 832 |
+
code_html = f'<span class="code">{html.escape(code)}</span>'
|
| 833 |
+
no_spec_tag = ' <span class="no-spec">(no spec source)</span>'
|
| 834 |
+
msg = _relativize_msg(e["msg"], target)
|
| 835 |
+
items_html_parts.append(
|
| 836 |
+
f'<li>{code_html}: {html.escape(msg) if msg else "No guidance available."}{no_spec_tag}</li>'
|
| 837 |
+
)
|
| 838 |
+
items_html = "".join(items_html_parts)
|
| 839 |
+
coverage = (
|
| 840 |
+
f'<p class="muted">{accounted} of {len(code_to_entry)} requirement '
|
| 841 |
+
f'code(s) link to a foundation spec source. The rest are flagged '
|
| 842 |
+
f'<em>(no spec source)</em>.</p>'
|
| 843 |
+
)
|
| 844 |
+
return f"""
|
| 845 |
+
<section class="compliance">
|
| 846 |
+
<h2>How to comply with failed requirements</h2>
|
| 847 |
+
<p class="muted">Each requirement code below is shown with the first message recorded for it.</p>
|
| 848 |
+
{coverage}
|
| 849 |
+
<ul class="compliance-list">{items_html}</ul>
|
| 850 |
+
</section>"""
|
tools/validation/plugins/simready-report/skills/simready-report/validate.py
ADDED
|
@@ -0,0 +1,1243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SimReady asset validator — entry point for the /simready-report skill.
|
| 2 |
+
|
| 3 |
+
Usage:
|
| 4 |
+
python validate.py <target_dir> [--profile NAME] [--version X.Y.Z] [--output DIR]
|
| 5 |
+
|
| 6 |
+
Discovers every USD file under <target_dir>, validates each against the named
|
| 7 |
+
profile via omni.asset_validator, walks composition arcs for external deps,
|
| 8 |
+
and writes the dashboard + provenance reports into <output> (default
|
| 9 |
+
<target_dir>/validation_output).
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import argparse
|
| 14 |
+
import json
|
| 15 |
+
import logging
|
| 16 |
+
import os
|
| 17 |
+
import shutil
|
| 18 |
+
import sys
|
| 19 |
+
import tomllib
|
| 20 |
+
from concurrent.futures import ProcessPoolExecutor, as_completed
|
| 21 |
+
from dataclasses import dataclass
|
| 22 |
+
from datetime import datetime, timezone
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
SKILL_DIR = Path(__file__).resolve().parent
|
| 26 |
+
|
| 27 |
+
# Profiles whose validation rules need a real Kit runtime (PhysxSchema +
|
| 28 |
+
# Kit's MDL search path). Used both by the early Kit re-exec gate and by
|
| 29 |
+
# the auto-default in main(). Keep these in sync.
|
| 30 |
+
_PHYSX_PROFILES = frozenset({
|
| 31 |
+
"Robot-Body-Physx", "Robot-Body-Isaac", "Robot-Body-Runnable",
|
| 32 |
+
"Prop-Robotics-Physx", "Prop-Robotics-Isaac",
|
| 33 |
+
})
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _sniff_arg(argv: list[str], name: str, default: str | None = None) -> str | None:
|
| 37 |
+
"""Pull `--<name> <value>` out of argv without argparse. Returns default if absent."""
|
| 38 |
+
flag = f"--{name}"
|
| 39 |
+
if flag in argv:
|
| 40 |
+
i = argv.index(flag)
|
| 41 |
+
if i + 1 < len(argv):
|
| 42 |
+
return argv[i + 1]
|
| 43 |
+
return default
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ---- Kit re-exec gate ---------------------------------------------------
|
| 47 |
+
# When --use-kit is on and we're not already inside a Kit-rooted process,
|
| 48 |
+
# re-exec this script through _kit_wrapper.py using the Kit-rooted Python.
|
| 49 |
+
# Must run BEFORE any pxr / omni.asset_validator import attempt below
|
| 50 |
+
# (otherwise _check_bootstrap fires against the pip venv first). Argparse
|
| 51 |
+
# is too heavy here — sniff sys.argv directly.
|
| 52 |
+
def _maybe_reexec_under_kit() -> None:
|
| 53 |
+
if __name__ != "__main__":
|
| 54 |
+
return
|
| 55 |
+
if os.environ.get("SIMREADY_INSIDE_KIT") == "1":
|
| 56 |
+
return
|
| 57 |
+
# Explicit opt-in/out takes precedence. Otherwise auto-enable for
|
| 58 |
+
# PhysX-bearing profiles. Mirror this in main() — keep _PHYSX_PROFILES
|
| 59 |
+
# the source of truth.
|
| 60 |
+
if "--no-use-kit" in sys.argv:
|
| 61 |
+
return
|
| 62 |
+
auto = False
|
| 63 |
+
if "--use-kit" not in sys.argv:
|
| 64 |
+
profile = _sniff_arg(sys.argv, "profile", default="Robot-Body-Runnable")
|
| 65 |
+
if profile in _PHYSX_PROFILES:
|
| 66 |
+
print(f"[skill] auto-enabled --use-kit for PhysX-bearing profile "
|
| 67 |
+
f"{profile}; pass --no-use-kit to override.", flush=True)
|
| 68 |
+
auto = True
|
| 69 |
+
else:
|
| 70 |
+
return
|
| 71 |
+
# Resolve the Kit-rooted Python.
|
| 72 |
+
kit_python = os.environ.get("SIMREADY_KIT_PYTHON") or ""
|
| 73 |
+
if not kit_python:
|
| 74 |
+
# Best-known default on this team's machines (Isaac Sim 6 source build).
|
| 75 |
+
default = r"C:\isaacsim6\_build\windows-x86_64\release\python.bat"
|
| 76 |
+
if Path(default).is_file():
|
| 77 |
+
kit_python = default
|
| 78 |
+
# Allow --kit-python <path> override (sniff without argparse).
|
| 79 |
+
if "--kit-python" in sys.argv:
|
| 80 |
+
i = sys.argv.index("--kit-python")
|
| 81 |
+
if i + 1 < len(sys.argv):
|
| 82 |
+
kit_python = sys.argv[i + 1]
|
| 83 |
+
if not kit_python or not Path(kit_python).is_file():
|
| 84 |
+
print(
|
| 85 |
+
"ERROR: --use-kit requested but no Kit-rooted Python found.\n"
|
| 86 |
+
" Set SIMREADY_KIT_PYTHON to an Isaac Sim 6 python.bat,\n"
|
| 87 |
+
" or pass --kit-python <path>.",
|
| 88 |
+
flush=True,
|
| 89 |
+
)
|
| 90 |
+
sys.exit(2)
|
| 91 |
+
# Drop --use-kit and --kit-python from the child's argv. The child runs
|
| 92 |
+
# validate.py via _kit_wrapper.py, which sets SIMREADY_INSIDE_KIT=1 so
|
| 93 |
+
# this gate is a no-op the second time around.
|
| 94 |
+
child_argv: list[str] = []
|
| 95 |
+
skip_next = False
|
| 96 |
+
for a in sys.argv[1:]:
|
| 97 |
+
if skip_next:
|
| 98 |
+
skip_next = False
|
| 99 |
+
continue
|
| 100 |
+
if a == "--use-kit":
|
| 101 |
+
continue
|
| 102 |
+
if a == "--kit-python":
|
| 103 |
+
skip_next = True
|
| 104 |
+
continue
|
| 105 |
+
child_argv.append(a)
|
| 106 |
+
wrapper = SKILL_DIR / "_kit_wrapper.py"
|
| 107 |
+
print(f" --use-kit: re-exec via {kit_python}", flush=True)
|
| 108 |
+
print(f" (SimulationApp boot — expect 10-30s of Kit startup)", flush=True)
|
| 109 |
+
import subprocess
|
| 110 |
+
rc = subprocess.call([kit_python, str(wrapper), *child_argv])
|
| 111 |
+
sys.exit(rc)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
_maybe_reexec_under_kit()
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _find_workspace() -> Path | None:
|
| 118 |
+
"""Locate the simready-playbook workspace by walking up from this script.
|
| 119 |
+
|
| 120 |
+
The workspace is identified by a `BOT.md` marker at its root. This works
|
| 121 |
+
when the skill is loaded from the cloned playbook (the historical layout
|
| 122 |
+
AND the new plugin-in-repo layout). When the plugin is installed via
|
| 123 |
+
marketplace into a user-global location, no `BOT.md` is in the ancestry,
|
| 124 |
+
so this returns None and the script falls back to CWD-anchored defaults.
|
| 125 |
+
"""
|
| 126 |
+
for ancestor in SKILL_DIR.parents:
|
| 127 |
+
if (ancestor / "BOT.md").is_file():
|
| 128 |
+
return ancestor
|
| 129 |
+
return None
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
WORKSPACE_ROOT = _find_workspace()
|
| 133 |
+
# Reports default to <target>/.reports/<name>--<profile>/ — i.e. dropped
|
| 134 |
+
# next to the assets they describe. The `.reports/` prefix starts with `.`
|
| 135 |
+
# so `discover_assets` automatically skips it on subsequent runs even when
|
| 136 |
+
# the report dir lives inside the target. The default is computed in main()
|
| 137 |
+
# from the *pre-packaging* target so the report ends up where the user
|
| 138 |
+
# pointed, not under packages/.
|
| 139 |
+
DEFAULT_TARGET_DIR = (WORKSPACE_ROOT / "assets_to_validate") if WORKSPACE_ROOT else None
|
| 140 |
+
# Foundation specs + SDK checkouts: env var first; otherwise sibling-of-workspace
|
| 141 |
+
# fallback (the layout `bootstrap.ps1` produces). When WORKSPACE_ROOT is None,
|
| 142 |
+
# only the env vars matter — there's nothing to fall back to.
|
| 143 |
+
_specs_env = os.environ.get("SIMREADY_FOUNDATIONS_PATH")
|
| 144 |
+
_sdk_env = os.environ.get("SIMREADY_SDK_PATH")
|
| 145 |
+
SPECS_DIR = Path(_specs_env) if _specs_env else (
|
| 146 |
+
WORKSPACE_ROOT.parent / "simready_foundations" if WORKSPACE_ROOT else None
|
| 147 |
+
)
|
| 148 |
+
SDK_DIR = Path(_sdk_env) if _sdk_env else (
|
| 149 |
+
WORKSPACE_ROOT.parent / "simready-oem-sdk-poc" if WORKSPACE_ROOT else None
|
| 150 |
+
)
|
| 151 |
+
# Optional reference dashboard checkout — used only for its `_static/` (theme
|
| 152 |
+
# CSS/JS/fonts) so generated doc pages match the reference look. Resolution order:
|
| 153 |
+
# 1. SIMREADY_DASHBOARD_PATH env var (points at the inner `.../docs` dir or
|
| 154 |
+
# the outer `usd_validation_dashboard_final/` — both forms accepted)
|
| 155 |
+
# 2. sibling-of-workspace (what bootstrap.ps1 produces)
|
| 156 |
+
# 3. ~/Downloads/usd_validation_dashboard_final/usd_validation_dashboard_final/docs
|
| 157 |
+
def _resolve_dashboard_docs_dir() -> Path | None:
|
| 158 |
+
env = os.environ.get("SIMREADY_DASHBOARD_PATH")
|
| 159 |
+
candidates: list[Path] = []
|
| 160 |
+
if env:
|
| 161 |
+
p = Path(env)
|
| 162 |
+
# Accept both <root>/usd_validation_dashboard_final/docs and the docs dir directly.
|
| 163 |
+
candidates += [p, p / "docs", p / "usd_validation_dashboard_final" / "docs"]
|
| 164 |
+
if WORKSPACE_ROOT:
|
| 165 |
+
candidates.append(WORKSPACE_ROOT.parent / "usd_validation_dashboard_final" / "usd_validation_dashboard_final" / "docs")
|
| 166 |
+
home = Path.home()
|
| 167 |
+
candidates.append(home / "Downloads" / "usd_validation_dashboard_final" / "usd_validation_dashboard_final" / "docs")
|
| 168 |
+
for c in candidates:
|
| 169 |
+
if c and c.is_dir() and (c / "_static").is_dir():
|
| 170 |
+
return c
|
| 171 |
+
return None
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
DASHBOARD_DOCS_DIR = _resolve_dashboard_docs_dir()
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
# Default to loading only the OAV plugin; SimReadyPlugin's entry-point
|
| 179 |
+
# isn't needed because we populate the registries from on-disk paths via
|
| 180 |
+
# `simready.validate.impl.loader.load_validation_implementation` (matching
|
| 181 |
+
# the official simready-validate CLI). Pass `--use-plugin` to opt back in
|
| 182 |
+
# to the entry-point flow (requires the simready-foundation-core editable
|
| 183 |
+
# install — kept as a fallback during the GitHub migration).
|
| 184 |
+
os.environ.setdefault(
|
| 185 |
+
"OMNI_ASSET_VALIDATOR_ISOLATE_ENTRYPOINTS",
|
| 186 |
+
"omni.asset_validator:DefaultPlugin",
|
| 187 |
+
)
|
| 188 |
+
if "--use-plugin" in sys.argv:
|
| 189 |
+
os.environ["OMNI_ASSET_VALIDATOR_ISOLATE_ENTRYPOINTS"] = (
|
| 190 |
+
"omni.asset_validator:DefaultPlugin,simready.foundation.core:SimReadyPlugin"
|
| 191 |
+
)
|
| 192 |
+
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
| 193 |
+
if SPECS_DIR and SPECS_DIR.is_dir():
|
| 194 |
+
os.environ.setdefault("SIMREADY_SPECS_PATH", str(SPECS_DIR))
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _check_bootstrap() -> None:
|
| 198 |
+
"""Verify the runtime can do its job before importing foundation modules.
|
| 199 |
+
|
| 200 |
+
What matters is that the Python imports work and the foundation specs are
|
| 201 |
+
findable; we don't care *where* they came from (sibling venv, system
|
| 202 |
+
Python, an active venv, conda, etc.). Missing pieces are reported with
|
| 203 |
+
actionable hints rather than a raw ImportError.
|
| 204 |
+
"""
|
| 205 |
+
missing: list[str] = []
|
| 206 |
+
try:
|
| 207 |
+
import omni.asset_validator # noqa: F401
|
| 208 |
+
import pxr # noqa: F401
|
| 209 |
+
import markdown_it # noqa: F401
|
| 210 |
+
import simready.validate.impl.loader # noqa: F401 -- the CLI loader the default path depends on
|
| 211 |
+
except ImportError as e:
|
| 212 |
+
missing.append(
|
| 213 |
+
f"Python package not importable: {e.name or e}. "
|
| 214 |
+
f"Install runtime deps: pip install usd-core omniverse-asset-validator "
|
| 215 |
+
f"markdown-it-py 'simready-validate>=2026.4.8'."
|
| 216 |
+
)
|
| 217 |
+
if SPECS_DIR is None or not SPECS_DIR.is_dir():
|
| 218 |
+
missing.append(
|
| 219 |
+
"simready_foundations checkout not findable"
|
| 220 |
+
+ (f" at {SPECS_DIR}" if SPECS_DIR else "")
|
| 221 |
+
+ ". Clone github.com/NVIDIA/simready-foundation (or -staging) and set "
|
| 222 |
+
"SIMREADY_FOUNDATIONS_PATH to the checkout."
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
if missing:
|
| 226 |
+
print("ERROR: runtime prerequisites missing:", flush=True)
|
| 227 |
+
for m in missing:
|
| 228 |
+
print(f" - {m}", flush=True)
|
| 229 |
+
print("", flush=True)
|
| 230 |
+
if os.environ.get("SIMREADY_INSIDE_KIT") == "1":
|
| 231 |
+
print(
|
| 232 |
+
"Running inside Kit (SIMREADY_INSIDE_KIT=1) but the validator stack "
|
| 233 |
+
"isn't installed in this Kit-rooted Python.\n"
|
| 234 |
+
f"Install once with: \"{sys.executable}\" -m pip install omniverse-asset-validator markdown-it-py "
|
| 235 |
+
"-e <foundations>/nv_core/sr_specs (and -e <sdk> if auto-packaging is needed).",
|
| 236 |
+
flush=True,
|
| 237 |
+
)
|
| 238 |
+
elif WORKSPACE_ROOT is not None:
|
| 239 |
+
bootstrap = WORKSPACE_ROOT / "bootstrap.ps1"
|
| 240 |
+
print(f"On Windows, the canned setup is: & '{bootstrap}'", flush=True)
|
| 241 |
+
print("Install the runtime deps (usd-core, omniverse-asset-validator, "
|
| 242 |
+
"markdown-it-py) into your active Python and set SIMREADY_FOUNDATIONS_PATH "
|
| 243 |
+
"(and optionally SIMREADY_SDK_PATH) to existing checkouts.", flush=True)
|
| 244 |
+
raise SystemExit(2)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
_check_bootstrap()
|
| 248 |
+
|
| 249 |
+
from pxr import Usd # noqa: E402 (deferred until _check_bootstrap verifies env)
|
| 250 |
+
import omni.asset_validator as oav # noqa: E402
|
| 251 |
+
|
| 252 |
+
import report # noqa: E402
|
| 253 |
+
import external_deps # noqa: E402
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def _patch_register_json_variant_features() -> None:
|
| 257 |
+
"""LOCAL PATCH for PROBLEMS.md P1 — remove once foundations codegen is fixed.
|
| 258 |
+
|
| 259 |
+
The foundations `repo usd_profiles_codegen` tool only emits Feature
|
| 260 |
+
entries from each `.md` file's declared `Internal ID`, missing the
|
| 261 |
+
JSON variant feature definitions next to the markdown
|
| 262 |
+
(`<features>/FET_*.json`). This patch does two things:
|
| 263 |
+
|
| 264 |
+
1. Read every `FET_*.json` and register each variant in
|
| 265 |
+
`omni.asset_validator.FeatureRegistry()` (skipping ones already
|
| 266 |
+
registered by codegen).
|
| 267 |
+
2. Re-resolve every profile's feature list against the now-fuller
|
| 268 |
+
registry. Profiles are built once at plugin-load time with
|
| 269 |
+
eager feature resolution, so features added after the fact
|
| 270 |
+
never join existing Profile objects unless we mutate them
|
| 271 |
+
directly. `Profile` is a frozen dataclass but `.features` is a
|
| 272 |
+
mutable list, so we append in place.
|
| 273 |
+
|
| 274 |
+
Step 2 is what actually makes profiles like `Robot-Body-Runnable`
|
| 275 |
+
resolve their `_ROBOT_PHYSX` / `_RUNNABLE` / `_DRIVEN_JOINTS_PHYSX`
|
| 276 |
+
entries. Without it, registering features alone is invisible to
|
| 277 |
+
the validator.
|
| 278 |
+
"""
|
| 279 |
+
if SPECS_DIR is None or not SPECS_DIR.is_dir():
|
| 280 |
+
return
|
| 281 |
+
features_dir = SPECS_DIR / "nv_core" / "sr_specs" / "docs" / "features"
|
| 282 |
+
profiles_toml = SPECS_DIR / "nv_core" / "sr_specs" / "docs" / "profiles" / "profiles.toml"
|
| 283 |
+
if not features_dir.is_dir() or not profiles_toml.is_file():
|
| 284 |
+
return
|
| 285 |
+
|
| 286 |
+
# Force plugin init so codegen-derived features + profiles are in
|
| 287 |
+
# their respective registries first; we additively patch on top.
|
| 288 |
+
fr = oav.FeatureRegistry()
|
| 289 |
+
pr = oav.ProfileRegistry()
|
| 290 |
+
rr = oav.RequirementsRegistry()
|
| 291 |
+
code_to_req = {r.code: r for r in rr.values() if getattr(r, "code", None)}
|
| 292 |
+
|
| 293 |
+
@dataclass(frozen=True)
|
| 294 |
+
class _PatchedFeature:
|
| 295 |
+
id: str
|
| 296 |
+
version: str
|
| 297 |
+
path: str
|
| 298 |
+
requirements: list
|
| 299 |
+
|
| 300 |
+
# ---- Step 1: register JSON variant features ----
|
| 301 |
+
added = 0
|
| 302 |
+
skipped_existing = 0
|
| 303 |
+
unresolved_codes: dict[str, list[str]] = {}
|
| 304 |
+
# Glob is intentionally permissive — JSON variant filenames in this
|
| 305 |
+
# repo use a mix of `FET_021-robot_core_isaac-0.1.0.json`,
|
| 306 |
+
# `FET_021_robot_core_runnable_0.2.0.json`, and
|
| 307 |
+
# `FET_004_base_physx-0.2.0-simulate_multi_body_phyics.json` styles.
|
| 308 |
+
# We rely on `id` + `version` from the JSON content, not the filename.
|
| 309 |
+
for jf in sorted(features_dir.glob("FET_*.json")):
|
| 310 |
+
try:
|
| 311 |
+
data = json.loads(jf.read_text(encoding="utf-8"))
|
| 312 |
+
except Exception:
|
| 313 |
+
continue
|
| 314 |
+
fid = data.get("id")
|
| 315 |
+
fver = data.get("version")
|
| 316 |
+
if not fid or not fver:
|
| 317 |
+
continue
|
| 318 |
+
if fr.find(fid, fver) is not None:
|
| 319 |
+
skipped_existing += 1
|
| 320 |
+
continue
|
| 321 |
+
resolved, missing = [], []
|
| 322 |
+
for code in data.get("requirements", []):
|
| 323 |
+
if code in code_to_req:
|
| 324 |
+
resolved.append(code_to_req[code])
|
| 325 |
+
else:
|
| 326 |
+
missing.append(code)
|
| 327 |
+
if missing:
|
| 328 |
+
unresolved_codes[f"{fid} v{fver}"] = missing
|
| 329 |
+
continue
|
| 330 |
+
feat = _PatchedFeature(
|
| 331 |
+
id=fid, version=fver, path=data.get("path", ""), requirements=resolved
|
| 332 |
+
)
|
| 333 |
+
try:
|
| 334 |
+
fr.add(feat)
|
| 335 |
+
added += 1
|
| 336 |
+
except Exception:
|
| 337 |
+
pass
|
| 338 |
+
|
| 339 |
+
# ---- Step 2: re-resolve each profile's feature list ----
|
| 340 |
+
try:
|
| 341 |
+
with profiles_toml.open("rb") as fh:
|
| 342 |
+
toml_data = tomllib.load(fh)
|
| 343 |
+
except Exception:
|
| 344 |
+
toml_data = {}
|
| 345 |
+
profiles_patched = 0
|
| 346 |
+
profile_features_appended = 0
|
| 347 |
+
for profile_id, versions in toml_data.items():
|
| 348 |
+
if not isinstance(versions, dict):
|
| 349 |
+
continue
|
| 350 |
+
for ver, body in versions.items():
|
| 351 |
+
if not isinstance(body, dict) or "features" not in body:
|
| 352 |
+
continue
|
| 353 |
+
try:
|
| 354 |
+
profile = pr.find(profile_id, ver)
|
| 355 |
+
except Exception:
|
| 356 |
+
continue
|
| 357 |
+
if profile is None:
|
| 358 |
+
continue
|
| 359 |
+
existing = {(f.id, str(f.version)) for f in profile.features}
|
| 360 |
+
appended_here = 0
|
| 361 |
+
for entry in body["features"]:
|
| 362 |
+
if not isinstance(entry, dict) or len(entry) != 1:
|
| 363 |
+
continue
|
| 364 |
+
fid, meta = next(iter(entry.items()))
|
| 365 |
+
fver_decl = str((meta or {}).get("version", ""))
|
| 366 |
+
if (fid, fver_decl) in existing:
|
| 367 |
+
continue
|
| 368 |
+
feat = fr.find(fid, fver_decl)
|
| 369 |
+
if feat is None:
|
| 370 |
+
continue
|
| 371 |
+
profile.features.append(feat) # mutate the list inside the frozen dataclass
|
| 372 |
+
appended_here += 1
|
| 373 |
+
if appended_here:
|
| 374 |
+
profiles_patched += 1
|
| 375 |
+
profile_features_appended += appended_here
|
| 376 |
+
|
| 377 |
+
if added or unresolved_codes or profiles_patched:
|
| 378 |
+
msg = (
|
| 379 |
+
f" [PATCH P1] FeatureRegistry: +{added} JSON-variant feature(s) "
|
| 380 |
+
f"({skipped_existing} already registered, "
|
| 381 |
+
f"{len(unresolved_codes)} skipped on missing requirement codes); "
|
| 382 |
+
f"ProfileRegistry: rebuilt feature lists for {profiles_patched} profile(s) "
|
| 383 |
+
f"(+{profile_features_appended} feature(s))."
|
| 384 |
+
)
|
| 385 |
+
print(msg, flush=True)
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
# P1 patch is no longer applied unconditionally. It runs only when
|
| 389 |
+
# --use-plugin is set (the legacy SimReadyPlugin entry-point path). The
|
| 390 |
+
# default CLI-loader path populates the registry from JSON variant files
|
| 391 |
+
# natively, making this patch redundant. Kept callable here so workers
|
| 392 |
+
# can invoke it through _pool_init when the legacy path is selected.
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(name)s: %(message)s")
|
| 396 |
+
log = logging.getLogger("validate")
|
| 397 |
+
|
| 398 |
+
USD_EXTS = (".usd", ".usda", ".usdc", ".usdz")
|
| 399 |
+
SEVERITY_NAMES = {
|
| 400 |
+
oav.IssueSeverity.ERROR: "error",
|
| 401 |
+
oav.IssueSeverity.FAILURE: "failure",
|
| 402 |
+
oav.IssueSeverity.WARNING: "warning",
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
def _find_simready_cli() -> str | None:
|
| 407 |
+
"""Locate the `simready` CLI in a layout-agnostic way.
|
| 408 |
+
|
| 409 |
+
Order: (1) on PATH, (2) in the same bin/ or Scripts/ dir as `sys.executable`
|
| 410 |
+
— covers any active venv/conda regardless of OS.
|
| 411 |
+
"""
|
| 412 |
+
on_path = shutil.which("simready")
|
| 413 |
+
if on_path:
|
| 414 |
+
return on_path
|
| 415 |
+
py_bin = Path(sys.executable).parent
|
| 416 |
+
for candidate in (py_bin / "simready.exe", py_bin / "simready"):
|
| 417 |
+
if candidate.is_file():
|
| 418 |
+
return str(candidate)
|
| 419 |
+
return None
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
def maybe_package_target(target: Path) -> Path:
|
| 423 |
+
"""If `target` is a raw asset set under assets_to_validate/, run
|
| 424 |
+
`simready ingest usd` per interface file into packages/<name>/ and
|
| 425 |
+
return the packaged path. Otherwise return `target` unchanged.
|
| 426 |
+
|
| 427 |
+
A raw set is detected by the path containing `/assets_to_validate/`.
|
| 428 |
+
The packaged tree is built only if absent (idempotent). Each subfolder
|
| 429 |
+
of the raw set is treated as one asset; the interface file is the USD
|
| 430 |
+
inside it whose stem matches the folder name (case-insensitive,
|
| 431 |
+
`-`/`_` normalized), or — if there's exactly one USD — that one.
|
| 432 |
+
"""
|
| 433 |
+
target_str = str(target).replace("\\", "/")
|
| 434 |
+
if "/assets_to_validate/" not in target_str:
|
| 435 |
+
return target
|
| 436 |
+
|
| 437 |
+
name = target.name
|
| 438 |
+
# Anchor the packaged tree to the workspace when present; otherwise to the
|
| 439 |
+
# parent of the assets_to_validate/ dir so the layout is preserved without
|
| 440 |
+
# requiring a workspace.
|
| 441 |
+
if WORKSPACE_ROOT is not None:
|
| 442 |
+
packaged = WORKSPACE_ROOT / "packages" / name
|
| 443 |
+
else:
|
| 444 |
+
# target is <X>/assets_to_validate/<name>; sibling-of-X for packages.
|
| 445 |
+
packaged = target.parent.parent / "packages" / name
|
| 446 |
+
packaged.mkdir(parents=True, exist_ok=True)
|
| 447 |
+
|
| 448 |
+
simready_exe = _find_simready_cli()
|
| 449 |
+
if simready_exe is None:
|
| 450 |
+
print(" WARNING: `simready` CLI not found on PATH or in the active Python's bin/ "
|
| 451 |
+
"directory. Cannot auto-package; validating the raw tree instead.", flush=True)
|
| 452 |
+
return target
|
| 453 |
+
|
| 454 |
+
import subprocess
|
| 455 |
+
print(f" auto-package: {target} -> {packaged}", flush=True)
|
| 456 |
+
interface_files = []
|
| 457 |
+
for sub in sorted(target.iterdir()):
|
| 458 |
+
if not sub.is_dir() or sub.name.startswith((".", "_")):
|
| 459 |
+
continue
|
| 460 |
+
usds = [p for ext in USD_EXTS for p in sub.glob(f"*{ext}")]
|
| 461 |
+
if not usds:
|
| 462 |
+
continue
|
| 463 |
+
if len(usds) == 1:
|
| 464 |
+
interface_files.append(usds[0])
|
| 465 |
+
continue
|
| 466 |
+
named = [p for p in usds if p.stem.lower() == sub.name.lower().replace("-", "_")]
|
| 467 |
+
if named:
|
| 468 |
+
interface_files.append(named[0])
|
| 469 |
+
else:
|
| 470 |
+
interface_files.extend(usds)
|
| 471 |
+
|
| 472 |
+
env = {**os.environ, "PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1"}
|
| 473 |
+
packaged_count = 0
|
| 474 |
+
for usd in interface_files:
|
| 475 |
+
already = packaged / usd.stem / usd.name
|
| 476 |
+
if already.is_file():
|
| 477 |
+
continue
|
| 478 |
+
cmd = [simready_exe, "ingest", "usd", str(usd),
|
| 479 |
+
"-o", str(packaged), "-p", "Robot-Body-Runnable", "--no-validate"]
|
| 480 |
+
try:
|
| 481 |
+
subprocess.run(cmd, env=env, check=True, capture_output=True, text=True, timeout=120)
|
| 482 |
+
packaged_count += 1
|
| 483 |
+
except subprocess.CalledProcessError as e:
|
| 484 |
+
print(f" WARNING: simready ingest failed for {usd.name}: {e.stderr[-300:] if e.stderr else e}", flush=True)
|
| 485 |
+
if packaged_count:
|
| 486 |
+
print(f" auto-package: {packaged_count} asset(s) packaged", flush=True)
|
| 487 |
+
return packaged
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
def resolve_target(arg: str | None) -> Path | None:
|
| 491 |
+
"""Map the user's argument to a concrete asset directory.
|
| 492 |
+
|
| 493 |
+
Rules:
|
| 494 |
+
- No arg: use the current working directory.
|
| 495 |
+
- Absolute or existing relative path: use as-is.
|
| 496 |
+
- Bare name `<name>` (no path separators): try CWD/<name>, then the
|
| 497 |
+
workspace's `assets_to_validate/<name>` as a fallback for users still
|
| 498 |
+
using the original layout.
|
| 499 |
+
"""
|
| 500 |
+
if arg is None:
|
| 501 |
+
cwd = Path.cwd().resolve()
|
| 502 |
+
print(f" target: {cwd} (cwd)", flush=True)
|
| 503 |
+
return cwd
|
| 504 |
+
|
| 505 |
+
candidate = Path(arg)
|
| 506 |
+
if candidate.is_absolute() and candidate.is_dir():
|
| 507 |
+
return candidate.resolve()
|
| 508 |
+
if candidate.is_dir():
|
| 509 |
+
return candidate.resolve()
|
| 510 |
+
# Bare name — try CWD-relative first, then the workspace's
|
| 511 |
+
# assets_to_validate/<arg> when running inside a playbook clone.
|
| 512 |
+
cwd_fallback = (Path.cwd() / arg).resolve()
|
| 513 |
+
if cwd_fallback.is_dir():
|
| 514 |
+
return cwd_fallback
|
| 515 |
+
workspace_fallback = DEFAULT_TARGET_DIR / arg if DEFAULT_TARGET_DIR else None
|
| 516 |
+
if workspace_fallback and workspace_fallback.is_dir():
|
| 517 |
+
return workspace_fallback.resolve()
|
| 518 |
+
print(f"ERROR: target dir does not exist: {arg}", flush=True)
|
| 519 |
+
print(f" Tried: {candidate.resolve()}", flush=True)
|
| 520 |
+
print(f" Tried: {cwd_fallback}", flush=True)
|
| 521 |
+
if workspace_fallback:
|
| 522 |
+
print(f" Tried: {workspace_fallback}", flush=True)
|
| 523 |
+
return None
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
def sanity_check_target(target: Path) -> list[str]:
|
| 527 |
+
"""Refuse obviously-wrong invocations before any work happens.
|
| 528 |
+
|
| 529 |
+
Returns a list of human-readable problems. Empty list means OK.
|
| 530 |
+
Used to interrupt `/simready-report` (especially when it ran with no arg and
|
| 531 |
+
fell back to CWD) when the resolved target couldn't sensibly be an
|
| 532 |
+
asset directory.
|
| 533 |
+
"""
|
| 534 |
+
problems: list[str] = []
|
| 535 |
+
target = target.resolve()
|
| 536 |
+
|
| 537 |
+
def _under(child: Path, parent: Path) -> bool:
|
| 538 |
+
try:
|
| 539 |
+
child.relative_to(parent)
|
| 540 |
+
return True
|
| 541 |
+
except ValueError:
|
| 542 |
+
return False
|
| 543 |
+
|
| 544 |
+
# Workspace-aware guards only apply when running inside a playbook clone.
|
| 545 |
+
if WORKSPACE_ROOT is not None:
|
| 546 |
+
workspace = WORKSPACE_ROOT.resolve()
|
| 547 |
+
if target == workspace:
|
| 548 |
+
problems.append(
|
| 549 |
+
f"target is the workspace root ({target}). "
|
| 550 |
+
f"cd into an asset directory or pass a path."
|
| 551 |
+
)
|
| 552 |
+
for guard in (".claude", "playbooks", "report", "_reports", "packages", "plugins"):
|
| 553 |
+
guard_path = (workspace / guard).resolve()
|
| 554 |
+
if target == guard_path or _under(target, guard_path):
|
| 555 |
+
problems.append(
|
| 556 |
+
f"target sits under <workspace>/{guard}/ ({target}). "
|
| 557 |
+
f"that's not an asset directory — pick a customer-asset folder."
|
| 558 |
+
)
|
| 559 |
+
break
|
| 560 |
+
|
| 561 |
+
# Refuse anything close to a drive root or user home — likely a stray cd.
|
| 562 |
+
if len(target.parts) <= 2:
|
| 563 |
+
problems.append(
|
| 564 |
+
f"target ({target}) is too high in the filesystem. "
|
| 565 |
+
f"cd into a specific asset directory before running /simready-report."
|
| 566 |
+
)
|
| 567 |
+
|
| 568 |
+
# Refuse if the target has no USDs at root and no USDs in immediate subfolders.
|
| 569 |
+
if target.is_dir():
|
| 570 |
+
root_usds = [p for ext in USD_EXTS for p in target.glob(f"*{ext}")]
|
| 571 |
+
sub_with_usds = 0
|
| 572 |
+
for sub in target.iterdir():
|
| 573 |
+
if not sub.is_dir() or sub.name.startswith((".", "_")):
|
| 574 |
+
continue
|
| 575 |
+
if any(sub.glob(f"*{ext}") for ext in USD_EXTS):
|
| 576 |
+
sub_with_usds += 1
|
| 577 |
+
if sub_with_usds >= 1:
|
| 578 |
+
break
|
| 579 |
+
if not root_usds and sub_with_usds == 0:
|
| 580 |
+
problems.append(
|
| 581 |
+
f"no USD files found in {target} or its immediate subfolders. "
|
| 582 |
+
f"`/simready-report` expects either a single asset directory containing "
|
| 583 |
+
f"<name>.usd, or a parent directory whose subfolders each contain "
|
| 584 |
+
f"an interface USD."
|
| 585 |
+
)
|
| 586 |
+
|
| 587 |
+
return problems
|
| 588 |
+
|
| 589 |
+
|
| 590 |
+
def discover_assets(root: Path, exclude: Path | None = None) -> list[Path]:
|
| 591 |
+
"""Return one interface USD per top-level subdirectory of root.
|
| 592 |
+
|
| 593 |
+
Per the SimReady packaging spec, each asset lives under <root>/<name>/
|
| 594 |
+
with an interface file at <root>/<name>/<name>.usd (and sublayers under
|
| 595 |
+
configuration/). We validate only the interface file — never individual
|
| 596 |
+
config layers — per BOT.md.
|
| 597 |
+
|
| 598 |
+
Heuristic: in each top-level subfolder, take the single top-level USD if
|
| 599 |
+
there is one, otherwise the one whose stem matches the folder name.
|
| 600 |
+
"""
|
| 601 |
+
found: list[Path] = []
|
| 602 |
+
for sub in sorted(root.iterdir()):
|
| 603 |
+
if not sub.is_dir() or sub.name.startswith(".") or sub.name.startswith("_"):
|
| 604 |
+
continue
|
| 605 |
+
if exclude is not None and (sub == exclude or exclude in sub.parents):
|
| 606 |
+
continue
|
| 607 |
+
if sub.name in ("validation_output", "validations", "report", "images"):
|
| 608 |
+
continue
|
| 609 |
+
usds = [p for ext in USD_EXTS for p in sub.glob(f"*{ext}")]
|
| 610 |
+
if not usds:
|
| 611 |
+
continue
|
| 612 |
+
if len(usds) == 1:
|
| 613 |
+
found.append(usds[0])
|
| 614 |
+
continue
|
| 615 |
+
named = [p for p in usds if p.stem.lower() == sub.name.lower().replace("-", "_")]
|
| 616 |
+
if named:
|
| 617 |
+
found.append(named[0])
|
| 618 |
+
else:
|
| 619 |
+
found.extend(usds)
|
| 620 |
+
return sorted(set(found))
|
| 621 |
+
|
| 622 |
+
|
| 623 |
+
# Issue rule + message patterns we drop because they're produced by the validator
|
| 624 |
+
# detecting that its own runtime is incomplete (PhysX schema not available,
|
| 625 |
+
# Kit-bundled MDL not resolvable in a pip-only venv), NOT by an asset defect.
|
| 626 |
+
# Documented in PROBLEMS.md P2.
|
| 627 |
+
_P2_PHYSX_RULES = {
|
| 628 |
+
"JointHasJointStateAPI",
|
| 629 |
+
"PhysicsDriveAndJointState",
|
| 630 |
+
"PhysicsJointHasDriveOrMimicAPI",
|
| 631 |
+
"MimicAPICheck",
|
| 632 |
+
"PhysicsJointMaxVelocity",
|
| 633 |
+
"DriveJointValueReasonable",
|
| 634 |
+
}
|
| 635 |
+
_P2_MDL_RULES = {
|
| 636 |
+
"MissingReferenceChecker",
|
| 637 |
+
"MaterialPathChecker",
|
| 638 |
+
"PathsExistChecker",
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
|
| 642 |
+
def _drop_env_blocked_issues(issues: list[dict]) -> tuple[list[dict], dict]:
|
| 643 |
+
"""LOCAL PATCH for PROBLEMS.md P2 — drop env-blocked issues.
|
| 644 |
+
|
| 645 |
+
The PhysX rules raise `"PhysxSchema is not available in this environment"`
|
| 646 |
+
once per joint when `from pxr import PhysxSchema` fails (i.e. running in
|
| 647 |
+
a pip-only venv without Kit). The MDL checkers report `OmniPBR.mdl` as
|
| 648 |
+
unresolvable for the same reason — `OmniPBR.mdl` ships with Kit, not with
|
| 649 |
+
`usd-core`. Both are environment limitations, not asset defects, and the
|
| 650 |
+
asset's own SimReady_Metadata records that these features previously
|
| 651 |
+
passed in a Kit-rooted environment.
|
| 652 |
+
|
| 653 |
+
We only drop when the rule + message pattern match precisely, so real
|
| 654 |
+
findings from these same checkers (e.g. a different unresolvable
|
| 655 |
+
reference, or a genuine path that doesn't exist) are preserved.
|
| 656 |
+
|
| 657 |
+
Returns (kept_issues, dropped_counts) where dropped_counts is a dict like
|
| 658 |
+
`{"physxschema_unavailable": int, "omnipbr_unresolved": int}`.
|
| 659 |
+
"""
|
| 660 |
+
kept: list[dict] = []
|
| 661 |
+
dropped = {"physxschema_unavailable": 0, "omnipbr_unresolved": 0}
|
| 662 |
+
for iss in issues:
|
| 663 |
+
rule = iss.get("rule") or ""
|
| 664 |
+
msg = iss.get("msg") or ""
|
| 665 |
+
if rule in _P2_PHYSX_RULES and msg.startswith("PhysxSchema is not available"):
|
| 666 |
+
dropped["physxschema_unavailable"] += 1
|
| 667 |
+
continue
|
| 668 |
+
if rule in _P2_MDL_RULES and "OmniPBR.mdl" in msg:
|
| 669 |
+
dropped["omnipbr_unresolved"] += 1
|
| 670 |
+
continue
|
| 671 |
+
kept.append(iss)
|
| 672 |
+
return kept, dropped
|
| 673 |
+
|
| 674 |
+
|
| 675 |
+
def issue_to_dict(iss) -> dict:
|
| 676 |
+
sev = SEVERITY_NAMES.get(iss.severity, str(iss.severity))
|
| 677 |
+
req = getattr(iss.requirement, "code", None) if iss.requirement else None
|
| 678 |
+
# Pull the requirement's spec-doc rel path so the dashboard can link every
|
| 679 |
+
# issue (including ones outside the loaded profile) back to the foundation
|
| 680 |
+
# markdown that defines it.
|
| 681 |
+
req_path = getattr(iss.requirement, "path", None) if iss.requirement else None
|
| 682 |
+
rule = iss.rule.__name__ if iss.rule else None
|
| 683 |
+
at = None
|
| 684 |
+
try:
|
| 685 |
+
if hasattr(iss.at, "GetPath"):
|
| 686 |
+
at = str(iss.at.GetPath())
|
| 687 |
+
except Exception:
|
| 688 |
+
pass
|
| 689 |
+
msg = iss.message if hasattr(iss, "message") else str(iss)
|
| 690 |
+
return {
|
| 691 |
+
"code": req or "UNKNOWN",
|
| 692 |
+
"path": req_path,
|
| 693 |
+
"severity": sev,
|
| 694 |
+
"msg": msg,
|
| 695 |
+
"prim": at or "/",
|
| 696 |
+
"rule": rule,
|
| 697 |
+
}
|
| 698 |
+
|
| 699 |
+
|
| 700 |
+
def compute_profile_coverage(profile_id: str, profile_version: str,
|
| 701 |
+
loaded_profile: oav.Profile) -> dict:
|
| 702 |
+
"""Compare what `profiles.toml` declares for this profile against what
|
| 703 |
+
the validator actually loaded. The validator silently drops feature
|
| 704 |
+
entries whose ID + version aren't in the registry; this helper surfaces
|
| 705 |
+
that delta so the dashboard can warn the user.
|
| 706 |
+
|
| 707 |
+
Returns:
|
| 708 |
+
{
|
| 709 |
+
"declared": int, # feature count in profiles.toml
|
| 710 |
+
"loaded": int, # feature count on `loaded_profile`
|
| 711 |
+
"missing": [{"id": str, "version": str}, ...], # declared but not loaded
|
| 712 |
+
}
|
| 713 |
+
Or an empty `{"declared": None, ...}` shape if profiles.toml can't be
|
| 714 |
+
located (best-effort — coverage display is suppressed when unknown).
|
| 715 |
+
"""
|
| 716 |
+
none_result = {"declared": None, "loaded": len(loaded_profile.features),
|
| 717 |
+
"missing": [], "specs_path": None}
|
| 718 |
+
if SPECS_DIR is None or not SPECS_DIR.is_dir():
|
| 719 |
+
return none_result
|
| 720 |
+
profiles_toml = SPECS_DIR / "nv_core" / "sr_specs" / "docs" / "profiles" / "profiles.toml"
|
| 721 |
+
if not profiles_toml.is_file():
|
| 722 |
+
return none_result
|
| 723 |
+
try:
|
| 724 |
+
with profiles_toml.open("rb") as fh:
|
| 725 |
+
data = tomllib.load(fh)
|
| 726 |
+
except Exception:
|
| 727 |
+
return none_result
|
| 728 |
+
block = data.get(profile_id, {}).get(profile_version)
|
| 729 |
+
if not block or "features" not in block:
|
| 730 |
+
return none_result
|
| 731 |
+
declared: list[dict] = []
|
| 732 |
+
for entry in block["features"]:
|
| 733 |
+
# Each entry is a single-key table: {"FET_ID": {"version": "X"}}.
|
| 734 |
+
if not isinstance(entry, dict) or len(entry) != 1:
|
| 735 |
+
continue
|
| 736 |
+
fid, meta = next(iter(entry.items()))
|
| 737 |
+
version = (meta or {}).get("version", "")
|
| 738 |
+
declared.append({"id": fid, "version": str(version)})
|
| 739 |
+
loaded_keys = {(f.id, str(f.version)) for f in loaded_profile.features}
|
| 740 |
+
missing = [d for d in declared if (d["id"], d["version"]) not in loaded_keys]
|
| 741 |
+
return {
|
| 742 |
+
"declared": len(declared),
|
| 743 |
+
"loaded": len(loaded_profile.features),
|
| 744 |
+
"missing": missing,
|
| 745 |
+
"specs_path": str(profiles_toml.relative_to(SPECS_DIR)) if SPECS_DIR else None,
|
| 746 |
+
}
|
| 747 |
+
|
| 748 |
+
|
| 749 |
+
def _load_via_cli_loader() -> None:
|
| 750 |
+
"""Populate OAV registries by file paths, the way `simready-validate` does.
|
| 751 |
+
|
| 752 |
+
Used by `--use-cli-loader`. Mirrors what
|
| 753 |
+
`simready.validate.impl.loader.load_validation_implementation` does:
|
| 754 |
+
clears the OAV registries, then loads rules/requirements (Python),
|
| 755 |
+
features (JSON/TOML), and profiles (TOML) from filesystem paths under
|
| 756 |
+
the foundations checkout. No dependency on the
|
| 757 |
+
`simready.foundation.core:SimReadyPlugin` entry point.
|
| 758 |
+
|
| 759 |
+
The loader handles JSON variant feature files natively, so the P1
|
| 760 |
+
patch is redundant when this path is active (we just skip calling it
|
| 761 |
+
from the call-site).
|
| 762 |
+
"""
|
| 763 |
+
if SPECS_DIR is None or not SPECS_DIR.is_dir():
|
| 764 |
+
raise SystemExit(
|
| 765 |
+
"ERROR: --use-cli-loader requires SIMREADY_FOUNDATIONS_PATH "
|
| 766 |
+
"(or a sibling-of-workspace simready_foundations/ checkout). "
|
| 767 |
+
"Nothing to load."
|
| 768 |
+
)
|
| 769 |
+
try:
|
| 770 |
+
from simready.validate.impl.loader import load_validation_implementation
|
| 771 |
+
except ImportError as e:
|
| 772 |
+
raise SystemExit(
|
| 773 |
+
f"ERROR: --use-cli-loader needs `simready-validate` installed in "
|
| 774 |
+
f"the active Python (the PyPI package, not just the editable "
|
| 775 |
+
f"foundations). pip install simready-validate>=2026.4.8 . "
|
| 776 |
+
f"Underlying: {e}"
|
| 777 |
+
)
|
| 778 |
+
rules_path = SPECS_DIR / "nv_core" / "sr_specs" / "docs" / "capabilities"
|
| 779 |
+
features_path = SPECS_DIR / "nv_core" / "sr_specs" / "docs" / "features"
|
| 780 |
+
profiles_toml = SPECS_DIR / "nv_core" / "sr_specs" / "docs" / "profiles" / "profiles.toml"
|
| 781 |
+
for p, label in ((rules_path, "rules"), (features_path, "features"), (profiles_toml, "profiles.toml")):
|
| 782 |
+
if not p.exists():
|
| 783 |
+
raise SystemExit(f"ERROR: --use-cli-loader: {label} path not found at {p}")
|
| 784 |
+
print(f" [CLI-loader] rules={rules_path}", flush=True)
|
| 785 |
+
print(f" [CLI-loader] features={features_path}", flush=True)
|
| 786 |
+
print(f" [CLI-loader] profiles={profiles_toml}", flush=True)
|
| 787 |
+
load_validation_implementation(
|
| 788 |
+
rules_and_requirements_paths=[rules_path],
|
| 789 |
+
features_paths=[features_path],
|
| 790 |
+
profiles_paths=[profiles_toml],
|
| 791 |
+
)
|
| 792 |
+
pr = oav.ProfileRegistry()
|
| 793 |
+
# The loader creates Profile objects with `.capabilities`; the rest of
|
| 794 |
+
# validate.py expects `.features` (the older OAV plugin API). Alias so
|
| 795 |
+
# the call sites keep working without a sweeping rename. Profile is a
|
| 796 |
+
# frozen dataclass, so bypass via object.__setattr__.
|
| 797 |
+
for profile in pr.values():
|
| 798 |
+
if not hasattr(profile, "features") and hasattr(profile, "capabilities"):
|
| 799 |
+
object.__setattr__(profile, "features", profile.capabilities)
|
| 800 |
+
cr = oav.CapabilityRegistry()
|
| 801 |
+
print(f" [CLI-loader] loaded: profiles={len(pr)} capabilities={len(cr)}", flush=True)
|
| 802 |
+
|
| 803 |
+
|
| 804 |
+
def build_engine(profile_id: str, profile_version: str) -> tuple[oav.ValidationEngine, oav.Profile]:
|
| 805 |
+
pr = oav.ProfileRegistry()
|
| 806 |
+
profile = pr.find(profile_id, profile_version)
|
| 807 |
+
if profile is None:
|
| 808 |
+
raise SystemExit(f"FATAL: profile {profile_id} v{profile_version} not registered")
|
| 809 |
+
|
| 810 |
+
# Engine runs every registered rule regardless of enable_*/disable_* state
|
| 811 |
+
# (those only adjust counters), so don't bother filtering the engine. We
|
| 812 |
+
# scope to the profile by filtering issues against profile requirements
|
| 813 |
+
# in validate_one / derive_features.
|
| 814 |
+
engine = oav.ValidationEngine(init_rules=True, variants=False)
|
| 815 |
+
return engine, profile
|
| 816 |
+
|
| 817 |
+
|
| 818 |
+
# ---- Parallel-validation worker plumbing ---------------------------------
|
| 819 |
+
# Each worker process initializes its own engine + profile once via
|
| 820 |
+
# `_pool_init`, then handles N tasks via `_pool_validate`. The engine isn't
|
| 821 |
+
# picklable so we can't share a single one across processes; instead the
|
| 822 |
+
# init cost is amortized over multiple assets per worker.
|
| 823 |
+
|
| 824 |
+
_pool_engine: oav.ValidationEngine | None = None
|
| 825 |
+
_pool_profile: oav.Profile | None = None
|
| 826 |
+
|
| 827 |
+
|
| 828 |
+
def _pool_init(profile_id: str, profile_version: str, use_plugin: bool = False) -> None:
|
| 829 |
+
global _pool_engine, _pool_profile
|
| 830 |
+
if use_plugin:
|
| 831 |
+
_patch_register_json_variant_features()
|
| 832 |
+
else:
|
| 833 |
+
_load_via_cli_loader()
|
| 834 |
+
_pool_engine, _pool_profile = build_engine(profile_id, profile_version)
|
| 835 |
+
|
| 836 |
+
|
| 837 |
+
def _pool_validate(asset_path_str: str, target_root_str: str,
|
| 838 |
+
profile_id: str, profile_version: str) -> tuple[dict, dict, list]:
|
| 839 |
+
"""Validate one asset in a worker process.
|
| 840 |
+
|
| 841 |
+
Returns: (result_dict, external_records_by_identifier, issue_records).
|
| 842 |
+
The tracker pieces are returned (not the live tracker) so they can be
|
| 843 |
+
merged into the main process's tracker without sharing state.
|
| 844 |
+
"""
|
| 845 |
+
asset = Path(asset_path_str)
|
| 846 |
+
target_root = Path(target_root_str)
|
| 847 |
+
tracker = external_deps.ExternalDepsTracker(target_root=target_root)
|
| 848 |
+
try:
|
| 849 |
+
result = validate_one(_pool_engine, asset, target_root, _pool_profile,
|
| 850 |
+
profile_id, profile_version, tracker)
|
| 851 |
+
except Exception as e:
|
| 852 |
+
rel = asset.relative_to(target_root) if asset.is_relative_to(target_root) else asset
|
| 853 |
+
tracker.record_issue(str(asset), f"Validator crashed: {type(e).__name__}: {e}")
|
| 854 |
+
result = {
|
| 855 |
+
"name": asset.name, "path": str(asset),
|
| 856 |
+
"rel_path": str(rel),
|
| 857 |
+
"profile": profile_id, "profile_version": profile_version,
|
| 858 |
+
"passed": False,
|
| 859 |
+
"issues": [{"code": "SDK.CRASH", "severity": "error",
|
| 860 |
+
"msg": f"{type(e).__name__}: {e}", "prim": "/", "rule": None}],
|
| 861 |
+
"passed_features": [], "failed_features": [], "affected_prims": [],
|
| 862 |
+
}
|
| 863 |
+
return result, dict(tracker.external), list(tracker.issues)
|
| 864 |
+
|
| 865 |
+
|
| 866 |
+
def _merge_tracker(dst: external_deps.ExternalDepsTracker,
|
| 867 |
+
ext_records: dict, issues: list) -> None:
|
| 868 |
+
"""Fold a worker's tracker state into the main tracker."""
|
| 869 |
+
for key, rec in ext_records.items():
|
| 870 |
+
existing = dst.external.get(key)
|
| 871 |
+
if existing is None:
|
| 872 |
+
dst.external[key] = rec
|
| 873 |
+
else:
|
| 874 |
+
for ref in rec.referenced_by:
|
| 875 |
+
if ref not in existing.referenced_by:
|
| 876 |
+
existing.referenced_by.append(ref)
|
| 877 |
+
for action in rec.actions:
|
| 878 |
+
if action not in existing.actions:
|
| 879 |
+
existing.actions.append(action)
|
| 880 |
+
dst.issues.extend(issues)
|
| 881 |
+
|
| 882 |
+
|
| 883 |
+
def validate_one(engine: oav.ValidationEngine, asset_path: Path, target_root: Path,
|
| 884 |
+
profile: oav.Profile, profile_id: str, profile_version: str,
|
| 885 |
+
ext_tracker: external_deps.ExternalDepsTracker) -> dict:
|
| 886 |
+
rel = asset_path.relative_to(target_root) if asset_path.is_relative_to(target_root) else asset_path
|
| 887 |
+
name = asset_path.name
|
| 888 |
+
print(f" validate: {rel}", flush=True)
|
| 889 |
+
|
| 890 |
+
stage = Usd.Stage.Open(str(asset_path))
|
| 891 |
+
if not stage:
|
| 892 |
+
ext_tracker.record_issue(str(asset_path), "Failed to open USD stage")
|
| 893 |
+
return {
|
| 894 |
+
"name": name,
|
| 895 |
+
"path": str(asset_path),
|
| 896 |
+
"rel_path": str(rel),
|
| 897 |
+
"profile": profile_id,
|
| 898 |
+
"profile_version": profile_version,
|
| 899 |
+
"passed": False,
|
| 900 |
+
"issues": [{"code": "SDK.STAGE_OPEN", "severity": "error",
|
| 901 |
+
"msg": f"Failed to open USD stage: {asset_path}", "prim": "/", "rule": None}],
|
| 902 |
+
"passed_features": [],
|
| 903 |
+
"failed_features": [],
|
| 904 |
+
"affected_prims": [],
|
| 905 |
+
}
|
| 906 |
+
|
| 907 |
+
ext_tracker.scan_stage(stage, asset_path, target_root)
|
| 908 |
+
|
| 909 |
+
profile_codes = {r.code for f in profile.features for r in f.requirements}
|
| 910 |
+
|
| 911 |
+
oav_results = engine.validate(stage)
|
| 912 |
+
issues = [issue_to_dict(i) for i in oav_results.issues()]
|
| 913 |
+
issues, env_blocked = _drop_env_blocked_issues(issues)
|
| 914 |
+
if any(env_blocked.values()):
|
| 915 |
+
parts = [f"{n} {k}" for k, n in env_blocked.items() if n]
|
| 916 |
+
print(f" [PATCH P2] env-blocked: dropped " + ", ".join(parts), flush=True)
|
| 917 |
+
all_failures = [i for i in issues if i["severity"] in ("failure", "error")]
|
| 918 |
+
profile_failures = [i for i in all_failures if i["code"] in profile_codes]
|
| 919 |
+
extra_failures = [i for i in all_failures if i["code"] not in profile_codes]
|
| 920 |
+
# An asset passes only if no profile requirement fails. Issues from rules
|
| 921 |
+
# outside the loaded profile (per deviations.md #3) are reported separately
|
| 922 |
+
# but don't drive the pass/fail flag.
|
| 923 |
+
passed = not profile_failures
|
| 924 |
+
|
| 925 |
+
failed_codes = sorted({i["code"] for i in profile_failures})
|
| 926 |
+
passed_features, failed_features = derive_features(profile, failed_codes)
|
| 927 |
+
|
| 928 |
+
# Bucket extra failures by code -> example message + count + spec path,
|
| 929 |
+
# for the "outside-profile" panel in the report. The path lets the
|
| 930 |
+
# dashboard link each code back to its foundation markdown.
|
| 931 |
+
extra_by_code: dict[str, dict] = {}
|
| 932 |
+
for i in extra_failures:
|
| 933 |
+
bucket = extra_by_code.setdefault(i["code"], {
|
| 934 |
+
"code": i["code"],
|
| 935 |
+
"msg": i["msg"],
|
| 936 |
+
"path": i.get("path"),
|
| 937 |
+
"count": 0,
|
| 938 |
+
})
|
| 939 |
+
bucket["count"] += 1
|
| 940 |
+
extra_failures_summary = sorted(extra_by_code.values(), key=lambda x: -x["count"])
|
| 941 |
+
|
| 942 |
+
affected_prims = sorted({i["prim"] for i in all_failures if i["prim"] and i["prim"] != "/"})
|
| 943 |
+
|
| 944 |
+
return {
|
| 945 |
+
"name": name,
|
| 946 |
+
"path": str(asset_path),
|
| 947 |
+
"rel_path": str(rel),
|
| 948 |
+
"profile": profile_id,
|
| 949 |
+
"profile_version": profile_version,
|
| 950 |
+
"passed": passed,
|
| 951 |
+
"issues": issues,
|
| 952 |
+
"passed_features": passed_features,
|
| 953 |
+
"failed_features": failed_features,
|
| 954 |
+
"extra_failures": extra_failures_summary,
|
| 955 |
+
"affected_prims": affected_prims,
|
| 956 |
+
"env_blocked": env_blocked,
|
| 957 |
+
}
|
| 958 |
+
|
| 959 |
+
|
| 960 |
+
def derive_features(profile: oav.Profile, failed_codes: list[str]) -> tuple[list[dict], list[dict]]:
|
| 961 |
+
"""Map profile features to passed/failed feature entries based on requirement codes."""
|
| 962 |
+
passed: list[dict] = []
|
| 963 |
+
failed: list[dict] = []
|
| 964 |
+
for feat in profile.features:
|
| 965 |
+
feat_path = getattr(feat, "path", None)
|
| 966 |
+
req_path_by_code = {r.code: getattr(r, "path", None) for r in feat.requirements}
|
| 967 |
+
feat_codes = sorted(req_path_by_code.keys())
|
| 968 |
+
feat_failed = [c for c in feat_codes if c in failed_codes]
|
| 969 |
+
entry = {
|
| 970 |
+
"id": feat.id,
|
| 971 |
+
"version": str(feat.version),
|
| 972 |
+
"path": feat_path,
|
| 973 |
+
"requirement_codes": feat_codes,
|
| 974 |
+
"requirement_paths": req_path_by_code,
|
| 975 |
+
}
|
| 976 |
+
if feat_failed:
|
| 977 |
+
entry["failed_codes"] = feat_failed
|
| 978 |
+
failed.append(entry)
|
| 979 |
+
else:
|
| 980 |
+
passed.append(entry)
|
| 981 |
+
return passed, failed
|
| 982 |
+
|
| 983 |
+
|
| 984 |
+
def main() -> int:
|
| 985 |
+
ap = argparse.ArgumentParser(description="Validate SimReady customer assets in a directory.")
|
| 986 |
+
ap.add_argument("target", nargs="?", default=None,
|
| 987 |
+
help=f"Directory containing customer assets (default: {DEFAULT_TARGET_DIR})")
|
| 988 |
+
ap.add_argument("--profile", default="Robot-Body-Runnable", help="Profile ID (default: Robot-Body-Runnable)")
|
| 989 |
+
ap.add_argument("--version", default="1.0.0", help="Profile version (default: 1.0.0)")
|
| 990 |
+
ap.add_argument("--output", default=None,
|
| 991 |
+
help="Output dir (default: <target>/.reports/<target-name>.<profile>)")
|
| 992 |
+
ap.add_argument("--workers", type=int, default=0,
|
| 993 |
+
help="Number of parallel worker processes. 0=auto "
|
| 994 |
+
"(min(cpu_count, asset_count, 8)); 1=sequential.")
|
| 995 |
+
ap.add_argument("--list-profiles", action="store_true",
|
| 996 |
+
help="Print every registered profile (one `PROFILE: <id> v<version>` line each), "
|
| 997 |
+
"then exit. Used by /simready-report to drive the spec selector before validating.")
|
| 998 |
+
ap.add_argument("--use-kit", action=argparse.BooleanOptionalAction, default=None,
|
| 999 |
+
help="Re-exec this script through a Kit-rooted Python (Isaac Sim 6) via "
|
| 1000 |
+
"_kit_wrapper.py so `pxr.PhysxSchema` and Kit's MDL search path are "
|
| 1001 |
+
"registered for real, instead of the pip-only venv falling back to "
|
| 1002 |
+
"the P2 filter. Adds ~10-30s of SimulationApp boot to the main process "
|
| 1003 |
+
"(plus per worker, so prefer --workers 1 in Kit mode). Default: auto-enabled "
|
| 1004 |
+
"for PhysX-bearing profiles (Robot-Body-Physx/Isaac/Runnable, "
|
| 1005 |
+
"Prop-Robotics-Physx/Isaac); pass --no-use-kit to override. "
|
| 1006 |
+
"See PROBLEMS.md P2.")
|
| 1007 |
+
ap.add_argument("--kit-python", default=None,
|
| 1008 |
+
help="Path to the Kit-rooted python.bat used by --use-kit. "
|
| 1009 |
+
"Defaults to $SIMREADY_KIT_PYTHON or "
|
| 1010 |
+
r"C:\isaacsim6\_build\windows-x86_64\release\python.bat.")
|
| 1011 |
+
ap.add_argument("--use-plugin", action="store_true",
|
| 1012 |
+
help="Fall back to loading rules/features/profiles via the "
|
| 1013 |
+
"simready.foundation.core:SimReadyPlugin entry-point (the "
|
| 1014 |
+
"pre-GitHub-migration flow). Requires the editable install "
|
| 1015 |
+
"of nv_core/sr_specs in the active Python. Default behavior "
|
| 1016 |
+
"is to load from on-disk paths via "
|
| 1017 |
+
"simready.validate.impl.loader.load_validation_implementation, "
|
| 1018 |
+
"matching the official simready-validate CLI. See PROBLEMS.md P2.")
|
| 1019 |
+
args = ap.parse_args()
|
| 1020 |
+
|
| 1021 |
+
# Auto-enable --use-kit for profiles that pull in PhysX features. The
|
| 1022 |
+
# pip-only path's P2 filter silently drops physxschema_unavailable /
|
| 1023 |
+
# omnipbr_unresolved findings, which makes "passed" misleading for these
|
| 1024 |
+
# profiles — the rules that matter never actually ran. The same auto
|
| 1025 |
+
# logic runs earlier in _maybe_reexec_under_kit() so this just keeps
|
| 1026 |
+
# args.use_kit truthful for the rest of main() (e.g. logging, worker
|
| 1027 |
+
# forcing). _PHYSX_PROFILES is the module-level source of truth.
|
| 1028 |
+
if args.use_kit is None:
|
| 1029 |
+
args.use_kit = args.profile in _PHYSX_PROFILES
|
| 1030 |
+
|
| 1031 |
+
# Default path: populate the OAV registries via the CLI loader. The
|
| 1032 |
+
# legacy --use-plugin route keeps the SimReadyPlugin entry-point as a
|
| 1033 |
+
# fallback, in which case we also run the P1 JSON-variant patch on top
|
| 1034 |
+
# (the loader makes P1 redundant on the default path).
|
| 1035 |
+
if not args.use_plugin:
|
| 1036 |
+
_load_via_cli_loader()
|
| 1037 |
+
else:
|
| 1038 |
+
_patch_register_json_variant_features()
|
| 1039 |
+
|
| 1040 |
+
if args.list_profiles:
|
| 1041 |
+
pr = oav.ProfileRegistry()
|
| 1042 |
+
profiles = sorted(pr.values(), key=lambda p: (p.id, str(p.version)))
|
| 1043 |
+
for p in profiles:
|
| 1044 |
+
print(f"PROFILE: {p.id} v{p.version}", flush=True)
|
| 1045 |
+
return 0
|
| 1046 |
+
|
| 1047 |
+
target = resolve_target(args.target)
|
| 1048 |
+
if target is None:
|
| 1049 |
+
return 2
|
| 1050 |
+
if not target.is_dir():
|
| 1051 |
+
print(f"ERROR: target dir does not exist: {target}", flush=True)
|
| 1052 |
+
return 2
|
| 1053 |
+
|
| 1054 |
+
problems = sanity_check_target(target)
|
| 1055 |
+
if problems:
|
| 1056 |
+
print(f"ERROR: target {target} failed sanity check; nothing to validate.", flush=True)
|
| 1057 |
+
for p in problems:
|
| 1058 |
+
print(f" - {p}", flush=True)
|
| 1059 |
+
if args.target is None:
|
| 1060 |
+
print(" hint: /simready-report inferred the current working directory. "
|
| 1061 |
+
"cd into an asset directory or pass an explicit path.", flush=True)
|
| 1062 |
+
return 2
|
| 1063 |
+
|
| 1064 |
+
# Capture the pre-packaging target so the default output lands where
|
| 1065 |
+
# the user pointed (inside their asset dir), not under packages/.
|
| 1066 |
+
original_target = target
|
| 1067 |
+
# If the user pointed at raw assets under assets_to_validate/, auto-package
|
| 1068 |
+
# into packages/<name>/ first and validate the packaged tree.
|
| 1069 |
+
target = maybe_package_target(target)
|
| 1070 |
+
|
| 1071 |
+
# Default output: <original_target>/.reports/<name>.<profile>/. The
|
| 1072 |
+
# `.reports/` prefix is auto-skipped by discover_assets so re-running
|
| 1073 |
+
# /simready-report from inside the target is safe. The `.` separator
|
| 1074 |
+
# between target name and profile id is unambiguous because asset bundle
|
| 1075 |
+
# names use snake_case (no dots) and profile ids use Title-Case-Hyphens
|
| 1076 |
+
# (no dots either). No PASS/FAIL suffix — status belongs inside the report.
|
| 1077 |
+
default_out_name = f"{original_target.name}.{args.profile}"
|
| 1078 |
+
out_dir = Path(args.output).resolve() if args.output else original_target / ".reports" / default_out_name
|
| 1079 |
+
|
| 1080 |
+
if SPECS_DIR is None or not SPECS_DIR.is_dir():
|
| 1081 |
+
print(
|
| 1082 |
+
"WARNING: spec source not found"
|
| 1083 |
+
+ (f" at {SPECS_DIR}" if SPECS_DIR else "")
|
| 1084 |
+
+ ". Set SIMREADY_FOUNDATIONS_PATH so doc rendering can locate the markdown source.",
|
| 1085 |
+
flush=True,
|
| 1086 |
+
)
|
| 1087 |
+
|
| 1088 |
+
print(f"Target: {target}", flush=True)
|
| 1089 |
+
print(f"Output: {out_dir}", flush=True)
|
| 1090 |
+
print(f"Profile: {args.profile} v{args.version}", flush=True)
|
| 1091 |
+
|
| 1092 |
+
assets = discover_assets(target, exclude=out_dir)
|
| 1093 |
+
print(f"Discovered {len(assets)} USD asset(s) under {target}", flush=True)
|
| 1094 |
+
if not assets:
|
| 1095 |
+
print(f"ERROR: no USD assets (.usd/.usda/.usdc/.usdz) found under {target}. "
|
| 1096 |
+
f"Add assets and re-run.", flush=True)
|
| 1097 |
+
return 2
|
| 1098 |
+
|
| 1099 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 1100 |
+
|
| 1101 |
+
# Resolve worker count. Default: cap at cpu_count, asset count, and 8 (each
|
| 1102 |
+
# worker re-imports the validator stack at startup, so wider isn't always
|
| 1103 |
+
# faster).
|
| 1104 |
+
if args.workers <= 0:
|
| 1105 |
+
workers = max(1, min(os.cpu_count() or 1, len(assets), 8))
|
| 1106 |
+
else:
|
| 1107 |
+
workers = args.workers
|
| 1108 |
+
# In Kit mode each worker would have to re-boot SimulationApp (10-30 s of
|
| 1109 |
+
# Kit startup per worker — net slowdown). Force sequential.
|
| 1110 |
+
if os.environ.get("SIMREADY_INSIDE_KIT") == "1" and workers > 1:
|
| 1111 |
+
print(f" --use-kit: forcing workers=1 (Kit-rooted runs avoid per-worker SimulationApp boot)", flush=True)
|
| 1112 |
+
workers = 1
|
| 1113 |
+
print(f"Workers: {workers} ({'parallel' if workers > 1 else 'sequential'})", flush=True)
|
| 1114 |
+
|
| 1115 |
+
ext_tracker = external_deps.ExternalDepsTracker(target_root=target)
|
| 1116 |
+
results: list[dict] = []
|
| 1117 |
+
|
| 1118 |
+
if workers <= 1 or len(assets) <= 1:
|
| 1119 |
+
# Sequential path. One engine, all assets.
|
| 1120 |
+
engine, profile = build_engine(args.profile, args.version)
|
| 1121 |
+
profile_req_count = sum(len(f.requirements) for f in profile.features)
|
| 1122 |
+
print(f" profile features: {len(profile.features)}", flush=True)
|
| 1123 |
+
print(f" profile requirements: {profile_req_count}", flush=True)
|
| 1124 |
+
for asset in assets:
|
| 1125 |
+
try:
|
| 1126 |
+
results.append(validate_one(engine, asset, target, profile, args.profile, args.version, ext_tracker))
|
| 1127 |
+
except Exception as e:
|
| 1128 |
+
log.exception("Validation failed for %s", asset)
|
| 1129 |
+
ext_tracker.record_issue(str(asset), f"Validator crashed: {type(e).__name__}: {e}")
|
| 1130 |
+
results.append({
|
| 1131 |
+
"name": asset.name, "path": str(asset),
|
| 1132 |
+
"rel_path": str(asset.relative_to(target)) if asset.is_relative_to(target) else str(asset),
|
| 1133 |
+
"profile": args.profile, "profile_version": args.version,
|
| 1134 |
+
"passed": False,
|
| 1135 |
+
"issues": [{"code": "SDK.CRASH", "severity": "error", "msg": str(e), "prim": "/", "rule": None}],
|
| 1136 |
+
"passed_features": [], "failed_features": [], "affected_prims": [],
|
| 1137 |
+
})
|
| 1138 |
+
else:
|
| 1139 |
+
# Parallel path. Each worker initializes its own engine via _pool_init.
|
| 1140 |
+
# Profile metadata is logged from the main process (also constructs an
|
| 1141 |
+
# engine here just for the print; cheap relative to parallel speedup).
|
| 1142 |
+
_, profile = build_engine(args.profile, args.version)
|
| 1143 |
+
profile_req_count = sum(len(f.requirements) for f in profile.features)
|
| 1144 |
+
print(f" profile features: {len(profile.features)}", flush=True)
|
| 1145 |
+
print(f" profile requirements: {profile_req_count}", flush=True)
|
| 1146 |
+
with ProcessPoolExecutor(
|
| 1147 |
+
max_workers=workers,
|
| 1148 |
+
initializer=_pool_init,
|
| 1149 |
+
initargs=(args.profile, args.version, args.use_plugin),
|
| 1150 |
+
) as ex:
|
| 1151 |
+
futures = {
|
| 1152 |
+
ex.submit(_pool_validate, str(a), str(target), args.profile, args.version): a
|
| 1153 |
+
for a in assets
|
| 1154 |
+
}
|
| 1155 |
+
for fut in as_completed(futures):
|
| 1156 |
+
asset = futures[fut]
|
| 1157 |
+
try:
|
| 1158 |
+
result, ext_records, issues = fut.result()
|
| 1159 |
+
except Exception as e:
|
| 1160 |
+
log.exception("Worker failed for %s", asset)
|
| 1161 |
+
ext_tracker.record_issue(str(asset), f"Worker crashed: {type(e).__name__}: {e}")
|
| 1162 |
+
results.append({
|
| 1163 |
+
"name": asset.name, "path": str(asset),
|
| 1164 |
+
"rel_path": str(asset.relative_to(target)) if asset.is_relative_to(target) else str(asset),
|
| 1165 |
+
"profile": args.profile, "profile_version": args.version,
|
| 1166 |
+
"passed": False,
|
| 1167 |
+
"issues": [{"code": "SDK.WORKER_CRASH", "severity": "error",
|
| 1168 |
+
"msg": str(e), "prim": "/", "rule": None}],
|
| 1169 |
+
"passed_features": [], "failed_features": [], "affected_prims": [],
|
| 1170 |
+
})
|
| 1171 |
+
continue
|
| 1172 |
+
results.append(result)
|
| 1173 |
+
_merge_tracker(ext_tracker, ext_records, issues)
|
| 1174 |
+
# Sort results to match the assets list order (parallel completion is
|
| 1175 |
+
# arbitrary; stable order makes the dashboard reproducible).
|
| 1176 |
+
order = {str(a): i for i, a in enumerate(assets)}
|
| 1177 |
+
results.sort(key=lambda r: order.get(r["path"], len(order)))
|
| 1178 |
+
|
| 1179 |
+
# Mirror per-asset thumbnails from <asset_dir>/.thumbs/256x256/<filename>.png
|
| 1180 |
+
# into <out_dir>/images/<filename>.png so the dashboard can reference them
|
| 1181 |
+
# via a relative path (matches the reference dashboard's `images/` layout).
|
| 1182 |
+
# The source PNGs were sourced externally (e.g. NVIDIA Isaac content
|
| 1183 |
+
# bucket) and are not produced by the packaging pipeline — `thumbnail_provenance`
|
| 1184 |
+
# records each so the dashboard can surface that fact.
|
| 1185 |
+
images_dir = out_dir / "images"
|
| 1186 |
+
images_dir.mkdir(parents=True, exist_ok=True)
|
| 1187 |
+
thumbnail_provenance: list[dict] = []
|
| 1188 |
+
import shutil as _shutil
|
| 1189 |
+
for r in results:
|
| 1190 |
+
usd_path = Path(r["path"])
|
| 1191 |
+
spec_thumb = usd_path.parent / ".thumbs" / "256x256" / f"{usd_path.name}.png"
|
| 1192 |
+
local_thumb = images_dir / f"{usd_path.name}.png"
|
| 1193 |
+
rel = f"images/{usd_path.name}.png"
|
| 1194 |
+
entry = {"asset": r["name"], "rel": rel}
|
| 1195 |
+
if spec_thumb.is_file():
|
| 1196 |
+
try:
|
| 1197 |
+
_shutil.copy2(spec_thumb, local_thumb)
|
| 1198 |
+
entry.update({"present": True, "copied_from": str(spec_thumb)})
|
| 1199 |
+
except Exception as e:
|
| 1200 |
+
entry.update({"present": False, "error": f"{type(e).__name__}: {e}"})
|
| 1201 |
+
else:
|
| 1202 |
+
entry["present"] = False
|
| 1203 |
+
thumbnail_provenance.append(entry)
|
| 1204 |
+
|
| 1205 |
+
specs_docs_dir = (SPECS_DIR / "nv_core" / "sr_specs" / "docs") if (SPECS_DIR and SPECS_DIR.is_dir()) else None
|
| 1206 |
+
dashboard_docs_dir = DASHBOARD_DOCS_DIR if (DASHBOARD_DOCS_DIR and DASHBOARD_DOCS_DIR.is_dir()) else None
|
| 1207 |
+
coverage = compute_profile_coverage(args.profile, args.version, profile)
|
| 1208 |
+
if coverage["declared"] is not None and coverage["missing"]:
|
| 1209 |
+
print(f" profile coverage: {coverage['loaded']}/{coverage['declared']} features loaded "
|
| 1210 |
+
f"({len(coverage['missing'])} silently dropped — see PROBLEMS.md P1)", flush=True)
|
| 1211 |
+
(out_dir / "results.json").write_text(json.dumps({
|
| 1212 |
+
"generated": datetime.now(timezone.utc).isoformat(),
|
| 1213 |
+
"target": str(target),
|
| 1214 |
+
"profile": args.profile,
|
| 1215 |
+
"profile_version": args.version,
|
| 1216 |
+
"specs_docs_dir": str(specs_docs_dir) if specs_docs_dir else None,
|
| 1217 |
+
"dashboard_docs_dir": str(dashboard_docs_dir) if dashboard_docs_dir else None,
|
| 1218 |
+
"profile_coverage": coverage,
|
| 1219 |
+
"thumbnail_provenance": thumbnail_provenance,
|
| 1220 |
+
"results": results,
|
| 1221 |
+
}, indent=2), encoding="utf-8")
|
| 1222 |
+
|
| 1223 |
+
report.write_dashboard(out_dir, target, args.profile, args.version, results,
|
| 1224 |
+
specs_docs_dir=specs_docs_dir,
|
| 1225 |
+
dashboard_docs_dir=dashboard_docs_dir,
|
| 1226 |
+
thumbnail_provenance=thumbnail_provenance,
|
| 1227 |
+
profile_coverage=coverage)
|
| 1228 |
+
ext_tracker.write_reports(out_dir)
|
| 1229 |
+
|
| 1230 |
+
summary = {
|
| 1231 |
+
"total": len(results),
|
| 1232 |
+
"passed": sum(1 for r in results if r["passed"]),
|
| 1233 |
+
"failed": sum(1 for r in results if not r["passed"]),
|
| 1234 |
+
"issues": sum(len(r["issues"]) for r in results),
|
| 1235 |
+
"external": ext_tracker.external_count(),
|
| 1236 |
+
}
|
| 1237 |
+
print(f"\nReport: {out_dir / 'index.html'}", flush=True)
|
| 1238 |
+
print(f"SUMMARY: {summary}", flush=True)
|
| 1239 |
+
return 0
|
| 1240 |
+
|
| 1241 |
+
|
| 1242 |
+
if __name__ == "__main__":
|
| 1243 |
+
sys.exit(main())
|