loginowskid commited on
Commit
cd53438
·
verified ·
1 Parent(s): dc49c4d

Sync from simready-oem-library-pm@c858e9dd

Browse files
Dockerfile CHANGED
@@ -1,112 +1,112 @@
1
- # SimReady Validator — HuggingFace Space image
2
- #
3
- # Base: nvcr.io/nvidia/isaac-sim:4.5.0. Ships Kit at /isaac-sim/ so
4
- # runner._kit_available() auto-selects --use-kit and the validator
5
- # runs PhysX / MDL rules without a code change.
6
- #
7
- # UID 1000 is required by HF Spaces. The Isaac Sim base already has
8
- # a user at UID 1000 (varies between releases — ubuntu / kit / etc.),
9
- # so we REUSE it rather than create one. /app is the workspace dir
10
- # with HOME=/app so Claude Code CLI finds skills under ~/.claude/skills
11
- # regardless of the existing user's name.
12
-
13
- FROM nvcr.io/nvidia/isaac-sim:4.5.0
14
-
15
- # Skip interactive prompts during apt-get (the deadsnakes PPA install
16
- # pulls tzdata in as a dep, which otherwise blocks the build asking
17
- # for a geographic area).
18
- ENV DEBIAN_FRONTEND=noninteractive
19
- ENV TZ=Etc/UTC
20
-
21
- # System deps (Ubuntu 22.04). Isaac Sim base may ship apt/pip configs
22
- # pointing at NVIDIA-internal mirrors (urm.nvidia.com) that HF Spaces'
23
- # build infra can't reach — wipe those before any install. The base
24
- # default Python is 3.10; simready-validate requires >=3.11, so we
25
- # install python3.11 from the deadsnakes PPA and use it explicitly
26
- # for everything below.
27
- RUN apt-get update && apt-get install -y --no-install-recommends \
28
- git curl ca-certificates rsync gnupg software-properties-common \
29
- && add-apt-repository -y ppa:deadsnakes/ppa \
30
- && apt-get update && apt-get install -y --no-install-recommends \
31
- python3.11 python3.11-venv python3.11-distutils \
32
- && curl -fsSL https://bootstrap.pypa.io/get-pip.py | python3.11 \
33
- && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
34
- && apt-get install -y --no-install-recommends nodejs \
35
- && rm -rf /var/lib/apt/lists/*
36
-
37
- RUN npm config set registry https://registry.npmjs.org/ \
38
- && rm -f /root/.npmrc /etc/npmrc \
39
- && rm -f /root/.pip/pip.conf /etc/pip.conf
40
-
41
- # Claude Code CLI.
42
- RUN npm install -g --registry=https://registry.npmjs.org/ \
43
- @anthropic-ai/claude-code@latest
44
-
45
- # Workspace nesting matches the legacy layout (/home/appuser/app)
46
- # because runner.py uses Path(__file__).resolve().parents[2] to find
47
- # tools/spec_sync/state.json — needs at least 3 levels of parent.
48
- # We don't create the appuser account (UID 1000 already exists in the
49
- # base image with a different name); we just use the path. HOME points
50
- # at the workspace's parent so Claude Code CLI finds ~/.claude/skills.
51
- RUN mkdir -p /home/appuser/app /home/appuser/.claude/skills
52
- WORKDIR /home/appuser/app
53
- ENV HOME=/home/appuser
54
-
55
- # Python deps (system Python, public PyPI). --break-system-packages
56
- # because Ubuntu 22.04 marks system Python as PEP-668 externally-
57
- # managed; on a build-once container image that's the right trade-off.
58
- COPY tools/hf_space/requirements.txt ./requirements.txt
59
- RUN python3.11 -m pip install --no-cache-dir --break-system-packages \
60
- --index-url https://pypi.org/simple/ \
61
- -r requirements.txt
62
-
63
- # Foundation specs.
64
- ENV SIMREADY_FOUNDATIONS_PATH=/opt/simready_foundations
65
- ENV SIMREADY_FOUNDATIONS_COMMIT=805d2c50179a9878c89b0f41baaa0ecafe47c3d7
66
- RUN git clone https://github.com/NVIDIA/simready-foundation \
67
- ${SIMREADY_FOUNDATIONS_PATH} \
68
- && cd ${SIMREADY_FOUNDATIONS_PATH} \
69
- && git checkout ${SIMREADY_FOUNDATIONS_COMMIT}
70
-
71
- # Vendored simready-foundation-core wheel.
72
- COPY tools/hf_space/wheels/simready_foundation_core-0.2.0a1-py3-none-any.whl /tmp/
73
- RUN python3.11 -m pip install --no-cache-dir --break-system-packages \
74
- --index-url https://pypi.org/simple/ \
75
- /tmp/simready_foundation_core-0.2.0a1-py3-none-any.whl \
76
- && rm /tmp/simready_foundation_core-0.2.0a1-py3-none-any.whl
77
-
78
- # Validator + Space code.
79
- COPY tools/validation /home/appuser/app/tools/validation
80
- COPY tools/hf_space/app.py ./app.py
81
- COPY tools/hf_space/runner.py ./runner.py
82
- COPY tools/hf_space/github_issues.py ./github_issues.py
83
- COPY tools/hf_space/agentic_issues.py ./agentic_issues.py
84
- COPY README.md ./README.md
85
- COPY VALIDATE.md ./VALIDATE.md
86
-
87
- # Claude Code skills (HOME=/home/appuser → ~/.claude/skills).
88
- COPY tools/hf_space/skills/simready-validator-agent /home/appuser/.claude/skills/simready-validator-agent
89
- COPY tools/validation/plugins/simready-report/skills/simready-report /home/appuser/.claude/skills/simready-report
90
-
91
- # UID 1000 owns the workspace + foundation clone.
92
- RUN chown -R 1000:1000 /home/appuser ${SIMREADY_FOUNDATIONS_PATH}
93
-
94
- # runner._kit_available() probes /isaac-sim/python.sh — present in the
95
- # base image — so the validator auto-selects --use-kit at runtime.
96
- # The validator's --use-kit path needs to know WHERE Kit's python lives;
97
- # wire it via SIMREADY_KIT_PYTHON so the auto-detect is fully self-
98
- # configuring (no per-call --kit-python flag needed).
99
- ENV SIMREADY_KIT_PYTHON=/isaac-sim/python.sh
100
-
101
- USER 1000:1000
102
-
103
- ENV GRADIO_SERVER_NAME=0.0.0.0
104
- ENV GRADIO_SERVER_PORT=7860
105
- EXPOSE 7860
106
-
107
- # Clear the Isaac Sim base's ENTRYPOINT (it runs /isaac-sim/runheadless.sh
108
- # and treats CMD as its args — fails with 'Permission denied' under
109
- # UID 1000). We invoke the Python app directly; Kit is invoked
110
- # explicitly by the validator via /isaac-sim/python.sh when needed.
111
- ENTRYPOINT []
112
- CMD ["python3.11", "app.py"]
 
1
+ # SimReady Validator — HuggingFace Space image
2
+ #
3
+ # Base: nvcr.io/nvidia/isaac-sim:4.5.0. Ships Kit at /isaac-sim/ so
4
+ # runner._kit_available() auto-selects --use-kit and the validator
5
+ # runs PhysX / MDL rules without a code change.
6
+ #
7
+ # UID 1000 is required by HF Spaces. The Isaac Sim base already has
8
+ # a user at UID 1000 (varies between releases — ubuntu / kit / etc.),
9
+ # so we REUSE it rather than create one. /app is the workspace dir
10
+ # with HOME=/app so Claude Code CLI finds skills under ~/.claude/skills
11
+ # regardless of the existing user's name.
12
+
13
+ FROM nvcr.io/nvidia/isaac-sim:4.5.0
14
+
15
+ # Skip interactive prompts during apt-get (the deadsnakes PPA install
16
+ # pulls tzdata in as a dep, which otherwise blocks the build asking
17
+ # for a geographic area).
18
+ ENV DEBIAN_FRONTEND=noninteractive
19
+ ENV TZ=Etc/UTC
20
+
21
+ # System deps (Ubuntu 22.04). Isaac Sim base may ship apt/pip configs
22
+ # pointing at NVIDIA-internal mirrors (urm.nvidia.com) that HF Spaces'
23
+ # build infra can't reach — wipe those before any install. The base
24
+ # default Python is 3.10; simready-validate requires >=3.11, so we
25
+ # install python3.11 from the deadsnakes PPA and use it explicitly
26
+ # for everything below.
27
+ RUN apt-get update && apt-get install -y --no-install-recommends \
28
+ git curl ca-certificates rsync gnupg software-properties-common \
29
+ && add-apt-repository -y ppa:deadsnakes/ppa \
30
+ && apt-get update && apt-get install -y --no-install-recommends \
31
+ python3.11 python3.11-venv python3.11-distutils \
32
+ && curl -fsSL https://bootstrap.pypa.io/get-pip.py | python3.11 \
33
+ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
34
+ && apt-get install -y --no-install-recommends nodejs \
35
+ && rm -rf /var/lib/apt/lists/*
36
+
37
+ RUN npm config set registry https://registry.npmjs.org/ \
38
+ && rm -f /root/.npmrc /etc/npmrc \
39
+ && rm -f /root/.pip/pip.conf /etc/pip.conf
40
+
41
+ # Claude Code CLI.
42
+ RUN npm install -g --registry=https://registry.npmjs.org/ \
43
+ @anthropic-ai/claude-code@latest
44
+
45
+ # Workspace nesting matches the legacy layout (/home/appuser/app)
46
+ # because runner.py uses Path(__file__).resolve().parents[2] to find
47
+ # tools/spec_sync/state.json — needs at least 3 levels of parent.
48
+ # We don't create the appuser account (UID 1000 already exists in the
49
+ # base image with a different name); we just use the path. HOME points
50
+ # at the workspace's parent so Claude Code CLI finds ~/.claude/skills.
51
+ RUN mkdir -p /home/appuser/app /home/appuser/.claude/skills
52
+ WORKDIR /home/appuser/app
53
+ ENV HOME=/home/appuser
54
+
55
+ # Python deps (system Python, public PyPI). --break-system-packages
56
+ # because Ubuntu 22.04 marks system Python as PEP-668 externally-
57
+ # managed; on a build-once container image that's the right trade-off.
58
+ COPY tools/hf_space/requirements.txt ./requirements.txt
59
+ RUN python3.11 -m pip install --no-cache-dir --break-system-packages \
60
+ --index-url https://pypi.org/simple/ \
61
+ -r requirements.txt
62
+
63
+ # Foundation specs.
64
+ ENV SIMREADY_FOUNDATIONS_PATH=/opt/simready_foundations
65
+ ENV SIMREADY_FOUNDATIONS_COMMIT=805d2c50179a9878c89b0f41baaa0ecafe47c3d7
66
+ RUN git clone https://github.com/NVIDIA/simready-foundation \
67
+ ${SIMREADY_FOUNDATIONS_PATH} \
68
+ && cd ${SIMREADY_FOUNDATIONS_PATH} \
69
+ && git checkout ${SIMREADY_FOUNDATIONS_COMMIT}
70
+
71
+ # Vendored simready-foundation-core wheel.
72
+ COPY tools/hf_space/wheels/simready_foundation_core-0.2.0a1-py3-none-any.whl /tmp/
73
+ RUN python3.11 -m pip install --no-cache-dir --break-system-packages \
74
+ --index-url https://pypi.org/simple/ \
75
+ /tmp/simready_foundation_core-0.2.0a1-py3-none-any.whl \
76
+ && rm /tmp/simready_foundation_core-0.2.0a1-py3-none-any.whl
77
+
78
+ # Validator + Space code.
79
+ COPY tools/validation /home/appuser/app/tools/validation
80
+ COPY tools/hf_space/app.py ./app.py
81
+ COPY tools/hf_space/runner.py ./runner.py
82
+ COPY tools/hf_space/github_issues.py ./github_issues.py
83
+ COPY tools/hf_space/agentic_issues.py ./agentic_issues.py
84
+ COPY README.md ./README.md
85
+ COPY VALIDATE.md ./VALIDATE.md
86
+
87
+ # Claude Code skills (HOME=/home/appuser → ~/.claude/skills).
88
+ COPY tools/hf_space/skills/simready-validator-agent /home/appuser/.claude/skills/simready-validator-agent
89
+ COPY tools/validation/plugins/simready-report/skills/simready-report /home/appuser/.claude/skills/simready-report
90
+
91
+ # UID 1000 owns the workspace + foundation clone.
92
+ RUN chown -R 1000:1000 /home/appuser ${SIMREADY_FOUNDATIONS_PATH}
93
+
94
+ # runner._kit_available() probes /isaac-sim/python.sh — present in the
95
+ # base image — so the validator auto-selects --use-kit at runtime.
96
+ # The validator's --use-kit path needs to know WHERE Kit's python lives;
97
+ # wire it via SIMREADY_KIT_PYTHON so the auto-detect is fully self-
98
+ # configuring (no per-call --kit-python flag needed).
99
+ ENV SIMREADY_KIT_PYTHON=/isaac-sim/python.sh
100
+
101
+ USER 1000:1000
102
+
103
+ ENV GRADIO_SERVER_NAME=0.0.0.0
104
+ ENV GRADIO_SERVER_PORT=7860
105
+ EXPOSE 7860
106
+
107
+ # Clear the Isaac Sim base's ENTRYPOINT (it runs /isaac-sim/runheadless.sh
108
+ # and treats CMD as its args — fails with 'Permission denied' under
109
+ # UID 1000). We invoke the Python app directly; Kit is invoked
110
+ # explicitly by the validator via /isaac-sim/python.sh when needed.
111
+ ENTRYPOINT []
112
+ CMD ["python3.11", "app.py"]
README.md CHANGED
@@ -1,44 +1,44 @@
1
- ---
2
- title: Simready Validator
3
- emoji: 🏢
4
- colorFrom: indigo
5
- colorTo: gray
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- # SimReady Validator
11
-
12
- A hosted validator for [SimReady](https://github.com/NVIDIA/simready-foundation)
13
- asset packages. Submit a HuggingFace dataset, pick a profile, and the Space
14
- runs the SimReady foundation rules against it and reports back what passed,
15
- what failed, and why — without copying your assets off HuggingFace.
16
-
17
- ## Using the validator
18
-
19
- Click the **App** tab above to open the validator UI. In the form:
20
-
21
- | Field | What to enter |
22
- |---|---|
23
- | **Dataset** | The HuggingFace dataset slug you want to validate (e.g. `your-org/your-asset-bundle`). The dataset must be readable by this Space's `HF_TOKEN`. |
24
- | **Profile** | The SimReady profile that matches your asset type — props vs. robots, neutral vs. PhysX vs. Isaac. See the validation guide for the full list. |
25
- | **Version** | Profile version. Leave at `1.0.0` unless your profile is pinned to a different release. |
26
- | **Open PR on dataset with verdict** | When ticked, the verdict is written back to the dataset as a `validation/results.json` pull request. Leave off for ad-hoc smoke tests. |
27
-
28
- Click **Validate** and the log streams in real time. When it finishes the
29
- results.json appears in the **HTML report** download.
30
-
31
- ## Going deeper
32
-
33
- - **[Partner walkthrough → VALIDATE.md](./blob/main/VALIDATE.md)** — the full nine-step guide from cloning the foundation repo to submitting your assets. Read this first if you're packaging assets for SimReady for the first time.
34
- - **[NVIDIA/simready-foundation](https://github.com/NVIDIA/simready-foundation)** — public spec repo with profile authoring guides and sample assets.
35
- - **[WRAPP on NGC](https://catalog.ngc.nvidia.com/orgs/nvidia/teams/omniverse/collections/wrapp)** — packaging tool referenced in the walkthrough.
36
-
37
- ## About this Space
38
-
39
- The validator reads the requested HuggingFace dataset in place via
40
- `huggingface_hub.snapshot_download` — assets stay on HuggingFace, no
41
- transfer onto NVIDIA infrastructure. Running on a `t4-medium` GPU
42
- tier. PhysX / MDL rules that require an Isaac Sim runtime are not
43
- covered here yet (Isaac Sim isn't in the Space image — that's a
44
- separate workstream).
 
1
+ ---
2
+ title: Simready Validator
3
+ emoji: 🏢
4
+ colorFrom: indigo
5
+ colorTo: gray
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ # SimReady Validator
11
+
12
+ A hosted validator for [SimReady](https://github.com/NVIDIA/simready-foundation)
13
+ asset packages. Submit a HuggingFace dataset, pick a profile, and the Space
14
+ runs the SimReady foundation rules against it and reports back what passed,
15
+ what failed, and why — without copying your assets off HuggingFace.
16
+
17
+ ## Using the validator
18
+
19
+ Click the **App** tab above to open the validator UI. In the form:
20
+
21
+ | Field | What to enter |
22
+ |---|---|
23
+ | **Dataset** | The HuggingFace dataset slug you want to validate (e.g. `your-org/your-asset-bundle`). The dataset must be readable by this Space's `HF_TOKEN`. |
24
+ | **Profile** | The SimReady profile that matches your asset type — props vs. robots, neutral vs. PhysX vs. Isaac. See the validation guide for the full list. |
25
+ | **Version** | Profile version. Leave at `1.0.0` unless your profile is pinned to a different release. |
26
+ | **Open PR on dataset with verdict** | When ticked, the verdict is written back to the dataset as a `validation/results.json` pull request. Leave off for ad-hoc smoke tests. |
27
+
28
+ Click **Validate** and the log streams in real time. When it finishes the
29
+ results.json appears in the **HTML report** download.
30
+
31
+ ## Going deeper
32
+
33
+ - **[Partner walkthrough → VALIDATE.md](./blob/main/VALIDATE.md)** — the full nine-step guide from cloning the foundation repo to submitting your assets. Read this first if you're packaging assets for SimReady for the first time.
34
+ - **[NVIDIA/simready-foundation](https://github.com/NVIDIA/simready-foundation)** — public spec repo with profile authoring guides and sample assets.
35
+ - **[WRAPP on NGC](https://catalog.ngc.nvidia.com/orgs/nvidia/teams/omniverse/collections/wrapp)** — packaging tool referenced in the walkthrough.
36
+
37
+ ## About this Space
38
+
39
+ The validator reads the requested HuggingFace dataset in place via
40
+ `huggingface_hub.snapshot_download` — assets stay on HuggingFace, no
41
+ transfer onto NVIDIA infrastructure. Running on a `t4-medium` GPU
42
+ tier. PhysX / MDL rules that require an Isaac Sim runtime are not
43
+ covered here yet (Isaac Sim isn't in the Space image — that's a
44
+ separate workstream).
tools/hf_space/Dockerfile CHANGED
@@ -1,112 +1,112 @@
1
- # SimReady Validator — HuggingFace Space image
2
- #
3
- # Base: nvcr.io/nvidia/isaac-sim:4.5.0. Ships Kit at /isaac-sim/ so
4
- # runner._kit_available() auto-selects --use-kit and the validator
5
- # runs PhysX / MDL rules without a code change.
6
- #
7
- # UID 1000 is required by HF Spaces. The Isaac Sim base already has
8
- # a user at UID 1000 (varies between releases — ubuntu / kit / etc.),
9
- # so we REUSE it rather than create one. /app is the workspace dir
10
- # with HOME=/app so Claude Code CLI finds skills under ~/.claude/skills
11
- # regardless of the existing user's name.
12
-
13
- FROM nvcr.io/nvidia/isaac-sim:4.5.0
14
-
15
- # Skip interactive prompts during apt-get (the deadsnakes PPA install
16
- # pulls tzdata in as a dep, which otherwise blocks the build asking
17
- # for a geographic area).
18
- ENV DEBIAN_FRONTEND=noninteractive
19
- ENV TZ=Etc/UTC
20
-
21
- # System deps (Ubuntu 22.04). Isaac Sim base may ship apt/pip configs
22
- # pointing at NVIDIA-internal mirrors (urm.nvidia.com) that HF Spaces'
23
- # build infra can't reach — wipe those before any install. The base
24
- # default Python is 3.10; simready-validate requires >=3.11, so we
25
- # install python3.11 from the deadsnakes PPA and use it explicitly
26
- # for everything below.
27
- RUN apt-get update && apt-get install -y --no-install-recommends \
28
- git curl ca-certificates rsync gnupg software-properties-common \
29
- && add-apt-repository -y ppa:deadsnakes/ppa \
30
- && apt-get update && apt-get install -y --no-install-recommends \
31
- python3.11 python3.11-venv python3.11-distutils \
32
- && curl -fsSL https://bootstrap.pypa.io/get-pip.py | python3.11 \
33
- && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
34
- && apt-get install -y --no-install-recommends nodejs \
35
- && rm -rf /var/lib/apt/lists/*
36
-
37
- RUN npm config set registry https://registry.npmjs.org/ \
38
- && rm -f /root/.npmrc /etc/npmrc \
39
- && rm -f /root/.pip/pip.conf /etc/pip.conf
40
-
41
- # Claude Code CLI.
42
- RUN npm install -g --registry=https://registry.npmjs.org/ \
43
- @anthropic-ai/claude-code@latest
44
-
45
- # Workspace nesting matches the legacy layout (/home/appuser/app)
46
- # because runner.py uses Path(__file__).resolve().parents[2] to find
47
- # tools/spec_sync/state.json — needs at least 3 levels of parent.
48
- # We don't create the appuser account (UID 1000 already exists in the
49
- # base image with a different name); we just use the path. HOME points
50
- # at the workspace's parent so Claude Code CLI finds ~/.claude/skills.
51
- RUN mkdir -p /home/appuser/app /home/appuser/.claude/skills
52
- WORKDIR /home/appuser/app
53
- ENV HOME=/home/appuser
54
-
55
- # Python deps (system Python, public PyPI). --break-system-packages
56
- # because Ubuntu 22.04 marks system Python as PEP-668 externally-
57
- # managed; on a build-once container image that's the right trade-off.
58
- COPY tools/hf_space/requirements.txt ./requirements.txt
59
- RUN python3.11 -m pip install --no-cache-dir --break-system-packages \
60
- --index-url https://pypi.org/simple/ \
61
- -r requirements.txt
62
-
63
- # Foundation specs.
64
- ENV SIMREADY_FOUNDATIONS_PATH=/opt/simready_foundations
65
- ENV SIMREADY_FOUNDATIONS_COMMIT=805d2c50179a9878c89b0f41baaa0ecafe47c3d7
66
- RUN git clone https://github.com/NVIDIA/simready-foundation \
67
- ${SIMREADY_FOUNDATIONS_PATH} \
68
- && cd ${SIMREADY_FOUNDATIONS_PATH} \
69
- && git checkout ${SIMREADY_FOUNDATIONS_COMMIT}
70
-
71
- # Vendored simready-foundation-core wheel.
72
- COPY tools/hf_space/wheels/simready_foundation_core-0.2.0a1-py3-none-any.whl /tmp/
73
- RUN python3.11 -m pip install --no-cache-dir --break-system-packages \
74
- --index-url https://pypi.org/simple/ \
75
- /tmp/simready_foundation_core-0.2.0a1-py3-none-any.whl \
76
- && rm /tmp/simready_foundation_core-0.2.0a1-py3-none-any.whl
77
-
78
- # Validator + Space code.
79
- COPY tools/validation /home/appuser/app/tools/validation
80
- COPY tools/hf_space/app.py ./app.py
81
- COPY tools/hf_space/runner.py ./runner.py
82
- COPY tools/hf_space/github_issues.py ./github_issues.py
83
- COPY tools/hf_space/agentic_issues.py ./agentic_issues.py
84
- COPY README.md ./README.md
85
- COPY VALIDATE.md ./VALIDATE.md
86
-
87
- # Claude Code skills (HOME=/home/appuser → ~/.claude/skills).
88
- COPY tools/hf_space/skills/simready-validator-agent /home/appuser/.claude/skills/simready-validator-agent
89
- COPY tools/validation/plugins/simready-report/skills/simready-report /home/appuser/.claude/skills/simready-report
90
-
91
- # UID 1000 owns the workspace + foundation clone.
92
- RUN chown -R 1000:1000 /home/appuser ${SIMREADY_FOUNDATIONS_PATH}
93
-
94
- # runner._kit_available() probes /isaac-sim/python.sh — present in the
95
- # base image — so the validator auto-selects --use-kit at runtime.
96
- # The validator's --use-kit path needs to know WHERE Kit's python lives;
97
- # wire it via SIMREADY_KIT_PYTHON so the auto-detect is fully self-
98
- # configuring (no per-call --kit-python flag needed).
99
- ENV SIMREADY_KIT_PYTHON=/isaac-sim/python.sh
100
-
101
- USER 1000:1000
102
-
103
- ENV GRADIO_SERVER_NAME=0.0.0.0
104
- ENV GRADIO_SERVER_PORT=7860
105
- EXPOSE 7860
106
-
107
- # Clear the Isaac Sim base's ENTRYPOINT (it runs /isaac-sim/runheadless.sh
108
- # and treats CMD as its args — fails with 'Permission denied' under
109
- # UID 1000). We invoke the Python app directly; Kit is invoked
110
- # explicitly by the validator via /isaac-sim/python.sh when needed.
111
- ENTRYPOINT []
112
- CMD ["python3.11", "app.py"]
 
1
+ # SimReady Validator — HuggingFace Space image
2
+ #
3
+ # Base: nvcr.io/nvidia/isaac-sim:4.5.0. Ships Kit at /isaac-sim/ so
4
+ # runner._kit_available() auto-selects --use-kit and the validator
5
+ # runs PhysX / MDL rules without a code change.
6
+ #
7
+ # UID 1000 is required by HF Spaces. The Isaac Sim base already has
8
+ # a user at UID 1000 (varies between releases — ubuntu / kit / etc.),
9
+ # so we REUSE it rather than create one. /app is the workspace dir
10
+ # with HOME=/app so Claude Code CLI finds skills under ~/.claude/skills
11
+ # regardless of the existing user's name.
12
+
13
+ FROM nvcr.io/nvidia/isaac-sim:4.5.0
14
+
15
+ # Skip interactive prompts during apt-get (the deadsnakes PPA install
16
+ # pulls tzdata in as a dep, which otherwise blocks the build asking
17
+ # for a geographic area).
18
+ ENV DEBIAN_FRONTEND=noninteractive
19
+ ENV TZ=Etc/UTC
20
+
21
+ # System deps (Ubuntu 22.04). Isaac Sim base may ship apt/pip configs
22
+ # pointing at NVIDIA-internal mirrors (urm.nvidia.com) that HF Spaces'
23
+ # build infra can't reach — wipe those before any install. The base
24
+ # default Python is 3.10; simready-validate requires >=3.11, so we
25
+ # install python3.11 from the deadsnakes PPA and use it explicitly
26
+ # for everything below.
27
+ RUN apt-get update && apt-get install -y --no-install-recommends \
28
+ git curl ca-certificates rsync gnupg software-properties-common \
29
+ && add-apt-repository -y ppa:deadsnakes/ppa \
30
+ && apt-get update && apt-get install -y --no-install-recommends \
31
+ python3.11 python3.11-venv python3.11-distutils \
32
+ && curl -fsSL https://bootstrap.pypa.io/get-pip.py | python3.11 \
33
+ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
34
+ && apt-get install -y --no-install-recommends nodejs \
35
+ && rm -rf /var/lib/apt/lists/*
36
+
37
+ RUN npm config set registry https://registry.npmjs.org/ \
38
+ && rm -f /root/.npmrc /etc/npmrc \
39
+ && rm -f /root/.pip/pip.conf /etc/pip.conf
40
+
41
+ # Claude Code CLI.
42
+ RUN npm install -g --registry=https://registry.npmjs.org/ \
43
+ @anthropic-ai/claude-code@latest
44
+
45
+ # Workspace nesting matches the legacy layout (/home/appuser/app)
46
+ # because runner.py uses Path(__file__).resolve().parents[2] to find
47
+ # tools/spec_sync/state.json — needs at least 3 levels of parent.
48
+ # We don't create the appuser account (UID 1000 already exists in the
49
+ # base image with a different name); we just use the path. HOME points
50
+ # at the workspace's parent so Claude Code CLI finds ~/.claude/skills.
51
+ RUN mkdir -p /home/appuser/app /home/appuser/.claude/skills
52
+ WORKDIR /home/appuser/app
53
+ ENV HOME=/home/appuser
54
+
55
+ # Python deps (system Python, public PyPI). --break-system-packages
56
+ # because Ubuntu 22.04 marks system Python as PEP-668 externally-
57
+ # managed; on a build-once container image that's the right trade-off.
58
+ COPY tools/hf_space/requirements.txt ./requirements.txt
59
+ RUN python3.11 -m pip install --no-cache-dir --break-system-packages \
60
+ --index-url https://pypi.org/simple/ \
61
+ -r requirements.txt
62
+
63
+ # Foundation specs.
64
+ ENV SIMREADY_FOUNDATIONS_PATH=/opt/simready_foundations
65
+ ENV SIMREADY_FOUNDATIONS_COMMIT=805d2c50179a9878c89b0f41baaa0ecafe47c3d7
66
+ RUN git clone https://github.com/NVIDIA/simready-foundation \
67
+ ${SIMREADY_FOUNDATIONS_PATH} \
68
+ && cd ${SIMREADY_FOUNDATIONS_PATH} \
69
+ && git checkout ${SIMREADY_FOUNDATIONS_COMMIT}
70
+
71
+ # Vendored simready-foundation-core wheel.
72
+ COPY tools/hf_space/wheels/simready_foundation_core-0.2.0a1-py3-none-any.whl /tmp/
73
+ RUN python3.11 -m pip install --no-cache-dir --break-system-packages \
74
+ --index-url https://pypi.org/simple/ \
75
+ /tmp/simready_foundation_core-0.2.0a1-py3-none-any.whl \
76
+ && rm /tmp/simready_foundation_core-0.2.0a1-py3-none-any.whl
77
+
78
+ # Validator + Space code.
79
+ COPY tools/validation /home/appuser/app/tools/validation
80
+ COPY tools/hf_space/app.py ./app.py
81
+ COPY tools/hf_space/runner.py ./runner.py
82
+ COPY tools/hf_space/github_issues.py ./github_issues.py
83
+ COPY tools/hf_space/agentic_issues.py ./agentic_issues.py
84
+ COPY README.md ./README.md
85
+ COPY VALIDATE.md ./VALIDATE.md
86
+
87
+ # Claude Code skills (HOME=/home/appuser → ~/.claude/skills).
88
+ COPY tools/hf_space/skills/simready-validator-agent /home/appuser/.claude/skills/simready-validator-agent
89
+ COPY tools/validation/plugins/simready-report/skills/simready-report /home/appuser/.claude/skills/simready-report
90
+
91
+ # UID 1000 owns the workspace + foundation clone.
92
+ RUN chown -R 1000:1000 /home/appuser ${SIMREADY_FOUNDATIONS_PATH}
93
+
94
+ # runner._kit_available() probes /isaac-sim/python.sh — present in the
95
+ # base image — so the validator auto-selects --use-kit at runtime.
96
+ # The validator's --use-kit path needs to know WHERE Kit's python lives;
97
+ # wire it via SIMREADY_KIT_PYTHON so the auto-detect is fully self-
98
+ # configuring (no per-call --kit-python flag needed).
99
+ ENV SIMREADY_KIT_PYTHON=/isaac-sim/python.sh
100
+
101
+ USER 1000:1000
102
+
103
+ ENV GRADIO_SERVER_NAME=0.0.0.0
104
+ ENV GRADIO_SERVER_PORT=7860
105
+ EXPOSE 7860
106
+
107
+ # Clear the Isaac Sim base's ENTRYPOINT (it runs /isaac-sim/runheadless.sh
108
+ # and treats CMD as its args — fails with 'Permission denied' under
109
+ # UID 1000). We invoke the Python app directly; Kit is invoked
110
+ # explicitly by the validator via /isaac-sim/python.sh when needed.
111
+ ENTRYPOINT []
112
+ CMD ["python3.11", "app.py"]
tools/hf_space/README_HEADER.md CHANGED
@@ -1,44 +1,44 @@
1
- ---
2
- title: Simready Validator
3
- emoji: 🏢
4
- colorFrom: indigo
5
- colorTo: gray
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- # SimReady Validator
11
-
12
- A hosted validator for [SimReady](https://github.com/NVIDIA/simready-foundation)
13
- asset packages. Submit a HuggingFace dataset, pick a profile, and the Space
14
- runs the SimReady foundation rules against it and reports back what passed,
15
- what failed, and why — without copying your assets off HuggingFace.
16
-
17
- ## Using the validator
18
-
19
- Click the **App** tab above to open the validator UI. In the form:
20
-
21
- | Field | What to enter |
22
- |---|---|
23
- | **Dataset** | The HuggingFace dataset slug you want to validate (e.g. `your-org/your-asset-bundle`). The dataset must be readable by this Space's `HF_TOKEN`. |
24
- | **Profile** | The SimReady profile that matches your asset type — props vs. robots, neutral vs. PhysX vs. Isaac. See the validation guide for the full list. |
25
- | **Version** | Profile version. Leave at `1.0.0` unless your profile is pinned to a different release. |
26
- | **Open PR on dataset with verdict** | When ticked, the verdict is written back to the dataset as a `validation/results.json` pull request. Leave off for ad-hoc smoke tests. |
27
-
28
- Click **Validate** and the log streams in real time. When it finishes the
29
- results.json appears in the **HTML report** download.
30
-
31
- ## Going deeper
32
-
33
- - **[Partner walkthrough → VALIDATE.md](./blob/main/VALIDATE.md)** — the full nine-step guide from cloning the foundation repo to submitting your assets. Read this first if you're packaging assets for SimReady for the first time.
34
- - **[NVIDIA/simready-foundation](https://github.com/NVIDIA/simready-foundation)** — public spec repo with profile authoring guides and sample assets.
35
- - **[WRAPP on NGC](https://catalog.ngc.nvidia.com/orgs/nvidia/teams/omniverse/collections/wrapp)** — packaging tool referenced in the walkthrough.
36
-
37
- ## About this Space
38
-
39
- The validator reads the requested HuggingFace dataset in place via
40
- `huggingface_hub.snapshot_download` — assets stay on HuggingFace, no
41
- transfer onto NVIDIA infrastructure. Running on a `t4-medium` GPU
42
- tier. PhysX / MDL rules that require an Isaac Sim runtime are not
43
- covered here yet (Isaac Sim isn't in the Space image — that's a
44
- separate workstream).
 
1
+ ---
2
+ title: Simready Validator
3
+ emoji: 🏢
4
+ colorFrom: indigo
5
+ colorTo: gray
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ # SimReady Validator
11
+
12
+ A hosted validator for [SimReady](https://github.com/NVIDIA/simready-foundation)
13
+ asset packages. Submit a HuggingFace dataset, pick a profile, and the Space
14
+ runs the SimReady foundation rules against it and reports back what passed,
15
+ what failed, and why — without copying your assets off HuggingFace.
16
+
17
+ ## Using the validator
18
+
19
+ Click the **App** tab above to open the validator UI. In the form:
20
+
21
+ | Field | What to enter |
22
+ |---|---|
23
+ | **Dataset** | The HuggingFace dataset slug you want to validate (e.g. `your-org/your-asset-bundle`). The dataset must be readable by this Space's `HF_TOKEN`. |
24
+ | **Profile** | The SimReady profile that matches your asset type — props vs. robots, neutral vs. PhysX vs. Isaac. See the validation guide for the full list. |
25
+ | **Version** | Profile version. Leave at `1.0.0` unless your profile is pinned to a different release. |
26
+ | **Open PR on dataset with verdict** | When ticked, the verdict is written back to the dataset as a `validation/results.json` pull request. Leave off for ad-hoc smoke tests. |
27
+
28
+ Click **Validate** and the log streams in real time. When it finishes the
29
+ results.json appears in the **HTML report** download.
30
+
31
+ ## Going deeper
32
+
33
+ - **[Partner walkthrough → VALIDATE.md](./blob/main/VALIDATE.md)** — the full nine-step guide from cloning the foundation repo to submitting your assets. Read this first if you're packaging assets for SimReady for the first time.
34
+ - **[NVIDIA/simready-foundation](https://github.com/NVIDIA/simready-foundation)** — public spec repo with profile authoring guides and sample assets.
35
+ - **[WRAPP on NGC](https://catalog.ngc.nvidia.com/orgs/nvidia/teams/omniverse/collections/wrapp)** — packaging tool referenced in the walkthrough.
36
+
37
+ ## About this Space
38
+
39
+ The validator reads the requested HuggingFace dataset in place via
40
+ `huggingface_hub.snapshot_download` — assets stay on HuggingFace, no
41
+ transfer onto NVIDIA infrastructure. Running on a `t4-medium` GPU
42
+ tier. PhysX / MDL rules that require an Isaac Sim runtime are not
43
+ covered here yet (Isaac Sim isn't in the Space image — that's a
44
+ separate workstream).
tools/hf_space/app.py CHANGED
@@ -1,426 +1,429 @@
1
- """SimReady Validator — Gradio UI for the HuggingFace Space.
2
-
3
- Two surfaces, same engine:
4
-
5
- - **/run** (the on-screen button) — streams log lines to the UI for
6
- interactive use by an operator in the browser.
7
- - **/run_api** (hidden, programmatic) — returns the full RunResult as
8
- a JSON-serializable dict. This is what `tools/hf_watch/call_hf_space.py`
9
- hits from the GitHub Actions runner so the workflow can patch
10
- status.json and asset-status.json without scraping the UI's text.
11
-
12
- Both go through `runner.run()`. The split is purely about output
13
- shape (streaming text vs. one-shot dict).
14
-
15
- The Space is internal-pilot scope: HF_TOKEN comes from the Space's
16
- secrets, NOT from the requester. When a customer's dataset PR triggers
17
- this (next milestone), the webhook payload identifies the dataset and
18
- the Space's own token opens the verdict PR.
19
- """
20
- from __future__ import annotations
21
-
22
- import json
23
- import os
24
- from pathlib import Path
25
-
26
- import gradio as gr
27
-
28
- from runner import (run as run_validator, progress_path_for, cancel_path_for,
29
- run_token_path_for, CANCEL_DIR)
30
-
31
-
32
- PROFILE_CHOICES = [
33
- "Prop-Robotics-Neutral",
34
- "Prop-Robotics-Physx",
35
- "Prop-Robotics-Isaac",
36
- "Robot-Body-Neutral",
37
- "Robot-Body-Runnable",
38
- "Robot-Body-Isaac",
39
- "Package",
40
- "Package-Candidate",
41
- ]
42
- DEFAULT_PROFILE = "Prop-Robotics-Neutral"
43
- DEFAULT_VERSION = "1.0.0"
44
-
45
-
46
- def _run_api(dataset: str, profile: str, version: str, open_pr: bool,
47
- submission_id: str = "", force: bool = False,
48
- preliminary: bool = False) -> dict:
49
- """Programmatic endpoint. Returns the RunResult as a JSON dict.
50
-
51
- Caller is typically `tools/hf_watch/call_hf_space.py` running from
52
- a GitHub Actions ubuntu-latest runner. Output shape must stay
53
- stable — bump `schema_version` if you change it. The receiver
54
- pattern-matches on the same field names `tools/hf_watch/validate.py`
55
- produces, so status.json patching is identical regardless of which
56
- backend ran the validation.
57
-
58
- `submission_id` is optional — when set, the validator writes
59
- per-asset progress to /tmp/sr-progress/<id>.json, which the
60
- get_progress endpoint serves to the dashboard.
61
-
62
- `preliminary` switches the runner to a structure-only sweep:
63
- zip-bundled datasets are scanned (instead of failing
64
- PKG.NO-ARCHIVES at the listing stage) and per-asset validation is
65
- sliced to the first asset only. Used by the dashboard's
66
- Preliminary scan tab.
67
- """
68
- print(f"[run_api] preliminary={preliminary!r} force={force!r} "
69
- f"submission_id={submission_id!r}", flush=True)
70
- # Untrusted callers can hit /run_api directly — profile/version flow
71
- # into the validator's argv, so validate them before use. Empty
72
- # falls back to the defaults (existing behavior).
73
- import re
74
- profile = profile or DEFAULT_PROFILE
75
- if profile not in PROFILE_CHOICES and profile.lower() != "auto":
76
- raise ValueError(f"invalid profile: {profile!r}")
77
- version = (version or DEFAULT_VERSION).strip()
78
- if not re.fullmatch(r"[\w.\-]+", version):
79
- raise ValueError(f"invalid version: {version!r}")
80
- result = run_validator(
81
- dataset=(dataset or "").strip(),
82
- profile=profile,
83
- version=version,
84
- open_pr=bool(open_pr),
85
- submission_id=(submission_id or "").strip(),
86
- force=bool(force),
87
- preliminary=bool(preliminary),
88
- )
89
- return {
90
- "schema_version": 1,
91
- "dataset": result.dataset,
92
- "profile": result.profile,
93
- "version": result.version,
94
- "status": result.status,
95
- "summary": result.summary,
96
- "results_json": _sanitize_results_json(result.results_json),
97
- "pr_url": result.pr_url,
98
- }
99
-
100
-
101
- def _list_profiles() -> dict:
102
- """Return the set of profiles that actually load on this Space's
103
- foundation+validator combination. The dashboard polls this to
104
- populate its dropdown so operators can't pick a profile that
105
- would fatally fail at registration time.
106
-
107
- Uses --use-plugin since the default CLI loader has known
108
- registration mismatches against the current foundation pin; the
109
- plugin path is what runner.py's streaming-zip flow falls back
110
- to and is the source of truth for "actually usable" here.
111
-
112
- Output format from validate.py is `PROFILE: <id> v<version>`
113
- per profile, one per line.
114
- """
115
- import subprocess, sys
116
- from runner import VALIDATOR, _KIT_FLAG
117
- try:
118
- proc = subprocess.run(
119
- # Kit flag is auto-selected at runtime by runner.py based on
120
- # what's installed. Without Kit, --no-use-kit lets the
121
- # --list-profiles loader run (the validator's Kit auto-enable
122
- # would otherwise error before listing).
123
- [sys.executable, str(VALIDATOR), "--list-profiles", "--use-plugin", _KIT_FLAG],
124
- capture_output=True, text=True, timeout=300,
125
- )
126
- names: list[str] = []
127
- for line in (proc.stdout or "").splitlines():
128
- s = line.strip()
129
- # Validator emits "PROFILE: <id> v<version>" — that's our
130
- # only authoritative shape. Anything else is noise.
131
- if s.startswith("PROFILE:"):
132
- rest = s[len("PROFILE:"):].strip()
133
- pid = rest.split()[0] if rest else ""
134
- if pid:
135
- names.append(pid)
136
- # Dedupe while preserving order.
137
- seen = set()
138
- unique = []
139
- for n in names:
140
- if n not in seen:
141
- seen.add(n)
142
- unique.append(n)
143
- result: dict = {"profiles": unique, "schema_version": 1, "rc": proc.returncode}
144
- if not unique:
145
- # No profiles registered AND no parse hits — surface why so
146
- # the dashboard can show something useful. Truncate so the
147
- # JSON response stays small.
148
- stderr_tail = "\n".join((proc.stderr or "").splitlines()[-20:])[:2000]
149
- stdout_tail = "\n".join((proc.stdout or "").splitlines()[-20:])[:2000]
150
- result["stderr_tail"] = stderr_tail
151
- result["stdout_tail"] = stdout_tail
152
- return result
153
- except subprocess.TimeoutExpired:
154
- return {"profiles": [], "error": "timeout after 300s (spec load >5 min)"}
155
- except Exception as e:
156
- return {"profiles": [], "error": f"{type(e).__name__}: {e}"}
157
-
158
-
159
- def _cancel_run(submission_id: str, run_token: str = "") -> dict:
160
- """Write the cancel-signal file for a given submission. The
161
- streaming-zip loop in runner.py checks for this file between zips
162
- and aborts when present. Idempotent calling multiple times has no
163
- extra effect; consuming runner.py deletes it.
164
-
165
- `run_token` is the per-run token the dashboard read from get_progress.
166
- It becomes the flag's content so runner._is_cancelled only honors it
167
- for the exact run it was issued against — a flag left over from a
168
- prior run of this submission can never abort a fresh one."""
169
- sid = (submission_id or "").strip()
170
- if not sid:
171
- return {"state": "no_id"}
172
- path = cancel_path_for(sid)
173
- if path is None:
174
- return {"state": "no_id"}
175
- try:
176
- CANCEL_DIR.mkdir(parents=True, exist_ok=True)
177
- path.write_text((run_token or "").strip(), encoding="utf-8")
178
- return {"state": "signaled", "path": str(path)}
179
- except OSError as e:
180
- return {"state": "error", "error": f"{type(e).__name__}: {e}"}
181
-
182
-
183
- def _get_progress(submission_id: str) -> dict:
184
- """Read the validator's per-asset progress file for this submission.
185
-
186
- Polled by the dashboard ~every 3 s while a Validate-now click is
187
- in-flight, so the "Validate now" button can fill up as the
188
- validator works through the asset list.
189
-
190
- Returns one of three shapes:
191
- - {"state": "not_found"} no progress file (Space restarted, or
192
- the dashboard is polling a Space-run that never happened).
193
- - {"state": "starting"} — file seeded by runner.py before the
194
- validator started its loop. processed/total are 0.
195
- - {processed, total, current, started_at, updated_at} live
196
- per-asset progress written by validate.py._emit_progress.
197
-
198
- Every shape also carries `run_token` (the current run's cancel
199
- token, from the sidecar file) when one exists, so the dashboard can
200
- echo it back to cancel_run and target the exact run.
201
-
202
- Caller treats anything with total > 0 as "show the fill bar".
203
- """
204
- sid = (submission_id or "").strip()
205
- if not sid:
206
- return {"state": "no_id"}
207
- # Per-run cancel token (sidecar; see runner.run_token_path_for).
208
- # Surfaced on every shape so the dashboard can echo it back to
209
- # cancel_run — a cancel then only aborts the run it was issued
210
- # against, never a later one that reused the submission_id.
211
- run_token = ""
212
- tok_path = run_token_path_for(sid)
213
- if tok_path and tok_path.is_file():
214
- try:
215
- run_token = tok_path.read_text(encoding="utf-8").strip()
216
- except OSError:
217
- pass
218
- path = progress_path_for(sid)
219
- if path is None or not path.is_file():
220
- return {"state": "not_found", "run_token": run_token}
221
- try:
222
- data = json.loads(path.read_text(encoding="utf-8"))
223
- if isinstance(data, dict) and run_token:
224
- data["run_token"] = run_token
225
- return data
226
- except (OSError, json.JSONDecodeError):
227
- # Mid-write — caller will poll again in a few seconds.
228
- return {"state": "transient", "run_token": run_token}
229
-
230
-
231
- def _sanitize_results_json(raw: dict) -> dict:
232
- """Strip absolute filesystem paths from results_json before returning.
233
-
234
- Gradio's JSON serializer treats string fields that resolve to files
235
- on the Space's filesystem as downloadable references and tries to
236
- serve them through `/gradio_api/file=...`. The validator's
237
- results.json contains absolute paths (target dir + per-asset
238
- `path`) which point into the Space's ephemeral tempdir and are
239
- NOT exposed through gradio's allowed_paths — gradio_client then
240
- fails with 403 trying to auto-fetch them after a successful run.
241
-
242
- Callers don't need filesystem paths anyway only `rel_path`
243
- (dataset-relative), `passed`, and `issues` are used downstream.
244
- Keep the rest of the report intact (profile_coverage, summary,
245
- layout_findings, etc.).
246
- """
247
- if not isinstance(raw, dict):
248
- return raw
249
- sanitized = {k: v for k, v in raw.items() if k != "target"}
250
- if "results" in sanitized and isinstance(sanitized["results"], list):
251
- sanitized["results"] = [
252
- {k: v for k, v in asset.items() if k != "path"}
253
- for asset in sanitized["results"]
254
- if isinstance(asset, dict)
255
- ]
256
- # Specs/dashboard dir paths are local to the Space, useless to caller.
257
- for k in ("specs_docs_dir", "dashboard_docs_dir"):
258
- sanitized.pop(k, None)
259
- return sanitized
260
-
261
-
262
- def _run_streaming(dataset: str, profile: str, version: str, open_pr: bool):
263
- """Generator that yields incremental log output to the UI as the
264
- validator runs. Gradio streams each yielded tuple to the connected
265
- outputs."""
266
- lines: list[str] = []
267
-
268
- def log(line: str) -> None:
269
- lines.append(line)
270
-
271
- yield "\n".join(lines), "", "(running…)", None
272
-
273
- try:
274
- result = run_validator(
275
- dataset=dataset.strip(),
276
- profile=profile,
277
- version=version.strip() or DEFAULT_VERSION,
278
- open_pr=open_pr,
279
- log=log,
280
- )
281
- except Exception as e:
282
- lines.append(f"\nERROR: {type(e).__name__}: {e}")
283
- yield "\n".join(lines), "", f"error: {e}", None
284
- return
285
-
286
- status_badge = f"**{result.status.upper()}** {result.summary}"
287
- if result.pr_url:
288
- status_badge += f"\n\nPR: {result.pr_url}"
289
-
290
- report_index = result.report_path / "index.html"
291
- report_url = str(report_index) if report_index.is_file() else None
292
-
293
- yield (
294
- "\n".join(lines),
295
- status_badge,
296
- result.summary,
297
- report_url,
298
- )
299
-
300
-
301
- def _read_md(name: str) -> str:
302
- """Return the contents of name (relative to this file's dir),
303
- stripping a leading YAML frontmatter block if present. Falls back
304
- to a friendly stub when the file is missing — keeps the Space
305
- bootable even before the space-deploy workflow has synced the
306
- assembled docs into the container."""
307
- from pathlib import Path
308
- p = Path(__file__).resolve().parent / name
309
- try:
310
- src = p.read_text(encoding="utf-8")
311
- except FileNotFoundError:
312
- return f"_{name} not yet synced into this Space — check back after the next deploy._"
313
- if src.startswith("---"):
314
- end = src.find("\n---\n", 4)
315
- if end > 0:
316
- src = src[end + len("\n---\n"):].lstrip()
317
- return src
318
-
319
-
320
- with gr.Blocks(title="SimReady Validator") as demo:
321
- with gr.Tabs():
322
- with gr.Tab("Overview"):
323
- gr.Markdown(_read_md("README.md"))
324
- with gr.Tab("Validator"):
325
- gr.Markdown(
326
- "Submit a HuggingFace dataset to validate against a SimReady "
327
- "profile. With **Open PR** enabled, the verdict is uploaded "
328
- "back to the dataset as a `validation/` pull request."
329
- )
330
- with gr.Row():
331
- dataset = gr.Textbox(
332
- label="Dataset",
333
- placeholder="org/dataset (e.g. imagineio/PhysicalAI-SimReady-Kitchens-v1)",
334
- )
335
- with gr.Row():
336
- profile = gr.Dropdown(
337
- choices=PROFILE_CHOICES, value=DEFAULT_PROFILE, label="Profile",
338
- )
339
- version = gr.Textbox(label="Version", value=DEFAULT_VERSION)
340
- open_pr = gr.Checkbox(label="Open PR on dataset with verdict", value=False)
341
- run_btn = gr.Button("Validate", variant="primary")
342
- status_md = gr.Markdown(label="Verdict")
343
- summary_box = gr.Textbox(label="Summary", interactive=False)
344
- log_box = gr.Textbox(label="Log", lines=20, interactive=False)
345
- report_link = gr.File(label="HTML report (download)", interactive=False)
346
- with gr.Tab("Partner walkthrough"):
347
- gr.Markdown(_read_md("VALIDATE.md"))
348
-
349
- run_btn.click(
350
- fn=_run_streaming,
351
- inputs=[dataset, profile, version, open_pr],
352
- outputs=[log_box, status_md, summary_box, report_link],
353
- api_name="run",
354
- )
355
-
356
- # Programmatic endpoint — bound to invisible components so the UI
357
- # doesn't render anything extra, but the Gradio queue still exposes
358
- # an `/api/predict/run_api` route the gradio_client can hit. The
359
- # outputs[0] is the JSON return; api_name turns it into a stable
360
- # path the GitHub Actions caller depends on.
361
- api_dataset = gr.Textbox(visible=False)
362
- api_profile = gr.Textbox(visible=False)
363
- api_version = gr.Textbox(visible=False)
364
- api_open_pr = gr.Checkbox(visible=False)
365
- api_submission_id = gr.Textbox(visible=False)
366
- api_force = gr.Checkbox(visible=False)
367
- api_preliminary = gr.Checkbox(visible=False)
368
- api_output = gr.JSON(visible=False)
369
- api_button = gr.Button(visible=False)
370
- api_button.click(
371
- fn=_run_api,
372
- inputs=[api_dataset, api_profile, api_version, api_open_pr,
373
- api_submission_id, api_force, api_preliminary],
374
- outputs=api_output,
375
- api_name="run_api",
376
- )
377
-
378
- # Progress endpoint — polled by the dashboard while a row is
379
- # validating. CORS is open on /gradio_api/* by default, so the
380
- # browser can fetch this from github.io directly without any
381
- # GitHub-Actions side polling/commit churn.
382
- prog_in = gr.Textbox(visible=False)
383
- prog_out = gr.JSON(visible=False)
384
- prog_button = gr.Button(visible=False)
385
- prog_button.click(
386
- fn=_get_progress,
387
- inputs=[prog_in],
388
- outputs=prog_out,
389
- api_name="get_progress",
390
- )
391
-
392
- # Profile-listing endpoint — polled by the dashboard at startup
393
- # so its dropdown reflects what's actually loadable on this Space
394
- # right now (foundation+validator pin determines which profiles
395
- # register). Stops the operator from picking something that
396
- # would fatal at runtime.
397
- profiles_out = gr.JSON(visible=False)
398
- profiles_button = gr.Button(visible=False)
399
- profiles_button.click(
400
- fn=_list_profiles,
401
- inputs=None,
402
- outputs=profiles_out,
403
- api_name="list_profiles",
404
- )
405
-
406
- # Cancel endpoint — the dashboard's Cancel button calls this AFTER
407
- # cancelling the GH Action so the in-flight server-side gradio call
408
- # actually stops (cancelling the Action alone only kills the
409
- # gradio_client wrapper, the Space's loop keeps going).
410
- cancel_in = gr.Textbox(visible=False)
411
- cancel_token = gr.Textbox(visible=False)
412
- cancel_out = gr.JSON(visible=False)
413
- cancel_button = gr.Button(visible=False)
414
- cancel_button.click(
415
- fn=_cancel_run,
416
- inputs=[cancel_in, cancel_token],
417
- outputs=cancel_out,
418
- api_name="cancel_run",
419
- )
420
-
421
-
422
- if __name__ == "__main__":
423
- demo.queue().launch(
424
- server_name=os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0"),
425
- server_port=int(os.environ.get("GRADIO_SERVER_PORT", "7860")),
426
- )
 
 
 
 
1
+ """SimReady Validator — Gradio UI for the HuggingFace Space.
2
+
3
+ Two surfaces, same engine:
4
+
5
+ - **/run** (the on-screen button) — streams log lines to the UI for
6
+ interactive use by an operator in the browser.
7
+ - **/run_api** (hidden, programmatic) — returns the full RunResult as
8
+ a JSON-serializable dict. This is what `tools/hf_watch/call_hf_space.py`
9
+ hits from the GitHub Actions runner so the workflow can patch
10
+ status.json and asset-status.json without scraping the UI's text.
11
+
12
+ Both go through `runner.run()`. The split is purely about output
13
+ shape (streaming text vs. one-shot dict).
14
+
15
+ The Space is internal-pilot scope: HF_TOKEN comes from the Space's
16
+ secrets, NOT from the requester. When a customer's dataset PR triggers
17
+ this (next milestone), the webhook payload identifies the dataset and
18
+ the Space's own token opens the verdict PR.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ from pathlib import Path
25
+
26
+ import gradio as gr
27
+
28
+ from runner import (run as run_validator, progress_path_for, cancel_path_for,
29
+ run_token_path_for, CANCEL_DIR)
30
+
31
+
32
+ PROFILE_CHOICES = [
33
+ "Prop-Robotics-Neutral",
34
+ "Prop-Robotics-Physx",
35
+ "Prop-Robotics-Isaac",
36
+ "Robot-Body-Neutral",
37
+ "Robot-Body-Runnable",
38
+ "Robot-Body-Isaac",
39
+ "Package",
40
+ "Package-Candidate",
41
+ ]
42
+ DEFAULT_PROFILE = "Prop-Robotics-Neutral"
43
+ DEFAULT_VERSION = "1.0.0"
44
+
45
+
46
+ def _run_api(dataset: str, profile: str, version: str, open_pr: bool,
47
+ submission_id: str = "", force: bool = False,
48
+ preliminary: bool = False) -> dict:
49
+ """Programmatic endpoint. Returns the RunResult as a JSON dict.
50
+
51
+ Caller is typically `tools/hf_watch/call_hf_space.py` running from
52
+ a GitHub Actions ubuntu-latest runner. Output shape must stay
53
+ stable — bump `schema_version` if you change it. The receiver
54
+ pattern-matches on the same field names `tools/hf_watch/validate.py`
55
+ produces, so status.json patching is identical regardless of which
56
+ backend ran the validation.
57
+
58
+ `submission_id` is optional — when set, the validator writes
59
+ per-asset progress to /tmp/sr-progress/<id>.json, which the
60
+ get_progress endpoint serves to the dashboard.
61
+
62
+ `preliminary` switches the runner to a structure-only sweep:
63
+ zip-bundled datasets are scanned (instead of failing
64
+ PKG.NO-ARCHIVES at the listing stage) and per-asset validation is
65
+ sliced to the first asset only. Used by the dashboard's
66
+ Preliminary scan tab.
67
+ """
68
+ print(f"[run_api] preliminary={preliminary!r} force={force!r} "
69
+ f"submission_id={submission_id!r}", flush=True)
70
+ # Untrusted callers can hit /run_api directly — profile/version flow
71
+ # into the validator's argv, so validate them before use. Empty
72
+ # falls back to the defaults (existing behavior).
73
+ import re
74
+ profile = profile or DEFAULT_PROFILE
75
+ if profile not in PROFILE_CHOICES and profile.lower() != "auto":
76
+ raise ValueError(f"invalid profile: {profile!r}")
77
+ version = (version or DEFAULT_VERSION).strip()
78
+ if not re.fullmatch(r"[\w.\-]+", version):
79
+ raise ValueError(f"invalid version: {version!r}")
80
+ result = run_validator(
81
+ dataset=(dataset or "").strip(),
82
+ profile=profile,
83
+ version=version,
84
+ open_pr=bool(open_pr),
85
+ submission_id=(submission_id or "").strip(),
86
+ force=bool(force),
87
+ preliminary=bool(preliminary),
88
+ )
89
+ return {
90
+ "schema_version": 1,
91
+ "dataset": result.dataset,
92
+ "profile": result.profile,
93
+ "version": result.version,
94
+ "status": result.status,
95
+ "summary": result.summary,
96
+ "results_json": _sanitize_results_json(result.results_json),
97
+ "pr_url": result.pr_url,
98
+ }
99
+
100
+
101
+ def _list_profiles() -> dict:
102
+ """Return the set of profiles that actually load on this Space's
103
+ foundation+validator combination. The dashboard polls this to
104
+ populate its dropdown so operators can't pick a profile that
105
+ would fatally fail at registration time.
106
+
107
+ Uses --use-plugin since the default CLI loader has known
108
+ registration mismatches against the current foundation pin; the
109
+ plugin path is what runner.py's streaming-zip flow falls back
110
+ to and is the source of truth for "actually usable" here.
111
+
112
+ Output format from validate.py is `PROFILE: <id> v<version>`
113
+ per profile, one per line.
114
+ """
115
+ import subprocess, sys
116
+ from runner import VALIDATOR
117
+ try:
118
+ proc = subprocess.run(
119
+ # --list-profiles only ENUMERATES registered profiles from the
120
+ # spec/plugin registry (--use-plugin) it runs no validation
121
+ # rules, so it never needs Kit. Force --no-use-kit: on a
122
+ # Kit-enabled image the validator auto-enables --use-kit for the
123
+ # PhysX-bearing default profile and boots the full Isaac Sim
124
+ # runtime (~5 min) just to print the list, blowing the 300s
125
+ # timeout below. Actual validation (runner.py) still uses Kit.
126
+ [sys.executable, str(VALIDATOR), "--list-profiles", "--use-plugin", "--no-use-kit"],
127
+ capture_output=True, text=True, timeout=300,
128
+ )
129
+ names: list[str] = []
130
+ for line in (proc.stdout or "").splitlines():
131
+ s = line.strip()
132
+ # Validator emits "PROFILE: <id> v<version>" — that's our
133
+ # only authoritative shape. Anything else is noise.
134
+ if s.startswith("PROFILE:"):
135
+ rest = s[len("PROFILE:"):].strip()
136
+ pid = rest.split()[0] if rest else ""
137
+ if pid:
138
+ names.append(pid)
139
+ # Dedupe while preserving order.
140
+ seen = set()
141
+ unique = []
142
+ for n in names:
143
+ if n not in seen:
144
+ seen.add(n)
145
+ unique.append(n)
146
+ result: dict = {"profiles": unique, "schema_version": 1, "rc": proc.returncode}
147
+ if not unique:
148
+ # No profiles registered AND no parse hits — surface why so
149
+ # the dashboard can show something useful. Truncate so the
150
+ # JSON response stays small.
151
+ stderr_tail = "\n".join((proc.stderr or "").splitlines()[-20:])[:2000]
152
+ stdout_tail = "\n".join((proc.stdout or "").splitlines()[-20:])[:2000]
153
+ result["stderr_tail"] = stderr_tail
154
+ result["stdout_tail"] = stdout_tail
155
+ return result
156
+ except subprocess.TimeoutExpired:
157
+ return {"profiles": [], "error": "timeout after 300s (spec load >5 min)"}
158
+ except Exception as e:
159
+ return {"profiles": [], "error": f"{type(e).__name__}: {e}"}
160
+
161
+
162
+ def _cancel_run(submission_id: str, run_token: str = "") -> dict:
163
+ """Write the cancel-signal file for a given submission. The
164
+ streaming-zip loop in runner.py checks for this file between zips
165
+ and aborts when present. Idempotent calling multiple times has no
166
+ extra effect; consuming runner.py deletes it.
167
+
168
+ `run_token` is the per-run token the dashboard read from get_progress.
169
+ It becomes the flag's content so runner._is_cancelled only honors it
170
+ for the exact run it was issued against — a flag left over from a
171
+ prior run of this submission can never abort a fresh one."""
172
+ sid = (submission_id or "").strip()
173
+ if not sid:
174
+ return {"state": "no_id"}
175
+ path = cancel_path_for(sid)
176
+ if path is None:
177
+ return {"state": "no_id"}
178
+ try:
179
+ CANCEL_DIR.mkdir(parents=True, exist_ok=True)
180
+ path.write_text((run_token or "").strip(), encoding="utf-8")
181
+ return {"state": "signaled", "path": str(path)}
182
+ except OSError as e:
183
+ return {"state": "error", "error": f"{type(e).__name__}: {e}"}
184
+
185
+
186
+ def _get_progress(submission_id: str) -> dict:
187
+ """Read the validator's per-asset progress file for this submission.
188
+
189
+ Polled by the dashboard ~every 3 s while a Validate-now click is
190
+ in-flight, so the "Validate now" button can fill up as the
191
+ validator works through the asset list.
192
+
193
+ Returns one of three shapes:
194
+ - {"state": "not_found"} no progress file (Space restarted, or
195
+ the dashboard is polling a Space-run that never happened).
196
+ - {"state": "starting"} — file seeded by runner.py before the
197
+ validator started its loop. processed/total are 0.
198
+ - {processed, total, current, started_at, updated_at} live
199
+ per-asset progress written by validate.py._emit_progress.
200
+
201
+ Every shape also carries `run_token` (the current run's cancel
202
+ token, from the sidecar file) when one exists, so the dashboard can
203
+ echo it back to cancel_run and target the exact run.
204
+
205
+ Caller treats anything with total > 0 as "show the fill bar".
206
+ """
207
+ sid = (submission_id or "").strip()
208
+ if not sid:
209
+ return {"state": "no_id"}
210
+ # Per-run cancel token (sidecar; see runner.run_token_path_for).
211
+ # Surfaced on every shape so the dashboard can echo it back to
212
+ # cancel_run — a cancel then only aborts the run it was issued
213
+ # against, never a later one that reused the submission_id.
214
+ run_token = ""
215
+ tok_path = run_token_path_for(sid)
216
+ if tok_path and tok_path.is_file():
217
+ try:
218
+ run_token = tok_path.read_text(encoding="utf-8").strip()
219
+ except OSError:
220
+ pass
221
+ path = progress_path_for(sid)
222
+ if path is None or not path.is_file():
223
+ return {"state": "not_found", "run_token": run_token}
224
+ try:
225
+ data = json.loads(path.read_text(encoding="utf-8"))
226
+ if isinstance(data, dict) and run_token:
227
+ data["run_token"] = run_token
228
+ return data
229
+ except (OSError, json.JSONDecodeError):
230
+ # Mid-write — caller will poll again in a few seconds.
231
+ return {"state": "transient", "run_token": run_token}
232
+
233
+
234
+ def _sanitize_results_json(raw: dict) -> dict:
235
+ """Strip absolute filesystem paths from results_json before returning.
236
+
237
+ Gradio's JSON serializer treats string fields that resolve to files
238
+ on the Space's filesystem as downloadable references and tries to
239
+ serve them through `/gradio_api/file=...`. The validator's
240
+ results.json contains absolute paths (target dir + per-asset
241
+ `path`) which point into the Space's ephemeral tempdir and are
242
+ NOT exposed through gradio's allowed_pathsgradio_client then
243
+ fails with 403 trying to auto-fetch them after a successful run.
244
+
245
+ Callers don't need filesystem paths anyway — only `rel_path`
246
+ (dataset-relative), `passed`, and `issues` are used downstream.
247
+ Keep the rest of the report intact (profile_coverage, summary,
248
+ layout_findings, etc.).
249
+ """
250
+ if not isinstance(raw, dict):
251
+ return raw
252
+ sanitized = {k: v for k, v in raw.items() if k != "target"}
253
+ if "results" in sanitized and isinstance(sanitized["results"], list):
254
+ sanitized["results"] = [
255
+ {k: v for k, v in asset.items() if k != "path"}
256
+ for asset in sanitized["results"]
257
+ if isinstance(asset, dict)
258
+ ]
259
+ # Specs/dashboard dir paths are local to the Space, useless to caller.
260
+ for k in ("specs_docs_dir", "dashboard_docs_dir"):
261
+ sanitized.pop(k, None)
262
+ return sanitized
263
+
264
+
265
+ def _run_streaming(dataset: str, profile: str, version: str, open_pr: bool):
266
+ """Generator that yields incremental log output to the UI as the
267
+ validator runs. Gradio streams each yielded tuple to the connected
268
+ outputs."""
269
+ lines: list[str] = []
270
+
271
+ def log(line: str) -> None:
272
+ lines.append(line)
273
+
274
+ yield "\n".join(lines), "", "(running…)", None
275
+
276
+ try:
277
+ result = run_validator(
278
+ dataset=dataset.strip(),
279
+ profile=profile,
280
+ version=version.strip() or DEFAULT_VERSION,
281
+ open_pr=open_pr,
282
+ log=log,
283
+ )
284
+ except Exception as e:
285
+ lines.append(f"\nERROR: {type(e).__name__}: {e}")
286
+ yield "\n".join(lines), "", f"error: {e}", None
287
+ return
288
+
289
+ status_badge = f"**{result.status.upper()}** — {result.summary}"
290
+ if result.pr_url:
291
+ status_badge += f"\n\nPR: {result.pr_url}"
292
+
293
+ report_index = result.report_path / "index.html"
294
+ report_url = str(report_index) if report_index.is_file() else None
295
+
296
+ yield (
297
+ "\n".join(lines),
298
+ status_badge,
299
+ result.summary,
300
+ report_url,
301
+ )
302
+
303
+
304
+ def _read_md(name: str) -> str:
305
+ """Return the contents of name (relative to this file's dir),
306
+ stripping a leading YAML frontmatter block if present. Falls back
307
+ to a friendly stub when the file is missing — keeps the Space
308
+ bootable even before the space-deploy workflow has synced the
309
+ assembled docs into the container."""
310
+ from pathlib import Path
311
+ p = Path(__file__).resolve().parent / name
312
+ try:
313
+ src = p.read_text(encoding="utf-8")
314
+ except FileNotFoundError:
315
+ return f"_{name} not yet synced into this Space — check back after the next deploy._"
316
+ if src.startswith("---"):
317
+ end = src.find("\n---\n", 4)
318
+ if end > 0:
319
+ src = src[end + len("\n---\n"):].lstrip()
320
+ return src
321
+
322
+
323
+ with gr.Blocks(title="SimReady Validator") as demo:
324
+ with gr.Tabs():
325
+ with gr.Tab("Overview"):
326
+ gr.Markdown(_read_md("README.md"))
327
+ with gr.Tab("Validator"):
328
+ gr.Markdown(
329
+ "Submit a HuggingFace dataset to validate against a SimReady "
330
+ "profile. With **Open PR** enabled, the verdict is uploaded "
331
+ "back to the dataset as a `validation/` pull request."
332
+ )
333
+ with gr.Row():
334
+ dataset = gr.Textbox(
335
+ label="Dataset",
336
+ placeholder="org/dataset (e.g. imagineio/PhysicalAI-SimReady-Kitchens-v1)",
337
+ )
338
+ with gr.Row():
339
+ profile = gr.Dropdown(
340
+ choices=PROFILE_CHOICES, value=DEFAULT_PROFILE, label="Profile",
341
+ )
342
+ version = gr.Textbox(label="Version", value=DEFAULT_VERSION)
343
+ open_pr = gr.Checkbox(label="Open PR on dataset with verdict", value=False)
344
+ run_btn = gr.Button("Validate", variant="primary")
345
+ status_md = gr.Markdown(label="Verdict")
346
+ summary_box = gr.Textbox(label="Summary", interactive=False)
347
+ log_box = gr.Textbox(label="Log", lines=20, interactive=False)
348
+ report_link = gr.File(label="HTML report (download)", interactive=False)
349
+ with gr.Tab("Partner walkthrough"):
350
+ gr.Markdown(_read_md("VALIDATE.md"))
351
+
352
+ run_btn.click(
353
+ fn=_run_streaming,
354
+ inputs=[dataset, profile, version, open_pr],
355
+ outputs=[log_box, status_md, summary_box, report_link],
356
+ api_name="run",
357
+ )
358
+
359
+ # Programmatic endpoint bound to invisible components so the UI
360
+ # doesn't render anything extra, but the Gradio queue still exposes
361
+ # an `/api/predict/run_api` route the gradio_client can hit. The
362
+ # outputs[0] is the JSON return; api_name turns it into a stable
363
+ # path the GitHub Actions caller depends on.
364
+ api_dataset = gr.Textbox(visible=False)
365
+ api_profile = gr.Textbox(visible=False)
366
+ api_version = gr.Textbox(visible=False)
367
+ api_open_pr = gr.Checkbox(visible=False)
368
+ api_submission_id = gr.Textbox(visible=False)
369
+ api_force = gr.Checkbox(visible=False)
370
+ api_preliminary = gr.Checkbox(visible=False)
371
+ api_output = gr.JSON(visible=False)
372
+ api_button = gr.Button(visible=False)
373
+ api_button.click(
374
+ fn=_run_api,
375
+ inputs=[api_dataset, api_profile, api_version, api_open_pr,
376
+ api_submission_id, api_force, api_preliminary],
377
+ outputs=api_output,
378
+ api_name="run_api",
379
+ )
380
+
381
+ # Progress endpoint polled by the dashboard while a row is
382
+ # validating. CORS is open on /gradio_api/* by default, so the
383
+ # browser can fetch this from github.io directly without any
384
+ # GitHub-Actions side polling/commit churn.
385
+ prog_in = gr.Textbox(visible=False)
386
+ prog_out = gr.JSON(visible=False)
387
+ prog_button = gr.Button(visible=False)
388
+ prog_button.click(
389
+ fn=_get_progress,
390
+ inputs=[prog_in],
391
+ outputs=prog_out,
392
+ api_name="get_progress",
393
+ )
394
+
395
+ # Profile-listing endpoint polled by the dashboard at startup
396
+ # so its dropdown reflects what's actually loadable on this Space
397
+ # right now (foundation+validator pin determines which profiles
398
+ # register). Stops the operator from picking something that
399
+ # would fatal at runtime.
400
+ profiles_out = gr.JSON(visible=False)
401
+ profiles_button = gr.Button(visible=False)
402
+ profiles_button.click(
403
+ fn=_list_profiles,
404
+ inputs=None,
405
+ outputs=profiles_out,
406
+ api_name="list_profiles",
407
+ )
408
+
409
+ # Cancel endpoint the dashboard's Cancel button calls this AFTER
410
+ # cancelling the GH Action so the in-flight server-side gradio call
411
+ # actually stops (cancelling the Action alone only kills the
412
+ # gradio_client wrapper, the Space's loop keeps going).
413
+ cancel_in = gr.Textbox(visible=False)
414
+ cancel_token = gr.Textbox(visible=False)
415
+ cancel_out = gr.JSON(visible=False)
416
+ cancel_button = gr.Button(visible=False)
417
+ cancel_button.click(
418
+ fn=_cancel_run,
419
+ inputs=[cancel_in, cancel_token],
420
+ outputs=cancel_out,
421
+ api_name="cancel_run",
422
+ )
423
+
424
+
425
+ if __name__ == "__main__":
426
+ demo.queue().launch(
427
+ server_name=os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0"),
428
+ server_port=int(os.environ.get("GRADIO_SERVER_PORT", "7860")),
429
+ )
tools/hf_space/github_issues.py CHANGED
@@ -1,324 +1,324 @@
1
- """Auto-file GitHub issues for validator-internal bugs.
2
-
3
- Mirror of `tools/hf_watch/validate.py::_ensure_validator_internal_issues`
4
- moved to the HF Space side so the Space can self-report tooling
5
- failures without depending on the GH Actions wrapper to do it.
6
-
7
- Policy reminder (from CLAUDE.md / project conventions):
8
- - GitHub Issues track NVIDIA-internal *tooling* problems only.
9
- - Customer-asset findings (real spec violations) stay on the dashboard;
10
- they do NOT become issues.
11
- - Distinguishing the two is `is_validator_internal_issue`'s job.
12
-
13
- Token: GH_VALIDATOR_TOKEN (or GITHUB_TOKEN) — set as a Space secret.
14
- A fine-grained PAT with `issues: read+write` on
15
- `NVIDIA-dev/simready-oem-library-pm` is enough; no other scope needed.
16
- """
17
- from __future__ import annotations
18
-
19
- import json
20
- import os
21
- import re
22
- import urllib.parse
23
- import urllib.request
24
- from typing import Any
25
-
26
-
27
- GH_REPO = "NVIDIA-dev/simready-oem-library-pm"
28
-
29
- # Redact token-shaped strings before they land in a (public-ish) issue.
30
- # Covers GitHub PATs (gh[ps]_…), HF tokens (hf_…), and long base64-ish
31
- # secrets. Validator log text passes through here on its way into bodies.
32
- _SECRET_RE = re.compile(r"gh[ps]_\w+|hf_\w+|[A-Za-z0-9+/]{40,}={0,2}")
33
-
34
-
35
- def scrub_secrets(text: str) -> str:
36
- return _SECRET_RE.sub("[REDACTED]", text or "")
37
-
38
-
39
- def _gh_token() -> str | None:
40
- return os.environ.get("GH_VALIDATOR_TOKEN") or os.environ.get("GITHUB_TOKEN")
41
-
42
-
43
- def is_validator_internal_issue(iss: dict) -> bool:
44
- """Heuristic: distinguish validator-package crashes from real
45
- asset findings."""
46
- code = iss.get("code") or ""
47
- msg = iss.get("msg") or ""
48
- if code == "UNKNOWN" or code.startswith("SDK."):
49
- return True
50
- if "Uncaught error" in msg:
51
- return True
52
- if "is not registered to requirement" in msg:
53
- return True
54
- return False
55
-
56
-
57
- def _gh_request(method: str, path: str, payload: dict | None = None) -> dict | list:
58
- token = _gh_token()
59
- if not token:
60
- raise RuntimeError("no GitHub token in env (GH_VALIDATOR_TOKEN or GITHUB_TOKEN)")
61
- url = f"https://api.github.com/repos/{GH_REPO}{path}"
62
- headers = {
63
- "Accept": "application/vnd.github+json",
64
- "Authorization": f"Bearer {token}",
65
- "X-GitHub-Api-Version": "2022-11-28",
66
- "User-Agent": "simready-validator-space/0.1",
67
- }
68
- body = None
69
- if payload is not None:
70
- body = json.dumps(payload).encode("utf-8")
71
- headers["Content-Type"] = "application/json"
72
- req = urllib.request.Request(url, data=body, headers=headers, method=method)
73
- with urllib.request.urlopen(req, timeout=30) as r:
74
- return json.loads(r.read() or "null")
75
-
76
-
77
- def _find_issue(title: str) -> dict | None:
78
- # Strip `"` — title is untrusted and a quote would break the
79
- # quoted-phrase search query (and could inject extra qualifiers).
80
- safe_title = title.replace('"', "")
81
- q = urllib.parse.quote(f'repo:{GH_REPO} in:title "{safe_title}" is:issue')
82
- result = _gh_request("GET", f"/../../search/issues?q={q}")
83
- items = (result or {}).get("items") or []
84
- for it in items:
85
- if it.get("title") == title:
86
- return it
87
- return None
88
-
89
-
90
- def _create_issue(title: str, body: str, labels: list[str]) -> int:
91
- result = _gh_request("POST", "/issues",
92
- {"title": title, "body": body, "labels": labels})
93
- return result.get("number", 0)
94
-
95
-
96
- def _add_comment(issue_num: int, body: str) -> None:
97
- _gh_request("POST", f"/issues/{issue_num}/comments", {"body": body})
98
-
99
-
100
- def _build_dataset_issue_body(by_pair: dict, dataset: str, profile: str,
101
- total: int) -> str:
102
- rows = "\n".join(
103
- f"| `{rule}` | `{code}` | {g['severity'] or '?'} | {g['count']} | `{g['sample_msg']}` |"
104
- for (rule, code), g in sorted(by_pair.items(), key=lambda kv: -kv[1]["count"])
105
- )
106
- return (
107
- f"**Validator-internal bugs on a single dataset** — surfaced during "
108
- f"automatic SimReady validation. NOT a customer-asset finding; the "
109
- f"validator's own rule registration / spec loading is misbehaving "
110
- f"on this dataset and emitting errors that don't map to any real "
111
- f"spec violation.\n\n"
112
- f"| Field | Value |\n|---|---|\n"
113
- f"| Dataset | `{dataset}` |\n"
114
- f"| Profile (first run) | `{profile}` |\n"
115
- f"| Total internal occurrences (first run) | {total} |\n"
116
- f"| Distinct (rule, code) pairs (first run) | {len(by_pair)} |\n\n"
117
- f"**Breakdown** (sorted by occurrence count, descending):\n\n"
118
- f"| Rule | Code | Severity | Count | Sample message |\n"
119
- f"|---|---|---|---|---|\n{rows}\n\n"
120
- f"---\n"
121
- f"_Filed automatically by the HF Space (`tools/hf_space/github_issues.py`). "
122
- f"One issue per dataset — re-validating the same dataset comments "
123
- f"here with the new counts instead of opening a duplicate._"
124
- )
125
-
126
-
127
- def _build_dataset_recurrence_comment(by_pair: dict, dataset: str, profile: str,
128
- total: int) -> str:
129
- rows = "\n".join(
130
- f"| `{rule}` | `{code}` | {g['count']} |"
131
- for (rule, code), g in sorted(by_pair.items(), key=lambda kv: -kv[1]["count"])
132
- )
133
- return (
134
- f"Re-hit during validation of `{dataset}` (profile `{profile}`).\n"
135
- f"This run: **{total}** internal occurrences across **{len(by_pair)}** "
136
- f"distinct (rule, code) pairs.\n\n"
137
- f"| Rule | Code | Count this run |\n|---|---|---|\n{rows}"
138
- )
139
-
140
-
141
- def _list_existing_internal_issues() -> list[dict]:
142
- """Pull every validator-internal issue (open + recently closed) so
143
- the agent can dedupe semantically against them. Closed issues are
144
- included because the same bug can come back after a fix is
145
- reverted or after a regression."""
146
- q = urllib.parse.quote(
147
- f'repo:{GH_REPO} label:"validator-internal" is:issue')
148
- try:
149
- result = _gh_request("GET", f"/../../search/issues?q={q}&per_page=100")
150
- except Exception:
151
- return []
152
- return (result or {}).get("items") or []
153
-
154
-
155
- def _execute_decisions(decisions: list[dict], by_pair: dict, dataset: str,
156
- profile: str, total: int, log_fn) -> dict:
157
- """Carry out the agent's decisions. Returns counters."""
158
- out = log_fn
159
- created = updated = skipped = 0
160
- aborted = False
161
- # Map negative placeholder numbers → real issue numbers as `create`
162
- # decisions resolve. The agent uses negatives to cross-reference
163
- # decisions that share a parent issue when several groups roll up
164
- # into one new bug.
165
- placeholder_to_real: dict[int, int] = {}
166
- # Two passes: creates first (so their numbers are known), then
167
- # comments (so cross-references resolve). Skips are free.
168
- creates = [d for d in decisions if d.get("action") == "create"]
169
- comments = [d for d in decisions if d.get("action") == "comment"]
170
- skips = [d for d in decisions if d.get("action") == "skip"]
171
- for d in creates:
172
- if aborted: break
173
- title = d.get("title") or f"[validator-internal] {dataset} :: {d.get('rule')} / {d.get('code')}"
174
- body = d.get("body") or "(agent did not provide a body)"
175
- try:
176
- existing = _find_issue(title)
177
- if existing:
178
- _add_comment(existing["number"],
179
- f"_Re-hit during validation of `{dataset}` "
180
- f"(profile `{profile}`)._\n\n{body}")
181
- updated += 1
182
- out(f" internal-issue #{existing['number']}: comment added "
183
- f"(agent: create→existing match by title)")
184
- placeholder_to_real.setdefault(-(creates.index(d) + 1), existing["number"])
185
- else:
186
- num = _create_issue(title, body, ["validator-internal", "process", "agent-reviewed"])
187
- created += 1
188
- out(f" internal-issue #{num}: opened ({title!r}) — agent reasoning: "
189
- f"{d.get('reasoning', '')[:160]}")
190
- placeholder_to_real[-(creates.index(d) + 1)] = num
191
- except Exception as e:
192
- msg = f"{type(e).__name__}: {e}"
193
- if "404" in msg:
194
- out(f" ! internal-issue tracking aborted (404 — token lacks issues:write on {GH_REPO})")
195
- aborted = True
196
- else:
197
- out(f" ! create failed for {title!r}: {msg}")
198
- for d in comments:
199
- if aborted: break
200
- target = d.get("target_issue")
201
- if target is None:
202
- out(f" ! comment decision has no target_issue; skipping ({d.get('reasoning', '')[:100]})")
203
- continue
204
- if target < 0:
205
- target = placeholder_to_real.get(target)
206
- if target is None:
207
- out(f" ! comment decision cross-references an unresolved placeholder; skipping")
208
- continue
209
- body = d.get("body") or (
210
- f"Re-hit during validation of `{dataset}` (profile `{profile}`). "
211
- f"Same underlying bug as this issue — see agent reasoning: "
212
- f"{d.get('reasoning', '')}"
213
- )
214
- try:
215
- _add_comment(target, body)
216
- updated += 1
217
- out(f" internal-issue #{target}: comment added (agent: comment) — {d.get('reasoning', '')[:120]}")
218
- except Exception as e:
219
- msg = f"{type(e).__name__}: {e}"
220
- if "404" in msg:
221
- out(f" ! comment tracking aborted (404)")
222
- aborted = True
223
- else:
224
- out(f" ! comment failed for #{target}: {msg}")
225
- for d in skips:
226
- skipped += 1
227
- out(f" internal-issue {d.get('rule')}/{d.get('code')}: skipped — {d.get('reasoning', '')[:160]}")
228
- return {"created": created, "updated": updated, "skipped": skipped,
229
- "aborted_404": aborted}
230
-
231
-
232
- def _ensure_internal_issues_simple(by_pair: dict, dataset: str, profile: str,
233
- total: int, log_fn) -> dict:
234
- """Fallback (no agentic review): one issue per dataset, dedup by
235
- exact title match. This is what we used before the agent was
236
- wired up; kept as a backstop for when ANTHROPIC_API_KEY is unset,
237
- the SDK is missing, or the Claude call fails."""
238
- out = log_fn
239
- title = f"[validator-internal] {dataset}"
240
- try:
241
- existing = _find_issue(title)
242
- if existing:
243
- _add_comment(existing["number"],
244
- _build_dataset_recurrence_comment(by_pair, dataset, profile, total))
245
- out(f" internal-issue #{existing['number']}: comment added for dataset "
246
- f"{dataset} ({total} occurrences, {len(by_pair)} pairs) — fallback policy")
247
- return {"created": 0, "updated": 1, "pairs": len(by_pair), "total": total,
248
- "fallback": True}
249
- num = _create_issue(title,
250
- _build_dataset_issue_body(by_pair, dataset, profile, total),
251
- ["validator-internal", "process"])
252
- out(f" internal-issue #{num}: opened for dataset {dataset} "
253
- f"({total} occurrences, {len(by_pair)} pairs) — fallback policy")
254
- return {"created": 1, "updated": 0, "pairs": len(by_pair), "total": total,
255
- "fallback": True}
256
- except Exception as e:
257
- msg = f"{type(e).__name__}: {e}"
258
- if "404" in msg:
259
- out(f" ! internal-issue tracking aborted (404 — token lacks issues:write on {GH_REPO})")
260
- return {"created": 0, "updated": 0, "aborted_404": True, "fallback": True}
261
- out(f" ! internal-issue tracking for dataset {dataset} failed: {msg}")
262
- return {"created": 0, "updated": 0, "error": msg, "fallback": True}
263
-
264
-
265
- def ensure_internal_issues(results_json: dict, dataset: str, profile: str,
266
- log_fn=None) -> dict:
267
- """Scan results.json for validator-internal bugs and route them to
268
- GitHub issues via an agentic review pass (Claude) that classifies,
269
- dedupes against existing issues, and writes plain-language
270
- explanations. Falls back to a simple one-issue-per-dataset policy
271
- if the agent is unavailable.
272
-
273
- Best-effort — swallowed exceptions return {"error": ...} so the
274
- validator's verdict is never blocked on GitHub being flaky."""
275
- out = log_fn or (lambda s: print(s, flush=True))
276
- if not _gh_token():
277
- out(" (skipping internal-issue tracking: no GH token)")
278
- return {"skipped": True, "reason": "no_token"}
279
-
280
- # Group across the whole dataset: (rule, code) → {count, sample, severity}
281
- by_pair: dict[tuple[str, str], dict[str, Any]] = {}
282
- total = 0
283
- for asset in results_json.get("results", []):
284
- for iss in (asset.get("issues") or []):
285
- if not is_validator_internal_issue(iss):
286
- continue
287
- rule = iss.get("rule") or "?"
288
- code = iss.get("code") or "UNKNOWN"
289
- key = (rule, code)
290
- g = by_pair.setdefault(key, {
291
- "count": 0,
292
- "sample_msg": scrub_secrets((iss.get("msg") or "")[:200]),
293
- "severity": (iss.get("severity") or "").lower(),
294
- })
295
- g["count"] += 1
296
- total += 1
297
-
298
- if not by_pair:
299
- return {"created": 0, "updated": 0}
300
-
301
- # Agentic path: Claude classifies + writes the issue body.
302
- try:
303
- from agentic_issues import is_available as _agent_available, review_and_decide
304
- except Exception as e:
305
- out(f" (agentic_issues import failed: {type(e).__name__}: {str(e)[:120]}); "
306
- f"using fallback policy")
307
- return _ensure_internal_issues_simple(by_pair, dataset, profile, total, out)
308
-
309
- if not _agent_available():
310
- out(" (agentic review unavailable; using fallback policy)")
311
- return _ensure_internal_issues_simple(by_pair, dataset, profile, total, out)
312
-
313
- existing = _list_existing_internal_issues()
314
- out(f" agentic review: {len(by_pair)} group(s) vs {len(existing)} existing "
315
- f"validator-internal issue(s)")
316
- review = review_and_decide(by_pair, dataset, profile, total, existing, log_fn=out)
317
- if review is None or not review.get("decisions"):
318
- return _ensure_internal_issues_simple(by_pair, dataset, profile, total, out)
319
-
320
- if review.get("summary"):
321
- out(f" agent summary: {review['summary'][:300]}")
322
- result = _execute_decisions(review["decisions"], by_pair, dataset, profile, total, out)
323
- result.update({"pairs": len(by_pair), "total": total, "agentic": True})
324
- return result
 
1
+ """Auto-file GitHub issues for validator-internal bugs.
2
+
3
+ Mirror of `tools/hf_watch/validate.py::_ensure_validator_internal_issues`
4
+ moved to the HF Space side so the Space can self-report tooling
5
+ failures without depending on the GH Actions wrapper to do it.
6
+
7
+ Policy reminder (from CLAUDE.md / project conventions):
8
+ - GitHub Issues track NVIDIA-internal *tooling* problems only.
9
+ - Customer-asset findings (real spec violations) stay on the dashboard;
10
+ they do NOT become issues.
11
+ - Distinguishing the two is `is_validator_internal_issue`'s job.
12
+
13
+ Token: GH_VALIDATOR_TOKEN (or GITHUB_TOKEN) — set as a Space secret.
14
+ A fine-grained PAT with `issues: read+write` on
15
+ `NVIDIA-dev/simready-oem-library-pm` is enough; no other scope needed.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import re
22
+ import urllib.parse
23
+ import urllib.request
24
+ from typing import Any
25
+
26
+
27
+ GH_REPO = "NVIDIA-dev/simready-oem-library-pm"
28
+
29
+ # Redact token-shaped strings before they land in a (public-ish) issue.
30
+ # Covers GitHub PATs (gh[ps]_…), HF tokens (hf_…), and long base64-ish
31
+ # secrets. Validator log text passes through here on its way into bodies.
32
+ _SECRET_RE = re.compile(r"gh[ps]_\w+|hf_\w+|[A-Za-z0-9+/]{40,}={0,2}")
33
+
34
+
35
+ def scrub_secrets(text: str) -> str:
36
+ return _SECRET_RE.sub("[REDACTED]", text or "")
37
+
38
+
39
+ def _gh_token() -> str | None:
40
+ return os.environ.get("GH_VALIDATOR_TOKEN") or os.environ.get("GITHUB_TOKEN")
41
+
42
+
43
+ def is_validator_internal_issue(iss: dict) -> bool:
44
+ """Heuristic: distinguish validator-package crashes from real
45
+ asset findings."""
46
+ code = iss.get("code") or ""
47
+ msg = iss.get("msg") or ""
48
+ if code == "UNKNOWN" or code.startswith("SDK."):
49
+ return True
50
+ if "Uncaught error" in msg:
51
+ return True
52
+ if "is not registered to requirement" in msg:
53
+ return True
54
+ return False
55
+
56
+
57
+ def _gh_request(method: str, path: str, payload: dict | None = None) -> dict | list:
58
+ token = _gh_token()
59
+ if not token:
60
+ raise RuntimeError("no GitHub token in env (GH_VALIDATOR_TOKEN or GITHUB_TOKEN)")
61
+ url = f"https://api.github.com/repos/{GH_REPO}{path}"
62
+ headers = {
63
+ "Accept": "application/vnd.github+json",
64
+ "Authorization": f"Bearer {token}",
65
+ "X-GitHub-Api-Version": "2022-11-28",
66
+ "User-Agent": "simready-validator-space/0.1",
67
+ }
68
+ body = None
69
+ if payload is not None:
70
+ body = json.dumps(payload).encode("utf-8")
71
+ headers["Content-Type"] = "application/json"
72
+ req = urllib.request.Request(url, data=body, headers=headers, method=method)
73
+ with urllib.request.urlopen(req, timeout=30) as r:
74
+ return json.loads(r.read() or "null")
75
+
76
+
77
+ def _find_issue(title: str) -> dict | None:
78
+ # Strip `"` — title is untrusted and a quote would break the
79
+ # quoted-phrase search query (and could inject extra qualifiers).
80
+ safe_title = title.replace('"', "")
81
+ q = urllib.parse.quote(f'repo:{GH_REPO} in:title "{safe_title}" is:issue')
82
+ result = _gh_request("GET", f"/../../search/issues?q={q}")
83
+ items = (result or {}).get("items") or []
84
+ for it in items:
85
+ if it.get("title") == title:
86
+ return it
87
+ return None
88
+
89
+
90
+ def _create_issue(title: str, body: str, labels: list[str]) -> int:
91
+ result = _gh_request("POST", "/issues",
92
+ {"title": title, "body": body, "labels": labels})
93
+ return result.get("number", 0)
94
+
95
+
96
+ def _add_comment(issue_num: int, body: str) -> None:
97
+ _gh_request("POST", f"/issues/{issue_num}/comments", {"body": body})
98
+
99
+
100
+ def _build_dataset_issue_body(by_pair: dict, dataset: str, profile: str,
101
+ total: int) -> str:
102
+ rows = "\n".join(
103
+ f"| `{rule}` | `{code}` | {g['severity'] or '?'} | {g['count']} | `{g['sample_msg']}` |"
104
+ for (rule, code), g in sorted(by_pair.items(), key=lambda kv: -kv[1]["count"])
105
+ )
106
+ return (
107
+ f"**Validator-internal bugs on a single dataset** — surfaced during "
108
+ f"automatic SimReady validation. NOT a customer-asset finding; the "
109
+ f"validator's own rule registration / spec loading is misbehaving "
110
+ f"on this dataset and emitting errors that don't map to any real "
111
+ f"spec violation.\n\n"
112
+ f"| Field | Value |\n|---|---|\n"
113
+ f"| Dataset | `{dataset}` |\n"
114
+ f"| Profile (first run) | `{profile}` |\n"
115
+ f"| Total internal occurrences (first run) | {total} |\n"
116
+ f"| Distinct (rule, code) pairs (first run) | {len(by_pair)} |\n\n"
117
+ f"**Breakdown** (sorted by occurrence count, descending):\n\n"
118
+ f"| Rule | Code | Severity | Count | Sample message |\n"
119
+ f"|---|---|---|---|---|\n{rows}\n\n"
120
+ f"---\n"
121
+ f"_Filed automatically by the HF Space (`tools/hf_space/github_issues.py`). "
122
+ f"One issue per dataset — re-validating the same dataset comments "
123
+ f"here with the new counts instead of opening a duplicate._"
124
+ )
125
+
126
+
127
+ def _build_dataset_recurrence_comment(by_pair: dict, dataset: str, profile: str,
128
+ total: int) -> str:
129
+ rows = "\n".join(
130
+ f"| `{rule}` | `{code}` | {g['count']} |"
131
+ for (rule, code), g in sorted(by_pair.items(), key=lambda kv: -kv[1]["count"])
132
+ )
133
+ return (
134
+ f"Re-hit during validation of `{dataset}` (profile `{profile}`).\n"
135
+ f"This run: **{total}** internal occurrences across **{len(by_pair)}** "
136
+ f"distinct (rule, code) pairs.\n\n"
137
+ f"| Rule | Code | Count this run |\n|---|---|---|\n{rows}"
138
+ )
139
+
140
+
141
+ def _list_existing_internal_issues() -> list[dict]:
142
+ """Pull every validator-internal issue (open + recently closed) so
143
+ the agent can dedupe semantically against them. Closed issues are
144
+ included because the same bug can come back after a fix is
145
+ reverted or after a regression."""
146
+ q = urllib.parse.quote(
147
+ f'repo:{GH_REPO} label:"validator-internal" is:issue')
148
+ try:
149
+ result = _gh_request("GET", f"/../../search/issues?q={q}&per_page=100")
150
+ except Exception:
151
+ return []
152
+ return (result or {}).get("items") or []
153
+
154
+
155
+ def _execute_decisions(decisions: list[dict], by_pair: dict, dataset: str,
156
+ profile: str, total: int, log_fn) -> dict:
157
+ """Carry out the agent's decisions. Returns counters."""
158
+ out = log_fn
159
+ created = updated = skipped = 0
160
+ aborted = False
161
+ # Map negative placeholder numbers → real issue numbers as `create`
162
+ # decisions resolve. The agent uses negatives to cross-reference
163
+ # decisions that share a parent issue when several groups roll up
164
+ # into one new bug.
165
+ placeholder_to_real: dict[int, int] = {}
166
+ # Two passes: creates first (so their numbers are known), then
167
+ # comments (so cross-references resolve). Skips are free.
168
+ creates = [d for d in decisions if d.get("action") == "create"]
169
+ comments = [d for d in decisions if d.get("action") == "comment"]
170
+ skips = [d for d in decisions if d.get("action") == "skip"]
171
+ for d in creates:
172
+ if aborted: break
173
+ title = d.get("title") or f"[validator-internal] {dataset} :: {d.get('rule')} / {d.get('code')}"
174
+ body = d.get("body") or "(agent did not provide a body)"
175
+ try:
176
+ existing = _find_issue(title)
177
+ if existing:
178
+ _add_comment(existing["number"],
179
+ f"_Re-hit during validation of `{dataset}` "
180
+ f"(profile `{profile}`)._\n\n{body}")
181
+ updated += 1
182
+ out(f" internal-issue #{existing['number']}: comment added "
183
+ f"(agent: create→existing match by title)")
184
+ placeholder_to_real.setdefault(-(creates.index(d) + 1), existing["number"])
185
+ else:
186
+ num = _create_issue(title, body, ["validator-internal", "process", "agent-reviewed"])
187
+ created += 1
188
+ out(f" internal-issue #{num}: opened ({title!r}) — agent reasoning: "
189
+ f"{d.get('reasoning', '')[:160]}")
190
+ placeholder_to_real[-(creates.index(d) + 1)] = num
191
+ except Exception as e:
192
+ msg = f"{type(e).__name__}: {e}"
193
+ if "404" in msg:
194
+ out(f" ! internal-issue tracking aborted (404 — token lacks issues:write on {GH_REPO})")
195
+ aborted = True
196
+ else:
197
+ out(f" ! create failed for {title!r}: {msg}")
198
+ for d in comments:
199
+ if aborted: break
200
+ target = d.get("target_issue")
201
+ if target is None:
202
+ out(f" ! comment decision has no target_issue; skipping ({d.get('reasoning', '')[:100]})")
203
+ continue
204
+ if target < 0:
205
+ target = placeholder_to_real.get(target)
206
+ if target is None:
207
+ out(f" ! comment decision cross-references an unresolved placeholder; skipping")
208
+ continue
209
+ body = d.get("body") or (
210
+ f"Re-hit during validation of `{dataset}` (profile `{profile}`). "
211
+ f"Same underlying bug as this issue — see agent reasoning: "
212
+ f"{d.get('reasoning', '')}"
213
+ )
214
+ try:
215
+ _add_comment(target, body)
216
+ updated += 1
217
+ out(f" internal-issue #{target}: comment added (agent: comment) — {d.get('reasoning', '')[:120]}")
218
+ except Exception as e:
219
+ msg = f"{type(e).__name__}: {e}"
220
+ if "404" in msg:
221
+ out(f" ! comment tracking aborted (404)")
222
+ aborted = True
223
+ else:
224
+ out(f" ! comment failed for #{target}: {msg}")
225
+ for d in skips:
226
+ skipped += 1
227
+ out(f" internal-issue {d.get('rule')}/{d.get('code')}: skipped — {d.get('reasoning', '')[:160]}")
228
+ return {"created": created, "updated": updated, "skipped": skipped,
229
+ "aborted_404": aborted}
230
+
231
+
232
+ def _ensure_internal_issues_simple(by_pair: dict, dataset: str, profile: str,
233
+ total: int, log_fn) -> dict:
234
+ """Fallback (no agentic review): one issue per dataset, dedup by
235
+ exact title match. This is what we used before the agent was
236
+ wired up; kept as a backstop for when ANTHROPIC_API_KEY is unset,
237
+ the SDK is missing, or the Claude call fails."""
238
+ out = log_fn
239
+ title = f"[validator-internal] {dataset}"
240
+ try:
241
+ existing = _find_issue(title)
242
+ if existing:
243
+ _add_comment(existing["number"],
244
+ _build_dataset_recurrence_comment(by_pair, dataset, profile, total))
245
+ out(f" internal-issue #{existing['number']}: comment added for dataset "
246
+ f"{dataset} ({total} occurrences, {len(by_pair)} pairs) — fallback policy")
247
+ return {"created": 0, "updated": 1, "pairs": len(by_pair), "total": total,
248
+ "fallback": True}
249
+ num = _create_issue(title,
250
+ _build_dataset_issue_body(by_pair, dataset, profile, total),
251
+ ["validator-internal", "process"])
252
+ out(f" internal-issue #{num}: opened for dataset {dataset} "
253
+ f"({total} occurrences, {len(by_pair)} pairs) — fallback policy")
254
+ return {"created": 1, "updated": 0, "pairs": len(by_pair), "total": total,
255
+ "fallback": True}
256
+ except Exception as e:
257
+ msg = f"{type(e).__name__}: {e}"
258
+ if "404" in msg:
259
+ out(f" ! internal-issue tracking aborted (404 — token lacks issues:write on {GH_REPO})")
260
+ return {"created": 0, "updated": 0, "aborted_404": True, "fallback": True}
261
+ out(f" ! internal-issue tracking for dataset {dataset} failed: {msg}")
262
+ return {"created": 0, "updated": 0, "error": msg, "fallback": True}
263
+
264
+
265
+ def ensure_internal_issues(results_json: dict, dataset: str, profile: str,
266
+ log_fn=None) -> dict:
267
+ """Scan results.json for validator-internal bugs and route them to
268
+ GitHub issues via an agentic review pass (Claude) that classifies,
269
+ dedupes against existing issues, and writes plain-language
270
+ explanations. Falls back to a simple one-issue-per-dataset policy
271
+ if the agent is unavailable.
272
+
273
+ Best-effort — swallowed exceptions return {"error": ...} so the
274
+ validator's verdict is never blocked on GitHub being flaky."""
275
+ out = log_fn or (lambda s: print(s, flush=True))
276
+ if not _gh_token():
277
+ out(" (skipping internal-issue tracking: no GH token)")
278
+ return {"skipped": True, "reason": "no_token"}
279
+
280
+ # Group across the whole dataset: (rule, code) → {count, sample, severity}
281
+ by_pair: dict[tuple[str, str], dict[str, Any]] = {}
282
+ total = 0
283
+ for asset in results_json.get("results", []):
284
+ for iss in (asset.get("issues") or []):
285
+ if not is_validator_internal_issue(iss):
286
+ continue
287
+ rule = iss.get("rule") or "?"
288
+ code = iss.get("code") or "UNKNOWN"
289
+ key = (rule, code)
290
+ g = by_pair.setdefault(key, {
291
+ "count": 0,
292
+ "sample_msg": scrub_secrets((iss.get("msg") or "")[:200]),
293
+ "severity": (iss.get("severity") or "").lower(),
294
+ })
295
+ g["count"] += 1
296
+ total += 1
297
+
298
+ if not by_pair:
299
+ return {"created": 0, "updated": 0}
300
+
301
+ # Agentic path: Claude classifies + writes the issue body.
302
+ try:
303
+ from agentic_issues import is_available as _agent_available, review_and_decide
304
+ except Exception as e:
305
+ out(f" (agentic_issues import failed: {type(e).__name__}: {str(e)[:120]}); "
306
+ f"using fallback policy")
307
+ return _ensure_internal_issues_simple(by_pair, dataset, profile, total, out)
308
+
309
+ if not _agent_available():
310
+ out(" (agentic review unavailable; using fallback policy)")
311
+ return _ensure_internal_issues_simple(by_pair, dataset, profile, total, out)
312
+
313
+ existing = _list_existing_internal_issues()
314
+ out(f" agentic review: {len(by_pair)} group(s) vs {len(existing)} existing "
315
+ f"validator-internal issue(s)")
316
+ review = review_and_decide(by_pair, dataset, profile, total, existing, log_fn=out)
317
+ if review is None or not review.get("decisions"):
318
+ return _ensure_internal_issues_simple(by_pair, dataset, profile, total, out)
319
+
320
+ if review.get("summary"):
321
+ out(f" agent summary: {review['summary'][:300]}")
322
+ result = _execute_decisions(review["decisions"], by_pair, dataset, profile, total, out)
323
+ result.update({"pairs": len(by_pair), "total": total, "agentic": True})
324
+ return result
tools/hf_space/plugin.json CHANGED
@@ -1,11 +1,11 @@
1
- {
2
- "$schema": "https://json.schemastore.org/claude-code-plugin",
3
- "name": "deploy-hf-space",
4
- "version": "0.1.0",
5
- "description": "Deploy the SimReady validator HuggingFace Space (phase-3 of the PRD). Ships one slash command: /deploy-hf-space — clones the Space repo, vendors in the code from this checkout, pushes, and confirms the build went green.",
6
- "author": {
7
- "name": "loginowskid",
8
- "email": "dloginowski@nvidia.com"
9
- },
10
- "homepage": "https://github.com/NVIDIA-dev/simready-oem-library-pm"
11
- }
 
1
+ {
2
+ "$schema": "https://json.schemastore.org/claude-code-plugin",
3
+ "name": "deploy-hf-space",
4
+ "version": "0.1.0",
5
+ "description": "Deploy the SimReady validator HuggingFace Space (phase-3 of the PRD). Ships one slash command: /deploy-hf-space — clones the Space repo, vendors in the code from this checkout, pushes, and confirms the build went green.",
6
+ "author": {
7
+ "name": "loginowskid",
8
+ "email": "dloginowski@nvidia.com"
9
+ },
10
+ "homepage": "https://github.com/NVIDIA-dev/simready-oem-library-pm"
11
+ }
tools/hf_space/runner.py CHANGED
The diff for this file is too large to render. See raw diff
 
tools/hf_space/skills/deploy-hf-space/SKILL.md CHANGED
@@ -1,257 +1,257 @@
1
- ---
2
- name: deploy-hf-space
3
- description: Stand up or update the SimReady validator HuggingFace Space (nvidia/simready-validator or wherever the operator points). Handles the cp-dance the Dockerfile's COPY paths require, the hf CLI launcher gotcha, secret/hardware setup, and post-push build verification. Invoke with /deploy-hf-space [<space-slug>].
4
- ---
5
-
6
- # /deploy-hf-space
7
-
8
- Deploy or update the HuggingFace Space that runs the SimReady validator
9
- on HF-hosted datasets (PRD phase-3). Use this when the operator wants
10
- the dashboard validator running on HF infrastructure instead of (or in
11
- addition to) the DGXC self-hosted runner.
12
-
13
- ## When to use
14
-
15
- - First-time stand-up of a new SimReady-validator Space
16
- - Pushing updated `tools/hf_space/runner.py`, `tools/hf_space/app.py`,
17
- or `tools/validation/` after fixing something in the validator engine
18
- - Diagnosing a green-build-but-broken-runtime situation (the skill's
19
- verification step pulls the run logs)
20
-
21
- ## When NOT to use
22
-
23
- - The Space repo lives at `https://huggingface.co/spaces/<owner>/<name>`
24
- — that's a separate git repo from this monorepo. This skill clones
25
- it, vendors files in, and pushes. It does **not** make any changes
26
- to this repo. If you're changing validator code, commit it here
27
- first; this skill just sync-deploys the snapshot.
28
- - Customer-facing per-tenant deployments — the current Dockerfile
29
- uses the Space's own `HF_TOKEN` to open verdict PRs. For
30
- multi-tenant deployment (one Space per partner), additional work is
31
- needed (see Open issues below).
32
-
33
- ## Pre-flight
34
-
35
- The operator needs:
36
-
37
- 1. **`huggingface_hub` Python package installed.** Verify with:
38
- ```
39
- <py> -c "import huggingface_hub; print(huggingface_hub.__version__)"
40
- ```
41
- where `<py>` is whichever python actually owns it. On Windows the
42
- `hf.exe` launcher often points at a non-existent `C:\Python314\`
43
- path — use the python directly instead of trusting `hf.exe`.
44
-
45
- 2. **HF auth.** The `hf auth whoami` (or `<py> -m huggingface_hub.cli.hf
46
- auth whoami`) should return the user + orgs. If not, log in:
47
- ```
48
- <py> -m huggingface_hub.cli.hf auth login
49
- ```
50
- Paste a **write-scoped** PAT from https://huggingface.co/settings/tokens
51
- at the interactive prompt. **Never echo a PAT into the chat input
52
- itself** — that puts it in the transcript. If the operator does, tell
53
- them to immediately rotate it.
54
-
55
- 3. **A Space already exists at the target slug.** This skill expects
56
- the Space to be created via the HF UI first:
57
- - https://huggingface.co/new-space
58
- - **SDK**: Docker (required — the validator's deps don't fit
59
- Gradio's default Python SDK)
60
- - **Hardware**: `cpu-basic` (free) is enough for the spike; the
61
- validator runs without Kit and the work is CPU-bound USD parsing
62
- - **Visibility**: Private while internal-pilot
63
-
64
- 4. **The `HF_TOKEN` secret is set on the Space.** Settings → Variables
65
- and secrets → New secret. Required for `Open PR` mode to commit
66
- verdicts back to customer datasets.
67
-
68
- If pre-flight 3 or 4 isn't done, ask the operator to do them in the
69
- browser, then resume. Don't attempt to create the Space via the API —
70
- the manual flow is what the team is set up for.
71
-
72
- ## Steps for Claude when `/deploy-hf-space [<slug>]` is invoked
73
-
74
- The skill operates on the slug `<owner>/<name>` (e.g.
75
- `nvidia/simready-validator`). If the operator omits the slug,
76
- default to `nvidia/simready-validator` for the current spike.
77
-
78
- 1. **Verify the Space exists and runtime is queryable.**
79
- ```
80
- <py> -c "
81
- from huggingface_hub import HfApi
82
- api = HfApi()
83
- info = api.space_info('<slug>')
84
- print(info.runtime, info.sdk)
85
- "
86
- ```
87
- `sdk` must be `docker`. If it's `gradio` or `static`, refuse to
88
- continue and tell the operator to recreate as Docker.
89
-
90
- 2. **Stage the Space repo at `/tmp/space-stage` (or equivalent).**
91
- ```
92
- git clone https://huggingface.co/spaces/<slug> /tmp/space-stage
93
- ```
94
- The HF git-credential helper that `hf auth login --add-to-git-credential`
95
- sets up handles auth. If clone prompts for a password, the operator's
96
- git creds aren't wired — back to pre-flight.
97
-
98
- 3. **Vendor in the deployable files.** The Dockerfile inside
99
- `tools/hf_space/Dockerfile` COPYs from these paths in the build
100
- context, so the Space repo root needs them mirrored:
101
-
102
- - `tools/hf_space/Dockerfile` → Space `Dockerfile` (root)
103
- - `tools/hf_space/` → Space `tools/hf_space/`
104
- - `tools/validation/` → Space `tools/validation/`
105
-
106
- In Bash:
107
- ```
108
- cp tools/hf_space/Dockerfile /tmp/space-stage/Dockerfile
109
- mkdir -p /tmp/space-stage/tools
110
- cp -r tools/hf_space /tmp/space-stage/tools/
111
- cp -r tools/validation /tmp/space-stage/tools/
112
- ```
113
-
114
- **DO NOT** drop a wholesale copy of this monorepo into the Space —
115
- it'd include unrelated subtrees (`docs/`, `goals/`, `clients.yaml`)
116
- that don't belong in a Space and may contain secrets-shaped content.
117
- Keep the COPY surface minimal.
118
-
119
- 4. **Preserve the HF-side README frontmatter.** HF auto-generates a
120
- `README.md` with the Space's title/emoji/sdk YAML frontmatter on
121
- creation. **Keep that frontmatter intact** — rewriting the README
122
- body is fine, but blowing away the frontmatter de-registers the
123
- Space card. The shipped README in `tools/hf_space/README.md` is the
124
- internal-docs version, NOT what should land at the Space root.
125
- Build a fresh `/tmp/space-stage/README.md` that keeps HF's
126
- frontmatter and adds a short body pointing back at this repo as the
127
- source of truth.
128
-
129
- 5. **Commit + push.**
130
- ```
131
- cd /tmp/space-stage
132
- git add -A
133
- git -c user.email=<op-email> -c user.name=<op-name> commit -m \\
134
- "Update from <sha> in simready-oem-library-pm"
135
- git push
136
- ```
137
- Use the **operator's** identity, not a bot — Spaces are usually
138
- personal accounts in this team. The commit message should reference
139
- the source-repo sha so the Space's history maps back.
140
-
141
- 6. **Verify the build.**
142
- ```
143
- <py> -c "
144
- from huggingface_hub import HfApi
145
- info = HfApi().space_info('<slug>')
146
- print(f'stage: {info.runtime.stage}')
147
- print(f'sha: {info.sha}')
148
- "
149
- ```
150
- Stages of interest:
151
- - `BUILDING` / `RUNNING_BUILDING` — wait. First build ~5 min;
152
- incremental builds (when only `runner.py` / `app.py` changed,
153
- not `requirements.txt` or `Dockerfile`) finish in ~1 min thanks
154
- to layer cache.
155
- - `RUNNING` — done. Open `https://huggingface.co/spaces/<slug>`
156
- in the browser to interact.
157
- - `BUILD_ERROR` / `RUNTIME_ERROR` — pull the build/run logs (see
158
- step 7) and report the failing lines to the operator.
159
-
160
- 7. **Pull logs if needed.** The HF API exposes the logs at:
161
- ```
162
- GET https://huggingface.co/api/spaces/<slug>/logs/{build,run}
163
- ```
164
- This is a Server-Sent Events stream — not a static endpoint. With
165
- the SDK >= 1.16 use `api.fetch_logs(...)` if available; otherwise
166
- open the URL via `urllib.request` with an `Authorization: Bearer
167
- <token>` header and read with a short idle timeout (the stream
168
- blocks indefinitely once it catches up to head).
169
-
170
- For a one-shot dump, this Python snippet works on Windows:
171
- ```python
172
- import http.client, socket, json
173
- from huggingface_hub import get_token
174
- conn = http.client.HTTPSConnection("huggingface.co", timeout=15)
175
- conn.request("GET", "/api/spaces/<slug>/logs/run",
176
- headers={"Authorization": f"Bearer {get_token()}"})
177
- resp = conn.getresponse()
178
- buf = b""
179
- try:
180
- while True:
181
- chunk = resp.read(2048)
182
- if not chunk: break
183
- buf += chunk
184
- except (socket.timeout, TimeoutError):
185
- pass
186
- # parse SSE: each `data: {...}` line carries one log line.
187
- ```
188
-
189
- ## Verification of the deploy
190
-
191
- After the push lands and runtime flips to `RUNNING`:
192
-
193
- 1. Open `https://huggingface.co/spaces/<slug>` in a browser.
194
- 2. Enter a USD-native (not zip-bundled) dataset in the **Dataset** box.
195
- Good smoke-test candidates:
196
- - `nvidia/PhysicalAI-SimReady-Warehouse-01`
197
- - Anything from the `nvidia/PhysicalAI-SimReady-*` family that ships
198
- `.usd` files directly (not in archives)
199
- 3. Leave **Profile = Robot-Body-Runnable**, **Version = 1.0.0**,
200
- **Open PR unchecked** for the first run.
201
- 4. Click **Validate**. Watch the **Log** box. Expected progression:
202
- ```
203
- [...] validating dataset=... profile=Robot-Body-Runnable v1.0.0
204
- workdir: /tmp/hfsp-<safe-name>-XXXX
205
- $ snapshot_download <repo> ...
206
- validator target: /tmp/hfsp-<safe-name>-XXXX/raw
207
- $ python /home/appuser/app/tools/validation/.../validate.py ...
208
- [validator spec loading — ~243 lines of "Registering feature ..."]
209
- Discovered N USD asset(s) under ...
210
- layout: M asset(s) deviate from SimReady packaging spec ... (if any)
211
- [per-asset "validate: ..." lines]
212
- PASS: K/N assets passed
213
- ```
214
- 5. If the verdict matches DGXC's verdict on the same dataset
215
- (byte-for-byte on `results.json`), the cutover criterion in
216
- `tools/hf_space/README.md` is met for that dataset.
217
-
218
- ## Known limitations
219
-
220
- These are real constraints that bit the spike — flag them to the
221
- operator if the dataset they're trying to validate hits one:
222
-
223
- - **Zip-bundled datasets fail with "no USD assets".** `HF_DOWNLOAD_EXCLUDES`
224
- in `runner.py` skips `.zip`, and even if we kept them, 800 × 600 MB
225
- doesn't fit on `cpu-basic`'s ephemeral disk (~50 GiB). The
226
- imagineio Kitchens-v1 dataset is the canonical example. Fix is to
227
- add zip-streaming validation (download one zip, extract, validate,
228
- delete, next) — a separate workstream.
229
- - **PhysX-bearing profiles are partially validated.** `--no-use-kit`
230
- is hard-coded because Spaces don't have Isaac Sim. The validator's
231
- P2 patch drops the resulting `physxschema_unavailable` /
232
- `omnipbr_unresolved` issues so the verdict isn't artificially
233
- failing, but PhysX/MDL rules that need Kit are silently not
234
- exercised. To restore them, bake an `a10g-small` tier with Isaac
235
- Sim wheels in the Dockerfile — different workstream.
236
- - **One Space, one HF_TOKEN.** Every customer's verdict PR is opened
237
- by the Space's own token. Fine for internal pilot, not safe for
238
- customer-facing deployment where customers shouldn't see each
239
- other's commit identities. Refactor `runner.run()` to accept a
240
- caller-provided token before exposing the Space externally.
241
- - **Validator subprocess is a single Claude-less Python invocation.**
242
- The DGXC path runs the validator inside a Claude Code subprocess
243
- (`tools/hf_watch/validate.py` does this) so the simready-report
244
- skill's agentic decisions (profile selection, error recovery) are
245
- exercised. The Space bypasses Claude entirely — calls
246
- `validate.py` directly. If/when we want the agentic decisions on
247
- HF too, bundle Claude Code into the Dockerfile + add an
248
- `ANTHROPIC_API_KEY` Space secret.
249
-
250
- ## Session memory hooks
251
-
252
- This skill is the source of truth for the deployment workflow. If the
253
- operator hits a step that doesn't match what's documented here (e.g.
254
- HF changes their Space API, hf CLI launcher works again, a new
255
- hardware tier appears), update **this file** rather than just patching
256
- the conversation. The next session that invokes `/deploy-hf-space`
257
- reads this file, not the old transcript.
 
1
+ ---
2
+ name: deploy-hf-space
3
+ description: Stand up or update the SimReady validator HuggingFace Space (nvidia/simready-validator or wherever the operator points). Handles the cp-dance the Dockerfile's COPY paths require, the hf CLI launcher gotcha, secret/hardware setup, and post-push build verification. Invoke with /deploy-hf-space [<space-slug>].
4
+ ---
5
+
6
+ # /deploy-hf-space
7
+
8
+ Deploy or update the HuggingFace Space that runs the SimReady validator
9
+ on HF-hosted datasets (PRD phase-3). Use this when the operator wants
10
+ the dashboard validator running on HF infrastructure instead of (or in
11
+ addition to) the DGXC self-hosted runner.
12
+
13
+ ## When to use
14
+
15
+ - First-time stand-up of a new SimReady-validator Space
16
+ - Pushing updated `tools/hf_space/runner.py`, `tools/hf_space/app.py`,
17
+ or `tools/validation/` after fixing something in the validator engine
18
+ - Diagnosing a green-build-but-broken-runtime situation (the skill's
19
+ verification step pulls the run logs)
20
+
21
+ ## When NOT to use
22
+
23
+ - The Space repo lives at `https://huggingface.co/spaces/<owner>/<name>`
24
+ — that's a separate git repo from this monorepo. This skill clones
25
+ it, vendors files in, and pushes. It does **not** make any changes
26
+ to this repo. If you're changing validator code, commit it here
27
+ first; this skill just sync-deploys the snapshot.
28
+ - Customer-facing per-tenant deployments — the current Dockerfile
29
+ uses the Space's own `HF_TOKEN` to open verdict PRs. For
30
+ multi-tenant deployment (one Space per partner), additional work is
31
+ needed (see Open issues below).
32
+
33
+ ## Pre-flight
34
+
35
+ The operator needs:
36
+
37
+ 1. **`huggingface_hub` Python package installed.** Verify with:
38
+ ```
39
+ <py> -c "import huggingface_hub; print(huggingface_hub.__version__)"
40
+ ```
41
+ where `<py>` is whichever python actually owns it. On Windows the
42
+ `hf.exe` launcher often points at a non-existent `C:\Python314\`
43
+ path — use the python directly instead of trusting `hf.exe`.
44
+
45
+ 2. **HF auth.** The `hf auth whoami` (or `<py> -m huggingface_hub.cli.hf
46
+ auth whoami`) should return the user + orgs. If not, log in:
47
+ ```
48
+ <py> -m huggingface_hub.cli.hf auth login
49
+ ```
50
+ Paste a **write-scoped** PAT from https://huggingface.co/settings/tokens
51
+ at the interactive prompt. **Never echo a PAT into the chat input
52
+ itself** — that puts it in the transcript. If the operator does, tell
53
+ them to immediately rotate it.
54
+
55
+ 3. **A Space already exists at the target slug.** This skill expects
56
+ the Space to be created via the HF UI first:
57
+ - https://huggingface.co/new-space
58
+ - **SDK**: Docker (required — the validator's deps don't fit
59
+ Gradio's default Python SDK)
60
+ - **Hardware**: `cpu-basic` (free) is enough for the spike; the
61
+ validator runs without Kit and the work is CPU-bound USD parsing
62
+ - **Visibility**: Private while internal-pilot
63
+
64
+ 4. **The `HF_TOKEN` secret is set on the Space.** Settings → Variables
65
+ and secrets → New secret. Required for `Open PR` mode to commit
66
+ verdicts back to customer datasets.
67
+
68
+ If pre-flight 3 or 4 isn't done, ask the operator to do them in the
69
+ browser, then resume. Don't attempt to create the Space via the API —
70
+ the manual flow is what the team is set up for.
71
+
72
+ ## Steps for Claude when `/deploy-hf-space [<slug>]` is invoked
73
+
74
+ The skill operates on the slug `<owner>/<name>` (e.g.
75
+ `nvidia/simready-validator`). If the operator omits the slug,
76
+ default to `nvidia/simready-validator` for the current spike.
77
+
78
+ 1. **Verify the Space exists and runtime is queryable.**
79
+ ```
80
+ <py> -c "
81
+ from huggingface_hub import HfApi
82
+ api = HfApi()
83
+ info = api.space_info('<slug>')
84
+ print(info.runtime, info.sdk)
85
+ "
86
+ ```
87
+ `sdk` must be `docker`. If it's `gradio` or `static`, refuse to
88
+ continue and tell the operator to recreate as Docker.
89
+
90
+ 2. **Stage the Space repo at `/tmp/space-stage` (or equivalent).**
91
+ ```
92
+ git clone https://huggingface.co/spaces/<slug> /tmp/space-stage
93
+ ```
94
+ The HF git-credential helper that `hf auth login --add-to-git-credential`
95
+ sets up handles auth. If clone prompts for a password, the operator's
96
+ git creds aren't wired — back to pre-flight.
97
+
98
+ 3. **Vendor in the deployable files.** The Dockerfile inside
99
+ `tools/hf_space/Dockerfile` COPYs from these paths in the build
100
+ context, so the Space repo root needs them mirrored:
101
+
102
+ - `tools/hf_space/Dockerfile` → Space `Dockerfile` (root)
103
+ - `tools/hf_space/` → Space `tools/hf_space/`
104
+ - `tools/validation/` → Space `tools/validation/`
105
+
106
+ In Bash:
107
+ ```
108
+ cp tools/hf_space/Dockerfile /tmp/space-stage/Dockerfile
109
+ mkdir -p /tmp/space-stage/tools
110
+ cp -r tools/hf_space /tmp/space-stage/tools/
111
+ cp -r tools/validation /tmp/space-stage/tools/
112
+ ```
113
+
114
+ **DO NOT** drop a wholesale copy of this monorepo into the Space —
115
+ it'd include unrelated subtrees (`docs/`, `goals/`, `clients.yaml`)
116
+ that don't belong in a Space and may contain secrets-shaped content.
117
+ Keep the COPY surface minimal.
118
+
119
+ 4. **Preserve the HF-side README frontmatter.** HF auto-generates a
120
+ `README.md` with the Space's title/emoji/sdk YAML frontmatter on
121
+ creation. **Keep that frontmatter intact** — rewriting the README
122
+ body is fine, but blowing away the frontmatter de-registers the
123
+ Space card. The shipped README in `tools/hf_space/README.md` is the
124
+ internal-docs version, NOT what should land at the Space root.
125
+ Build a fresh `/tmp/space-stage/README.md` that keeps HF's
126
+ frontmatter and adds a short body pointing back at this repo as the
127
+ source of truth.
128
+
129
+ 5. **Commit + push.**
130
+ ```
131
+ cd /tmp/space-stage
132
+ git add -A
133
+ git -c user.email=<op-email> -c user.name=<op-name> commit -m \\
134
+ "Update from <sha> in simready-oem-library-pm"
135
+ git push
136
+ ```
137
+ Use the **operator's** identity, not a bot — Spaces are usually
138
+ personal accounts in this team. The commit message should reference
139
+ the source-repo sha so the Space's history maps back.
140
+
141
+ 6. **Verify the build.**
142
+ ```
143
+ <py> -c "
144
+ from huggingface_hub import HfApi
145
+ info = HfApi().space_info('<slug>')
146
+ print(f'stage: {info.runtime.stage}')
147
+ print(f'sha: {info.sha}')
148
+ "
149
+ ```
150
+ Stages of interest:
151
+ - `BUILDING` / `RUNNING_BUILDING` — wait. First build ~5 min;
152
+ incremental builds (when only `runner.py` / `app.py` changed,
153
+ not `requirements.txt` or `Dockerfile`) finish in ~1 min thanks
154
+ to layer cache.
155
+ - `RUNNING` — done. Open `https://huggingface.co/spaces/<slug>`
156
+ in the browser to interact.
157
+ - `BUILD_ERROR` / `RUNTIME_ERROR` — pull the build/run logs (see
158
+ step 7) and report the failing lines to the operator.
159
+
160
+ 7. **Pull logs if needed.** The HF API exposes the logs at:
161
+ ```
162
+ GET https://huggingface.co/api/spaces/<slug>/logs/{build,run}
163
+ ```
164
+ This is a Server-Sent Events stream — not a static endpoint. With
165
+ the SDK >= 1.16 use `api.fetch_logs(...)` if available; otherwise
166
+ open the URL via `urllib.request` with an `Authorization: Bearer
167
+ <token>` header and read with a short idle timeout (the stream
168
+ blocks indefinitely once it catches up to head).
169
+
170
+ For a one-shot dump, this Python snippet works on Windows:
171
+ ```python
172
+ import http.client, socket, json
173
+ from huggingface_hub import get_token
174
+ conn = http.client.HTTPSConnection("huggingface.co", timeout=15)
175
+ conn.request("GET", "/api/spaces/<slug>/logs/run",
176
+ headers={"Authorization": f"Bearer {get_token()}"})
177
+ resp = conn.getresponse()
178
+ buf = b""
179
+ try:
180
+ while True:
181
+ chunk = resp.read(2048)
182
+ if not chunk: break
183
+ buf += chunk
184
+ except (socket.timeout, TimeoutError):
185
+ pass
186
+ # parse SSE: each `data: {...}` line carries one log line.
187
+ ```
188
+
189
+ ## Verification of the deploy
190
+
191
+ After the push lands and runtime flips to `RUNNING`:
192
+
193
+ 1. Open `https://huggingface.co/spaces/<slug>` in a browser.
194
+ 2. Enter a USD-native (not zip-bundled) dataset in the **Dataset** box.
195
+ Good smoke-test candidates:
196
+ - `nvidia/PhysicalAI-SimReady-Warehouse-01`
197
+ - Anything from the `nvidia/PhysicalAI-SimReady-*` family that ships
198
+ `.usd` files directly (not in archives)
199
+ 3. Leave **Profile = Robot-Body-Runnable**, **Version = 1.0.0**,
200
+ **Open PR unchecked** for the first run.
201
+ 4. Click **Validate**. Watch the **Log** box. Expected progression:
202
+ ```
203
+ [...] validating dataset=... profile=Robot-Body-Runnable v1.0.0
204
+ workdir: /tmp/hfsp-<safe-name>-XXXX
205
+ $ snapshot_download <repo> ...
206
+ validator target: /tmp/hfsp-<safe-name>-XXXX/raw
207
+ $ python /home/appuser/app/tools/validation/.../validate.py ...
208
+ [validator spec loading — ~243 lines of "Registering feature ..."]
209
+ Discovered N USD asset(s) under ...
210
+ layout: M asset(s) deviate from SimReady packaging spec ... (if any)
211
+ [per-asset "validate: ..." lines]
212
+ PASS: K/N assets passed
213
+ ```
214
+ 5. If the verdict matches DGXC's verdict on the same dataset
215
+ (byte-for-byte on `results.json`), the cutover criterion in
216
+ `tools/hf_space/README.md` is met for that dataset.
217
+
218
+ ## Known limitations
219
+
220
+ These are real constraints that bit the spike — flag them to the
221
+ operator if the dataset they're trying to validate hits one:
222
+
223
+ - **Zip-bundled datasets fail with "no USD assets".** `HF_DOWNLOAD_EXCLUDES`
224
+ in `runner.py` skips `.zip`, and even if we kept them, 800 × 600 MB
225
+ doesn't fit on `cpu-basic`'s ephemeral disk (~50 GiB). The
226
+ imagineio Kitchens-v1 dataset is the canonical example. Fix is to
227
+ add zip-streaming validation (download one zip, extract, validate,
228
+ delete, next) — a separate workstream.
229
+ - **PhysX-bearing profiles are partially validated.** `--no-use-kit`
230
+ is hard-coded because Spaces don't have Isaac Sim. The validator's
231
+ P2 patch drops the resulting `physxschema_unavailable` /
232
+ `omnipbr_unresolved` issues so the verdict isn't artificially
233
+ failing, but PhysX/MDL rules that need Kit are silently not
234
+ exercised. To restore them, bake an `a10g-small` tier with Isaac
235
+ Sim wheels in the Dockerfile — different workstream.
236
+ - **One Space, one HF_TOKEN.** Every customer's verdict PR is opened
237
+ by the Space's own token. Fine for internal pilot, not safe for
238
+ customer-facing deployment where customers shouldn't see each
239
+ other's commit identities. Refactor `runner.run()` to accept a
240
+ caller-provided token before exposing the Space externally.
241
+ - **Validator subprocess is a single Claude-less Python invocation.**
242
+ The DGXC path runs the validator inside a Claude Code subprocess
243
+ (`tools/hf_watch/validate.py` does this) so the simready-report
244
+ skill's agentic decisions (profile selection, error recovery) are
245
+ exercised. The Space bypasses Claude entirely — calls
246
+ `validate.py` directly. If/when we want the agentic decisions on
247
+ HF too, bundle Claude Code into the Dockerfile + add an
248
+ `ANTHROPIC_API_KEY` Space secret.
249
+
250
+ ## Session memory hooks
251
+
252
+ This skill is the source of truth for the deployment workflow. If the
253
+ operator hits a step that doesn't match what's documented here (e.g.
254
+ HF changes their Space API, hf CLI launcher works again, a new
255
+ hardware tier appears), update **this file** rather than just patching
256
+ the conversation. The next session that invokes `/deploy-hf-space`
257
+ reads this file, not the old transcript.
tools/hf_space/test_discover_dgxc.py CHANGED
@@ -1,142 +1,142 @@
1
- """DGXC discovery verifier — exercise the new recursive discover_assets
2
- against four synthetic layouts. Self-contained: imports validate.py with
3
- SIMREADY_INSIDE_KIT + SIMREADY_FOUNDATIONS_PATH already set so the bootstrap
4
- gate clears.
5
-
6
- Usage on the pod:
7
- export SIMREADY_FOUNDATIONS_PATH=/home/horde/.simready/simready_foundations
8
- /home/horde/.simready/venv/bin/python3 test_discover_dgxc.py
9
- """
10
- from __future__ import annotations
11
-
12
- import os
13
- import sys
14
- import tempfile
15
- from pathlib import Path
16
-
17
- # Skip the Kit re-exec gate (we're testing pure-Python discovery, no Kit).
18
- os.environ.setdefault("SIMREADY_INSIDE_KIT", "1")
19
-
20
- # Resolve the repo: env var first (DGXC ad-hoc run), then assume the
21
- # script lives inside a checkout (workflow / local).
22
- _env_repo = os.environ.get("SIMREADY_REPO")
23
- if _env_repo:
24
- REPO = Path(_env_repo)
25
- else:
26
- REPO = Path(__file__).resolve().parents[2]
27
- SKILL = REPO / "tools" / "validation" / "plugins" / "simready-report" / "skills" / "simready-report"
28
- if not SKILL.is_dir():
29
- raise SystemExit(
30
- f"Validator skill dir not found at {SKILL}. "
31
- f"Set SIMREADY_REPO to the simready-oem-library-pm checkout root."
32
- )
33
- sys.path.insert(0, str(SKILL))
34
-
35
- import validate as v # noqa: E402
36
-
37
- CASES = [
38
- # (label, build_tree, expected_assets, expected_finding_codes)
39
- ("well_shaped",
40
- lambda root: [
41
- (root / "well_shaped" / "well_shaped.usda", "interface"),
42
- (root / "well_shaped" / "Geometry" / "mesh.usda", "sublayer (Geometry)"),
43
- (root / "well_shaped" / "Materials" / "mat.usda", "sublayer (Materials)"),
44
- ],
45
- # Old behavior preserved: only the bundle interface is returned.
46
- ["well_shaped/well_shaped.usda"],
47
- []),
48
- ("flat",
49
- lambda root: [
50
- (root / "flat" / "scene_a.usda", "root-level USD, no bundle"),
51
- (root / "flat" / "scene_b.usda", "root-level USD, no bundle"),
52
- ],
53
- # New behavior: both found, both tagged as non-standard.
54
- ["flat/scene_a.usda", "flat/scene_b.usda"],
55
- ["LAYOUT.NON_STANDARD_BUNDLE", "LAYOUT.NON_STANDARD_BUNDLE"]),
56
- ("deep",
57
- lambda root: [
58
- (root / "deep" / "team1" / "v1" / "scenes" / "render_target.usda",
59
- "deeply-nested USD, no bundle wrapper"),
60
- ],
61
- # New behavior: found at depth, flagged.
62
- ["deep/team1/v1/scenes/render_target.usda"],
63
- ["LAYOUT.NON_STANDARD_BUNDLE"]),
64
- ("mixed",
65
- lambda root: [
66
- (root / "mixed" / "proper_bundle" / "proper_bundle.usda", "interface"),
67
- (root / "mixed" / "proper_bundle" / "Geometry" / "g.usda", "sublayer"),
68
- (root / "mixed" / "orphan.usda", "standalone — not a bundle"),
69
- ],
70
- # Bundle interface and the orphan validate; sublayer excluded; orphan flagged.
71
- ["mixed/orphan.usda", "mixed/proper_bundle/proper_bundle.usda"],
72
- ["LAYOUT.NON_STANDARD_BUNDLE"]),
73
- # Real-world layout from Aperdata's Kitchen-01 dataset:
74
- # `<top_dir>/<asset>.usd` for the top-level interface plus a
75
- # `<top_dir>/SubUSDs/<many>.usd` sublayer tree. The interface
76
- # stem (`Indoor`) doesn't match the dir name (`0_Kitchen_Indoor`),
77
- # so older logic missed the SubUSDs filter and counted them as
78
- # standalone assets — Aperdata went from 23 expected to 933
79
- # validated. New logic excludes anything under a SUBLAYER_DIR
80
- # ancestor structurally, regardless of bundle naming.
81
- ("aperdata_kitchen",
82
- lambda root: [
83
- (root / "aperdata_kitchen" / "0_Kitchen_Indoor" / "Indoor.usda", "interface (stem != parent dir)"),
84
- (root / "aperdata_kitchen" / "0_Kitchen_Indoor" / "SubUSDs" / "CL06.usda", "sublayer (under SubUSDs)"),
85
- (root / "aperdata_kitchen" / "0_Kitchen_Indoor" / "SubUSDs" / "CL06_1.usda", "sublayer (under SubUSDs)"),
86
- (root / "aperdata_kitchen" / "0_Kitchen_Indoor" / "SubUSDs" / "DEC_large_leaf.usda", "sublayer (under SubUSDs)"),
87
- (root / "aperdata_kitchen" / "mug_handheld" / "Collected_dining_mug_handheld" / "dining_mug_handheld.usda",
88
- "interface in nested Collected_* dir"),
89
- ],
90
- # Only the two interface USDs validate. SubUSDs/* are excluded
91
- # structurally. The deep mug_handheld interface IS the only
92
- # candidate in its top-level dir, so it counts as a bundle
93
- # interface (no layout warning). Indoor.usd is also the only
94
- # candidate under 0_Kitchen_Indoor → no warning.
95
- [
96
- "aperdata_kitchen/0_Kitchen_Indoor/Indoor.usda",
97
- "aperdata_kitchen/mug_handheld/Collected_dining_mug_handheld/dining_mug_handheld.usda",
98
- ],
99
- []),
100
- ]
101
-
102
-
103
- def _normalize(paths: list[Path], root: Path) -> list[str]:
104
- return sorted(str(p.relative_to(root)).replace("\\", "/") for p in paths)
105
-
106
-
107
- def main() -> int:
108
- failures = 0
109
- with tempfile.TemporaryDirectory(prefix="sr-discover-test-") as td:
110
- root = Path(td)
111
- for label, build, expected_assets, expected_codes in CASES:
112
- case_dir = root / label
113
- case_dir.mkdir(parents=True, exist_ok=True)
114
- for usd_path, _why in build(root):
115
- usd_path.parent.mkdir(parents=True, exist_ok=True)
116
- usd_path.write_text("()") # empty stage — discover_assets is pure FS walk
117
- assets, findings = v.discover_assets(case_dir)
118
- got_assets = _normalize(assets, root)
119
- got_codes = sorted([f["code"] for f in findings])
120
- exp_assets = sorted(expected_assets)
121
- exp_codes = sorted(expected_codes)
122
- ok = (got_assets == exp_assets and got_codes == exp_codes)
123
- mark = "PASS" if ok else "FAIL"
124
- print(f" [{mark}] {label}")
125
- print(f" expected assets: {exp_assets}")
126
- print(f" got assets: {got_assets}")
127
- print(f" expected findings: {exp_codes}")
128
- print(f" got findings: {got_codes}")
129
- if not ok:
130
- failures += 1
131
- for f in findings:
132
- print(f" finding detail: {f}")
133
- print()
134
- if failures:
135
- print(f"{failures} case(s) FAILED")
136
- return 1
137
- print("all cases passed — recursive discovery + layout findings working end-to-end on DGXC")
138
- return 0
139
-
140
-
141
- if __name__ == "__main__":
142
- raise SystemExit(main())
 
1
+ """DGXC discovery verifier — exercise the new recursive discover_assets
2
+ against four synthetic layouts. Self-contained: imports validate.py with
3
+ SIMREADY_INSIDE_KIT + SIMREADY_FOUNDATIONS_PATH already set so the bootstrap
4
+ gate clears.
5
+
6
+ Usage on the pod:
7
+ export SIMREADY_FOUNDATIONS_PATH=/home/horde/.simready/simready_foundations
8
+ /home/horde/.simready/venv/bin/python3 test_discover_dgxc.py
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import sys
14
+ import tempfile
15
+ from pathlib import Path
16
+
17
+ # Skip the Kit re-exec gate (we're testing pure-Python discovery, no Kit).
18
+ os.environ.setdefault("SIMREADY_INSIDE_KIT", "1")
19
+
20
+ # Resolve the repo: env var first (DGXC ad-hoc run), then assume the
21
+ # script lives inside a checkout (workflow / local).
22
+ _env_repo = os.environ.get("SIMREADY_REPO")
23
+ if _env_repo:
24
+ REPO = Path(_env_repo)
25
+ else:
26
+ REPO = Path(__file__).resolve().parents[2]
27
+ SKILL = REPO / "tools" / "validation" / "plugins" / "simready-report" / "skills" / "simready-report"
28
+ if not SKILL.is_dir():
29
+ raise SystemExit(
30
+ f"Validator skill dir not found at {SKILL}. "
31
+ f"Set SIMREADY_REPO to the simready-oem-library-pm checkout root."
32
+ )
33
+ sys.path.insert(0, str(SKILL))
34
+
35
+ import validate as v # noqa: E402
36
+
37
+ CASES = [
38
+ # (label, build_tree, expected_assets, expected_finding_codes)
39
+ ("well_shaped",
40
+ lambda root: [
41
+ (root / "well_shaped" / "well_shaped.usda", "interface"),
42
+ (root / "well_shaped" / "Geometry" / "mesh.usda", "sublayer (Geometry)"),
43
+ (root / "well_shaped" / "Materials" / "mat.usda", "sublayer (Materials)"),
44
+ ],
45
+ # Old behavior preserved: only the bundle interface is returned.
46
+ ["well_shaped/well_shaped.usda"],
47
+ []),
48
+ ("flat",
49
+ lambda root: [
50
+ (root / "flat" / "scene_a.usda", "root-level USD, no bundle"),
51
+ (root / "flat" / "scene_b.usda", "root-level USD, no bundle"),
52
+ ],
53
+ # New behavior: both found, both tagged as non-standard.
54
+ ["flat/scene_a.usda", "flat/scene_b.usda"],
55
+ ["LAYOUT.NON_STANDARD_BUNDLE", "LAYOUT.NON_STANDARD_BUNDLE"]),
56
+ ("deep",
57
+ lambda root: [
58
+ (root / "deep" / "team1" / "v1" / "scenes" / "render_target.usda",
59
+ "deeply-nested USD, no bundle wrapper"),
60
+ ],
61
+ # New behavior: found at depth, flagged.
62
+ ["deep/team1/v1/scenes/render_target.usda"],
63
+ ["LAYOUT.NON_STANDARD_BUNDLE"]),
64
+ ("mixed",
65
+ lambda root: [
66
+ (root / "mixed" / "proper_bundle" / "proper_bundle.usda", "interface"),
67
+ (root / "mixed" / "proper_bundle" / "Geometry" / "g.usda", "sublayer"),
68
+ (root / "mixed" / "orphan.usda", "standalone — not a bundle"),
69
+ ],
70
+ # Bundle interface and the orphan validate; sublayer excluded; orphan flagged.
71
+ ["mixed/orphan.usda", "mixed/proper_bundle/proper_bundle.usda"],
72
+ ["LAYOUT.NON_STANDARD_BUNDLE"]),
73
+ # Real-world layout from Aperdata's Kitchen-01 dataset:
74
+ # `<top_dir>/<asset>.usd` for the top-level interface plus a
75
+ # `<top_dir>/SubUSDs/<many>.usd` sublayer tree. The interface
76
+ # stem (`Indoor`) doesn't match the dir name (`0_Kitchen_Indoor`),
77
+ # so older logic missed the SubUSDs filter and counted them as
78
+ # standalone assets — Aperdata went from 23 expected to 933
79
+ # validated. New logic excludes anything under a SUBLAYER_DIR
80
+ # ancestor structurally, regardless of bundle naming.
81
+ ("aperdata_kitchen",
82
+ lambda root: [
83
+ (root / "aperdata_kitchen" / "0_Kitchen_Indoor" / "Indoor.usda", "interface (stem != parent dir)"),
84
+ (root / "aperdata_kitchen" / "0_Kitchen_Indoor" / "SubUSDs" / "CL06.usda", "sublayer (under SubUSDs)"),
85
+ (root / "aperdata_kitchen" / "0_Kitchen_Indoor" / "SubUSDs" / "CL06_1.usda", "sublayer (under SubUSDs)"),
86
+ (root / "aperdata_kitchen" / "0_Kitchen_Indoor" / "SubUSDs" / "DEC_large_leaf.usda", "sublayer (under SubUSDs)"),
87
+ (root / "aperdata_kitchen" / "mug_handheld" / "Collected_dining_mug_handheld" / "dining_mug_handheld.usda",
88
+ "interface in nested Collected_* dir"),
89
+ ],
90
+ # Only the two interface USDs validate. SubUSDs/* are excluded
91
+ # structurally. The deep mug_handheld interface IS the only
92
+ # candidate in its top-level dir, so it counts as a bundle
93
+ # interface (no layout warning). Indoor.usd is also the only
94
+ # candidate under 0_Kitchen_Indoor → no warning.
95
+ [
96
+ "aperdata_kitchen/0_Kitchen_Indoor/Indoor.usda",
97
+ "aperdata_kitchen/mug_handheld/Collected_dining_mug_handheld/dining_mug_handheld.usda",
98
+ ],
99
+ []),
100
+ ]
101
+
102
+
103
+ def _normalize(paths: list[Path], root: Path) -> list[str]:
104
+ return sorted(str(p.relative_to(root)).replace("\\", "/") for p in paths)
105
+
106
+
107
+ def main() -> int:
108
+ failures = 0
109
+ with tempfile.TemporaryDirectory(prefix="sr-discover-test-") as td:
110
+ root = Path(td)
111
+ for label, build, expected_assets, expected_codes in CASES:
112
+ case_dir = root / label
113
+ case_dir.mkdir(parents=True, exist_ok=True)
114
+ for usd_path, _why in build(root):
115
+ usd_path.parent.mkdir(parents=True, exist_ok=True)
116
+ usd_path.write_text("()") # empty stage — discover_assets is pure FS walk
117
+ assets, findings = v.discover_assets(case_dir)
118
+ got_assets = _normalize(assets, root)
119
+ got_codes = sorted([f["code"] for f in findings])
120
+ exp_assets = sorted(expected_assets)
121
+ exp_codes = sorted(expected_codes)
122
+ ok = (got_assets == exp_assets and got_codes == exp_codes)
123
+ mark = "PASS" if ok else "FAIL"
124
+ print(f" [{mark}] {label}")
125
+ print(f" expected assets: {exp_assets}")
126
+ print(f" got assets: {got_assets}")
127
+ print(f" expected findings: {exp_codes}")
128
+ print(f" got findings: {got_codes}")
129
+ if not ok:
130
+ failures += 1
131
+ for f in findings:
132
+ print(f" finding detail: {f}")
133
+ print()
134
+ if failures:
135
+ print(f"{failures} case(s) FAILED")
136
+ return 1
137
+ print("all cases passed — recursive discovery + layout findings working end-to-end on DGXC")
138
+ return 0
139
+
140
+
141
+ if __name__ == "__main__":
142
+ raise SystemExit(main())
tools/validation/.claude-plugin/marketplace.json CHANGED
@@ -1,16 +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
- }
 
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 CHANGED
@@ -1,327 +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.
 
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 CHANGED
@@ -1,139 +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`.
 
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 CHANGED
@@ -1,66 +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.
 
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 CHANGED
@@ -1,98 +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
 
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 CHANGED
@@ -1,207 +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
 
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 CHANGED
@@ -1,591 +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" |
 
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 CHANGED
@@ -1,11 +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
- }
 
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 CHANGED
@@ -1,62 +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`.
 
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 CHANGED
@@ -1,143 +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())
 
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 CHANGED
@@ -1,137 +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`
 
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 CHANGED
@@ -1,33 +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
 
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 CHANGED
@@ -1,259 +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 "")
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> &middot; 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 &mdash; 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
- """
 
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 "")
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> &middot; 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 &mdash; 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 CHANGED
@@ -1,860 +1,860 @@
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
- # html:True passes raw HTML through unescaped — safe only because the
135
- # markdown comes from the trusted foundations repo, not user input.
136
- _md_renderer = MarkdownIt("commonmark", {"html": True, "linkify": True, "typographer": True}).enable("table")
137
- return _md_renderer.render(md_text)
138
-
139
-
140
- _IMG_PLACEHOLDER_SVG = (
141
- "data:image/svg+xml;utf8,"
142
- "<svg xmlns='http://www.w3.org/2000/svg' width='320' height='180' viewBox='0 0 320 180'>"
143
- "<rect width='320' height='180' fill='%23f0f2f8' stroke='%23c7cee0' stroke-dasharray='6 4' stroke-width='2'/>"
144
- "<text x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' "
145
- "font-family='Segoe UI,Arial,sans-serif' font-size='14' fill='%237280a7'>image missing</text>"
146
- "</svg>"
147
- )
148
-
149
-
150
- def _localize_images(body_html: str, md_source: Path, out_html_path: Path) -> str:
151
- """Walk every `<img src="...">` in `body_html`. If src is a relative path,
152
- copy the source file (relative to `md_source.parent`) into the equivalent
153
- location next to `out_html_path`. When the source file is missing, replace
154
- the src with an inline SVG placeholder so the page never 404s.
155
- """
156
- import re
157
- import shutil
158
-
159
- md_dir = md_source.parent
160
- out_dir = out_html_path.parent
161
-
162
- def repl(match):
163
- full_tag = match.group(0)
164
- src_match = re.search(r'src="([^"]+)"', full_tag)
165
- if not src_match:
166
- return full_tag
167
- src = src_match.group(1)
168
- if src.startswith(("http://", "https://", "data:", "//", "/")):
169
- return full_tag # absolute / data URI / protocol-relative — leave alone
170
- rel = src.lstrip("./").replace("\\", "/")
171
- source_file = (md_dir / src).resolve()
172
- # Containment guard on the SOURCE: a crafted ../ src must not read
173
- # files outside the markdown tree. Escape -> skip to placeholder.
174
- try:
175
- source_file.relative_to(md_dir.resolve())
176
- except ValueError:
177
- import sys
178
- print(f"[report] skipping out-of-tree image src: {src!r}", file=sys.stderr)
179
- return re.sub(r'src="[^"]+"', f'src="{_IMG_PLACEHOLDER_SVG}"', full_tag)
180
- if source_file.is_file():
181
- dest_file = (out_dir / rel).resolve()
182
- try:
183
- dest_file.relative_to(out_dir.resolve()) # guard against ../ escape
184
- dest_file.parent.mkdir(parents=True, exist_ok=True)
185
- if not dest_file.exists() or dest_file.stat().st_size != source_file.stat().st_size:
186
- shutil.copy2(source_file, dest_file)
187
- return full_tag
188
- except (ValueError, OSError):
189
- pass
190
- # Missing source — swap to placeholder
191
- return re.sub(r'src="[^"]+"', f'src="{_IMG_PLACEHOLDER_SVG}"', full_tag)
192
-
193
- return re.sub(r"<img\b[^>]*>", repl, body_html)
194
-
195
-
196
- _A_TAG_RE = None
197
-
198
-
199
- def _localize_links(body_html: str, md_source: Path, out_html_path: Path,
200
- specs_docs_dir: Path, out_docs_root: Path,
201
- resolver) -> str:
202
- """Rewrite relative <a href="..."> targets so the docs tree is self-contained.
203
-
204
- - .md targets that resolve to a file inside `specs_docs_dir`: rewrite href
205
- to .html and recursively render via `resolver(<rel>.html)`.
206
- - non-.md targets inside `specs_docs_dir`: copy verbatim into the mirrored
207
- location under `out_docs_root` and rewrite href to that copy.
208
- - root-relative ("/...") hrefs treated as relative to `specs_docs_dir`;
209
- bare paths without an extension are tried as `.md`.
210
- - links pointing outside `specs_docs_dir` or at non-existent targets are
211
- replaced with <span class="docref-disabled" title="...">TEXT</span> so
212
- the visible text remains but no href can 404.
213
- """
214
- import os
215
- import re
216
- import shutil
217
-
218
- global _A_TAG_RE
219
- if _A_TAG_RE is None:
220
- _A_TAG_RE = re.compile(r'<a\s+([^>]*?)>(.*?)</a>', re.DOTALL | re.IGNORECASE)
221
-
222
- md_dir = md_source.parent
223
- out_dir = out_html_path.parent
224
- specs_root = specs_docs_dir.resolve()
225
- out_docs_resolved = out_docs_root.resolve()
226
-
227
- def _disable(inner: str, why: str = "link target not available in report") -> str:
228
- return f'<span class="docref-disabled" title="{html.escape(why)}">{inner}</span>'
229
-
230
- def _resolve_target(href_path: str) -> Path | None:
231
- if href_path.startswith("/"):
232
- base = specs_root / href_path.lstrip("/")
233
- if not base.suffix:
234
- base = base.with_suffix(".md")
235
- return base
236
- return (md_dir / href_path)
237
-
238
- def repl(m: "re.Match[str]") -> str:
239
- attrs = m.group(1)
240
- inner = m.group(2)
241
- href_m = re.search(r'href\s*=\s*"([^"]*)"', attrs)
242
- if not href_m:
243
- return m.group(0)
244
- href = href_m.group(1).strip()
245
- if not href:
246
- return m.group(0)
247
- if href.startswith(("http://", "https://", "data:", "mailto:", "//", "#", "javascript:")):
248
- return m.group(0)
249
-
250
- if "#" in href:
251
- path_part, frag = href.split("#", 1)
252
- frag = "#" + frag
253
- else:
254
- path_part, frag = href, ""
255
- if not path_part:
256
- return m.group(0)
257
-
258
- target = _resolve_target(path_part)
259
- if target is None:
260
- return _disable(inner)
261
- try:
262
- target = target.resolve()
263
- except OSError:
264
- return _disable(inner)
265
- try:
266
- rel_to_specs = target.relative_to(specs_root)
267
- except ValueError:
268
- return _disable(inner, "link target outside foundation docs")
269
- if not target.exists():
270
- return _disable(inner, "link target missing in foundation docs")
271
-
272
- rel_str = str(rel_to_specs).replace("\\", "/")
273
-
274
- if target.suffix.lower() == ".md":
275
- html_rel = rel_str[:-3] + ".html"
276
- url = resolver(html_rel)
277
- if url is None:
278
- return _disable(inner)
279
- target_out = out_docs_resolved / html_rel
280
- else:
281
- target_out = out_docs_resolved / rel_str
282
- try:
283
- target_out.parent.mkdir(parents=True, exist_ok=True)
284
- if not target_out.exists() or target_out.stat().st_size != target.stat().st_size:
285
- shutil.copy2(target, target_out)
286
- except OSError:
287
- return _disable(inner, "could not copy link target into report")
288
-
289
- try:
290
- new_href = os.path.relpath(target_out, out_dir).replace("\\", "/") + frag
291
- except ValueError:
292
- return _disable(inner)
293
- new_attrs = re.sub(r'href\s*=\s*"[^"]*"', f'href="{new_href}"', attrs, count=1)
294
- return f'<a {new_attrs}>{inner}</a>'
295
-
296
- return _A_TAG_RE.sub(repl, body_html)
297
-
298
-
299
- def _make_doc_url_resolver(out_dir: Path, dashboard_docs_dir: Path | None,
300
- specs_docs_dir: Path | None):
301
- """Build a `_doc_url(rel_path)` callable.
302
-
303
- Every resolvable doc is rendered from the foundation markdown source into
304
- `<out_dir>/docs/<rel_path>` so the dashboard has a single, self-contained
305
- docs tree. The reference-dashboard pre-built HTML is no longer used as a
306
- link target — it would split the source of truth.
307
-
308
- `dashboard_docs_dir` is kept on the signature for API stability but not
309
- consulted here.
310
- """
311
- rendered_cache: dict[str, str] = {}
312
- out_docs_dir = out_dir / "docs"
313
- out_docs_dir.mkdir(parents=True, exist_ok=True)
314
- has_static = _ensure_static_copied(out_docs_dir, dashboard_docs_dir)
315
-
316
- def resolve(rel_path: str | None) -> str | None:
317
- if not rel_path or specs_docs_dir is None:
318
- return None
319
- if rel_path in rendered_cache:
320
- return rendered_cache[rel_path]
321
- md_rel = rel_path[:-5] + ".md" if rel_path.endswith(".html") else rel_path
322
- md_source = specs_docs_dir / md_rel
323
- if not md_source.is_file():
324
- return None
325
- # Cache the URL before rendering body so cycles in the foundation's
326
- # cross-doc links (e.g. profile -> feature -> profile) terminate.
327
- url = "docs/" + rel_path.replace("\\", "/")
328
- rendered_cache[rel_path] = url
329
- out_path = out_docs_dir / rel_path
330
- out_path.parent.mkdir(parents=True, exist_ok=True)
331
- body = _md_to_html(md_source.read_text(encoding="utf-8"))
332
- body = _localize_images(body, md_source, out_path)
333
- body = _localize_links(body, md_source, out_path, specs_docs_dir, out_docs_dir, resolve)
334
- out_path.write_text(
335
- _render_md_page(rel_path, rel_path, md_source, body, has_static),
336
- encoding="utf-8",
337
- )
338
- return url
339
-
340
- return resolve
341
-
342
-
343
- CSS = """
344
- :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; }
345
- @media (prefers-color-scheme: dark) {
346
- :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; }
347
- }
348
- :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; }
349
- * { box-sizing: border-box; }
350
- body { font-family: "Segoe UI", Arial, sans-serif; margin:0; background:var(--bg); color:var(--text); padding:16px; }
351
- 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); }
352
- header .logo { width:36px; height:36px; border-radius:10px; background:#2b6de2; color:#fff; display:grid; place-items:center; font-weight:700; }
353
- header h1 { margin:0; font-size:18px; }
354
- header .meta { color:var(--muted); font-size:12px; }
355
- .summary { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin-bottom:20px; }
356
- .summary .card { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:14px; }
357
- .summary .card .label { color:var(--muted); font-size:12px; }
358
- .summary .card .value { font-size:20px; font-weight:700; }
359
- .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; }
360
- .asset-block .asset-thumb-wrap { flex-shrink:0; width:140px; }
361
- .asset-block .asset-thumb { width:100%; height:auto; aspect-ratio:1; object-fit:cover; border-radius:8px; background:var(--border); display:block; }
362
- .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; }
363
- .asset-block .asset-thumb-missing span { line-height:1.3; }
364
- .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; }
365
- .feat-id { font-family: ui-monospace, monospace; font-size:13px; }
366
- a.docref { color:var(--link); text-decoration:underline; text-decoration-style:dotted; text-underline-offset:2px; font-size:12px; }
367
- a.docref:hover { text-decoration-style:solid; }
368
- a.code { text-decoration:underline; text-decoration-style:dotted; text-underline-offset:2px; }
369
- a.code:hover { text-decoration-style:solid; }
370
- .failed-features .feat-id { color:var(--fail); }
371
- .passed-features .feat-id { color:var(--pass); }
372
- .asset-block .asset-body { flex:1; min-width:0; }
373
- .asset-block h2 { margin:0 0 6px 0; font-size:15px; }
374
- .asset-block .path { font-size:12px; color:var(--muted); margin-bottom:10px; word-break:break-all; }
375
- .asset-block section { margin-top:10px; }
376
- .asset-block section h3 { margin:0 0 6px 0; font-size:13px; color:var(--muted); }
377
- .asset-block ul { margin:0; padding-left:20px; }
378
- .asset-block li { margin:4px 0; }
379
- .failed-features { color:var(--fail); }
380
- .passed-features { color:var(--pass); }
381
- .filter { margin-bottom:12px; }
382
- .filter input { padding:8px 12px; width:100%; max-width:320px; border:1px solid var(--border); border-radius:8px; font-size:14px; }
383
- .compliance { margin-top:28px; background:var(--panel); border-radius:12px; padding:18px; border:1px solid var(--border); }
384
- .compliance h2 { margin:0 0 12px 0; font-size:16px; }
385
- .compliance-list { margin:0; padding-left:20px; }
386
- .compliance-list li { margin:8px 0; }
387
- .compliance-list .code { font-weight:700; }
388
- .affected-prims { font-size:12px; color:var(--muted); margin:8px 0; }
389
- .affected-prims .label { font-weight:600; color:var(--text); }
390
- .prim-list-inline { list-style:none; padding-left:0; margin:4px 0 0 0; display:flex; flex-wrap:wrap; gap:6px 12px; }
391
- .prim-list-inline li { font-family: ui-monospace, monospace; font-size:11px; word-break:break-all; }
392
- .muted { color:var(--muted); }
393
- .no-spec { color:var(--muted); font-style:italic; font-size:11px; }
394
- .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); }
395
- .packaging-note code { background:var(--note-code); padding:1px 5px; border-radius:4px; font-size:12px; }
396
- .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); }
397
- .coverage-banner code { background:var(--note-code); padding:1px 5px; border-radius:4px; font-size:12px; }
398
- .coverage-banner details { margin-top:8px; }
399
- .coverage-banner summary { cursor:pointer; font-weight:600; }
400
- .coverage-banner ul { margin:6px 0 0 0; padding-left:20px; }
401
- .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); }
402
- .outside-spec-banner code { background:var(--note-code); padding:1px 5px; border-radius:4px; font-size:12px; }
403
- .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); }
404
- .thumbnail-banner code { background:var(--note-code); padding:1px 5px; border-radius:4px; font-size:11px; word-break:break-all; }
405
- .thumbnail-banner details { margin-top:8px; }
406
- .thumbnail-banner summary { cursor:pointer; font-weight:600; }
407
- .thumbnail-banner ul { margin:6px 0 0 0; padding-left:20px; }
408
- .header-actions { margin-left:auto; display:flex; gap:8px; align-items:center; }
409
- .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; }
410
- .caveat-toggle:hover { filter:brightness(1.05); }
411
- .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; }
412
- .caveat-toggle.open { animation:none; box-shadow:none; }
413
- @keyframes caveat-pulse {
414
- 0%, 100% { box-shadow:0 0 0 0 var(--note-border); }
415
- 50% { box-shadow:0 0 0 6px transparent; }
416
- }
417
- #caveats[hidden] { display:none; }
418
- .theme-toggle { padding:6px 12px; background:transparent; border:1px solid var(--border); border-radius:8px; color:var(--text); cursor:pointer; font-size:13px; }
419
- .theme-toggle:hover { background:var(--bg); }
420
- """.strip()
421
-
422
-
423
- def write_dashboard(out_dir: Path, target: Path, profile: str, profile_version: str,
424
- results: list[dict],
425
- specs_docs_dir: Path | None = None,
426
- dashboard_docs_dir: Path | None = None,
427
- thumbnail_provenance: list[dict] | None = None,
428
- profile_coverage: dict | None = None) -> Path:
429
- total = len(results)
430
- failed = sum(1 for r in results if not r["passed"])
431
- passed_checks = sum(len(r.get("passed_features", [])) for r in results)
432
-
433
- doc_url = _make_doc_url_resolver(out_dir, dashboard_docs_dir, specs_docs_dir)
434
- # Map asset name -> relative path under <out_dir>/images/, only when actually
435
- # copied. Reference relatively so the dashboard contains no external URLs.
436
- thumb_rel_by_asset = {
437
- e["asset"]: e["rel"]
438
- for e in (thumbnail_provenance or [])
439
- if e.get("present")
440
- }
441
- asset_blocks = "\n".join(_render_asset_block(r, doc_url, thumb_rel_by_asset, target) for r in results)
442
- thumbnail_banner = _render_thumbnail_banner(thumbnail_provenance or [], target)
443
- coverage_banner = _render_coverage_banner(profile_coverage)
444
- outside_spec_banner = _render_outside_spec_banner(results)
445
- env_blocked_banner = _render_env_blocked_banner(results)
446
- compliance = _render_compliance(results, doc_url, target)
447
- generated = datetime.now(timezone.utc).isoformat()
448
- target_str = str(target).replace("\\", "/")
449
- # Show only the packages-relative tail, never the absolute path —
450
- # absolute paths leak machine-specific user dirs into shared reports.
451
- if "/packages/" in target_str:
452
- raw_hint = target_str.split("/packages/", 1)[1]
453
- packaging_note = (
454
- f'<div class="packaging-note">'
455
- f'<strong>Packaged tree.</strong> This report validates assets after the '
456
- f'<code>simready ingest usd</code> step. Raw inputs live at '
457
- f'<code>assets_to_validate/{html.escape(raw_hint)}</code>; '
458
- f'packaged output lives at <code>packages/{html.escape(raw_hint)}</code>.'
459
- f'</div>'
460
- )
461
- else:
462
- packaging_note = ""
463
-
464
- # Group all the yellow-warning banners under a single `Caveats` toggle so
465
- # the dashboard stays clean by default and the user opts in to read them.
466
- caveats_list = [b for b in (env_blocked_banner, coverage_banner, outside_spec_banner, packaging_note, thumbnail_banner) if b]
467
- caveats_count = len(caveats_list)
468
- if caveats_count > 0:
469
- caveats_button = (
470
- f'<button type="button" id="caveatsToggle" class="caveat-toggle" '
471
- f'aria-expanded="false" aria-controls="caveats" aria-label="Show caveats">'
472
- f'<span>Caveats</span>'
473
- f'<span class="caveat-count">{caveats_count}</span>'
474
- f'</button>'
475
- )
476
- caveats_section = (
477
- '<div id="caveats" hidden>' + "".join(caveats_list) + '</div>'
478
- )
479
- else:
480
- caveats_button = ""
481
- caveats_section = ""
482
-
483
- html_doc = f"""<!doctype html>
484
- <html lang="en">
485
- <head>
486
- <meta charset="utf-8" />
487
- <title>Validation Report &mdash; {html.escape(target.name)}</title>
488
- <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>
489
- <style>{CSS}</style>
490
- </head>
491
- <body>
492
- <header>
493
- <div class="logo">V</div>
494
- <div>
495
- <h1>Validation Report &mdash; {html.escape(target.name)}</h1>
496
- <div class="meta">Profile: <strong>{html.escape(profile)}</strong> v{html.escape(profile_version)} &middot; Generated: {html.escape(generated)}</div>
497
- </div>
498
- <div class="header-actions">
499
- {caveats_button}
500
- <button type="button" id="themeToggle" class="theme-toggle" aria-label="Toggle theme">Dark mode</button>
501
- </div>
502
- </header>
503
- {caveats_section}
504
- <div class="summary">
505
- <div class="card"><div class="label">Total assets</div><div class="value">{total}</div></div>
506
- <div class="card"><div class="label">Failed assets</div><div class="value">{failed}</div></div>
507
- <div class="card"><div class="label">Passed feature checks</div><div class="value">{passed_checks}</div></div>
508
- </div>
509
- <div class="filter"><input type="text" id="filterInput" placeholder="Filter by asset name..." /></div>
510
- {asset_blocks}
511
- {compliance}
512
- <script>
513
- (function() {{
514
- var input = document.getElementById("filterInput");
515
- var blocks = document.querySelectorAll(".asset-block");
516
- if (input) {{
517
- function filter() {{
518
- var q = (input.value || "").trim().toLowerCase();
519
- for (var i = 0; i < blocks.length; i++) {{
520
- var name = (blocks[i].getAttribute("data-asset-name") || "");
521
- blocks[i].style.display = (!q || name.indexOf(q) !== -1) ? "" : "none";
522
- }}
523
- }}
524
- input.addEventListener("input", filter);
525
- input.addEventListener("keyup", filter);
526
- }}
527
- var caveatsBtn = document.getElementById("caveatsToggle");
528
- var caveatsBox = document.getElementById("caveats");
529
- if (caveatsBtn && caveatsBox) {{
530
- caveatsBtn.addEventListener("click", function() {{
531
- var open = caveatsBox.hidden;
532
- caveatsBox.hidden = !open;
533
- caveatsBtn.setAttribute("aria-expanded", open ? "true" : "false");
534
- caveatsBtn.classList.toggle("open", open);
535
- }});
536
- }}
537
- var btn = document.getElementById("themeToggle");
538
- var mq = window.matchMedia ? window.matchMedia("(prefers-color-scheme: dark)") : null;
539
- function isDark() {{
540
- var attr = document.documentElement.getAttribute("data-theme");
541
- if (attr === "dark") return true;
542
- if (attr === "light") return false;
543
- return mq ? mq.matches : false;
544
- }}
545
- function syncLabel() {{
546
- if (btn) btn.textContent = isDark() ? "Light mode" : "Dark mode";
547
- }}
548
- syncLabel();
549
- if (mq && mq.addEventListener) mq.addEventListener("change", syncLabel);
550
- if (btn) {{
551
- btn.addEventListener("click", function() {{
552
- var next = isDark() ? "light" : "dark";
553
- document.documentElement.setAttribute("data-theme", next);
554
- try {{ localStorage.setItem("vr-theme", next); }} catch(e) {{}}
555
- syncLabel();
556
- }});
557
- }}
558
- }})();
559
- </script>
560
- </body>
561
- </html>
562
- """
563
- out = out_dir / "index.html"
564
- out.write_text(html_doc, encoding="utf-8")
565
- return out
566
-
567
-
568
- def _render_outside_spec_banner(results: list[dict]) -> str:
569
- """Banner summarizing how many issues the validator reported against
570
- requirements that aren't part of the loaded profile's feature set.
571
- These are deliberately suppressed from the per-asset blocks and the
572
- compliance footer — the report scope is the chosen spec only — so we
573
- surface the count here for transparency.
574
- """
575
- total_issues = 0
576
- unique_codes: set[str] = set()
577
- affected_assets: set[str] = set()
578
- for r in results:
579
- if not r.get("extra_failures"):
580
- continue
581
- affected_assets.add(r["name"])
582
- for e in r["extra_failures"]:
583
- total_issues += int(e.get("count") or 0)
584
- if e.get("code"):
585
- unique_codes.add(e["code"])
586
- if total_issues == 0:
587
- return ""
588
- return f'''
589
- <div class="outside-spec-banner">
590
- <strong>Out-of-spec issues hidden.</strong> The validator reported
591
- <strong>{total_issues}</strong> issue(s) against
592
- <strong>{len(unique_codes)}</strong> requirement code(s) that aren\'t
593
- part of this profile\'s feature set, across
594
- <strong>{len(affected_assets)}</strong> asset(s). These are not listed
595
- in the report — the displayed pass/fail is scoped to the loaded profile
596
- only. See <code>PROBLEMS.md</code> for why this filter exists.
597
- </div>
598
- '''
599
-
600
-
601
- def _render_coverage_banner(coverage: dict | None) -> str:
602
- """Banner shown when the loaded profile has fewer features than its
603
- `profiles.toml` entry declares. The validator silently drops feature
604
- entries whose ID + version aren't in the registry, which can leave a
605
- profile reporting against a tiny subset of what it claims to check.
606
- See PROBLEMS.md P1.
607
- """
608
- if not coverage or coverage.get("declared") is None:
609
- return ""
610
- missing = coverage.get("missing") or []
611
- if not missing:
612
- return ""
613
- declared = coverage["declared"]
614
- loaded = coverage["loaded"]
615
- missing_items = "".join(
616
- f'<li><code>{html.escape(m["id"])}</code> '
617
- f'<span class="muted">v{html.escape(m["version"])}</span></li>'
618
- for m in missing
619
- )
620
- return f'''
621
- <div class="coverage-banner">
622
- <strong>Coverage gap.</strong> This profile declares <strong>{declared}</strong> features
623
- in <code>profiles.toml</code> but the validator loaded only <strong>{loaded}</strong>.
624
- The remaining {len(missing)} were silently dropped because their ID + version
625
- isn\'t registered in the foundation specs. The pass/fail numbers below reflect
626
- only the loaded set — the dropped features were <em>not</em> checked.
627
- See <code>PROBLEMS.md</code> P1 for root cause and fix path.
628
- <details><summary>Dropped feature IDs ({len(missing)})</summary>
629
- <ul>{missing_items}</ul></details>
630
- </div>
631
- '''
632
-
633
-
634
- def _render_env_blocked_banner(results: list[dict]) -> str:
635
- """Banner shown when validate.py's P2 filter dropped env-blocked issues.
636
-
637
- Aggregates per-asset env_blocked counts so the user sees both that a
638
- suppression happened and roughly how much was suppressed. See PROBLEMS.md P2.
639
- """
640
- physx_total = 0
641
- mdl_total = 0
642
- for r in results:
643
- eb = r.get("env_blocked") or {}
644
- physx_total += int(eb.get("physxschema_unavailable") or 0)
645
- mdl_total += int(eb.get("omnipbr_unresolved") or 0)
646
- if not (physx_total or mdl_total):
647
- return ""
648
- parts = []
649
- if physx_total:
650
- parts.append(
651
- f'<strong>{physx_total}</strong> PhysX-rule errors '
652
- f'(<code>PhysxSchema is not available in this environment</code>)'
653
- )
654
- if mdl_total:
655
- parts.append(
656
- f'<strong>{mdl_total}</strong> stock MDL-reference failures '
657
- f'(<code>OmniPBR.mdl</code> not resolvable outside Kit)'
658
- )
659
- return f'''
660
- <div class="coverage-banner">
661
- <strong>Environment-blocked checks suppressed.</strong>
662
- This run dropped {" and ".join(parts)} because the active Python venv has
663
- <code>usd-core</code> but not the Kit-bundled <code>PhysxSchema</code> /
664
- Kit MDL search path. These checks couldn\'t produce useful results here,
665
- so they would otherwise have inflated the issue count without telling you
666
- anything about the asset. Pass/fail below reflects only checks that ran
667
- meaningfully. See <code>PROBLEMS.md</code> P2 for root cause and fix path.
668
- </div>
669
- '''
670
-
671
-
672
- def _render_thumbnail_banner(provenance: list[dict], target: Path | None = None) -> str:
673
- """Banner explaining where the asset thumbnails came from (since they
674
- weren't produced by this run's packaging step)."""
675
- present = [p for p in provenance if p.get("present")]
676
- if not present:
677
- return ""
678
- items = "".join(
679
- f'<li><code>{html.escape(p["rel"])}</code> &mdash; copied from <code>{html.escape(_relativize_msg(p.get("copied_from",""), target))}</code></li>'
680
- for p in present
681
- )
682
- return f'''
683
- <div class="thumbnail-banner">
684
- <strong>Asset thumbnails — sourced externally.</strong>
685
- These tiles were copied into this report\'s <code>images/</code> directory from
686
- per-asset spec paths (<code>&lt;asset_dir&gt;/.thumbs/256x256/&lt;filename&gt;.png</code>) populated
687
- earlier from NVIDIA\'s Isaac 6.0 content S3 bucket
688
- (<code>Assets/Isaac/6.0/Isaac/Robots/Yaskawa/Motoman Next/...</code>).
689
- They are <strong>not</strong> the output of this run\'s packaging step;
690
- the customer\'s pipeline should generate authoritative thumbnails before the
691
- validation reports them as spec-compliant.
692
- <details><summary>Per-tile source ({len(present)} of {len(provenance)})</summary>
693
- <ul>{items}</ul></details>
694
- </div>
695
- '''
696
-
697
-
698
- def _render_asset_block(r: dict, doc_url, thumb_rel_by_asset: dict | None = None,
699
- target: Path | None = None) -> str:
700
- name = r["name"]
701
- rel = r.get("rel_path") or r.get("path", "")
702
- affected = r.get("affected_prims", [])
703
- failed = r.get("failed_features", [])
704
- passed = r.get("passed_features", [])
705
-
706
- if affected:
707
- prims_html = (
708
- '<div class="affected-prims"><span class="label">Affected prims:</span>'
709
- '<ul class="prim-list-inline">'
710
- + "".join(f"<li>{html.escape(p)}</li>" for p in affected)
711
- + "</ul></div>"
712
- )
713
- else:
714
- prims_html = ""
715
-
716
- def _feature_link(rel_path: str | None) -> str:
717
- """Inline 'View feature doc.' link — empty string when no doc available."""
718
- url = doc_url(rel_path)
719
- if not url:
720
- return ''
721
- return f' <a class="docref" href="{html.escape(url)}" target="_blank" rel="noopener">View feature doc.</a>'
722
-
723
- def _capability_link(rel_path: str | None) -> str:
724
- """Inline 'View capability doc' link — empty string when no doc available."""
725
- url = doc_url(rel_path)
726
- if not url:
727
- return ''
728
- return f' <a class="docref" href="{html.escape(url)}" target="_blank" rel="noopener">View capability doc</a>'
729
-
730
- if failed:
731
- items = []
732
- for f in failed:
733
- req_paths = f.get("requirement_paths") or {}
734
- feat_link = _feature_link(f.get("path"))
735
- codes_parts = []
736
- for c in f.get("failed_codes", []):
737
- codes_parts.append(
738
- f'<span class="code">{html.escape(c)}</span>'
739
- f'{_capability_link(req_paths.get(c))}'
740
- )
741
- items.append(
742
- f'<li><strong class="feat-id">{html.escape(f["id"])}</strong>'
743
- f'{feat_link} '
744
- + " ".join(codes_parts)
745
- + '</li>'
746
- )
747
- failed_html = "<ul>" + "".join(items) + "</ul>"
748
- else:
749
- failed_html = '<p class="muted" style="margin:0">None</p>'
750
-
751
- if passed:
752
- # Passed features render as plain IDs — no doc link, matching the
753
- # reference dashboard. Users only need quick access to docs when
754
- # something failed.
755
- items = [
756
- f'<li><strong class="feat-id">{html.escape(f["id"])}</strong></li>'
757
- for f in passed
758
- ]
759
- passed_html = "<ul>" + "".join(items) + "</ul>"
760
- else:
761
- passed_html = '<p class="muted" style="margin:0">None</p>'
762
-
763
- thumb_rel = (thumb_rel_by_asset or {}).get(name)
764
- if thumb_rel:
765
- thumb_html = (
766
- f'<div class="asset-thumb-wrap">'
767
- f'<img class="asset-thumb" src="{html.escape(thumb_rel)}" alt="{html.escape(name)} thumbnail" />'
768
- f'</div>'
769
- )
770
- else:
771
- thumb_html = (
772
- '<div class="asset-thumb-wrap">'
773
- '<div class="asset-thumb asset-thumb-missing" aria-label="no thumbnail">'
774
- '<span>missing image</span>'
775
- '</div></div>'
776
- )
777
-
778
- # `extra_failures` (issues against requirements outside the loaded
779
- # profile) are intentionally NOT rendered per-asset. They get summarized
780
- # as a count in the Caveats panel; the report scope is the chosen
781
- # profile only.
782
- extras_html = ""
783
-
784
- return f"""
785
- <div class="asset-block" data-asset-name="{html.escape(name.lower())}">
786
- {thumb_html}
787
- <div class="asset-body">
788
- <h2>{html.escape(name)}</h2>
789
- <div class="path">{html.escape(rel)}</div>
790
- {prims_html}
791
- <section class="failed-features">
792
- <h3>Failed features</h3>
793
- {failed_html}
794
- </section>
795
- <section class="passed-features">
796
- <h3>Passed features</h3>
797
- {passed_html}
798
- </section>{extras_html}
799
- </div>
800
- </div>"""
801
-
802
-
803
- def _render_compliance(results: list[dict], doc_url=None, target: Path | None = None) -> str:
804
- """Footer listing every in-profile requirement code that failed, with its
805
- short message and (when available) a link to the foundation spec doc
806
- that defines it. Codes outside the loaded profile are NOT listed here —
807
- the report scope is the chosen spec only; out-of-spec failure counts
808
- surface in the Caveats panel instead. Codes without a `path` get a
809
- "(no spec source)" tag so the accountability gap is visible."""
810
- # Build the set of in-profile failure codes from `failed_features`.
811
- # Anything not in this set is an "extra" outside the loaded profile and
812
- # is intentionally suppressed from the per-asset and footer views.
813
- profile_failure_codes: set[str] = set()
814
- for r in results:
815
- for ff in r.get("failed_features", []):
816
- profile_failure_codes.update(ff.get("failed_codes", []))
817
- code_to_entry: dict[str, dict] = {}
818
- for r in results:
819
- for issue in r.get("issues", []):
820
- if issue["severity"] not in ("failure", "error"):
821
- continue
822
- code = issue.get("code") or "UNKNOWN"
823
- if code not in profile_failure_codes:
824
- continue
825
- if code not in code_to_entry:
826
- code_to_entry[code] = {
827
- "msg": issue.get("msg", ""),
828
- "path": issue.get("path"),
829
- }
830
- if not code_to_entry:
831
- return ""
832
-
833
- items_html_parts: list[str] = []
834
- accounted = 0
835
- for code, e in sorted(code_to_entry.items()):
836
- url = doc_url(e["path"]) if doc_url else None
837
- if url:
838
- code_html = f'<a class="code" href="{html.escape(url)}" target="_blank" rel="noopener">{html.escape(code)}</a>'
839
- accounted += 1
840
- no_spec_tag = ""
841
- else:
842
- code_html = f'<span class="code">{html.escape(code)}</span>'
843
- no_spec_tag = ' <span class="no-spec">(no spec source)</span>'
844
- msg = _relativize_msg(e["msg"], target)
845
- items_html_parts.append(
846
- f'<li>{code_html}: {html.escape(msg) if msg else "No guidance available."}{no_spec_tag}</li>'
847
- )
848
- items_html = "".join(items_html_parts)
849
- coverage = (
850
- f'<p class="muted">{accounted} of {len(code_to_entry)} requirement '
851
- f'code(s) link to a foundation spec source. The rest are flagged '
852
- f'<em>(no spec source)</em>.</p>'
853
- )
854
- return f"""
855
- <section class="compliance">
856
- <h2>How to comply with failed requirements</h2>
857
- <p class="muted">Each requirement code below is shown with the first message recorded for it.</p>
858
- {coverage}
859
- <ul class="compliance-list">{items_html}</ul>
860
- </section>"""
 
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
+ # html:True passes raw HTML through unescaped — safe only because the
135
+ # markdown comes from the trusted foundations repo, not user input.
136
+ _md_renderer = MarkdownIt("commonmark", {"html": True, "linkify": True, "typographer": True}).enable("table")
137
+ return _md_renderer.render(md_text)
138
+
139
+
140
+ _IMG_PLACEHOLDER_SVG = (
141
+ "data:image/svg+xml;utf8,"
142
+ "<svg xmlns='http://www.w3.org/2000/svg' width='320' height='180' viewBox='0 0 320 180'>"
143
+ "<rect width='320' height='180' fill='%23f0f2f8' stroke='%23c7cee0' stroke-dasharray='6 4' stroke-width='2'/>"
144
+ "<text x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' "
145
+ "font-family='Segoe UI,Arial,sans-serif' font-size='14' fill='%237280a7'>image missing</text>"
146
+ "</svg>"
147
+ )
148
+
149
+
150
+ def _localize_images(body_html: str, md_source: Path, out_html_path: Path) -> str:
151
+ """Walk every `<img src="...">` in `body_html`. If src is a relative path,
152
+ copy the source file (relative to `md_source.parent`) into the equivalent
153
+ location next to `out_html_path`. When the source file is missing, replace
154
+ the src with an inline SVG placeholder so the page never 404s.
155
+ """
156
+ import re
157
+ import shutil
158
+
159
+ md_dir = md_source.parent
160
+ out_dir = out_html_path.parent
161
+
162
+ def repl(match):
163
+ full_tag = match.group(0)
164
+ src_match = re.search(r'src="([^"]+)"', full_tag)
165
+ if not src_match:
166
+ return full_tag
167
+ src = src_match.group(1)
168
+ if src.startswith(("http://", "https://", "data:", "//", "/")):
169
+ return full_tag # absolute / data URI / protocol-relative — leave alone
170
+ rel = src.lstrip("./").replace("\\", "/")
171
+ source_file = (md_dir / src).resolve()
172
+ # Containment guard on the SOURCE: a crafted ../ src must not read
173
+ # files outside the markdown tree. Escape -> skip to placeholder.
174
+ try:
175
+ source_file.relative_to(md_dir.resolve())
176
+ except ValueError:
177
+ import sys
178
+ print(f"[report] skipping out-of-tree image src: {src!r}", file=sys.stderr)
179
+ return re.sub(r'src="[^"]+"', f'src="{_IMG_PLACEHOLDER_SVG}"', full_tag)
180
+ if source_file.is_file():
181
+ dest_file = (out_dir / rel).resolve()
182
+ try:
183
+ dest_file.relative_to(out_dir.resolve()) # guard against ../ escape
184
+ dest_file.parent.mkdir(parents=True, exist_ok=True)
185
+ if not dest_file.exists() or dest_file.stat().st_size != source_file.stat().st_size:
186
+ shutil.copy2(source_file, dest_file)
187
+ return full_tag
188
+ except (ValueError, OSError):
189
+ pass
190
+ # Missing source — swap to placeholder
191
+ return re.sub(r'src="[^"]+"', f'src="{_IMG_PLACEHOLDER_SVG}"', full_tag)
192
+
193
+ return re.sub(r"<img\b[^>]*>", repl, body_html)
194
+
195
+
196
+ _A_TAG_RE = None
197
+
198
+
199
+ def _localize_links(body_html: str, md_source: Path, out_html_path: Path,
200
+ specs_docs_dir: Path, out_docs_root: Path,
201
+ resolver) -> str:
202
+ """Rewrite relative <a href="..."> targets so the docs tree is self-contained.
203
+
204
+ - .md targets that resolve to a file inside `specs_docs_dir`: rewrite href
205
+ to .html and recursively render via `resolver(<rel>.html)`.
206
+ - non-.md targets inside `specs_docs_dir`: copy verbatim into the mirrored
207
+ location under `out_docs_root` and rewrite href to that copy.
208
+ - root-relative ("/...") hrefs treated as relative to `specs_docs_dir`;
209
+ bare paths without an extension are tried as `.md`.
210
+ - links pointing outside `specs_docs_dir` or at non-existent targets are
211
+ replaced with <span class="docref-disabled" title="...">TEXT</span> so
212
+ the visible text remains but no href can 404.
213
+ """
214
+ import os
215
+ import re
216
+ import shutil
217
+
218
+ global _A_TAG_RE
219
+ if _A_TAG_RE is None:
220
+ _A_TAG_RE = re.compile(r'<a\s+([^>]*?)>(.*?)</a>', re.DOTALL | re.IGNORECASE)
221
+
222
+ md_dir = md_source.parent
223
+ out_dir = out_html_path.parent
224
+ specs_root = specs_docs_dir.resolve()
225
+ out_docs_resolved = out_docs_root.resolve()
226
+
227
+ def _disable(inner: str, why: str = "link target not available in report") -> str:
228
+ return f'<span class="docref-disabled" title="{html.escape(why)}">{inner}</span>'
229
+
230
+ def _resolve_target(href_path: str) -> Path | None:
231
+ if href_path.startswith("/"):
232
+ base = specs_root / href_path.lstrip("/")
233
+ if not base.suffix:
234
+ base = base.with_suffix(".md")
235
+ return base
236
+ return (md_dir / href_path)
237
+
238
+ def repl(m: "re.Match[str]") -> str:
239
+ attrs = m.group(1)
240
+ inner = m.group(2)
241
+ href_m = re.search(r'href\s*=\s*"([^"]*)"', attrs)
242
+ if not href_m:
243
+ return m.group(0)
244
+ href = href_m.group(1).strip()
245
+ if not href:
246
+ return m.group(0)
247
+ if href.startswith(("http://", "https://", "data:", "mailto:", "//", "#", "javascript:")):
248
+ return m.group(0)
249
+
250
+ if "#" in href:
251
+ path_part, frag = href.split("#", 1)
252
+ frag = "#" + frag
253
+ else:
254
+ path_part, frag = href, ""
255
+ if not path_part:
256
+ return m.group(0)
257
+
258
+ target = _resolve_target(path_part)
259
+ if target is None:
260
+ return _disable(inner)
261
+ try:
262
+ target = target.resolve()
263
+ except OSError:
264
+ return _disable(inner)
265
+ try:
266
+ rel_to_specs = target.relative_to(specs_root)
267
+ except ValueError:
268
+ return _disable(inner, "link target outside foundation docs")
269
+ if not target.exists():
270
+ return _disable(inner, "link target missing in foundation docs")
271
+
272
+ rel_str = str(rel_to_specs).replace("\\", "/")
273
+
274
+ if target.suffix.lower() == ".md":
275
+ html_rel = rel_str[:-3] + ".html"
276
+ url = resolver(html_rel)
277
+ if url is None:
278
+ return _disable(inner)
279
+ target_out = out_docs_resolved / html_rel
280
+ else:
281
+ target_out = out_docs_resolved / rel_str
282
+ try:
283
+ target_out.parent.mkdir(parents=True, exist_ok=True)
284
+ if not target_out.exists() or target_out.stat().st_size != target.stat().st_size:
285
+ shutil.copy2(target, target_out)
286
+ except OSError:
287
+ return _disable(inner, "could not copy link target into report")
288
+
289
+ try:
290
+ new_href = os.path.relpath(target_out, out_dir).replace("\\", "/") + frag
291
+ except ValueError:
292
+ return _disable(inner)
293
+ new_attrs = re.sub(r'href\s*=\s*"[^"]*"', f'href="{new_href}"', attrs, count=1)
294
+ return f'<a {new_attrs}>{inner}</a>'
295
+
296
+ return _A_TAG_RE.sub(repl, body_html)
297
+
298
+
299
+ def _make_doc_url_resolver(out_dir: Path, dashboard_docs_dir: Path | None,
300
+ specs_docs_dir: Path | None):
301
+ """Build a `_doc_url(rel_path)` callable.
302
+
303
+ Every resolvable doc is rendered from the foundation markdown source into
304
+ `<out_dir>/docs/<rel_path>` so the dashboard has a single, self-contained
305
+ docs tree. The reference-dashboard pre-built HTML is no longer used as a
306
+ link target — it would split the source of truth.
307
+
308
+ `dashboard_docs_dir` is kept on the signature for API stability but not
309
+ consulted here.
310
+ """
311
+ rendered_cache: dict[str, str] = {}
312
+ out_docs_dir = out_dir / "docs"
313
+ out_docs_dir.mkdir(parents=True, exist_ok=True)
314
+ has_static = _ensure_static_copied(out_docs_dir, dashboard_docs_dir)
315
+
316
+ def resolve(rel_path: str | None) -> str | None:
317
+ if not rel_path or specs_docs_dir is None:
318
+ return None
319
+ if rel_path in rendered_cache:
320
+ return rendered_cache[rel_path]
321
+ md_rel = rel_path[:-5] + ".md" if rel_path.endswith(".html") else rel_path
322
+ md_source = specs_docs_dir / md_rel
323
+ if not md_source.is_file():
324
+ return None
325
+ # Cache the URL before rendering body so cycles in the foundation's
326
+ # cross-doc links (e.g. profile -> feature -> profile) terminate.
327
+ url = "docs/" + rel_path.replace("\\", "/")
328
+ rendered_cache[rel_path] = url
329
+ out_path = out_docs_dir / rel_path
330
+ out_path.parent.mkdir(parents=True, exist_ok=True)
331
+ body = _md_to_html(md_source.read_text(encoding="utf-8"))
332
+ body = _localize_images(body, md_source, out_path)
333
+ body = _localize_links(body, md_source, out_path, specs_docs_dir, out_docs_dir, resolve)
334
+ out_path.write_text(
335
+ _render_md_page(rel_path, rel_path, md_source, body, has_static),
336
+ encoding="utf-8",
337
+ )
338
+ return url
339
+
340
+ return resolve
341
+
342
+
343
+ CSS = """
344
+ :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; }
345
+ @media (prefers-color-scheme: dark) {
346
+ :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; }
347
+ }
348
+ :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; }
349
+ * { box-sizing: border-box; }
350
+ body { font-family: "Segoe UI", Arial, sans-serif; margin:0; background:var(--bg); color:var(--text); padding:16px; }
351
+ 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); }
352
+ header .logo { width:36px; height:36px; border-radius:10px; background:#2b6de2; color:#fff; display:grid; place-items:center; font-weight:700; }
353
+ header h1 { margin:0; font-size:18px; }
354
+ header .meta { color:var(--muted); font-size:12px; }
355
+ .summary { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin-bottom:20px; }
356
+ .summary .card { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:14px; }
357
+ .summary .card .label { color:var(--muted); font-size:12px; }
358
+ .summary .card .value { font-size:20px; font-weight:700; }
359
+ .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; }
360
+ .asset-block .asset-thumb-wrap { flex-shrink:0; width:140px; }
361
+ .asset-block .asset-thumb { width:100%; height:auto; aspect-ratio:1; object-fit:cover; border-radius:8px; background:var(--border); display:block; }
362
+ .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; }
363
+ .asset-block .asset-thumb-missing span { line-height:1.3; }
364
+ .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; }
365
+ .feat-id { font-family: ui-monospace, monospace; font-size:13px; }
366
+ a.docref { color:var(--link); text-decoration:underline; text-decoration-style:dotted; text-underline-offset:2px; font-size:12px; }
367
+ a.docref:hover { text-decoration-style:solid; }
368
+ a.code { text-decoration:underline; text-decoration-style:dotted; text-underline-offset:2px; }
369
+ a.code:hover { text-decoration-style:solid; }
370
+ .failed-features .feat-id { color:var(--fail); }
371
+ .passed-features .feat-id { color:var(--pass); }
372
+ .asset-block .asset-body { flex:1; min-width:0; }
373
+ .asset-block h2 { margin:0 0 6px 0; font-size:15px; }
374
+ .asset-block .path { font-size:12px; color:var(--muted); margin-bottom:10px; word-break:break-all; }
375
+ .asset-block section { margin-top:10px; }
376
+ .asset-block section h3 { margin:0 0 6px 0; font-size:13px; color:var(--muted); }
377
+ .asset-block ul { margin:0; padding-left:20px; }
378
+ .asset-block li { margin:4px 0; }
379
+ .failed-features { color:var(--fail); }
380
+ .passed-features { color:var(--pass); }
381
+ .filter { margin-bottom:12px; }
382
+ .filter input { padding:8px 12px; width:100%; max-width:320px; border:1px solid var(--border); border-radius:8px; font-size:14px; }
383
+ .compliance { margin-top:28px; background:var(--panel); border-radius:12px; padding:18px; border:1px solid var(--border); }
384
+ .compliance h2 { margin:0 0 12px 0; font-size:16px; }
385
+ .compliance-list { margin:0; padding-left:20px; }
386
+ .compliance-list li { margin:8px 0; }
387
+ .compliance-list .code { font-weight:700; }
388
+ .affected-prims { font-size:12px; color:var(--muted); margin:8px 0; }
389
+ .affected-prims .label { font-weight:600; color:var(--text); }
390
+ .prim-list-inline { list-style:none; padding-left:0; margin:4px 0 0 0; display:flex; flex-wrap:wrap; gap:6px 12px; }
391
+ .prim-list-inline li { font-family: ui-monospace, monospace; font-size:11px; word-break:break-all; }
392
+ .muted { color:var(--muted); }
393
+ .no-spec { color:var(--muted); font-style:italic; font-size:11px; }
394
+ .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); }
395
+ .packaging-note code { background:var(--note-code); padding:1px 5px; border-radius:4px; font-size:12px; }
396
+ .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); }
397
+ .coverage-banner code { background:var(--note-code); padding:1px 5px; border-radius:4px; font-size:12px; }
398
+ .coverage-banner details { margin-top:8px; }
399
+ .coverage-banner summary { cursor:pointer; font-weight:600; }
400
+ .coverage-banner ul { margin:6px 0 0 0; padding-left:20px; }
401
+ .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); }
402
+ .outside-spec-banner code { background:var(--note-code); padding:1px 5px; border-radius:4px; font-size:12px; }
403
+ .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); }
404
+ .thumbnail-banner code { background:var(--note-code); padding:1px 5px; border-radius:4px; font-size:11px; word-break:break-all; }
405
+ .thumbnail-banner details { margin-top:8px; }
406
+ .thumbnail-banner summary { cursor:pointer; font-weight:600; }
407
+ .thumbnail-banner ul { margin:6px 0 0 0; padding-left:20px; }
408
+ .header-actions { margin-left:auto; display:flex; gap:8px; align-items:center; }
409
+ .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; }
410
+ .caveat-toggle:hover { filter:brightness(1.05); }
411
+ .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; }
412
+ .caveat-toggle.open { animation:none; box-shadow:none; }
413
+ @keyframes caveat-pulse {
414
+ 0%, 100% { box-shadow:0 0 0 0 var(--note-border); }
415
+ 50% { box-shadow:0 0 0 6px transparent; }
416
+ }
417
+ #caveats[hidden] { display:none; }
418
+ .theme-toggle { padding:6px 12px; background:transparent; border:1px solid var(--border); border-radius:8px; color:var(--text); cursor:pointer; font-size:13px; }
419
+ .theme-toggle:hover { background:var(--bg); }
420
+ """.strip()
421
+
422
+
423
+ def write_dashboard(out_dir: Path, target: Path, profile: str, profile_version: str,
424
+ results: list[dict],
425
+ specs_docs_dir: Path | None = None,
426
+ dashboard_docs_dir: Path | None = None,
427
+ thumbnail_provenance: list[dict] | None = None,
428
+ profile_coverage: dict | None = None) -> Path:
429
+ total = len(results)
430
+ failed = sum(1 for r in results if not r["passed"])
431
+ passed_checks = sum(len(r.get("passed_features", [])) for r in results)
432
+
433
+ doc_url = _make_doc_url_resolver(out_dir, dashboard_docs_dir, specs_docs_dir)
434
+ # Map asset name -> relative path under <out_dir>/images/, only when actually
435
+ # copied. Reference relatively so the dashboard contains no external URLs.
436
+ thumb_rel_by_asset = {
437
+ e["asset"]: e["rel"]
438
+ for e in (thumbnail_provenance or [])
439
+ if e.get("present")
440
+ }
441
+ asset_blocks = "\n".join(_render_asset_block(r, doc_url, thumb_rel_by_asset, target) for r in results)
442
+ thumbnail_banner = _render_thumbnail_banner(thumbnail_provenance or [], target)
443
+ coverage_banner = _render_coverage_banner(profile_coverage)
444
+ outside_spec_banner = _render_outside_spec_banner(results)
445
+ env_blocked_banner = _render_env_blocked_banner(results)
446
+ compliance = _render_compliance(results, doc_url, target)
447
+ generated = datetime.now(timezone.utc).isoformat()
448
+ target_str = str(target).replace("\\", "/")
449
+ # Show only the packages-relative tail, never the absolute path —
450
+ # absolute paths leak machine-specific user dirs into shared reports.
451
+ if "/packages/" in target_str:
452
+ raw_hint = target_str.split("/packages/", 1)[1]
453
+ packaging_note = (
454
+ f'<div class="packaging-note">'
455
+ f'<strong>Packaged tree.</strong> This report validates assets after the '
456
+ f'<code>simready ingest usd</code> step. Raw inputs live at '
457
+ f'<code>assets_to_validate/{html.escape(raw_hint)}</code>; '
458
+ f'packaged output lives at <code>packages/{html.escape(raw_hint)}</code>.'
459
+ f'</div>'
460
+ )
461
+ else:
462
+ packaging_note = ""
463
+
464
+ # Group all the yellow-warning banners under a single `Caveats` toggle so
465
+ # the dashboard stays clean by default and the user opts in to read them.
466
+ caveats_list = [b for b in (env_blocked_banner, coverage_banner, outside_spec_banner, packaging_note, thumbnail_banner) if b]
467
+ caveats_count = len(caveats_list)
468
+ if caveats_count > 0:
469
+ caveats_button = (
470
+ f'<button type="button" id="caveatsToggle" class="caveat-toggle" '
471
+ f'aria-expanded="false" aria-controls="caveats" aria-label="Show caveats">'
472
+ f'<span>Caveats</span>'
473
+ f'<span class="caveat-count">{caveats_count}</span>'
474
+ f'</button>'
475
+ )
476
+ caveats_section = (
477
+ '<div id="caveats" hidden>' + "".join(caveats_list) + '</div>'
478
+ )
479
+ else:
480
+ caveats_button = ""
481
+ caveats_section = ""
482
+
483
+ html_doc = f"""<!doctype html>
484
+ <html lang="en">
485
+ <head>
486
+ <meta charset="utf-8" />
487
+ <title>Validation Report &mdash; {html.escape(target.name)}</title>
488
+ <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>
489
+ <style>{CSS}</style>
490
+ </head>
491
+ <body>
492
+ <header>
493
+ <div class="logo">V</div>
494
+ <div>
495
+ <h1>Validation Report &mdash; {html.escape(target.name)}</h1>
496
+ <div class="meta">Profile: <strong>{html.escape(profile)}</strong> v{html.escape(profile_version)} &middot; Generated: {html.escape(generated)}</div>
497
+ </div>
498
+ <div class="header-actions">
499
+ {caveats_button}
500
+ <button type="button" id="themeToggle" class="theme-toggle" aria-label="Toggle theme">Dark mode</button>
501
+ </div>
502
+ </header>
503
+ {caveats_section}
504
+ <div class="summary">
505
+ <div class="card"><div class="label">Total assets</div><div class="value">{total}</div></div>
506
+ <div class="card"><div class="label">Failed assets</div><div class="value">{failed}</div></div>
507
+ <div class="card"><div class="label">Passed feature checks</div><div class="value">{passed_checks}</div></div>
508
+ </div>
509
+ <div class="filter"><input type="text" id="filterInput" placeholder="Filter by asset name..." /></div>
510
+ {asset_blocks}
511
+ {compliance}
512
+ <script>
513
+ (function() {{
514
+ var input = document.getElementById("filterInput");
515
+ var blocks = document.querySelectorAll(".asset-block");
516
+ if (input) {{
517
+ function filter() {{
518
+ var q = (input.value || "").trim().toLowerCase();
519
+ for (var i = 0; i < blocks.length; i++) {{
520
+ var name = (blocks[i].getAttribute("data-asset-name") || "");
521
+ blocks[i].style.display = (!q || name.indexOf(q) !== -1) ? "" : "none";
522
+ }}
523
+ }}
524
+ input.addEventListener("input", filter);
525
+ input.addEventListener("keyup", filter);
526
+ }}
527
+ var caveatsBtn = document.getElementById("caveatsToggle");
528
+ var caveatsBox = document.getElementById("caveats");
529
+ if (caveatsBtn && caveatsBox) {{
530
+ caveatsBtn.addEventListener("click", function() {{
531
+ var open = caveatsBox.hidden;
532
+ caveatsBox.hidden = !open;
533
+ caveatsBtn.setAttribute("aria-expanded", open ? "true" : "false");
534
+ caveatsBtn.classList.toggle("open", open);
535
+ }});
536
+ }}
537
+ var btn = document.getElementById("themeToggle");
538
+ var mq = window.matchMedia ? window.matchMedia("(prefers-color-scheme: dark)") : null;
539
+ function isDark() {{
540
+ var attr = document.documentElement.getAttribute("data-theme");
541
+ if (attr === "dark") return true;
542
+ if (attr === "light") return false;
543
+ return mq ? mq.matches : false;
544
+ }}
545
+ function syncLabel() {{
546
+ if (btn) btn.textContent = isDark() ? "Light mode" : "Dark mode";
547
+ }}
548
+ syncLabel();
549
+ if (mq && mq.addEventListener) mq.addEventListener("change", syncLabel);
550
+ if (btn) {{
551
+ btn.addEventListener("click", function() {{
552
+ var next = isDark() ? "light" : "dark";
553
+ document.documentElement.setAttribute("data-theme", next);
554
+ try {{ localStorage.setItem("vr-theme", next); }} catch(e) {{}}
555
+ syncLabel();
556
+ }});
557
+ }}
558
+ }})();
559
+ </script>
560
+ </body>
561
+ </html>
562
+ """
563
+ out = out_dir / "index.html"
564
+ out.write_text(html_doc, encoding="utf-8")
565
+ return out
566
+
567
+
568
+ def _render_outside_spec_banner(results: list[dict]) -> str:
569
+ """Banner summarizing how many issues the validator reported against
570
+ requirements that aren't part of the loaded profile's feature set.
571
+ These are deliberately suppressed from the per-asset blocks and the
572
+ compliance footer — the report scope is the chosen spec only — so we
573
+ surface the count here for transparency.
574
+ """
575
+ total_issues = 0
576
+ unique_codes: set[str] = set()
577
+ affected_assets: set[str] = set()
578
+ for r in results:
579
+ if not r.get("extra_failures"):
580
+ continue
581
+ affected_assets.add(r["name"])
582
+ for e in r["extra_failures"]:
583
+ total_issues += int(e.get("count") or 0)
584
+ if e.get("code"):
585
+ unique_codes.add(e["code"])
586
+ if total_issues == 0:
587
+ return ""
588
+ return f'''
589
+ <div class="outside-spec-banner">
590
+ <strong>Out-of-spec issues hidden.</strong> The validator reported
591
+ <strong>{total_issues}</strong> issue(s) against
592
+ <strong>{len(unique_codes)}</strong> requirement code(s) that aren\'t
593
+ part of this profile\'s feature set, across
594
+ <strong>{len(affected_assets)}</strong> asset(s). These are not listed
595
+ in the report — the displayed pass/fail is scoped to the loaded profile
596
+ only. See <code>PROBLEMS.md</code> for why this filter exists.
597
+ </div>
598
+ '''
599
+
600
+
601
+ def _render_coverage_banner(coverage: dict | None) -> str:
602
+ """Banner shown when the loaded profile has fewer features than its
603
+ `profiles.toml` entry declares. The validator silently drops feature
604
+ entries whose ID + version aren't in the registry, which can leave a
605
+ profile reporting against a tiny subset of what it claims to check.
606
+ See PROBLEMS.md P1.
607
+ """
608
+ if not coverage or coverage.get("declared") is None:
609
+ return ""
610
+ missing = coverage.get("missing") or []
611
+ if not missing:
612
+ return ""
613
+ declared = coverage["declared"]
614
+ loaded = coverage["loaded"]
615
+ missing_items = "".join(
616
+ f'<li><code>{html.escape(m["id"])}</code> '
617
+ f'<span class="muted">v{html.escape(m["version"])}</span></li>'
618
+ for m in missing
619
+ )
620
+ return f'''
621
+ <div class="coverage-banner">
622
+ <strong>Coverage gap.</strong> This profile declares <strong>{declared}</strong> features
623
+ in <code>profiles.toml</code> but the validator loaded only <strong>{loaded}</strong>.
624
+ The remaining {len(missing)} were silently dropped because their ID + version
625
+ isn\'t registered in the foundation specs. The pass/fail numbers below reflect
626
+ only the loaded set — the dropped features were <em>not</em> checked.
627
+ See <code>PROBLEMS.md</code> P1 for root cause and fix path.
628
+ <details><summary>Dropped feature IDs ({len(missing)})</summary>
629
+ <ul>{missing_items}</ul></details>
630
+ </div>
631
+ '''
632
+
633
+
634
+ def _render_env_blocked_banner(results: list[dict]) -> str:
635
+ """Banner shown when validate.py's P2 filter dropped env-blocked issues.
636
+
637
+ Aggregates per-asset env_blocked counts so the user sees both that a
638
+ suppression happened and roughly how much was suppressed. See PROBLEMS.md P2.
639
+ """
640
+ physx_total = 0
641
+ mdl_total = 0
642
+ for r in results:
643
+ eb = r.get("env_blocked") or {}
644
+ physx_total += int(eb.get("physxschema_unavailable") or 0)
645
+ mdl_total += int(eb.get("omnipbr_unresolved") or 0)
646
+ if not (physx_total or mdl_total):
647
+ return ""
648
+ parts = []
649
+ if physx_total:
650
+ parts.append(
651
+ f'<strong>{physx_total}</strong> PhysX-rule errors '
652
+ f'(<code>PhysxSchema is not available in this environment</code>)'
653
+ )
654
+ if mdl_total:
655
+ parts.append(
656
+ f'<strong>{mdl_total}</strong> stock MDL-reference failures '
657
+ f'(<code>OmniPBR.mdl</code> not resolvable outside Kit)'
658
+ )
659
+ return f'''
660
+ <div class="coverage-banner">
661
+ <strong>Environment-blocked checks suppressed.</strong>
662
+ This run dropped {" and ".join(parts)} because the active Python venv has
663
+ <code>usd-core</code> but not the Kit-bundled <code>PhysxSchema</code> /
664
+ Kit MDL search path. These checks couldn\'t produce useful results here,
665
+ so they would otherwise have inflated the issue count without telling you
666
+ anything about the asset. Pass/fail below reflects only checks that ran
667
+ meaningfully. See <code>PROBLEMS.md</code> P2 for root cause and fix path.
668
+ </div>
669
+ '''
670
+
671
+
672
+ def _render_thumbnail_banner(provenance: list[dict], target: Path | None = None) -> str:
673
+ """Banner explaining where the asset thumbnails came from (since they
674
+ weren't produced by this run's packaging step)."""
675
+ present = [p for p in provenance if p.get("present")]
676
+ if not present:
677
+ return ""
678
+ items = "".join(
679
+ f'<li><code>{html.escape(p["rel"])}</code> &mdash; copied from <code>{html.escape(_relativize_msg(p.get("copied_from",""), target))}</code></li>'
680
+ for p in present
681
+ )
682
+ return f'''
683
+ <div class="thumbnail-banner">
684
+ <strong>Asset thumbnails — sourced externally.</strong>
685
+ These tiles were copied into this report\'s <code>images/</code> directory from
686
+ per-asset spec paths (<code>&lt;asset_dir&gt;/.thumbs/256x256/&lt;filename&gt;.png</code>) populated
687
+ earlier from NVIDIA\'s Isaac 6.0 content S3 bucket
688
+ (<code>Assets/Isaac/6.0/Isaac/Robots/Yaskawa/Motoman Next/...</code>).
689
+ They are <strong>not</strong> the output of this run\'s packaging step;
690
+ the customer\'s pipeline should generate authoritative thumbnails before the
691
+ validation reports them as spec-compliant.
692
+ <details><summary>Per-tile source ({len(present)} of {len(provenance)})</summary>
693
+ <ul>{items}</ul></details>
694
+ </div>
695
+ '''
696
+
697
+
698
+ def _render_asset_block(r: dict, doc_url, thumb_rel_by_asset: dict | None = None,
699
+ target: Path | None = None) -> str:
700
+ name = r["name"]
701
+ rel = r.get("rel_path") or r.get("path", "")
702
+ affected = r.get("affected_prims", [])
703
+ failed = r.get("failed_features", [])
704
+ passed = r.get("passed_features", [])
705
+
706
+ if affected:
707
+ prims_html = (
708
+ '<div class="affected-prims"><span class="label">Affected prims:</span>'
709
+ '<ul class="prim-list-inline">'
710
+ + "".join(f"<li>{html.escape(p)}</li>" for p in affected)
711
+ + "</ul></div>"
712
+ )
713
+ else:
714
+ prims_html = ""
715
+
716
+ def _feature_link(rel_path: str | None) -> str:
717
+ """Inline 'View feature doc.' link — empty string when no doc available."""
718
+ url = doc_url(rel_path)
719
+ if not url:
720
+ return ''
721
+ return f' <a class="docref" href="{html.escape(url)}" target="_blank" rel="noopener">View feature doc.</a>'
722
+
723
+ def _capability_link(rel_path: str | None) -> str:
724
+ """Inline 'View capability doc' link — empty string when no doc available."""
725
+ url = doc_url(rel_path)
726
+ if not url:
727
+ return ''
728
+ return f' <a class="docref" href="{html.escape(url)}" target="_blank" rel="noopener">View capability doc</a>'
729
+
730
+ if failed:
731
+ items = []
732
+ for f in failed:
733
+ req_paths = f.get("requirement_paths") or {}
734
+ feat_link = _feature_link(f.get("path"))
735
+ codes_parts = []
736
+ for c in f.get("failed_codes", []):
737
+ codes_parts.append(
738
+ f'<span class="code">{html.escape(c)}</span>'
739
+ f'{_capability_link(req_paths.get(c))}'
740
+ )
741
+ items.append(
742
+ f'<li><strong class="feat-id">{html.escape(f["id"])}</strong>'
743
+ f'{feat_link} '
744
+ + " ".join(codes_parts)
745
+ + '</li>'
746
+ )
747
+ failed_html = "<ul>" + "".join(items) + "</ul>"
748
+ else:
749
+ failed_html = '<p class="muted" style="margin:0">None</p>'
750
+
751
+ if passed:
752
+ # Passed features render as plain IDs — no doc link, matching the
753
+ # reference dashboard. Users only need quick access to docs when
754
+ # something failed.
755
+ items = [
756
+ f'<li><strong class="feat-id">{html.escape(f["id"])}</strong></li>'
757
+ for f in passed
758
+ ]
759
+ passed_html = "<ul>" + "".join(items) + "</ul>"
760
+ else:
761
+ passed_html = '<p class="muted" style="margin:0">None</p>'
762
+
763
+ thumb_rel = (thumb_rel_by_asset or {}).get(name)
764
+ if thumb_rel:
765
+ thumb_html = (
766
+ f'<div class="asset-thumb-wrap">'
767
+ f'<img class="asset-thumb" src="{html.escape(thumb_rel)}" alt="{html.escape(name)} thumbnail" />'
768
+ f'</div>'
769
+ )
770
+ else:
771
+ thumb_html = (
772
+ '<div class="asset-thumb-wrap">'
773
+ '<div class="asset-thumb asset-thumb-missing" aria-label="no thumbnail">'
774
+ '<span>missing image</span>'
775
+ '</div></div>'
776
+ )
777
+
778
+ # `extra_failures` (issues against requirements outside the loaded
779
+ # profile) are intentionally NOT rendered per-asset. They get summarized
780
+ # as a count in the Caveats panel; the report scope is the chosen
781
+ # profile only.
782
+ extras_html = ""
783
+
784
+ return f"""
785
+ <div class="asset-block" data-asset-name="{html.escape(name.lower())}">
786
+ {thumb_html}
787
+ <div class="asset-body">
788
+ <h2>{html.escape(name)}</h2>
789
+ <div class="path">{html.escape(rel)}</div>
790
+ {prims_html}
791
+ <section class="failed-features">
792
+ <h3>Failed features</h3>
793
+ {failed_html}
794
+ </section>
795
+ <section class="passed-features">
796
+ <h3>Passed features</h3>
797
+ {passed_html}
798
+ </section>{extras_html}
799
+ </div>
800
+ </div>"""
801
+
802
+
803
+ def _render_compliance(results: list[dict], doc_url=None, target: Path | None = None) -> str:
804
+ """Footer listing every in-profile requirement code that failed, with its
805
+ short message and (when available) a link to the foundation spec doc
806
+ that defines it. Codes outside the loaded profile are NOT listed here —
807
+ the report scope is the chosen spec only; out-of-spec failure counts
808
+ surface in the Caveats panel instead. Codes without a `path` get a
809
+ "(no spec source)" tag so the accountability gap is visible."""
810
+ # Build the set of in-profile failure codes from `failed_features`.
811
+ # Anything not in this set is an "extra" outside the loaded profile and
812
+ # is intentionally suppressed from the per-asset and footer views.
813
+ profile_failure_codes: set[str] = set()
814
+ for r in results:
815
+ for ff in r.get("failed_features", []):
816
+ profile_failure_codes.update(ff.get("failed_codes", []))
817
+ code_to_entry: dict[str, dict] = {}
818
+ for r in results:
819
+ for issue in r.get("issues", []):
820
+ if issue["severity"] not in ("failure", "error"):
821
+ continue
822
+ code = issue.get("code") or "UNKNOWN"
823
+ if code not in profile_failure_codes:
824
+ continue
825
+ if code not in code_to_entry:
826
+ code_to_entry[code] = {
827
+ "msg": issue.get("msg", ""),
828
+ "path": issue.get("path"),
829
+ }
830
+ if not code_to_entry:
831
+ return ""
832
+
833
+ items_html_parts: list[str] = []
834
+ accounted = 0
835
+ for code, e in sorted(code_to_entry.items()):
836
+ url = doc_url(e["path"]) if doc_url else None
837
+ if url:
838
+ code_html = f'<a class="code" href="{html.escape(url)}" target="_blank" rel="noopener">{html.escape(code)}</a>'
839
+ accounted += 1
840
+ no_spec_tag = ""
841
+ else:
842
+ code_html = f'<span class="code">{html.escape(code)}</span>'
843
+ no_spec_tag = ' <span class="no-spec">(no spec source)</span>'
844
+ msg = _relativize_msg(e["msg"], target)
845
+ items_html_parts.append(
846
+ f'<li>{code_html}: {html.escape(msg) if msg else "No guidance available."}{no_spec_tag}</li>'
847
+ )
848
+ items_html = "".join(items_html_parts)
849
+ coverage = (
850
+ f'<p class="muted">{accounted} of {len(code_to_entry)} requirement '
851
+ f'code(s) link to a foundation spec source. The rest are flagged '
852
+ f'<em>(no spec source)</em>.</p>'
853
+ )
854
+ return f"""
855
+ <section class="compliance">
856
+ <h2>How to comply with failed requirements</h2>
857
+ <p class="muted">Each requirement code below is shown with the first message recorded for it.</p>
858
+ {coverage}
859
+ <ul class="compliance-list">{items_html}</ul>
860
+ </section>"""
tools/validation/plugins/simready-report/skills/simready-report/validate.py CHANGED
The diff for this file is too large to render. See raw diff