diff --git a/.cspell.json b/.cspell.json index 51ad11eb..f37b88b6 100644 --- a/.cspell.json +++ b/.cspell.json @@ -39,6 +39,12 @@ "words": [ "DxMessaging", "dxmessaging", + "HKLM", + "Interp", + "msvc", + "napi", + "unrs", + "jestrc", "metas", "mtimes", "nofilter", @@ -139,6 +145,9 @@ "PROCS", "tokei", "TOKEI", + "TOCTOU", + "UAF", + "CWE", "venv", "ifne", "nographics", @@ -246,6 +255,7 @@ "Ldstr", "materialised", "misaligning", + "mojibake", "monomorphization", "normalise", "Normalise", @@ -270,7 +280,8 @@ "integ", "Integ", "jlumbroso", - "kubepods" + "kubepods", + "unanalyzable" ], "ignoreRegExpList": [ "/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g", diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 7b20ccab..81c44d37 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -11,7 +11,7 @@ FROM mcr.microsoft.com/devcontainers/dotnet:1-9.0-bookworm ARG TARGETARCH=amd64 # Container labels -LABEL org.opencontainers.image.source="https://github.com/wallstop/DxMessaging" +LABEL org.opencontainers.image.source="https://github.com/Ambiguous-Interactive/DxMessaging" LABEL org.opencontainers.image.description="DxMessaging development container" LABEL org.opencontainers.image.licenses="MIT" LABEL unity.support="docker-outside-of-docker" diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index f423bf9f..27ea7a66 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: false contact_links: - name: Questions & Discussion - url: https://github.com/wallstop/DxMessaging/discussions + url: https://github.com/Ambiguous-Interactive/DxMessaging/discussions about: Please ask questions and discuss ideas in GitHub Discussions instead of opening an issue. diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 00000000..e15d8dfd --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,21 @@ +# actionlint configuration. +# Declares custom self-hosted runner labels that actionlint cannot infer +# from the workflow files. Without this, actionlint reports +# "label \"\" is unknown" for every job referencing them. +self-hosted-runner: + labels: + # Marker label applied to self-hosted Windows runners with 64GB+ RAM, + # used by Unity workflows (release.yml, unity-benchmarks.yml, + # unity-il2cpp.yml, unity-tests.yml). Add additional custom labels here + # as new runner pools are introduced. + - RAM-64GB + # Speed marker reserved on ELI-MACHINE for future opt-in (e.g., + # hotfix dispatch); no currently-active workflow requests it. The + # workflow validator and actionlint both still allow the label so a + # future workflow can opt in without churn. + - fast + # Legacy marker applied to old-linux (documented in .llm/context.md); + # reserved here so any future inline `runs-on: [self-hosted, Linux, + # ..., old]` does not trip an actionlint false-positive. + - old +config-variables: null diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7f736769..9937ed75 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,3 +1,3 @@ # GitHub Copilot Instructions -See the [AI Agent Guidelines](../.llm/context.md) for all AI agent guidelines. +For full project guidance, see [AI Agent Guidelines](../.llm/context.md). diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 800d71e9..c55045ef 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -12,10 +12,6 @@ updates: - "*" labels: - "dependencies" - assignees: - - wallstop - reviewers: - - wallstop # NuGet: SourceGenerator project - Roslyn packages pinned for Unity compiler compatibility - package-ecosystem: "nuget" @@ -29,10 +25,6 @@ updates: - "*" labels: - "dependencies" - assignees: - - wallstop - reviewers: - - wallstop ignore: # SourceGenerator Roslyn packages - pinned for Unity compiler compatibility. # Unity's embedded compiler requires specific Roslyn versions; updating breaks SourceGenerator loading. @@ -56,10 +48,6 @@ updates: - "*" labels: - "dependencies" - assignees: - - wallstop - reviewers: - - wallstop ignore: # Shared Roslyn dependencies (pinned for Unity compatibility in SourceGenerator project) - dependency-name: "Microsoft.CodeAnalysis*" @@ -80,7 +68,3 @@ updates: labels: - "dependencies" versioning-strategy: increase - assignees: - - wallstop - reviewers: - - wallstop diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index bf965854..ca052c67 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -1,5 +1,5 @@ -name-template: "$RESOLVED_VERSION" -tag-template: "$RESOLVED_VERSION" +name-template: "v$RESOLVED_VERSION" +tag-template: "v$RESOLVED_VERSION" categories: - title: "šŸš€ Features" diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml index 28699802..283d6c61 100644 --- a/.github/workflows/actionlint.yml +++ b/.github/workflows/actionlint.yml @@ -4,6 +4,7 @@ on: pull_request: paths: - ".github/workflows/**" + - ".github/actionlint.yaml" - "scripts/validate-workflows.js" push: branches: @@ -11,6 +12,7 @@ on: - master paths: - ".github/workflows/**" + - ".github/actionlint.yaml" - "scripts/validate-workflows.js" workflow_dispatch: diff --git a/.github/workflows/cross-platform-preflight.yml b/.github/workflows/cross-platform-preflight.yml new file mode 100644 index 00000000..2c7d0471 --- /dev/null +++ b/.github/workflows/cross-platform-preflight.yml @@ -0,0 +1,103 @@ +name: Cross-platform preflight + +# Catches Windows-only regressions in the managed-Jest / integrity-gate / +# resolver-probe code paths that motivated the path-separator and resolver- +# health hardening. Runs the same preflight the developer runs locally +# (npm run preflight:pre-push) on every supported OS so a path-separator +# regression in a test or a resolver-binding failure cannot reach master +# unobserved. See .llm/skills/scripting/integrity-gate-robustness.md for the +# class-of-failure context. + +on: + pull_request: + paths: + - "package.json" + - ".pre-commit-config.yaml" + - "scripts/**" + - ".llm/**" + - ".github/workflows/cross-platform-preflight.yml" + push: + branches: + - main + - master + paths: + - "package.json" + - ".pre-commit-config.yaml" + - "scripts/**" + - ".llm/**" + - ".github/workflows/cross-platform-preflight.yml" + workflow_dispatch: + +concurrency: + group: cross-platform-preflight-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + preflight: + name: Preflight (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + - macos-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "22.18.0" + cache: npm + cache-dependency-path: package.json + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install Node dependencies + shell: bash + run: | + set -euo pipefail + if [ -f package-lock.json ]; then + npm ci + else + npm i --no-audit --no-fund + fi + + - name: Install pre-commit + run: python -m pip install pre-commit + + - name: Install pre-commit hooks + run: pre-commit install --install-hooks + + - name: Run path-separator + resolver-health regression suite + # Targeted Jest run: these are the tests that regression-proof the + # class of failures fixed in this branch. Keeping them as a first, + # dedicated step makes a CI failure immediately point at the right + # skill (integrity-gate-robustness) without hunting through a long + # combined log. + shell: bash + run: | + set -euo pipefail + node scripts/run-managed-jest.js --runTestsByPath \ + scripts/__tests__/path-classifier.test.js \ + scripts/__tests__/cross-platform-path-handling.test.js \ + scripts/__tests__/node-modules-integrity.test.js \ + scripts/__tests__/run-managed-jest.test.js + + - name: Run pre-push preflight + shell: bash + run: npm run preflight:pre-push diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 43de94e1..3c83bb21 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -3,8 +3,8 @@ # This workflow builds MkDocs documentation and deploys it to GitHub Pages. # It runs on pushes to master (for deployment) and on PRs (for validation only). # -# Badge URL: https://github.com/wallstop/DxMessaging/actions/workflows/deploy-docs.yml/badge.svg -# Pages URL: https://wallstop.github.io/DxMessaging/ +# Badge URL: https://github.com/Ambiguous-Interactive/DxMessaging/actions/workflows/deploy-docs.yml/badge.svg +# Pages URL: https://ambiguous-interactive.github.io/DxMessaging/ name: Deploy Documentation diff --git a/.github/workflows/pre-commit-tooling-check.yml b/.github/workflows/pre-commit-tooling-check.yml index 404c96b3..08950d47 100644 --- a/.github/workflows/pre-commit-tooling-check.yml +++ b/.github/workflows/pre-commit-tooling-check.yml @@ -137,6 +137,9 @@ jobs: npm i --no-audit --no-fund fi + - name: Validate local Node tooling health + run: npm run validate:node-tooling + - name: Validate pre-commit Node tooling policy run: pre-commit run validate-pre-commit-tooling --all-files @@ -149,6 +152,23 @@ jobs: - name: Run parser script tests hook run: pre-commit run --hook-stage pre-push script-parser-tests --all-files + - name: Run script tests hook + run: pre-commit run --hook-stage pre-push script-tests --all-files + + - name: Run Unity contract tests hook + run: pre-commit run --hook-stage pre-push unity-contract-tests --all-files + + - name: Run pre-push preflight (parity check) + # Redundant by design: if `preflight:pre-push` diverges from the + # explicit pre-push hook invocations above (e.g., someone adds a + # new pre-push hook to .pre-commit-config.yaml but forgets to + # widen the npm script), this step fails fast and points the + # author at the discrepancy. The coverage Jest test catches the + # static case; this step catches drift the test cannot model + # (per-platform shell quirks, npm script chaining, env handling). + shell: bash + run: npm run preflight:pre-push + - name: Diagnose managed Jest fallback environment shell: bash run: | @@ -157,12 +177,12 @@ jobs: echo "npm: $(npm --version)" node <<'NODE' const fs = require("fs"); - const runnerPath = "node_modules/jest-circus/build/runner.js"; - console.log("runner exists before deletion:", fs.existsSync(runnerPath)); try { - console.log("resolved runner path:", require.resolve("jest-circus/runner")); + const runnerPath = require.resolve("jest-circus/runner"); + console.log("resolved runner path before deletion:", runnerPath); + console.log("runner exists before deletion:", fs.existsSync(runnerPath)); } catch (error) { - console.log(`resolved runner path: unavailable (${error.message})`); + console.log(`resolved runner path before deletion: unavailable (${error.message})`); } NODE @@ -175,11 +195,16 @@ jobs: set -euo pipefail node <<'NODE' const fs = require("fs"); - const runnerPath = "node_modules/jest-circus/build/runner.js"; - const exists = fs.existsSync(runnerPath); - console.log("runner exists after deletion:", exists); - if (exists) { + try { + const runnerPath = require.resolve("jest-circus/runner"); + const exists = fs.existsSync(runnerPath); + console.log("resolved runner path after deletion:", runnerPath); + console.log("runner exists after deletion:", exists); + if (exists) { process.exit(1); + } + } catch (error) { + console.log(`resolved runner path after deletion: unavailable (${error.message})`); } NODE @@ -233,13 +258,15 @@ jobs: exit 1 fi - if ! grep -Fq "Injected isolated Jest test runner" "$output_file"; then - echo "::error::Expected isolated fallback test runner injection diagnostics." - exit 1 - fi + # NOTE: scripts/run-managed-jest.js intentionally does NOT inject + # --testRunner (Jest 27+ resolves jest-circus by default; injecting + # absolute paths breaks jest-config's runner validator on Windows). + # The "Injected isolated Jest test runner" diagnostic is therefore + # absent by design. Re-introducing that injection is blocked by + # scripts/__tests__/run-managed-jest-no-injected-test-runner.test.js. - name: Verify managed Jest fallback test run: >- node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/run-managed-jest.test.js --testNamePattern - "runIsolatedFallbackJest injects isolated testRunner when caller did not provide one" + "runIsolatedFallbackJest does not inject --testRunner when caller did not provide one" diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index d2aa7655..3fdf420e 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -17,7 +17,7 @@ concurrency: jobs: update-release-draft: - if: github.repository == 'wallstop/DxMessaging' + if: github.repository == 'Ambiguous-Interactive/DxMessaging' runs-on: ubuntu-latest steps: - name: Checkout diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..50fec845 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,235 @@ +name: Release + +on: + push: + tags: + - "v[0-9]*.[0-9]*.[0-9]*" + +concurrency: + group: release-${{ github.ref_name }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + verify-tag: + name: Verify release tag + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + package-version: ${{ steps.verify.outputs.package-version }} + tag: ${{ steps.verify.outputs.tag }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Verify semver tag matches package.json + id: verify + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + if ! printf '%s\n' "${tag}" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::Release tags must use vX.Y.Z semver format." + exit 1 + fi + PACKAGE_VERSION="$(jq -r '.version // empty' package.json)" + if [ -z "${PACKAGE_VERSION}" ]; then + echo "::error::package.json version is missing." + exit 1 + fi + if [ "${tag}" != "v${PACKAGE_VERSION}" ]; then + echo "::error::Tag ${tag} does not match package.json version v${PACKAGE_VERSION}." + exit 1 + fi + { + echo "package-version=${PACKAGE_VERSION}" + echo "tag=${tag}" + } >> "${GITHUB_OUTPUT}" + + validate: + name: Validate package + needs: verify-tag + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + attestations: write + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "22.18.0" + + - name: Install Node dependencies + run: | + set -euo pipefail + if [ -f package-lock.json ]; then + npm ci + else + npm i --no-audit --no-fund + fi + + - name: Run release validation + run: | + set -euo pipefail + npm run test:scripts + npm run test:unity-contracts + npm run validate:npm-meta + npm run validate:llms-txt + npm run validate:repo-identity + npm run validate:all + + - name: Pack npm package + id: pack + run: | + set -euo pipefail + mkdir -p .artifacts/release + pack_json="$(npm pack --json --provenance=false)" + package_file="$(printf '%s' "${pack_json}" | jq -r '.[0].filename')" + mv "${package_file}" ".artifacts/release/${package_file}" + (cd .artifacts/release && sha256sum "${package_file}" > "${package_file}.sha256") + # Single-quoted format contains literal backticks for Markdown code fences; + # SC2016 heuristically warns of unintended command substitution. + # shellcheck disable=SC2016 + printf 'Release %s\n\nPackage: `%s`\n' \ + "${{ needs.verify-tag.outputs.tag }}" \ + "${package_file}" > .artifacts/release/release-notes.md + echo "package-file=${package_file}" >> "${GITHUB_OUTPUT}" + + - name: Upload release artifacts + uses: actions/upload-artifact@v7 + with: + name: release-package + path: .artifacts/release/ + if-no-files-found: error + + - name: Attest release package + uses: actions/attest-build-provenance@v3 + with: + subject-path: .artifacts/release/*.tgz + + unity-checks: + name: Trusted Unity release checks + needs: verify-tag + runs-on: [self-hosted, Windows, RAM-64GB] + timeout-minutes: 120 + concurrency: + group: unity-pro-license + cancel-in-progress: false + steps: + - name: Print runner diagnostics + shell: bash + run: | + set -euo pipefail + echo "::group::Runner diagnostics" + echo "Runner name: ${RUNNER_NAME:-}" + echo "Runner OS: ${RUNNER_OS:-}" + echo "Runner arch: ${RUNNER_ARCH:-}" + echo "Runner workspace: ${RUNNER_WORKSPACE:-}" + echo "Concurrency group: unity-pro-license (single-seat Unity Pro license; cross-workflow serialization)" + echo "Requested labels: self-hosted, Windows, RAM-64GB" + echo "GitHub workflow: ${GITHUB_WORKFLOW:-}" + echo "GitHub job: ${GITHUB_JOB:-}" + echo "GitHub run id/attempt: ${GITHUB_RUN_ID:-} / ${GITHUB_RUN_ATTEMPT:-}" + echo "GitHub ref: ${GITHUB_REF:-} (protected=${GITHUB_REF_PROTECTED:-})" + echo "GitHub event: ${GITHUB_EVENT_NAME:-}" + echo "::endgroup::" + + - name: Checkout + uses: actions/checkout@v6 + with: + lfs: true + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "22.18.0" + + - name: Run Unity contract checks + shell: pwsh + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_VERSION: "2022.3.45f1" + CI: "true" + run: | + npm run test:unity-contracts + ./scripts/unity/run-tests.ps1 ` + -Platform editmode ` + -UnityVersion "2022.3.45f1" ` + -Runner docker ` + -Results ".artifacts/unity/release-editmode/results.xml" + + publish: + name: Publish npm package and GitHub Release + needs: + - verify-tag + - validate + - unity-checks + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + attestations: write + contents: write + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "22.18.0" + registry-url: "https://registry.npmjs.org" + + - name: Download release package + uses: actions/download-artifact@v7 + with: + name: release-package + path: .artifacts/release + + - name: Create or update GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + tag="${{ needs.verify-tag.outputs.tag }}" + if gh release view "${tag}" >/dev/null 2>&1; then + gh release upload "${tag}" \ + .artifacts/release/*.tgz \ + .artifacts/release/*.sha256 \ + --clobber + gh release edit "${tag}" \ + --title "${tag}" \ + --notes-file .artifacts/release/release-notes.md \ + --draft=false + else + gh release create "${tag}" \ + .artifacts/release/*.tgz \ + .artifacts/release/*.sha256 \ + --title "${tag}" \ + --notes-file .artifacts/release/release-notes.md \ + --verify-tag + fi + + - name: Publish to npm with provenance + run: | + set -euo pipefail + npx --yes --package=npm@^11.5.1 npm --version + package_file="$(find .artifacts/release -maxdepth 1 -name '*.tgz' -print -quit)" + if [ -z "${package_file}" ]; then + echo "::error::Packed npm package artifact is missing." + exit 1 + fi + npx --yes --package=npm@^11.5.1 npm publish "${package_file}" --provenance --access public diff --git a/.github/workflows/script-tests.yml b/.github/workflows/script-tests.yml index 56b33474..89a33967 100644 --- a/.github/workflows/script-tests.yml +++ b/.github/workflows/script-tests.yml @@ -66,3 +66,67 @@ jobs: - name: Run Jest script tests run: npm run test:scripts + + preflight-pre-push: + name: Run preflight:pre-push (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + permissions: + contents: read + # Phase 5 CI parity: run the full `npm run preflight:pre-push` chain on + # the two developer OS runners GitHub Actions provides for this matrix + # (Ubuntu + Windows). macOS parity is covered by the + # pre-commit-tooling-check.yml workflow's preflight step, which runs the + # same chain on macos-latest. The concurrency group is expanded with + # ${{ matrix.os }} per scripts/validate-workflows.js: a matrix job may + # not share a single concurrency slot across OS entries (one slot would + # cancel every successive OS the moment the next OS queues). + concurrency: + group: ${{ github.workflow }}-preflight-pre-push-${{ matrix.os }}-${{ github.ref }} + cancel-in-progress: true + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "22.18.0" + cache: npm + cache-dependency-path: package.json + + - name: Install pre-commit + run: python -m pip install pre-commit + + - name: Install dependencies + shell: bash + run: | + if [ -f package-lock.json ]; then + npm ci + else + npm i --no-audit --no-fund + fi + + - name: Run preflight:pre-push + # `shell: bash` ensures the chained npm script + pre-commit + # invocation resolves the same way on windows-latest (Git Bash) as + # on ubuntu-latest, so a Windows-only quoting or path-shim + # regression cannot pass CI unnoticed. + shell: bash + run: npm run preflight:pre-push diff --git a/.github/workflows/spellcheck.yml b/.github/workflows/spellcheck.yml index 8cf8be0a..a8d2912b 100644 --- a/.github/workflows/spellcheck.yml +++ b/.github/workflows/spellcheck.yml @@ -13,6 +13,7 @@ on: - ".github/**" - ".llm/**" - ".cspell.json" + - ".gitignore" push: branches: - main @@ -28,6 +29,7 @@ on: - ".github/**" - ".llm/**" - ".cspell.json" + - ".gitignore" workflow_dispatch: concurrency: @@ -56,4 +58,5 @@ jobs: cache-dependency-path: package.json - name: Run cspell - run: npx --yes cspell@10.0.0 --no-progress --no-summary . + # Keep the summary line on failures so CI logs show files-scanned vs. issues count. + run: npx --yes cspell@10.0.0 --no-progress . diff --git a/.github/workflows/stuck-job-watchdog.yml b/.github/workflows/stuck-job-watchdog.yml new file mode 100644 index 00000000..530301ef --- /dev/null +++ b/.github/workflows/stuck-job-watchdog.yml @@ -0,0 +1,479 @@ +name: Stuck Job Watchdog + +# Recovery automation for the known GitHub Actions self-hosted dispatcher bug +# documented at https://github.com/orgs/community/discussions/186811: +# self-hosted runners report Online/Idle but `runner_id` stays at 0 for +# 7+ minutes, so a queued job whose label set is satisfied by an idle runner +# nonetheless waits indefinitely until manually re-run. +# +# Detection requires ALL of the following to be true: +# * The workflow run is `status: queued` AND older than MIN_QUEUE_AGE_SECONDS +# (default 300s / 5 min; round-2 false-positive guards below -- zero +# in-progress jobs, self-run exclusion, excluded-workflow list -- make +# the previous conservative 600s buffer unnecessary). +# * No job in the run is `status: in_progress` - a run with even one +# in-progress job is by definition holding/using a runner, not +# dispatcher-stuck. This is what prevents false positives for matrix +# entries waiting on `strategy.max-parallel: 1` or jobs blocked by the +# `unity-pro-license` concurrency group (where another job from the same +# run is actively running). +# * At least one job in the run is `status: queued`. +# * At least one idle runner's labels satisfy a queued job's label +# requirements (superset match). +# * The run's workflow file is NOT in the exclusion list (`release.yml` +# is hard-excluded by default; additional entries may be added via the +# `WATCHDOG_EXCLUDED_WORKFLOWS` repo variable, whitespace-separated). +# * The run id is NOT the watchdog's own run id. +# +# Recovery action (per cli/cli#9221 and the gh-run-rerun manual, +# `gh run rerun --failed` cannot be used on a `status: queued` run because +# the run never reached `failed`; the documented workaround is cancel + +# redispatch / cancel + operator-managed re-run): +# * `gh run cancel ` kills the stuck dispatch. +# * If the run was triggered by push/schedule/workflow_dispatch on a +# branch (not a tag) AND the workflow file declares `workflow_dispatch:`, +# re-dispatch via REST API +# (`POST repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches`). +# * Otherwise (pull_request, tag, no workflow_dispatch trigger), emit a +# clear `GITHUB_STEP_SUMMARY` line instructing the operator to click +# "Re-run all jobs" in the GitHub UI. Do NOT push a commit, comment on +# the PR, or escalate any other automatic action. +# +# Cancel attempts are capped at 2 per run-id per 24h via a small state file +# on the `watchdog-state` orphan branch (commit-back pattern with a single +# rebase+retry on push failure). +# +# Self-canceling workflow-level concurrency guarantees only one watchdog +# instance ever runs. + +on: + schedule: + - cron: "*/5 * * * *" + workflow_dispatch: + +concurrency: + group: stuck-job-watchdog + cancel-in-progress: true + +permissions: + actions: write + contents: write + +jobs: + audit-queue: + name: Audit queued runs and recover dispatcher-stuck ones + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Audit + cancel-and-redispatch + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + OWNER: ${{ github.repository_owner }} + SELF_RUN_ID: ${{ github.run_id }} + STATE_BRANCH: watchdog-state + STATE_DIR: .watchdog-state + MAX_CANCELS_PER_DAY: "2" + MIN_QUEUE_AGE_SECONDS: "300" + DEFAULT_EXCLUDED_WORKFLOWS: "release.yml" + EXTRA_EXCLUDED_WORKFLOWS: ${{ vars.WATCHDOG_EXCLUDED_WORKFLOWS }} + run: | + set -euo pipefail + + summary_file="$(mktemp)" + : > "${summary_file}" + log_summary() { + printf '%s\n' "$1" | tee -a "${summary_file}" + } + + flush_summary_and_exit() { + local code="${1:-0}" + { + echo "## Watchdog summary" + cat "${summary_file}" + } >> "${GITHUB_STEP_SUMMARY}" + exit "${code}" + } + + # Category buckets for the final step-summary table. + healthy_runs_file="$(mktemp)" + : > "${healthy_runs_file}" + stuck_runs_file="$(mktemp)" + : > "${stuck_runs_file}" + excluded_runs_file="$(mktemp)" + : > "${excluded_runs_file}" + + log_summary "## Stuck-job watchdog audit ($(date -u +'%Y-%m-%dT%H:%M:%SZ'))" + log_summary "Repo: ${REPO}" + log_summary "Owner: ${OWNER}" + log_summary "Self run id (will skip): ${SELF_RUN_ID}" + + # Build the workflow-file exclusion list (default + repo variable). + declare -A EXCLUDED_BY_FILE=() + for wf in ${DEFAULT_EXCLUDED_WORKFLOWS} ${EXTRA_EXCLUDED_WORKFLOWS:-}; do + [[ -z "${wf}" ]] && continue + # Normalize: strip any leading .github/workflows/ if present. + base="${wf##*/}" + EXCLUDED_BY_FILE["${base}"]=1 + done + excluded_list="" + for k in "${!EXCLUDED_BY_FILE[@]}"; do + excluded_list+="${k} " + done + log_summary "Excluded workflows: ${excluded_list:-}" + + # ------------------------------------------------------------------ + # 1. Bootstrap or check out the watchdog-state orphan branch. + # ------------------------------------------------------------------ + work_dir="$(mktemp -d)" + pushd "${work_dir}" > /dev/null + # Scope git identity to this checkout - keep runner-global git + # config pristine. We pass -c on each git invocation that creates + # objects (commit) below; clone itself does not need identity. + git clone --depth 1 "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" repo + cd repo + GIT_AUTHOR_ID=(-c "user.email=actions@github.com" -c "user.name=stuck-job-watchdog") + + # Decoupled probe + bootstrap: ls-remote distinguishes + # "branch missing" (zero rows + exit 0) from "transient fetch + # failure" (non-zero exit). We MUST NOT bootstrap on transient + # failure - that would push-corrupt an existing branch by + # rewriting it as a fresh orphan. + state_branch_probe="$(mktemp)" + if ! git ls-remote --heads origin "${STATE_BRANCH}" > "${state_branch_probe}" 2>/dev/null; then + log_summary "WARN: 'git ls-remote --heads origin ${STATE_BRANCH}' failed (transient?); refusing to bootstrap. Skipping this cycle." + popd > /dev/null + flush_summary_and_exit 0 + fi + + if [[ -s "${state_branch_probe}" ]]; then + if ! git fetch origin "${STATE_BRANCH}"; then + log_summary "WARN: state branch '${STATE_BRANCH}' exists per ls-remote but fetch failed; skipping this cycle." + popd > /dev/null + flush_summary_and_exit 0 + fi + git checkout "${STATE_BRANCH}" + log_summary "State branch '${STATE_BRANCH}' checked out." + else + log_summary "State branch '${STATE_BRANCH}' missing -- bootstrapping orphan branch." + git checkout --orphan "${STATE_BRANCH}" + git rm -rf . > /dev/null 2>&1 || true + mkdir -p "${STATE_DIR}" + touch "${STATE_DIR}/.gitkeep" + git add "${STATE_DIR}/.gitkeep" + git "${GIT_AUTHOR_ID[@]}" commit -m "Initialize watchdog state" || true + if ! git push origin "${STATE_BRANCH}"; then + log_summary "WARN: bootstrap push failed; will retry on next run." + popd > /dev/null + flush_summary_and_exit 0 + fi + fi + mkdir -p "${STATE_DIR}" + + # ------------------------------------------------------------------ + # 2. Enumerate queued runs older than MIN_QUEUE_AGE_SECONDS. + # ------------------------------------------------------------------ + now_epoch="$(date -u +%s)" + queued_runs_json="$(mktemp)" + # `gh api --paginate` over the runs endpoint returns one JSON object + # per page; `jq -s '[.[] | .workflow_runs[]?]'` flattens all pages + # into a single array. Equivalent to using `--slurp` on newer gh + # versions but works on all gh versions shipped with ubuntu-latest. + if ! gh api --paginate "repos/${REPO}/actions/runs?status=queued&per_page=100" \ + | jq -s '[.[] | (.workflow_runs // [])[]]' > "${queued_runs_json}"; then + log_summary "ERROR: failed to list queued runs." + popd > /dev/null + flush_summary_and_exit 0 + fi + + mapfile -t queued_ids < <(jq -r --argjson now "${now_epoch}" --argjson min "${MIN_QUEUE_AGE_SECONDS}" ' + .[] + | select(.created_at != null) + | (.created_at | fromdateiso8601) as $created + | select(($now - $created) >= $min) + | .id + ' < "${queued_runs_json}" | sort -u) + + log_summary "Queued runs older than ${MIN_QUEUE_AGE_SECONDS}s: ${#queued_ids[@]}" + if [[ ${#queued_ids[@]} -eq 0 ]]; then + log_summary "Queue is clean. No action." + popd > /dev/null + flush_summary_and_exit 0 + fi + + # ------------------------------------------------------------------ + # 3. Fetch idle runners (org first, fall back to repo on 403). + # `gh api --paginate` over an object-shaped endpoint returns one + # object per page; without `jq -s`, downstream evaluation runs + # against only the last page (silent false negatives once the + # org grows past 100 runners - see cli/cli#1268). + # ------------------------------------------------------------------ + runners_json="$(mktemp)" + runners_scope="org" + if ! gh api --paginate "orgs/${OWNER}/actions/runners?per_page=100" \ + | jq -s '[.[] | (.runners // [])[]]' > "${runners_json}" 2>/dev/null; then + runners_scope="repo" + log_summary "WARN: org-level runner list unavailable (likely 403). Falling back to repo runners." + if ! gh api --paginate "repos/${REPO}/actions/runners?per_page=100" \ + | jq -s '[.[] | (.runners // [])[]]' > "${runners_json}"; then + log_summary "ERROR: repo-level runner list also failed; cannot evaluate idle runners. No action issued." + popd > /dev/null + flush_summary_and_exit 0 + fi + fi + log_summary "Runner inventory scope: ${runners_scope}" + + idle_runners_json="$(mktemp)" + jq ' + [ .[] + | select(.status == "online") + | select(.busy == false) + | { id: .id, name: .name, labels: ([(.labels // [])[].name]) } ] + ' < "${runners_json}" > "${idle_runners_json}" + idle_count="$(jq 'length' < "${idle_runners_json}")" + log_summary "Idle runners (online + not busy): ${idle_count}" + + # ------------------------------------------------------------------ + # 4. For each queued run, decide stuck vs healthy vs excluded. + # ------------------------------------------------------------------ + state_dirty=0 + for run_id in "${queued_ids[@]}"; do + # Skip the watchdog's own run id (defense in depth - the watchdog + # workflow file is excluded by name above, but if someone renames + # the file or runs ad-hoc the id check still protects us). + if [[ "${run_id}" == "${SELF_RUN_ID}" ]]; then + log_summary "run ${run_id}: this is the watchdog's own run; skipping." + continue + fi + + # Pull the run metadata we need from the cached list (workflow + # file path, event, head_branch). + run_meta="$( + jq -c --argjson id "${run_id}" ' + .[] + | select(.id == $id) + | { + path: (.path // ""), + event: (.event // ""), + workflow_id: (.workflow_id // 0), + head_branch: (.head_branch // ""), + html_url: (.html_url // "") + } + ' < "${queued_runs_json}" | head -n 1 + )" + if [[ -z "${run_meta}" ]]; then + log_summary "run ${run_id}: metadata unavailable; skipping." + continue + fi + run_path="$(jq -r '.path' <<< "${run_meta}")" + run_event="$(jq -r '.event' <<< "${run_meta}")" + run_workflow_id="$(jq -r '.workflow_id' <<< "${run_meta}")" + run_head_branch="$(jq -r '.head_branch' <<< "${run_meta}")" + run_html_url="$(jq -r '.html_url' <<< "${run_meta}")" + run_path_base="${run_path##*/}" + + # Workflow-file exclusion (must NOT cancel release.yml). + if [[ -n "${EXCLUDED_BY_FILE[${run_path_base}]+x}" ]]; then + log_summary "run ${run_id} (${run_path_base}, event=${run_event}): workflow is excluded; operator action needed if stuck." + printf '* run %s (%s, event=%s) -- %s\n' "${run_id}" "${run_path_base}" "${run_event}" "${run_html_url}" >> "${excluded_runs_file}" + continue + fi + + jobs_json="$(mktemp)" + if ! gh api --paginate "repos/${REPO}/actions/runs/${run_id}/jobs?per_page=100" \ + | jq -s '[.[] | (.jobs // [])[]]' > "${jobs_json}" 2>/dev/null; then + log_summary "run ${run_id}: failed to list jobs; skipping." + continue + fi + + in_progress_count="$(jq '[ .[] | select(.status == "in_progress") ] | length' < "${jobs_json}")" + queued_count="$(jq '[ .[] | select(.status == "queued") ] | length' < "${jobs_json}")" + + if (( in_progress_count > 0 )); then + # A run with even one in-progress job is by definition not + # dispatcher-stuck. This covers: matrix entries waiting on + # `strategy.max-parallel: 1`, jobs blocked by the + # `unity-pro-license` concurrency group while another job from + # the same run holds the license, and the general case of a + # run that has at least one runner. + log_summary "run ${run_id} (${run_path_base}, event=${run_event}): healthy queued (${in_progress_count} in_progress, ${queued_count} queued -- waiting on concurrency/matrix slot)." + printf '* run %s (%s, event=%s) -- %d in_progress, %d queued\n' "${run_id}" "${run_path_base}" "${run_event}" "${in_progress_count}" "${queued_count}" >> "${healthy_runs_file}" + continue + fi + + if (( queued_count == 0 )); then + # No queued jobs at all - the run is in some other transitional + # state, not the dispatcher-stuck pattern. + log_summary "run ${run_id} (${run_path_base}, event=${run_event}): no queued jobs yet (early state); skipping." + continue + fi + + mapfile -t queued_job_labels < <(jq -c ' + .[] + | select(.status == "queued") + | (.labels // []) + ' < "${jobs_json}") + + matched=0 + for labels_csv in "${queued_job_labels[@]}"; do + # labels_csv is a JSON array like ["self-hosted","Windows","RAM-64GB"] + if jq -e --argjson labels "${labels_csv}" ' + map( + . as $r + | ($labels | all(. as $l | $r.labels | index($l) | type == "number")) + ) | any + ' < "${idle_runners_json}" > /dev/null; then + matched=1 + break + fi + done + + if [[ ${matched} -eq 0 ]]; then + log_summary "run ${run_id} (${run_path_base}, event=${run_event}): no matching idle runner -- investigate label config. No action." + continue + fi + + # ------------------------------------------------------------------ + # 5. Genuinely stuck. Cap at MAX_CANCELS_PER_DAY per run-id. + # ------------------------------------------------------------------ + state_file="${STATE_DIR}/${run_id}.json" + cancels=0 + last_cancel=0 + if [[ -f "${state_file}" ]]; then + state_ok=1 + state_content="$(cat "${state_file}" 2>/dev/null)" || state_ok=0 + if [[ ${state_ok} -eq 1 ]]; then + parsed_cancels="$(jq -r '.cancels // .reruns // 0' <<< "${state_content}" 2>/dev/null)" || state_ok=0 + parsed_last="$(jq -r '.last_cancel // .last_rerun // 0' <<< "${state_content}" 2>/dev/null)" || state_ok=0 + fi + if [[ ${state_ok} -eq 1 ]]; then + cancels="${parsed_cancels}" + last_cancel="${parsed_last}" + else + log_summary "run ${run_id}: state file corrupt; resetting." + fi + fi + # Reset counter if last action was >24h ago. + if (( now_epoch - last_cancel > 86400 )); then + cancels=0 + fi + + if (( cancels >= MAX_CANCELS_PER_DAY )); then + log_summary "run ${run_id} (${run_path_base}, event=${run_event}): cancel cap (${MAX_CANCELS_PER_DAY}/24h) reached; skipping. Manual intervention required." + printf '* run %s (%s, event=%s) -- cap reached, operator action: %s\n' "${run_id}" "${run_path_base}" "${run_event}" "${run_html_url}" >> "${stuck_runs_file}" + continue + fi + + log_summary "run ${run_id} (${run_path_base}, event=${run_event}): dispatcher-stuck; cancelling (attempt $((cancels + 1))/${MAX_CANCELS_PER_DAY})." + if ! gh run cancel "${run_id}" --repo "${REPO}" 2>&1 | tee -a "${summary_file}"; then + log_summary "run ${run_id}: 'gh run cancel' failed; will try again next cycle." + continue + fi + + cancels=$((cancels + 1)) + printf '{"cancels": %d, "last_cancel": %d}\n' "${cancels}" "${now_epoch}" > "${state_file}" + git add "${state_file}" + state_dirty=1 + + # Decide redispatch path based on event + workflow_dispatch trigger. + workflow_def="$(mktemp)" + workflow_supports_dispatch=0 + if gh api "repos/${REPO}/actions/workflows/${run_workflow_id}" > "${workflow_def}" 2>/dev/null; then + workflow_path="$(jq -r '.path // ""' < "${workflow_def}")" + if [[ -n "${workflow_path}" ]]; then + # Look at the contents of the workflow file to see if it + # declares `workflow_dispatch:` (the trigger we need to use + # the dispatches REST API). + workflow_file_raw="$(mktemp)" + if gh api "repos/${REPO}/contents/${workflow_path}" --jq '.content' 2>/dev/null \ + | base64 -d > "${workflow_file_raw}" 2>/dev/null; then + if grep -Eq '^[[:space:]]*workflow_dispatch:' "${workflow_file_raw}"; then + workflow_supports_dispatch=1 + fi + fi + fi + fi + + case "${run_event}" in + push|schedule|workflow_dispatch) + if [[ -n "${run_head_branch}" && ${workflow_supports_dispatch} -eq 1 ]]; then + log_summary "run ${run_id}: re-dispatching workflow ${run_workflow_id} on ref '${run_head_branch}'." + if gh api -X POST "repos/${REPO}/actions/workflows/${run_workflow_id}/dispatches" \ + -f "ref=${run_head_branch}" 2>&1 | tee -a "${summary_file}"; then + printf '* run %s (%s, event=%s) -- cancelled and re-dispatched on %s\n' "${run_id}" "${run_path_base}" "${run_event}" "${run_head_branch}" >> "${stuck_runs_file}" + else + log_summary "run ${run_id}: re-dispatch failed; operator action: ${run_html_url}" + printf '* run %s (%s, event=%s) -- cancelled; re-dispatch FAILED, operator action: %s\n' "${run_id}" "${run_path_base}" "${run_event}" "${run_html_url}" >> "${stuck_runs_file}" + fi + else + log_summary "run ${run_id}: workflow does not support workflow_dispatch on ref '${run_head_branch}'; operator action: click 'Re-run all jobs' at ${run_html_url}" + printf \ + '* run %s (%s, event=%s) -- cancelled; operator action: click "Re-run all jobs" at %s\n' \ + "${run_id}" "${run_path_base}" "${run_event}" "${run_html_url}" >> "${stuck_runs_file}" + fi + ;; + pull_request|pull_request_target) + # No safe API path: the dispatches endpoint cannot re-trigger + # a pull_request run, and pushing a no-op commit to the head + # ref would tamper with the PR. Operator-visible cancel + + # explicit step-summary instruction is the supported path. + log_summary "run ${run_id}: pull_request-triggered; operator action: click 'Re-run all jobs' at ${run_html_url}" + printf '* run %s (%s, event=%s) -- cancelled; operator action: click "Re-run all jobs" at %s\n' "${run_id}" "${run_path_base}" "${run_event}" "${run_html_url}" >> "${stuck_runs_file}" + ;; + *) + log_summary "run ${run_id}: event '${run_event}' has no automatic recovery path; operator action: click 'Re-run all jobs' at ${run_html_url}" + printf '* run %s (%s, event=%s) -- cancelled; operator action: click "Re-run all jobs" at %s\n' "${run_id}" "${run_path_base}" "${run_event}" "${run_html_url}" >> "${stuck_runs_file}" + ;; + esac + done + + # ------------------------------------------------------------------ + # 6. Commit + push state changes with a single rebase+retry. + # ------------------------------------------------------------------ + if (( state_dirty == 1 )); then + git "${GIT_AUTHOR_ID[@]}" commit -m "Watchdog: record cancels at $(date -u +'%Y-%m-%dT%H:%M:%SZ')" || true + if git push origin "${STATE_BRANCH}"; then + log_summary "State branch updated." + else + log_summary "WARN: state push failed; attempting fetch + rebase + retry." + if git fetch origin "${STATE_BRANCH}" \ + && git rebase "origin/${STATE_BRANCH}" \ + && git push origin "${STATE_BRANCH}"; then + log_summary "State branch updated on second attempt (after rebase)." + else + log_summary "WARN: state push failed twice; counters may double-count next run." + fi + fi + fi + + popd > /dev/null + + # ------------------------------------------------------------------ + # Final categorized step-summary table. + # ------------------------------------------------------------------ + { + echo "## Watchdog summary" + cat "${summary_file}" + echo "" + echo "### Healthy queued (waiting on concurrency / matrix slot)" + if [[ -s "${healthy_runs_file}" ]]; then + cat "${healthy_runs_file}" + else + echo "_(none)_" + fi + echo "" + echo "### Stuck (auto-cancelled)" + if [[ -s "${stuck_runs_file}" ]]; then + cat "${stuck_runs_file}" + else + echo "_(none)_" + fi + echo "" + echo "### Stuck but excluded (operator action needed)" + if [[ -s "${excluded_runs_file}" ]]; then + cat "${excluded_runs_file}" + else + echo "_(none)_" + fi + } >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/unity-benchmarks.yml b/.github/workflows/unity-benchmarks.yml new file mode 100644 index 00000000..85717ee3 --- /dev/null +++ b/.github/workflows/unity-benchmarks.yml @@ -0,0 +1,124 @@ +name: Unity Benchmarks + +on: + schedule: + - cron: "29 10 * * 3" + workflow_dispatch: + inputs: + unity-version: + description: "Pin a single Unity version. Empty = 2022.3.45f1." + required: false + default: "" + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + checks: write + issues: write + +jobs: + matrix-config: + name: Resolve benchmark matrix + runs-on: ubuntu-latest + timeout-minutes: 2 + outputs: + unity-versions: ${{ steps.resolve.outputs.unity-versions }} + steps: + - name: Resolve dispatch overrides + id: resolve + env: + INPUT_UNITY_VERSION: ${{ inputs.unity-version }} + run: | + set -euo pipefail + if [ -n "${INPUT_UNITY_VERSION:-}" ]; then + versions="[\"${INPUT_UNITY_VERSION}\"]" + else + versions='["2022.3.45f1"]' + fi + echo "unity-versions=${versions}" >> "${GITHUB_OUTPUT}" + + benchmarks: + name: Benchmarks ${{ matrix.unity-version }} ${{ matrix.test-mode }} + needs: matrix-config + runs-on: [self-hosted, Windows, RAM-64GB] + timeout-minutes: 120 + concurrency: + group: unity-pro-license + cancel-in-progress: false + strategy: + fail-fast: false + max-parallel: 1 + matrix: + unity-version: ${{ fromJSON(needs.matrix-config.outputs.unity-versions) }} + test-mode: + - editmode + - playmode + steps: + - name: Print runner diagnostics + shell: bash + run: | + set -euo pipefail + echo "::group::Runner diagnostics" + echo "Runner name: ${RUNNER_NAME:-}" + echo "Runner OS: ${RUNNER_OS:-}" + echo "Runner arch: ${RUNNER_ARCH:-}" + echo "Runner workspace: ${RUNNER_WORKSPACE:-}" + echo "Concurrency group: unity-pro-license (single-seat Unity Pro license; cross-workflow serialization)" + echo "Matrix max-parallel: 1 (within-workflow license serialization)" + echo "Requested labels: self-hosted, Windows, RAM-64GB" + echo "GitHub workflow: ${GITHUB_WORKFLOW:-}" + echo "GitHub job: ${GITHUB_JOB:-}" + echo "GitHub run id/attempt: ${GITHUB_RUN_ID:-} / ${GITHUB_RUN_ATTEMPT:-}" + echo "GitHub ref: ${GITHUB_REF:-} (protected=${GITHUB_REF_PROTECTED:-})" + echo "GitHub event: ${GITHUB_EVENT_NAME:-}" + echo "::endgroup::" + + - name: Checkout + uses: actions/checkout@v6 + with: + lfs: true + + - name: Cache Unity Library + uses: actions/cache@v4 + env: + MANIFEST_HASH: ${{ hashFiles('.unity-test-project/Packages/manifest.json') }} + LOCK_HASH: ${{ hashFiles('.unity-test-project/Packages/packages-lock.json') }} + VERSION_HASH: ${{ hashFiles('.unity-test-project/ProjectSettings/ProjectVersion.txt') }} + with: + path: .unity-test-project/Library + key: Library-bench-${{ matrix.unity-version }}-${{ matrix.test-mode }}-${{ env.MANIFEST_HASH }}-${{ env.LOCK_HASH }}-${{ env.VERSION_HASH }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "22.18.0" + + - name: Run Unity benchmarks + shell: pwsh + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_VERSION: ${{ matrix.unity-version }} + CI: "true" + run: | + ./scripts/unity/run-tests.ps1 ` + -Platform "${{ matrix.test-mode }}" ` + -UnityVersion "${{ matrix.unity-version }}" ` + -IncludePerf ` + -Runner docker ` + -Results ".artifacts/unity/benchmarks/${{ matrix.unity-version }}-${{ matrix.test-mode }}/results.xml" + + - name: Upload benchmark artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: unity-benchmarks-${{ matrix.unity-version }}-${{ matrix.test-mode }} + path: .artifacts/unity/benchmarks/${{ matrix.unity-version }}-${{ matrix.test-mode }} + if-no-files-found: warn + retention-days: 90 diff --git a/.github/workflows/unity-il2cpp.yml b/.github/workflows/unity-il2cpp.yml new file mode 100644 index 00000000..12277e1f --- /dev/null +++ b/.github/workflows/unity-il2cpp.yml @@ -0,0 +1,133 @@ +name: Unity IL2CPP + +on: + pull_request: + branches: + - master + - main + push: + branches: + - master + - main + schedule: + - cron: "43 9 * * 2" + workflow_dispatch: + inputs: + unity-version: + description: "Pin a single Unity version. Empty = 2022.3.45f1." + required: false + default: "" + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + checks: write + +jobs: + matrix-config: + name: Resolve IL2CPP matrix + runs-on: ubuntu-latest + timeout-minutes: 2 + outputs: + unity-versions: ${{ steps.resolve.outputs.unity-versions }} + steps: + - name: Resolve dispatch overrides + id: resolve + env: + INPUT_UNITY_VERSION: ${{ inputs.unity-version }} + run: | + set -euo pipefail + if [ -n "${INPUT_UNITY_VERSION:-}" ]; then + versions="[\"${INPUT_UNITY_VERSION}\"]" + else + versions='["2022.3.45f1"]' + fi + echo "unity-versions=${versions}" >> "${GITHUB_OUTPUT}" + + il2cpp-tests: + name: IL2CPP ${{ matrix.unity-version }} + needs: matrix-config + if: >- + ${{ + (github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) && + (github.event_name != 'push' || github.ref_protected) + }} + runs-on: [self-hosted, Windows, RAM-64GB] + timeout-minutes: 120 + concurrency: + group: unity-pro-license + cancel-in-progress: false + strategy: + fail-fast: false + max-parallel: 1 + matrix: + unity-version: ${{ fromJSON(needs.matrix-config.outputs.unity-versions) }} + steps: + - name: Print runner diagnostics + shell: bash + run: | + set -euo pipefail + echo "::group::Runner diagnostics" + echo "Runner name: ${RUNNER_NAME:-}" + echo "Runner OS: ${RUNNER_OS:-}" + echo "Runner arch: ${RUNNER_ARCH:-}" + echo "Runner workspace: ${RUNNER_WORKSPACE:-}" + echo "Concurrency group: unity-pro-license (single-seat Unity Pro license; cross-workflow serialization)" + echo "Matrix max-parallel: 1 (within-workflow license serialization)" + echo "Requested labels: self-hosted, Windows, RAM-64GB" + echo "GitHub workflow: ${GITHUB_WORKFLOW:-}" + echo "GitHub job: ${GITHUB_JOB:-}" + echo "GitHub run id/attempt: ${GITHUB_RUN_ID:-} / ${GITHUB_RUN_ATTEMPT:-}" + echo "GitHub ref: ${GITHUB_REF:-} (protected=${GITHUB_REF_PROTECTED:-})" + echo "GitHub event: ${GITHUB_EVENT_NAME:-}" + echo "::endgroup::" + + - name: Checkout + uses: actions/checkout@v6 + with: + lfs: true + + - name: Cache Unity Library + uses: actions/cache@v4 + env: + MANIFEST_HASH: ${{ hashFiles('.unity-test-project/Packages/manifest.json') }} + LOCK_HASH: ${{ hashFiles('.unity-test-project/Packages/packages-lock.json') }} + VERSION_HASH: ${{ hashFiles('.unity-test-project/ProjectSettings/ProjectVersion.txt') }} + with: + path: .unity-test-project/Library + key: Library-il2cpp-${{ matrix.unity-version }}-${{ env.MANIFEST_HASH }}-${{ env.LOCK_HASH }}-${{ env.VERSION_HASH }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "22.18.0" + + - name: Run IL2CPP tests + shell: pwsh + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_VERSION: ${{ matrix.unity-version }} + CI: "true" + run: | + ./scripts/unity/run-tests.ps1 ` + -Platform standalone ` + -UnityVersion "${{ matrix.unity-version }}" ` + -Runner docker ` + -Results ".artifacts/unity/il2cpp-${{ matrix.unity-version }}/results.xml" + + - name: Upload IL2CPP artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: il2cpp-${{ matrix.unity-version }} + path: .artifacts/unity/il2cpp-${{ matrix.unity-version }} + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml new file mode 100644 index 00000000..982bdaa8 --- /dev/null +++ b/.github/workflows/unity-tests.yml @@ -0,0 +1,154 @@ +name: Unity Tests + +on: + pull_request: + branches: + - master + - main + push: + branches: + - master + - main + schedule: + - cron: "17 8 * * 1" + workflow_dispatch: + inputs: + unity-version: + description: "Pin a single Unity version. Empty = full matrix." + required: false + default: "" + type: string + test-mode: + description: "Pin a single test mode." + required: false + default: "all" + type: choice + options: + - all + - editmode + - playmode + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + checks: write + +jobs: + matrix-config: + name: Resolve Unity test matrix + runs-on: ubuntu-latest + timeout-minutes: 2 + outputs: + unity-versions: ${{ steps.resolve.outputs.unity-versions }} + test-modes: ${{ steps.resolve.outputs.test-modes }} + steps: + - name: Resolve dispatch overrides + id: resolve + env: + INPUT_UNITY_VERSION: ${{ inputs.unity-version }} + INPUT_TEST_MODE: ${{ inputs.test-mode }} + run: | + set -euo pipefail + if [ -n "${INPUT_UNITY_VERSION:-}" ]; then + versions="[\"${INPUT_UNITY_VERSION}\"]" + else + versions='["2021.3.45f1","2022.3.45f1","6000.0.32f1"]' + fi + if [ -n "${INPUT_TEST_MODE:-}" ] && [ "${INPUT_TEST_MODE}" != "all" ]; then + modes="[\"${INPUT_TEST_MODE}\"]" + else + modes='["editmode","playmode"]' + fi + { + echo "unity-versions=${versions}" + echo "test-modes=${modes}" + } >> "${GITHUB_OUTPUT}" + + unity-tests: + name: Unity ${{ matrix.unity-version }} ${{ matrix.test-mode }} + needs: matrix-config + if: >- + ${{ + (github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) && + (github.event_name != 'push' || github.ref_protected) + }} + runs-on: [self-hosted, Windows, RAM-64GB] + timeout-minutes: 90 + concurrency: + group: unity-pro-license + cancel-in-progress: false + strategy: + fail-fast: false + max-parallel: 1 + matrix: + unity-version: ${{ fromJSON(needs.matrix-config.outputs.unity-versions) }} + test-mode: ${{ fromJSON(needs.matrix-config.outputs.test-modes) }} + steps: + - name: Print runner diagnostics + shell: bash + run: | + set -euo pipefail + echo "::group::Runner diagnostics" + echo "Runner name: ${RUNNER_NAME:-}" + echo "Runner OS: ${RUNNER_OS:-}" + echo "Runner arch: ${RUNNER_ARCH:-}" + echo "Runner workspace: ${RUNNER_WORKSPACE:-}" + echo "Concurrency group: unity-pro-license (single-seat Unity Pro license; cross-workflow serialization)" + echo "Matrix max-parallel: 1 (within-workflow license serialization)" + echo "Requested labels: self-hosted, Windows, RAM-64GB" + echo "GitHub workflow: ${GITHUB_WORKFLOW:-}" + echo "GitHub job: ${GITHUB_JOB:-}" + echo "GitHub run id/attempt: ${GITHUB_RUN_ID:-} / ${GITHUB_RUN_ATTEMPT:-}" + echo "GitHub ref: ${GITHUB_REF:-} (protected=${GITHUB_REF_PROTECTED:-})" + echo "GitHub event: ${GITHUB_EVENT_NAME:-}" + echo "::endgroup::" + + - name: Checkout + uses: actions/checkout@v6 + with: + lfs: true + + - name: Cache Unity Library + uses: actions/cache@v4 + env: + MANIFEST_HASH: ${{ hashFiles('.unity-test-project/Packages/manifest.json') }} + LOCK_HASH: ${{ hashFiles('.unity-test-project/Packages/packages-lock.json') }} + VERSION_HASH: ${{ hashFiles('.unity-test-project/ProjectSettings/ProjectVersion.txt') }} + with: + path: .unity-test-project/Library + key: Library-${{ matrix.unity-version }}-${{ matrix.test-mode }}-${{ env.MANIFEST_HASH }}-${{ env.LOCK_HASH }}-${{ env.VERSION_HASH }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "22.18.0" + + - name: Run Unity tests + shell: pwsh + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_VERSION: ${{ matrix.unity-version }} + CI: "true" + run: | + $results = ".artifacts/unity/${{ matrix.unity-version }}-${{ matrix.test-mode }}/results.xml" + ./scripts/unity/run-tests.ps1 ` + -Platform "${{ matrix.test-mode }}" ` + -UnityVersion "${{ matrix.unity-version }}" ` + -Runner docker ` + -Results $results + + - name: Upload Unity test artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: unity-${{ matrix.unity-version }}-${{ matrix.test-mode }} + path: .artifacts/unity/${{ matrix.unity-version }}-${{ matrix.test-mode }} + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/unstick-run.yml b/.github/workflows/unstick-run.yml new file mode 100644 index 00000000..8c55cae8 --- /dev/null +++ b/.github/workflows/unstick-run.yml @@ -0,0 +1,330 @@ +name: Unstick Run + +# Manual one-click recovery for a single GitHub Actions run stuck on the +# known self-hosted dispatcher bug +# (https://github.com/orgs/community/discussions/186811). +# +# This workflow is the operator-driven sibling of stuck-job-watchdog.yml: +# the watchdog auto-scans every 5 minutes from the default branch, while +# this workflow targets ONE explicit run id on demand. Use this when: +# * The watchdog is not yet on the default branch (GitHub `schedule:` +# cron triggers fire only from the repo default branch, so a watchdog +# committed to a feature branch is inactive until merged to master). +# * You want immediate recovery and do not want to wait for the next +# cron tick or the queue-age threshold. +# +# Behavior mirrors the watchdog's per-run logic: +# * Confirm the run exists, is `status: queued`, and is older than +# MIN_AGE_SECONDS=30 (guards against accidental cancellation of fresh +# runs). +# * Honor the same workflow-file exclusion list (release.yml + +# `vars.WATCHDOG_EXCLUDED_WORKFLOWS`) unless `bypass_exclusion=true`. +# * `gh run cancel` the run. +# * If `force_redispatch=true` AND the run was push/schedule/ +# workflow_dispatch on a branch AND the workflow file declares +# `workflow_dispatch:`, REST-redispatch via +# `POST repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches`. +# * Otherwise, surface a step-summary line instructing the operator to +# click "Re-run all jobs" in the UI (the only safe recovery path for +# pull_request runs). +# +# This workflow does NOT touch the watchdog state branch and does NOT +# count against the watchdog's per-run cancel cap. + +on: + workflow_dispatch: + inputs: + run_id: + description: "GitHub Actions run id to recover (digits only)." + required: true + type: string + force_redispatch: + description: "Attempt REST re-dispatch after cancel (push/schedule/workflow_dispatch on a branch only)." + required: false + type: boolean + default: false + bypass_exclusion: + description: "Operate on a run whose workflow file is in the exclusion list (e.g., release.yml). Use deliberately." + required: false + type: boolean + default: false + +concurrency: + group: unstick-run-${{ inputs.run_id }} + cancel-in-progress: false + +permissions: + actions: write + contents: read + +jobs: + unstick: + name: Unstick a single dispatcher-stuck run + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Validate, cancel, and optionally re-dispatch + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + INPUT_RUN_ID: ${{ inputs.run_id }} + INPUT_FORCE_REDISPATCH: ${{ inputs.force_redispatch }} + INPUT_BYPASS_EXCLUSION: ${{ inputs.bypass_exclusion }} + MIN_AGE_SECONDS: "30" + DEFAULT_EXCLUDED_WORKFLOWS: "release.yml" + EXTRA_EXCLUDED_WORKFLOWS: ${{ vars.WATCHDOG_EXCLUDED_WORKFLOWS }} + run: | + set -euo pipefail + + summary_file="$(mktemp)" + : > "${summary_file}" + log_summary() { + printf '%s\n' "$1" | tee -a "${summary_file}" + } + + flush_summary_and_exit() { + local code="${1:-0}" + { + echo "## Unstick-run summary" + cat "${summary_file}" + } >> "${GITHUB_STEP_SUMMARY}" + exit "${code}" + } + + run_id="${INPUT_RUN_ID}" + force_redispatch="${INPUT_FORCE_REDISPATCH:-false}" + bypass_exclusion="${INPUT_BYPASS_EXCLUSION:-false}" + + log_summary "## Unstick-run audit ($(date -u +'%Y-%m-%dT%H:%M:%SZ'))" + log_summary "Repo: ${REPO}" + log_summary "Requested run id: ${run_id}" + log_summary "force_redispatch: ${force_redispatch}" + log_summary "bypass_exclusion: ${bypass_exclusion}" + + # -------------------------------------------------------------- + # 1. Validate run_id is a positive integer (string input from + # workflow_dispatch is free-form; reject anything else). + # -------------------------------------------------------------- + if ! [[ "${run_id}" =~ ^[0-9]+$ ]]; then + log_summary "ERROR: run_id '${run_id}' is not a positive integer." + flush_summary_and_exit 1 + fi + + # -------------------------------------------------------------- + # 1b. Defense-in-depth: refuse to act on this workflow's own + # run id. The watchdog has the same SELF_RUN_ID guard for + # the same reason -- a self-cancel mid-run produces a + # confusing partial step-summary and leaves the operator + # uncertain whether the target run was actually touched. + # -------------------------------------------------------------- + if [[ "${run_id}" == "${GITHUB_RUN_ID}" ]]; then + log_summary "ERROR: cannot unstick this workflow's own run (run_id == GITHUB_RUN_ID == ${GITHUB_RUN_ID})." + flush_summary_and_exit 1 + fi + + # -------------------------------------------------------------- + # 2. Fetch the run. 404 => either the id is bogus or it belongs + # to a different repository (gh api scopes the call to REPO). + # -------------------------------------------------------------- + run_json="$(mktemp)" + if ! gh api "repos/${REPO}/actions/runs/${run_id}" > "${run_json}" 2>/dev/null; then + log_summary "ERROR: run ${run_id} not found in ${REPO} (404). Confirm the run id and that it belongs to this repository." + flush_summary_and_exit 1 + fi + + run_status="$(jq -r '.status // ""' < "${run_json}")" + run_event="$(jq -r '.event // ""' < "${run_json}")" + run_head_branch="$(jq -r '.head_branch // ""' < "${run_json}")" + run_workflow_id="$(jq -r '.workflow_id // 0' < "${run_json}")" + run_path="$(jq -r '.path // ""' < "${run_json}")" + run_name="$(jq -r '.name // ""' < "${run_json}")" + run_html_url="$(jq -r '.html_url // ""' < "${run_json}")" + run_created_at="$(jq -r '.created_at // ""' < "${run_json}")" + run_path_base="${run_path##*/}" + + log_summary "Run name: ${run_name}" + log_summary "Workflow file: ${run_path_base}" + log_summary "Status (pre-cancel): ${run_status}" + log_summary "Event: ${run_event}" + log_summary "Head branch: ${run_head_branch:-}" + log_summary "URL: ${run_html_url}" + + # -------------------------------------------------------------- + # 3. Cheap safety guard: only operate on queued runs. + # -------------------------------------------------------------- + if [[ "${run_status}" != "queued" ]]; then + log_summary "Run is not currently queued (status: ${run_status}). Skipping cancel; nothing to recover." + flush_summary_and_exit 0 + fi + + # -------------------------------------------------------------- + # 4. Cheap safety guard: ensure the run is at least + # MIN_AGE_SECONDS old so a manual misclick does not nuke a + # fresh, legitimately-queued run. + # -------------------------------------------------------------- + if [[ -z "${run_created_at}" ]]; then + log_summary "ERROR: run ${run_id} has no created_at timestamp; refusing to act." + flush_summary_and_exit 1 + fi + created_epoch="$(date -u -d "${run_created_at}" +%s 2>/dev/null || echo 0)" + if [[ "${created_epoch}" -eq 0 ]]; then + log_summary "ERROR: could not parse created_at '${run_created_at}'." + flush_summary_and_exit 1 + fi + now_epoch="$(date -u +%s)" + age=$(( now_epoch - created_epoch )) + log_summary "Run age: ${age}s (min: ${MIN_AGE_SECONDS}s)" + if (( age < MIN_AGE_SECONDS )); then + log_summary "Run is younger than MIN_AGE_SECONDS=${MIN_AGE_SECONDS}; skipping. Re-invoke after the run has had time to dispatch normally." + flush_summary_and_exit 0 + fi + + # -------------------------------------------------------------- + # 5. Workflow-file exclusion check (release.yml + repo variable). + # NOTE: This exclusion-list construction is duplicated from + # stuck-job-watchdog.yml. Kept intentionally duplicated so each + # workflow file is self-contained; if you change the rules here, + # update the watchdog too. + # -------------------------------------------------------------- + declare -A EXCLUDED_BY_FILE=() + for wf in ${DEFAULT_EXCLUDED_WORKFLOWS} ${EXTRA_EXCLUDED_WORKFLOWS:-}; do + [[ -z "${wf}" ]] && continue + base="${wf##*/}" + EXCLUDED_BY_FILE["${base}"]=1 + done + excluded_list="" + for k in "${!EXCLUDED_BY_FILE[@]}"; do + excluded_list+="${k} " + done + log_summary "Excluded workflows: ${excluded_list:-}" + + if [[ -n "${EXCLUDED_BY_FILE[${run_path_base}]+x}" ]]; then + if [[ "${bypass_exclusion}" != "true" ]]; then + log_summary "Workflow '${run_path_base}' is in the exclusion list; pass bypass_exclusion=true to operate on it. Skipping." + flush_summary_and_exit 0 + fi + log_summary "Workflow '${run_path_base}' is excluded but bypass_exclusion=true; proceeding." + fi + + # -------------------------------------------------------------- + # 6. Cancel the run. From here on, this is the recovery action. + # -------------------------------------------------------------- + log_summary "Cancelling run ${run_id}..." + # TOCTOU note: if the run transitions out of `queued` between status check and + # cancel, gh run cancel returns non-zero. We let the step fail loudly so the + # operator notices; watchdog has the same intentional pattern. + if ! gh run cancel "${run_id}" --repo "${REPO}" 2>&1 | tee -a "${summary_file}"; then + log_summary "ERROR: 'gh run cancel ${run_id}' failed." + flush_summary_and_exit 1 + fi + log_summary "Cancel issued." + + # Re-fetch status post-cancel for the summary. If the re-fetch + # fails (transient API hiccup, run vanished), surface that + # explicitly rather than logging the stale pre-cancel status as + # though it were the post-cancel value. + post_status="unknown (status re-fetch failed)" + if gh api "repos/${REPO}/actions/runs/${run_id}" > "${run_json}" 2>/dev/null; then + post_status="$(jq -r '.status // ""' < "${run_json}")" + fi + log_summary "Status (post-cancel): ${post_status}" + + # -------------------------------------------------------------- + # 7. Optional REST re-dispatch. + # -------------------------------------------------------------- + redispatched="no" + redispatch_reason="" + if [[ "${force_redispatch}" == "true" ]]; then + case "${run_event}" in + push|schedule|workflow_dispatch) + if [[ -z "${run_head_branch}" ]]; then + redispatch_reason="event=${run_event} but head_branch is empty (likely a tag); cannot REST-dispatch." + else + # Parse the workflow file to confirm it declares + # `workflow_dispatch:` (required by the dispatches API). + workflow_file_raw="$(mktemp)" + has_dispatch=0 + if [[ -n "${run_path}" ]]; then + set +e + gh api "repos/${REPO}/contents/${run_path}" --jq '.content' 2>/dev/null \ + | base64 -d > "${workflow_file_raw}" 2>/dev/null + contents_rc=$? + set -e + if [[ "${contents_rc}" -eq 0 && -s "${workflow_file_raw}" ]]; then + set +e + grep -Eq '^[[:space:]]*workflow_dispatch:' "${workflow_file_raw}" + grep_rc=$? + set -e + if [[ "${grep_rc}" -eq 0 ]]; then + has_dispatch=1 + fi + fi + fi + + if [[ "${has_dispatch}" -eq 1 ]]; then + log_summary "Re-dispatching workflow ${run_workflow_id} on ref '${run_head_branch}'..." + if gh api -X POST \ + "repos/${REPO}/actions/workflows/${run_workflow_id}/dispatches" \ + -f "ref=${run_head_branch}" 2>&1 | tee -a "${summary_file}"; then + redispatched="yes" + else + redispatch_reason="REST dispatches call failed (see log above)." + fi + else + redispatch_reason="workflow file '${run_path_base}' does not declare 'workflow_dispatch:' (or could not be read)." + fi + fi + ;; + *) + redispatch_reason="event='${run_event}' has no safe REST re-dispatch path (only push/schedule/workflow_dispatch on a branch are supported). Operator must use the UI." + ;; + esac + else + redispatch_reason="force_redispatch=false (default)." + fi + + if [[ "${redispatched}" == "yes" ]]; then + log_summary "Re-dispatch: succeeded." + else + log_summary "Re-dispatch: skipped (${redispatch_reason})" + fi + + # -------------------------------------------------------------- + # 8. Operator next-step guidance. + # -------------------------------------------------------------- + if [[ "${redispatched}" == "yes" ]]; then + log_summary "Next step: the workflow has been re-dispatched. Watch ${run_html_url%/runs/*} for the new run." + else + case "${run_event}" in + pull_request|pull_request_target) + log_summary "Next step: open ${run_html_url} and click 'Re-run all jobs' (PR-triggered runs cannot be REST-dispatched)." + ;; + *) + # When force_redispatch=true was requested but the re-dispatch + # was skipped (event/branch/workflow_dispatch trigger guard), + # re-suggesting force_redispatch=true would just repeat the + # path the operator already tried. Branch the guidance. + if [[ "${force_redispatch}" == "true" ]]; then + log_summary "Next step: force_redispatch was attempted but skipped (see reason above). Click 'Re-run all jobs' on the run's UI page (${run_html_url}) to recover." + else + log_summary "Next step: open ${run_html_url} and click 'Re-run all jobs', OR re-invoke this workflow with force_redispatch=true if the event supports it." + fi + ;; + esac + fi + + # -------------------------------------------------------------- + # 9. Rate-limit guidance (this workflow has NO automatic cap). + # -------------------------------------------------------------- + # Unlike the watchdog (MAX_CANCELS_PER_DAY=2 per run-id via + # state branch), unstick-run.yml is operator-driven and relies on + # the implicit gate of a human clicking "Run workflow". That + # means rapid repeat invocations on the same run-id are not + # blocked here -- the operator must self-pace. GitHub's + # dispatcher state can take time to settle after a cancel, so a + # second attempt within seconds may race the previous one. + log_summary "Rate-limit note: unstick-run.yml has no automatic per-run cap (operator-driven)." + log_summary "If a single run id needs another recovery attempt, wait at least 5 minutes between invocations to let GitHub's dispatcher state settle." + + flush_summary_and_exit 0 diff --git a/.github/workflows/validate-banner.yml b/.github/workflows/validate-banner.yml new file mode 100644 index 00000000..1064fd1f --- /dev/null +++ b/.github/workflows/validate-banner.yml @@ -0,0 +1,49 @@ +name: Validate Banner + +on: + pull_request: + paths: + - "docs/images/DxMessaging-banner.svg" + - "scripts/validate-banner.js" + - "scripts/sync-banner-version.ps1" + - "package.json" + - ".github/workflows/validate-banner.yml" + push: + branches: + - main + - master + paths: + - "docs/images/DxMessaging-banner.svg" + - "scripts/validate-banner.js" + - "scripts/sync-banner-version.ps1" + - "package.json" + - ".github/workflows/validate-banner.yml" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + validate-banner: + name: Validate banner SVG + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "22.18.0" + cache: "npm" + cache-dependency-path: package.json + + - name: Run validate-banner + run: node scripts/validate-banner.js diff --git a/.github/workflows/validate-npm-meta.yml b/.github/workflows/validate-npm-meta.yml index 13e4d32d..068e4880 100644 --- a/.github/workflows/validate-npm-meta.yml +++ b/.github/workflows/validate-npm-meta.yml @@ -1,3 +1,4 @@ +# cspell:words lscache name: NPM Package Validation on: @@ -100,7 +101,7 @@ jobs: # produced tarball for any build artifact paths. The dry-run validator above # already enforces this, but a real pack on each runner OS catches platform # drift (e.g. case sensitivity, path separators, glob expansion differences). - # See https://github.com/wallstop/DxMessaging/issues/204 for the original + # See https://github.com/Ambiguous-Interactive/DxMessaging/issues/204 for the original # GuidDB::CreateMetaFileMappings regression that motivated this guard. # # IMPORTANT: keep the grep alternation below in sync with `buildArtifactPatterns` @@ -112,7 +113,7 @@ jobs: npm pack tarball="$(find . -maxdepth 1 -type f -name '*.tgz' -print | sort | head -n1)" echo "Inspecting tarball: ${tarball}" - leaks="$(tar -tzf "${tarball}" | grep -E '(^|/)(bin|obj)/|\.pdb$|\.tmp$|\.csproj\.user$|(^|/)\.vs/|(^|/)\.idea/|\.suo$|\.DotSettings\.user$|\.user$' || true)" + leaks="$(tar -tzf "${tarball}" | grep -E '(^|/)(bin|obj)/|\.pdb$|\.tmp$|\.csproj\.user$|(^|/)\.vs/|(^|/)\.idea/|\.suo$|\.DotSettings\.user$|\.lscache(\.meta)?$|\.user$' || true)" if [ -n "${leaks}" ]; then echo "::error::npm pack tarball contains forbidden build artifact paths (issue 204 regression):" echo "${leaks}" diff --git a/.gitignore b/.gitignore index b57773c2..81f78860 100644 --- a/.gitignore +++ b/.gitignore @@ -33,7 +33,10 @@ logs_*.zip.meta logs_* Editor/Analyzers/WallstopStudios.DxMessaging.SourceGenerators.pdb* -com.wallstop-studios.dxmessaging.sln.DotSettings.user.meta + +# C# Dev Kit per-project cache +*.lscache +*.lscache.meta # Visual Studio 2015 cache/options directory .vs/ @@ -115,6 +118,8 @@ $tf/ _ReSharper*/ *.[Rr]e[Ss]harper *.DotSettings.user +# .meta sidecar for the *.DotSettings.user rule above +*.DotSettings.user.meta # JustCode is a .NET coding add-in .JustCode @@ -352,6 +357,9 @@ pre-push.md* pre-push.txt* pr-description.md* +# Local operator runbooks may contain environment-specific execution notes. +.operator-runbooks/ + SourceGenerators/WallstopStudios.DxMessaging.Analyzer/bin.meta SourceGenerators/WallstopStudios.DxMessaging.Analyzer/obj.meta Temp diff --git a/.llm/context.md b/.llm/context.md index 4e0b4ed7..60347eb2 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -26,11 +26,17 @@ This file is intentionally concise. It contains only critical, high-signal guida - Never commit repository settings that auto-approve chat-invoked terminal commands. - Ensure fenced markdown examples are closed and do not swallow real sections (for example `## See Also`). - Run file-scoped validation during editing; do not treat git hooks as the first signal of quality issues. +- When editing `.llm/context.md` or `.llm/skills/**/*.md`, run `npm run validate:llm-markdown` before finishing. - For user-visible code edits (`Runtime/`, `Samples~/`, user-facing `Editor/`, or shipped `SourceGenerators/` code), run `npm run validate:changelog:coverage` before finishing and resolve any `W002` warnings by rewriting entries around user impact. - When editing `.cs`, `.md`, `.json`, `.yml`, `.yaml`, `.ps1`, or `.js` files, run file-scoped cspell on touched files and update `.cspell.json` in the same change for legitimate domain terms. - For Node child-process calls in `scripts/*.js`, prefer argument-array invocations (`spawnSync` / `execFileSync`) and `stdio` options instead of shell redirection. - For dynamic `import()` in `scripts/*.js`, convert filesystem paths with `pathToFileURL(...).href` before importing (raw Windows drive-letter paths fail Node's ESM loader). - When editing `.pre-commit-config.yaml`, `scripts/*` hook tooling, `.github/workflows/*.yml`, or hook-related scripts in `package.json`, run `npm run preflight:pre-commit` before finishing. +- When editing `.pre-commit-config.yaml`, `scripts/run-managed-jest.js`, `scripts/run-managed-prettier.js`, `scripts/validate-node-tooling.js`, `scripts/validate-pre-commit-tooling.js`, `.github/workflows/*.yml`, or any file gated by the `script-parser-tests`, `script-tests`, or `unity-contract-tests` pre-push hooks, run `npm run preflight:pre-push` before reporting the task complete. Editing `package.json`, `package-lock.json`, `scripts/lib/node-modules-integrity.js`, `scripts/run-managed-cspell.js`, or any `scripts/run-managed-*.js` also requires running `npm run doctor` before reporting done. On a `testRunner option was not found` or any `jest-circus` resolution error, run `npm run doctor` and consult [Jest Hook Robustness](./skills/scripting/jest-hook-robustness.md). On Windows, suspect a partial extract; `scripts/lib/node-modules-integrity.js` auto-repairs via `npm ci`, or run manually. Path-separator regressions are blocked by `scripts/__tests__/cross-platform-path-handling.test.js`; when interpolating a path into a `warnFn` / `console.warn` / `console.error` log line in the managed-Jest / integrity-gate scripts, wrap it in `toPosixPath` or `toRepoPosixRelative` from `scripts/lib/path-classifier.js`. +- When editing `.github/workflows/*.yml` or `.github/workflows/*.yaml`, run `npm run check:workflow-cspell`, `npm run validate:workflows`, and `npm run check:yaml` before finishing so spelling, policy, and line-length issues are surfaced before hook-time. +- When editing repository tests (`Tests/**`, `SourceGenerators/**`, `scripts/**/*.test.js`, `scripts/**/*.spec.js`) or banner sync tooling, run `npm run check:banner-sync` before finishing so badge drift is caught before push-time. +- `npm run check:banner-sync` validates banner freshness only. If it reports drift, run `npm run sync:banner` and re-run `npm run check:banner-sync`. +- `scripts/sync-banner-version.sh` prefers open-source PowerShell 7+ (`pwsh`), then legacy `powershell`, then falls back to `node scripts/sync-banner-version.js` so banner sync works on Linux, macOS, and Windows. - When running `preflight:pre-commit` with unstaged docs changes, the markdown hook may report 'files were modified' -- stage the changes first. - When editing `.pre-commit-config.yaml` or hook scripts, the new performance budget test (`scripts/__tests__/hook-perf-budget.test.js`) must pass; see [Git Hook Performance Budget](./skills/performance/git-hook-performance.md). - Never introduce `PLAN.md` / `PERF-PLAN.md` / `OLD-PLAN.md` / `GH-PAGES-PLAN.md` filename references or `T0.0` / `P0.0`-style milestone tags into shipping content (under `Runtime/`, `Editor/`, `SourceGenerators/`, `Samples~/`, `docs/`, root `*.md`, `llms.txt`). The `validate:no-plan-vocabulary` hook enforces this; treat any failure as a prose rewrite, not a hook bypass. See [No PLAN Vocabulary in Shipping Content](./skills/documentation/no-plan-vocabulary.md). @@ -43,9 +49,12 @@ This file is intentionally concise. It contains only critical, high-signal guida - Script tests: `npm run test:scripts` - Validate pre-commit Node tooling policy: `npm run validate:pre-commit-tooling` - Pre-commit Node tooling preflight: `npm run preflight:pre-commit` +- Pre-push hook parity preflight: `npm run preflight:pre-push` +- Diagnose local Node/hook environment: `npm run doctor` - Validate local Node tool dependency health: `npm run validate:node-tooling` - Run Unity/devcontainer contract tests: `npm run test:unity-contracts` - Run markdown hook parity check: `npm run validate:hook-markdown` +- Validate `.llm` markdown policy bundle: `npm run validate:llm-markdown` - Run parser hook suite exactly as pre-push executes it: `pre-commit run --hook-stage pre-push script-parser-tests --all-files` - Check package.json format explicitly: `npm run check:package-json-format` - Check hook-managed Prettier targets: `npm run check:prettier:hooks` @@ -58,6 +67,7 @@ This file is intentionally concise. It contains only critical, high-signal guida - Script-wide spellcheck preflight: `npm run check:cspell:scripts` - Note: Prettier does not auto-wrap long YAML lines; yamllint enforces the 200-character limit. - For long `.pre-commit-config.yaml` values (especially `description:` fields), use YAML folded scalars (`>-`) instead of single-line strings. +- For `.github/workflows/*.yml` `run:` blocks, keep shell statements multiline (`run: |` plus line breaks) instead of single long lines; `validate:workflows` enforces the same line-length ceiling early to keep hooks as a last-resort check. - Auto-fix markdown fragments/lists: `node scripts/fix-md029-md051.js ` - Lint markdown: `npx markdownlint-cli2 ` - Validate skills + context: `node scripts/validate-skills.js` @@ -81,6 +91,23 @@ For Unity-side tests in `Tests/Editor/` or `Tests/Runtime/` (excludes Benchmarks - ARM Mac (Apple Silicon): not supported locally -- use a non-ARM local shell or Codespace while Unity GitHub workflows are disabled - For source-generator tests (no Unity), use `dotnet test SourceGenerators/...Tests` +## GitHub Actions / CI Runners + +- Self-hosted runner topology (org-level, group "Default"): + - `ELI-MACHINE`: `self-hosted, X64, RAM-64GB, Windows, fast` + - `DAD-MACHINE`: `self-hosted, X64, RAM-64GB, Windows` + - `box-linux`: `self-hosted, Linux, X64, RAM-64GB` + - `mac-mini`: `self-hosted, RAM-16GB, macOS, ARM64` + - `old-linux`: `self-hosted, Linux, X64, RAM-16GB, old` + - `ubuntu-latest-large`: GitHub-hosted large runner +- Never use a single shared `concurrency.group` across multiple matrix entries without mitigation. GitHub Actions retains only one running + one pending slot per group, so every third matrix entry to enqueue cancels the previously-queued one. The two allowed escape hatches are (a) expand the group with at least one `${{ matrix.* }}` token (for example `unity-${{ matrix.unity-version }}-${{ matrix.test-mode }}`), or (b) declare `strategy.max-parallel: 1` so matrix entries serialize internally to the workflow run and never compete for the same group slot. +- The concurrency group name `wallstop-organization-builds` is a reserved sentinel. The validator hard-fails any workflow that reintroduces it, because that group is the historical root cause of Unity matrix-eviction cancellations and runner-pickup stalls. +- Unity Pro is a single-seat license. All four Unity-credential-using jobs (`unity-tests`, `il2cpp-tests`, `benchmarks`, `release.unity-checks`) share `concurrency.group: unity-pro-license` with `cancel-in-progress: false`, and the three matrix jobs MUST set `strategy.max-parallel: 1` so matrix entries do not compete for the license lock and evict each other. The validator (`findMatrixConcurrencyEvictionViolations`) enforces this combination. Both ELI-MACHINE and DAD-MACHINE are eligible for every Unity job via the uniform `runs-on: [self-hosted, Windows, RAM-64GB]`; cross-workflow serialization comes from the shared group, within-workflow serialization from `max-parallel: 1`. The `fast` label remains on ELI-MACHINE for future opt-in hotfix dispatches but no job requests it today. +- Per-runner Unity-cache safety is provided by each runner agent's exclusive workspace (a single self-hosted agent only runs one job at a time, so `.unity-test-project/Library` directories cannot collide). +- Known GitHub Actions dispatcher bug: self-hosted runners can report Online/Idle while `runner_id` stays at 0 for 7+ minutes, leaving a queued job stuck even when label sets match (see [Community Discussion #186811](https://github.com/orgs/community/discussions/186811)). Recovery paths: (a) `.github/workflows/stuck-job-watchdog.yml` auto-audits queued runs every 5 minutes (`MIN_QUEUE_AGE_SECONDS=300`) and reruns up to twice per 24h when an idle runner satisfies the queued job's labels; (b) `.github/workflows/unstick-run.yml` is a `workflow_dispatch`-only manual recovery for a single explicit `run_id` (optional `force_redispatch` + `bypass_exclusion` inputs), cancelling and optionally REST-redispatching on demand. Caveat: GitHub `schedule:` cron triggers fire only from the repo default branch, so the watchdog is INACTIVE until merged to `master`; until then, `unstick-run.yml` (or a manual `workflow_dispatch` of the watchdog from the Actions tab) is the only auto-recovery path. Manual recourse: re-run the failed job via the GitHub UI. +- Enforcement: `scripts/validate-workflows.js` (`npm run validate:workflows`) lints every workflow file for the sentinel group, matrix-with-shared-group eviction (requiring `${{ matrix.* }}` expansion or `max-parallel: 1`), and the self-hosted label allowlist. The shape contract test `scripts/__tests__/unity-workflow-shape.test.js` plus `scripts/__tests__/validate-workflows-concurrency-and-labels.test.js` keep the Unity-credential-using jobs honest. The validator scans `.github/workflows/*.yml` only; the `.github/workflows-disabled/*` mirror is intentionally left at the old `${{ github.workflow }}-${{ github.ref }}` group shape and is not policed. +- The workflow validator is invoked in CI by `.github/workflows/actionlint.yml` (Validate workflow patterns step) so any reintroduction of the sentinel, matrix-eviction footgun, or off-allowlist label set fails the PR before merge. + ## Devcontainer Workflow The agent runs from inside the slim devcontainer (.NET 9/10 base + docker-outside-of-docker). Unity tests spawn ephemeral `unityci/editor` containers via the host docker socket; the image is pulled lazily on first use, the `.unity-test-project/Library` cache is preserved in a named volume across runs. See [Devcontainer Cache Contract](./skills/unity/devcontainer-cache-contract.md) and [Headless Test Runner](./skills/unity/headless-test-runner.md). @@ -105,6 +132,9 @@ The agent runs from inside the slim devcontainer (.NET 9/10 base + docker-outsid - For pre-commit hooks that operate on staged files, remember pre-commit stashes unstaged changes and runs hooks against the staged snapshot on disk; reproduce failures through commit-equivalent hook runs when validating behavior. - For auto-fix hooks that restage files, guard restaging with `git diff --quiet -- "$@" || git add "$@"` so no-op runs do not touch the git index. - For Jest in hooks or npm scripts, use `node scripts/run-managed-jest.js` instead of bare `jest` invocations. +- When editing `scripts/run-managed-jest.js`, `scripts/verify-managed-jest-fallback.js`, or `scripts/validate-node-tooling.js`, run `npm run validate:node-tooling` first so missing Jest runner dependencies are caught before hook-time. +- For managed Jest tooling edits, run `node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/run-managed-jest.test.js scripts/__tests__/verify-managed-jest-fallback.test.js scripts/__tests__/validate-node-tooling.test.js` and then `pre-commit run --hook-stage pre-push script-tests --all-files`. +- For Jest-driven pre-push hooks (`script-parser-tests`, `script-tests`, `unity-contract-tests`), follow [Jest Hook Robustness](./skills/scripting/jest-hook-robustness.md). The wrapper at `scripts/run-managed-jest.js` MUST NOT inject `--testRunner `; the policy is enforced by `scripts/__tests__/run-managed-jest-no-injected-test-runner.test.js` (narrow source-scan) and `scripts/__tests__/no-testrunner-injection-policy.test.js` (repo-wide policy). Before pushing, run `npm run preflight:pre-push`. - For Prettier in npm scripts (`format:*`, `check:prettier:hooks`) and ad-hoc invocations, use `node scripts/run-managed-prettier.js` instead of hardcoded `prettier@X.Y.Z` commands. The managed runner resolves versions in this order: package-lock.json, package.json, then static fallback. Pre-commit hook entries themselves use the inline `bash -c '[ -f node_modules/prettier/bin/prettier.cjs ] && exec node ...; else exec npx --yes --package=prettier@ prettier ...; fi'` pattern (cspell/markdownlint shape) plus the parity test at `scripts/__tests__/prettier-version-parity.test.js`. - For `npm`/`npx` child-process calls in `scripts/*.js` (`spawnSync`, `execFileSync`, `execSync`), use `spawnPlatformCommandSync()` from `scripts/lib/shell-command.js`. Do not call `spawnSync(toShellCommand(...))` directly; the helper applies Windows shell-shim execution rules consistently. - For validators that depend on `git` metadata (for example ignore-policy checks), treat `ENOENT`/missing-git failures as hard errors; never silently default to permissive behavior. @@ -185,6 +215,9 @@ Use the index above and then select the most relevant skill pages. Frequently us - [Code Samples Must Compile](./skills/documentation/code-samples-must-compile.md) - [Human-Prose Documentation Policy](./skills/documentation/human-prose-policy.md) - [Cross-Platform Script Compatibility](./skills/scripting/cross-platform-compatibility.md) +- [Integrity Gate Robustness](./skills/scripting/integrity-gate-robustness.md) +- [Jest Hook Robustness](./skills/scripting/jest-hook-robustness.md) +- [Let Tools Resolve Modules](./skills/scripting/let-tools-resolve-modules.md) - [Test Failure Investigation and Zero-Flaky Policy](./skills/testing/test-failure-investigation.md) - [Lifecycle Edge-Case Test Coverage](./skills/testing/lifecycle-edge-coverage.md) - [LeakWatcher: Detecting Registration Leaks in Tests](./skills/testing/leak-watcher-usage.md) diff --git a/.llm/skills/documentation/ascii-only-docs.md b/.llm/skills/documentation/ascii-only-docs.md index 8831ec2f..4184d333 100644 --- a/.llm/skills/documentation/ascii-only-docs.md +++ b/.llm/skills/documentation/ascii-only-docs.md @@ -7,13 +7,13 @@ created: "2026-04-30" updated: "2026-04-30" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "docs/" - path: "README.md" - path: "Runtime/" - path: "Editor/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "documentation" diff --git a/.llm/skills/documentation/banner-svg-conventions.md b/.llm/skills/documentation/banner-svg-conventions.md new file mode 100644 index 00000000..1e7d89d3 --- /dev/null +++ b/.llm/skills/documentation/banner-svg-conventions.md @@ -0,0 +1,198 @@ +--- +title: "Banner SVG Conventions" +id: "banner-svg-conventions" +category: "documentation" +version: "1.0.0" +created: "2026-05-08" +updated: "2026-05-08" + +source: + repository: "Ambiguous-Interactive/DxMessaging" + files: + - path: "docs/images/DxMessaging-banner.svg" + - path: "site/images/DxMessaging-banner.svg" + - path: "scripts/sync-banner-version.ps1" + - path: "scripts/validate-banner.js" + - path: ".pre-commit-config.yaml" + - path: ".github/workflows/validate-banner.yml" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" + +tags: + - "documentation" + - "svg" + - "branding" + - "tooling" + - "policy" + - "accessibility" + +complexity: + level: "intermediate" + reasoning: "Geometry, accessibility, and drift-prevention concerns intersect; coordinate math must stay in sync with validator heuristics." + +impact: + performance: + rating: "none" + details: "Static asset" + maintainability: + rating: "high" + details: "Drift between the SVG, the sync script, and the validator silently corrupts a flagship asset; the rules below prevent that." + testability: + rating: "high" + details: "scripts/validate-banner.js is the executable specification." + +prerequisites: + - "Familiarity with SVG (viewBox, transforms, gradients, filters)" + - "Awareness of the project's ASCII-only policy" + +dependencies: + packages: [] + skills: + - "ascii-only-docs" + +applies_to: + languages: + - "SVG" + - "JavaScript" + - "PowerShell" + frameworks: + - "GitHub README rendering" + - "MkDocs" + +aliases: + - "DxMessaging banner" + - "README banner SVG" + - "Banner version sync" + +related: + - "ascii-only-docs" + - "documentation-style-guide" + +status: "stable" +--- + +# Banner SVG Conventions + +> **One-line summary**: The DxMessaging banner SVG is a flagship asset with strict invariants (version-block byte equality with a PowerShell heredoc, badge encapsulation, single-source-of-truth for mutable strings, ASCII source). All invariants are enforced by `scripts/validate-banner.js`, which runs in pre-commit and CI. + +## Overview + +The canonical banner lives at `docs/images/DxMessaging-banner.svg`. It is the asset rendered in the README; MkDocs copies it to the gitignored `site/` build output for the docs site, so only the `docs/` copy is version-controlled and validated. + +A PowerShell sync script (`scripts/sync-banner-version.ps1`) updates the version badge whenever `package.json#version` changes. A Node validator (`scripts/validate-banner.js`) enforces 18 separate invariants. The validator is wired into the pre-commit hook (`.pre-commit-config.yaml`) and CI (`.github/workflows/validate-banner.yml`). + +## Why this skill exists + +The banner went through 11 iterations of adversarial review to reach production quality. Several classes of defects were found and fixed; the validator codifies the lessons so they cannot regress: + +- **Drift**: version (`v3.0.1`), test count (`300+ Tests`), or feature labels duplicated across surfaces (e.g., ``, `<desc>`, comments) without sync. +- **Sync mismatch**: the SVG's version-badge block must be byte-identical to the heredoc in the PowerShell sync script. If it is not, the next sync silently rewrites and corrupts surrounding content. +- **Encapsulation**: stat-badge `<rect>` widths must clear the worst-case rendered text (emoji widths vary 1.5x across renderers). +- **Accessibility**: `<title>` and `<desc>` must be present, both non-empty, and `role="img"` must be on the root `<svg>`. +- **ASCII source**: comments and prose use only ASCII (em-dashes, smart quotes, etc. are forbidden). Numeric character references for emoji (e.g., `🔁`) are ASCII source and allowed. +- **Layout**: viewBox is always `0 0 800 200`; no element overflows. + +## Hard invariants (each enforced by `scripts/validate-banner.js`) + +### Sync / drift + +1. **Version-badge block matches the PowerShell heredoc** in `scripts/sync-banner-version.ps1`. The script's `$newVersionText` heredoc, with `$version` substituted from `package.json`, must appear verbatim in the SVG. Any deviation (whitespace, attribute ordering, missing/extra `px` unit, line wrapping) means the next version sync will mutate the SVG and destroy surrounding edits. +1. **Banner version matches `package.json#version`** (the version-badge text must contain `vX.Y.Z`). + +### Hard requirements + +1. **`viewBox="0 0 800 200"`** with `width="800"` and `height="200"`. +1. **No external resources**: no `<image href>`, no remote `xlink:href`, no `@import`, no `url(http*)`. +1. **No JavaScript**: no `<script>` elements, no `on*` event handlers, no `javascript:` schemes. +1. **File size <= 12 KiB.** +1. **ASCII-only source.** Numeric character references (`🔁`) are allowed because they are ASCII bytes. + +### Accessibility + +1. **`<title>` and `<desc>` exist** as direct children of root `<svg>` and are non-empty. +1. **`role="img"`** on the root SVG. +1. **`aria-labelledby` / `aria-describedby`** (if present) reference IDs that exist on `<title>` / `<desc>`. + +### Layout / encapsulation + +1. **Stat-badge encapsulation**: each `<rect>` width is at least the worst-case rendered text width plus 20 px padding. Worst-case text width uses these heuristics: monospace ~0.6em per char, emoji ~1.5em advance, VS-16 (`️`) is zero-width. +1. **Feature row labels** are exactly four items in this order: `Simple`, `Automatic`, `Dev-Friendly`, then any string matching `^\d+\+ Tests$` (e.g., `300+ Tests`). +1. **All `<rect>` and `<line>` bounding boxes within the viewBox.** + +### Code quality + +1. **XML well-formed.** +1. **No duplicate `id` attributes.** +1. **Unused `<defs>` IDs** are warned (not failed). + +### Drift prevention + +1. **`vX.Y.Z` semver appears only inside the version-badge `<text>`.** Forbidden in `<title>`, `<desc>`, and comments. +1. **`N+ Tests` appears only inside the feature-row label.** + +## Single-source-of-truth pattern + +| Mutable string | Source of truth | Enforced by | +| --------------- | ------------------------------------------ | --------------- | +| Version | `package.json#version` | sync script | +| Version display | Version badge `<text>` in both SVGs | validator (#2) | +| Test count | Feature row label (e.g., `300+ Tests`) | validator (#19) | +| Feature pillars | Feature row labels | validator (#13) | +| Studio name | Bottom-right `<text>` (`Wallstop Studios`) | manual | + +`<title>` and `<desc>` must NOT mention the version, test count, or feature pillars; they are short, fixed prose. + +## Working with the banner + +### Editing the banner + +1. Edit `docs/images/DxMessaging-banner.svg`. +1. Run the validator: `node scripts/validate-banner.js`. Fix any reported errors. +1. Commit. The pre-commit hook will rerun validation. + +### Bumping the version + +The sync script handles this automatically when `package.json#version` changes (pre-commit hook). Manual run: `pwsh -File scripts/sync-banner-version.ps1`. The script is idempotent: if the SVG already matches `package.json`, no file is touched. + +### Restructuring the version badge + +If you need to change the version badge's structure (size, position, font, fill, etc.): + +1. Update the heredoc in `scripts/sync-banner-version.ps1`. +1. Update the SVG to match. +1. Run `node scripts/validate-banner.js` and verify byte-equality. +1. Run `pwsh -File scripts/sync-banner-version.ps1` and confirm no SVG modification (idempotent). + +The heredoc and the SVG block must be character-for-character identical (after `$version` substitution). + +### Rewording feature labels + +If you change feature pillars (e.g., `300+ Tests` to `500+ Tests`): + +1. Edit the feature-row `<text>` in the SVG. +1. Update `scripts/validate-banner.js` if the new label does not match `^\d+\+ Tests$` (or if you change the four-item order). +1. Update this skill file if the meaning has changed. + +### Rewording `<title>` or `<desc>` + +`<title>` is the SVG's accessible name; keep it concise (`DxMessaging: Unity messaging library`). `<desc>` carries elaboration. Neither may include the version, test count, or stat-badge content (drift risk; enforced by validator #18 and #19). + +## Banned patterns + +- Hardcoding the version (`v3.0.1`) anywhere except inside the version-badge `<text>`. +- Hardcoding the test count (`300+ Tests`) anywhere except inside the feature-row label. +- Mirroring stat-badge text in `<desc>` (e.g., listing `Unity 2021.3+, Zero Alloc, High Perf`). +- Numeric coordinate assertions in comments (e.g., `Badge 3 bottom y=155`). They rot the moment any geometry changes; rewrite as rule-based language. +- Em-dashes, smart quotes, ellipsis, or any non-ASCII byte in the SVG source. +- `dominant-baseline="central"` on text elements that need cross-renderer stability; use explicit `y` offsets instead. + +## Enforcement layers + +1. **`scripts/validate-banner.js`** - Node validator, 19 independent checks, reports all errors before exiting. Run via `node scripts/validate-banner.js`. +1. **Pre-commit hook** (`.pre-commit-config.yaml#validate-banner`) - runs the validator when any of the banner files, sync script, validator, or `package.json` are staged. +1. **CI workflow** (`.github/workflows/validate-banner.yml`) - runs the validator on every PR and push to `main`/`master` that touches relevant paths. +1. **Sync hook** (`.pre-commit-config.yaml#sync-banner-version`) - already-existing pre-commit hook that auto-updates the version badge. + +## See Also + +- [ASCII-Only Documentation Policy](./ascii-only-docs.md) +- [Documentation Style Guide](./documentation-style-guide.md) diff --git a/.llm/skills/documentation/changelog-entry-writing.md b/.llm/skills/documentation/changelog-entry-writing.md index 31cdcc17..b873a2f1 100644 --- a/.llm/skills/documentation/changelog-entry-writing.md +++ b/.llm/skills/documentation/changelog-entry-writing.md @@ -7,10 +7,10 @@ created: "2026-01-22" updated: "2026-03-17" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "CHANGELOG.md" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "changelog" @@ -108,7 +108,7 @@ Write entries that are: ### Added - New `UntargetedInterceptableMessage` base class for broadcast messages that support - interception and cancellation ([#142](https://github.com/wallstop/DxMessaging/pull/142)) + interception and cancellation ([#142](https://github.com/Ambiguous-Interactive/DxMessaging/pull/142)) ``` ### Example 2: Fixing a Bug in Message Routing @@ -119,7 +119,7 @@ Write entries that are: ### Fixed - Messages are now correctly delivered to components on inactive GameObjects when - using `IncludeInactive` delivery option ([#156](https://github.com/wallstop/DxMessaging/issues/156)) + using `IncludeInactive` delivery option ([#156](https://github.com/Ambiguous-Interactive/DxMessaging/issues/156)) ``` ### Example 3: Deprecating an Old API @@ -264,7 +264,7 @@ To upgrade from v2.x: ### Fixed - Fixed message delivery order when using priority handlers - ([#178](https://github.com/wallstop/DxMessaging/issues/178)) + ([#178](https://github.com/Ambiguous-Interactive/DxMessaging/issues/178)) ``` ### Bad: Updating Changelog After Release @@ -287,7 +287,7 @@ To upgrade from v2.x: ### Fixed - Fixed message delivery order when using priority handlers - ([#178](https://github.com/wallstop/DxMessaging/issues/178)) + ([#178](https://github.com/Ambiguous-Interactive/DxMessaging/issues/178)) ``` ## See Also diff --git a/.llm/skills/documentation/changelog-management.md b/.llm/skills/documentation/changelog-management.md index 4af22182..8b642940 100644 --- a/.llm/skills/documentation/changelog-management.md +++ b/.llm/skills/documentation/changelog-management.md @@ -7,11 +7,11 @@ created: "2026-01-22" updated: "2026-01-22" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "CHANGELOG.md" - path: "package.json" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "changelog" @@ -131,7 +131,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Resolved issue with targeted message delivery when target is destroyed -[Unreleased]: https://github.com/wallstop/DxMessaging/compare/v2.1.4...HEAD +[Unreleased]: https://github.com/Ambiguous-Interactive/DxMessaging/compare/v2.1.4...HEAD ``` ### When to Update the Changelog diff --git a/.llm/skills/documentation/changelog-release-workflow.md b/.llm/skills/documentation/changelog-release-workflow.md index 152d8a86..dc88a8c9 100644 --- a/.llm/skills/documentation/changelog-release-workflow.md +++ b/.llm/skills/documentation/changelog-release-workflow.md @@ -7,11 +7,11 @@ created: "2026-01-22" updated: "2026-01-22" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "CHANGELOG.md" - path: "package.json" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "changelog" @@ -130,10 +130,10 @@ Use ISO 8601 dates and semantic versions in brackets: Add reference-style links at the bottom of the file: ```markdown -[Unreleased]: https://github.com/wallstop/DxMessaging/compare/v2.1.4...HEAD -[2.1.4]: https://github.com/wallstop/DxMessaging/compare/v2.1.3...v2.1.4 -[2.1.3]: https://github.com/wallstop/DxMessaging/compare/v2.1.2...v2.1.3 -[2.1.2]: https://github.com/wallstop/DxMessaging/releases/tag/v2.1.2 +[Unreleased]: https://github.com/Ambiguous-Interactive/DxMessaging/compare/v2.1.4...HEAD +[2.1.4]: https://github.com/Ambiguous-Interactive/DxMessaging/compare/v2.1.3...v2.1.4 +[2.1.3]: https://github.com/Ambiguous-Interactive/DxMessaging/compare/v2.1.2...v2.1.3 +[2.1.2]: https://github.com/Ambiguous-Interactive/DxMessaging/releases/tag/v2.1.2 ``` Note: The oldest version links to its release tag, not a comparison. diff --git a/.llm/skills/documentation/code-samples-must-compile.md b/.llm/skills/documentation/code-samples-must-compile.md index 955dd314..cea64ee3 100644 --- a/.llm/skills/documentation/code-samples-must-compile.md +++ b/.llm/skills/documentation/code-samples-must-compile.md @@ -7,13 +7,13 @@ created: "2026-04-30" updated: "2026-04-30" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "docs/" - path: "Runtime/" - path: "Editor/" - path: "SourceGenerators/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "documentation" diff --git a/.llm/skills/documentation/documentation-code-samples.md b/.llm/skills/documentation/documentation-code-samples.md index 1a7fa775..15f31f05 100644 --- a/.llm/skills/documentation/documentation-code-samples.md +++ b/.llm/skills/documentation/documentation-code-samples.md @@ -7,11 +7,11 @@ created: "2026-01-22" updated: "2026-01-22" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "docs/" - path: "README.md" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "documentation" diff --git a/.llm/skills/documentation/documentation-style-guide.md b/.llm/skills/documentation/documentation-style-guide.md index 310686e7..bbc80728 100644 --- a/.llm/skills/documentation/documentation-style-guide.md +++ b/.llm/skills/documentation/documentation-style-guide.md @@ -7,11 +7,11 @@ created: "2026-01-22" updated: "2026-01-22" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "docs/" - path: "README.md" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "documentation" diff --git a/.llm/skills/documentation/documentation-update-workflow.md b/.llm/skills/documentation/documentation-update-workflow.md index b6bd62df..96e4cc1d 100644 --- a/.llm/skills/documentation/documentation-update-workflow.md +++ b/.llm/skills/documentation/documentation-update-workflow.md @@ -7,12 +7,12 @@ created: "2026-01-22" updated: "2026-01-22" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "docs/" - path: "README.md" - path: "CHANGELOG.md" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "documentation" diff --git a/.llm/skills/documentation/documentation-updates.md b/.llm/skills/documentation/documentation-updates.md index 128cb860..0eb2880f 100644 --- a/.llm/skills/documentation/documentation-updates.md +++ b/.llm/skills/documentation/documentation-updates.md @@ -7,12 +7,12 @@ created: "2026-01-22" updated: "2026-01-22" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "docs/" - path: "README.md" - path: "CHANGELOG.md" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "documentation" diff --git a/.llm/skills/documentation/documentation-xml-docs.md b/.llm/skills/documentation/documentation-xml-docs.md index 682f0bf9..827349a7 100644 --- a/.llm/skills/documentation/documentation-xml-docs.md +++ b/.llm/skills/documentation/documentation-xml-docs.md @@ -7,10 +7,10 @@ created: "2026-01-22" updated: "2026-01-22" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "Runtime/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "documentation" diff --git a/.llm/skills/documentation/external-url-fragment-validation.md b/.llm/skills/documentation/external-url-fragment-validation.md index 2cb109c4..035b4432 100644 --- a/.llm/skills/documentation/external-url-fragment-validation.md +++ b/.llm/skills/documentation/external-url-fragment-validation.md @@ -7,11 +7,11 @@ created: "2026-01-27" updated: "2026-01-27" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "docs/" - path: ".llm/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "documentation" diff --git a/.llm/skills/documentation/github-actions-version-consistency.md b/.llm/skills/documentation/github-actions-version-consistency.md index 77674bba..819a7956 100644 --- a/.llm/skills/documentation/github-actions-version-consistency.md +++ b/.llm/skills/documentation/github-actions-version-consistency.md @@ -7,10 +7,10 @@ created: "2026-01-27" updated: "2026-01-27" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: ".github/workflows/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "github-actions" diff --git a/.llm/skills/documentation/human-prose-policy.md b/.llm/skills/documentation/human-prose-policy.md index 75ddca96..a9cb2d52 100644 --- a/.llm/skills/documentation/human-prose-policy.md +++ b/.llm/skills/documentation/human-prose-policy.md @@ -7,12 +7,12 @@ created: "2026-05-02" updated: "2026-05-02" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "docs/" - path: "README.md" - path: "Runtime/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "documentation" diff --git a/.llm/skills/documentation/link-quality-guidelines-part-1.md b/.llm/skills/documentation/link-quality-guidelines-part-1.md index 001d5596..bc6c3983 100644 --- a/.llm/skills/documentation/link-quality-guidelines-part-1.md +++ b/.llm/skills/documentation/link-quality-guidelines-part-1.md @@ -78,19 +78,19 @@ Skill files include repository metadata in the YAML frontmatter. These URLs must ```yaml source: - repository: "wallstop/DxMessaging" # Format: "owner/repo" - url: "https://github.com/wallstop/DxMessaging" # Full HTTPS URL + repository: "Ambiguous-Interactive/DxMessaging" # Format: "owner/repo" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" # Full HTTPS URL ``` #### Common Mistakes -| Mistake | Incorrect Value | Correct Value | -| --------------------- | ------------------------------------------- | ----------------------------------------- | -| Wrong organization | `wallstop-studios/DxMessaging` | `wallstop/DxMessaging` | -| Wrong repository name | `wallstop/com.wallstop-studios.dxmessaging` | `wallstop/DxMessaging` | -| Missing `https://` | `github.com/wallstop/DxMessaging` | `https://github.com/wallstop/DxMessaging` | -| Trailing slash | `https://github.com/wallstop/DxMessaging/` | `https://github.com/wallstop/DxMessaging` | -| SSH URL format | `git@github.com:wallstop/DxMessaging.git` | `https://github.com/wallstop/DxMessaging` | +| Mistake | Incorrect Value | Correct Value | +| --------------------- | ------------------------------------------------------- | ------------------------------------------------------ | +| Wrong organization | `wallstop-studios/DxMessaging` | `Ambiguous-Interactive/DxMessaging` | +| Wrong repository name | `wallstop/com.wallstop-studios.dxmessaging` | `Ambiguous-Interactive/DxMessaging` | +| Missing `https://` | `github.com/Ambiguous-Interactive/DxMessaging` | `https://github.com/Ambiguous-Interactive/DxMessaging` | +| Trailing slash | `https://github.com/Ambiguous-Interactive/DxMessaging/` | `https://github.com/Ambiguous-Interactive/DxMessaging` | +| SSH URL format | `git@github.com:Ambiguous-Interactive/DxMessaging.git` | `https://github.com/Ambiguous-Interactive/DxMessaging` | #### Verification Steps diff --git a/.llm/skills/documentation/link-quality-guidelines.md b/.llm/skills/documentation/link-quality-guidelines.md index 6c49d02e..43d80dad 100644 --- a/.llm/skills/documentation/link-quality-guidelines.md +++ b/.llm/skills/documentation/link-quality-guidelines.md @@ -7,13 +7,13 @@ created: "2026-01-22" updated: "2026-02-09" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "docs/" - path: "README.md" - path: ".llm/" - path: ".github/workflows/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "documentation" diff --git a/.llm/skills/documentation/markdown-compatibility.md b/.llm/skills/documentation/markdown-compatibility.md index 0a3fb016..c68f4f5f 100644 --- a/.llm/skills/documentation/markdown-compatibility.md +++ b/.llm/skills/documentation/markdown-compatibility.md @@ -7,11 +7,11 @@ created: "2026-01-29" updated: "2026-03-17" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "docs/" - path: "README.md" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "documentation" diff --git a/.llm/skills/documentation/memory-reclamation-docs.md b/.llm/skills/documentation/memory-reclamation-docs.md index 2d279ec4..80ad06d7 100644 --- a/.llm/skills/documentation/memory-reclamation-docs.md +++ b/.llm/skills/documentation/memory-reclamation-docs.md @@ -7,7 +7,7 @@ created: "2026-05-06" updated: "2026-05-06" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "Runtime/Core/Configuration/DxMessagingRuntimeSettings.cs" - path: "Runtime/Core/Configuration/DxMessagingRuntimeSettingsProvider.cs" @@ -17,7 +17,7 @@ source: - path: "docs/guides/memory-reclamation.md" - path: "docs/reference/runtime-settings.md" - path: "CHANGELOG.md" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "documentation" diff --git a/.llm/skills/documentation/mermaid-theming.md b/.llm/skills/documentation/mermaid-theming.md index bc5309ee..a0c17d50 100644 --- a/.llm/skills/documentation/mermaid-theming.md +++ b/.llm/skills/documentation/mermaid-theming.md @@ -7,13 +7,13 @@ created: "2026-01-29" updated: "2026-01-29" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "docs/javascripts/mermaid-config.js" - path: "docs/" - path: "README.md" - path: "Samples~/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" note: "Guidance applies to ALL markdown files in the repository" tags: diff --git a/.llm/skills/documentation/mkdocs-navigation.md b/.llm/skills/documentation/mkdocs-navigation.md index bf2c81a6..cfabf45b 100644 --- a/.llm/skills/documentation/mkdocs-navigation.md +++ b/.llm/skills/documentation/mkdocs-navigation.md @@ -7,11 +7,11 @@ created: "2026-01-29" updated: "2026-01-29" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "mkdocs.yml" - path: "docs/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "documentation" diff --git a/.llm/skills/documentation/no-plan-vocabulary.md b/.llm/skills/documentation/no-plan-vocabulary.md index aec68177..279e0203 100644 --- a/.llm/skills/documentation/no-plan-vocabulary.md +++ b/.llm/skills/documentation/no-plan-vocabulary.md @@ -7,7 +7,7 @@ created: "2026-05-06" updated: "2026-05-06" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "scripts/validate-no-plan-vocabulary.js" - path: "scripts/__tests__/validate-no-plan-vocabulary.test.js" @@ -17,7 +17,7 @@ source: - path: "Samples~/" - path: "docs/" - path: "llms.txt" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "documentation" diff --git a/.llm/skills/documentation/skill-file-sizing.md b/.llm/skills/documentation/skill-file-sizing.md index 8a3fc3ef..15aba2c1 100644 --- a/.llm/skills/documentation/skill-file-sizing.md +++ b/.llm/skills/documentation/skill-file-sizing.md @@ -7,11 +7,11 @@ created: "2026-01-22" updated: "2026-01-22" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: ".llm/skills/specification.md" - path: "scripts/validate-skills.js" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "documentation" diff --git a/.llm/skills/github-actions/cicd-devcontainer-workflows.md b/.llm/skills/github-actions/cicd-devcontainer-workflows.md index 241e35e5..50fe00b5 100644 --- a/.llm/skills/github-actions/cicd-devcontainer-workflows.md +++ b/.llm/skills/github-actions/cicd-devcontainer-workflows.md @@ -7,13 +7,13 @@ created: "2026-05-05" updated: "2026-05-05" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: ".github/workflows/devcontainer-prebuild.yml" - path: ".github/workflows/devcontainer-test.yml" - path: ".devcontainer/Dockerfile" - path: ".devcontainer/devcontainer.json" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "github-actions" @@ -205,7 +205,7 @@ Include a debug step early in the workflow: echo "Actor: ${{ github.actor }}" ``` -The repo's `wallstop-studios/com.wallstop-studios.dxmessaging` slug forces a lowercase conversion every time, so the debug step also surfaces a wrong-actor or wrong-ref problem before it consumes 15 minutes of runner time. +The repo's `Ambiguous-Interactive/DxMessaging` slug forces a lowercase conversion every time, so the debug step also surfaces a wrong-actor or wrong-ref problem before it consumes 15 minutes of runner time. ### Diagnostic Verification diff --git a/.llm/skills/github-actions/git-renormalize-patterns.md b/.llm/skills/github-actions/git-renormalize-patterns.md index 72b6fa16..376766f2 100644 --- a/.llm/skills/github-actions/git-renormalize-patterns.md +++ b/.llm/skills/github-actions/git-renormalize-patterns.md @@ -7,10 +7,10 @@ created: "2026-01-28" updated: "2026-01-28" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: ".github/workflows/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "github-actions" diff --git a/.llm/skills/github-actions/lychee-configuration.md b/.llm/skills/github-actions/lychee-configuration.md index 912441ba..414ec3ae 100644 --- a/.llm/skills/github-actions/lychee-configuration.md +++ b/.llm/skills/github-actions/lychee-configuration.md @@ -7,13 +7,13 @@ created: "2026-03-16" updated: "2026-03-16" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: ".lychee.toml" - path: "scripts/validate-lychee-config.js" - path: ".github/workflows/lint-doc-links.yml" - path: ".github/workflows/markdown-link-validity.yml" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "github-actions" diff --git a/.llm/skills/github-actions/workflow-consistency.md b/.llm/skills/github-actions/workflow-consistency.md index 89d9ef50..fd28742e 100644 --- a/.llm/skills/github-actions/workflow-consistency.md +++ b/.llm/skills/github-actions/workflow-consistency.md @@ -7,10 +7,10 @@ created: "2026-01-28" updated: "2026-01-28" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: ".github/workflows/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "github-actions" diff --git a/.llm/skills/index.md b/.llm/skills/index.md index 81cf2599..92ac6fd1 100644 --- a/.llm/skills/index.md +++ b/.llm/skills/index.md @@ -1,6 +1,6 @@ # Skills Index -> **Auto-generated** on 2026-05-06. Do not edit manually. +> **Auto-generated** on 2026-05-19. Do not edit manually. > Run `node scripts/generate-skills-index.js` to regenerate. --- @@ -9,18 +9,18 @@ | Metric | Value | | ------------ | ----- | -| Total Skills | 157 | +| Total Skills | 161 | | Categories | 8 | --- ## Table of Contents -- [Documentation](#documentation) (29) +- [Documentation](#documentation) (30) - [GitHub Actions](#github-actions) (6) - [Packaging](#packaging) (2) - [Performance](#performance) (45) -- [Scripting](#scripting) (15) +- [Scripting](#scripting) (18) - [Solid](#solid) (15) - [Testing](#testing) (38) - [Unity](#unity) (7) @@ -32,6 +32,7 @@ | Skill | Lines | Complexity | Status | Performance | Tags | | ----------------------------------------------------------------------------------------------------- | ---------- | -------------- | -------- | ------------ | --------------------------------- | | [ASCII-Only Documentation Policy](./documentation/ascii-only-docs.md) | [ok] 173 | [basic] | [stable] | [risk: none] | documentation, ascii | +| [Banner SVG Conventions](./documentation/banner-svg-conventions.md) | [ok] 199 | [intermediate] | [stable] | [risk: none] | documentation, svg | | [Changelog Entry Writing and Anti-Patterns](./documentation/changelog-entry-writing.md) | [warn] 296 | [basic] | [stable] | [risk: none] | changelog, release-notes | | [Changelog Entry Writing and Anti-Patterns Part 1](./documentation/changelog-entry-writing-part-1.md) | [draft] 56 | [intermediate] | [stable] | [risk: low] | migration, split | | [Changelog Management](./documentation/changelog-management.md) | [ok] 229 | [basic] | [stable] | [risk: none] | changelog, documentation | @@ -102,7 +103,7 @@ | [DxMessaging Dispatch Hot Path](./performance/dispatch-hot-path.md) | [ok] 213 | [advanced] | [stable] | [risk: critical] | dispatch, hot-path | | [DxMessaging Memory Reclamation](./performance/memory-reclamation.md) | [ok] 198 | [advanced] | [stable] | [risk: critical] | memory, reclamation | | [DxMessaging Sweep Gate Must Be Cheap](./performance/sweep-gate-must-be-cheap.md) | [ok] 185 | [advanced] | [stable] | [risk: critical] | sweep, eviction | -| [Git Hook Performance Budget](./performance/git-hook-performance.md) | [warn] 299 | [intermediate] | [stable] | [risk: high] | git-hooks, pre-commit | +| [Git Hook Performance Budget](./performance/git-hook-performance.md) | [warn] 300 | [intermediate] | [stable] | [risk: high] | git-hooks, pre-commit | | [Git Hook Performance: Stages and Tooling](./performance/git-hook-performance-tooling.md) | [ok] 240 | [intermediate] | [stable] | [risk: high] | git-hooks, pre-commit | | [High-Performance Cache with Eviction Policies](./performance/cache-eviction-policies.md) | [ok] 177 | [advanced] | [stable] | [risk: high] | caching, memory | | [Object Pooling Anti-Patterns](./performance/object-pooling-anti-patterns.md) | [ok] 145 | [intermediate] | [stable] | [risk: high] | memory, allocation | @@ -133,10 +134,13 @@ | Skill | Lines | Complexity | Status | Performance | Tags | | -------------------------------------------------------------------------------------------------------- | ----------- | -------------- | -------- | ------------ | -------------------------------- | -| [Cross-Platform Script Compatibility](./scripting/cross-platform-compatibility.md) | [ok] 225 | [intermediate] | [stable] | [risk: none] | cross-platform, case-sensitivity | +| [Cross-Platform Script Compatibility](./scripting/cross-platform-compatibility.md) | [ok] 227 | [intermediate] | [stable] | [risk: none] | cross-platform, case-sensitivity | +| [Integrity Gate Robustness](./scripting/integrity-gate-robustness.md) | [warn] 299 | [intermediate] | [stable] | [risk: low] | integrity, auto-repair | | [JavaScript Code Quality Practices](./scripting/javascript-code-quality.md) | [ok] 159 | [intermediate] | [stable] | [risk: none] | javascript, code-quality | | [JavaScript Code Quality Practices Part 1](./scripting/javascript-code-quality-part-1.md) | [ok] 178 | [intermediate] | [stable] | [risk: low] | migration, split | | [JavaScript Code Quality Practices Part 2](./scripting/javascript-code-quality-part-2.md) | [ok] 142 | [intermediate] | [stable] | [risk: low] | migration, split | +| [Jest Hook Robustness](./scripting/jest-hook-robustness.md) | [ok] 213 | [intermediate] | [stable] | [risk: none] | jest, pre-commit | +| [Let Tools Resolve Modules](./scripting/let-tools-resolve-modules.md) | [ok] 152 | [basic] | [stable] | [risk: none] | cross-platform, tooling | | [PowerShell Scripting Best Practices](./scripting/powershell-best-practices.md) | [draft] 105 | [intermediate] | [stable] | [risk: none] | powershell, scripting | | [PowerShell Scripting Best Practices Part 1](./scripting/powershell-best-practices-part-1.md) | [ok] 204 | [intermediate] | [stable] | [risk: low] | migration, split | | [PowerShell Scripting Best Practices Part 2](./scripting/powershell-best-practices-part-2.md) | [ok] 139 | [intermediate] | [stable] | [risk: low] | migration, split | diff --git a/.llm/skills/packaging/npm-package-configuration.md b/.llm/skills/packaging/npm-package-configuration.md index ccdffecb..bf2e4011 100644 --- a/.llm/skills/packaging/npm-package-configuration.md +++ b/.llm/skills/packaging/npm-package-configuration.md @@ -217,7 +217,7 @@ npm pack --dry-run 2>&1 | grep "Tests\.meta" || echo "Tests.meta correctly exclu ## Issue #204 invariants -[Issue #204](https://github.com/wallstop/DxMessaging/issues/204) shipped +[Issue #204](https://github.com/Ambiguous-Interactive/DxMessaging/issues/204) shipped build artifacts and orphaned `.meta` files in the npm tarball. The fix lives in `scripts/validate-npm-meta.js` and is enforced at pre-push, in `prepack`, and by the `validate-npm-meta` workflow. The invariants the diff --git a/.llm/skills/performance/dispatch-hot-path.md b/.llm/skills/performance/dispatch-hot-path.md index e3866142..2878d2e0 100644 --- a/.llm/skills/performance/dispatch-hot-path.md +++ b/.llm/skills/performance/dispatch-hot-path.md @@ -7,13 +7,13 @@ created: "2026-05-05" updated: "2026-05-05" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "Runtime/Core/MessageBus/MessageBus.cs" - path: "Runtime/Core/MessageHandler.cs" - path: "Tests/Runtime/Benchmarks/DispatchThroughputBenchmarks.cs" - path: "Tests/Editor/Allocations/EmitGateClockReadIsRare.cs" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "dispatch" diff --git a/.llm/skills/performance/git-hook-performance-tooling.md b/.llm/skills/performance/git-hook-performance-tooling.md index 720fac11..66dc8429 100644 --- a/.llm/skills/performance/git-hook-performance-tooling.md +++ b/.llm/skills/performance/git-hook-performance-tooling.md @@ -7,14 +7,14 @@ created: "2026-05-02" updated: "2026-05-02" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: ".pre-commit-config.yaml" - path: "scripts/run-staged-validators.js" - path: "scripts/run-staged-md-pipeline.js" - path: "scripts/measure-hook-wallclock.js" - path: ".github/workflows/hook-perf-measurement.yml" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "git-hooks" diff --git a/.llm/skills/performance/git-hook-performance.md b/.llm/skills/performance/git-hook-performance.md index 721426bf..62a204ec 100644 --- a/.llm/skills/performance/git-hook-performance.md +++ b/.llm/skills/performance/git-hook-performance.md @@ -7,7 +7,7 @@ created: "2026-05-02" updated: "2026-05-02" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: ".pre-commit-config.yaml" - path: "scripts/lib/precommit-perf-score.js" @@ -15,7 +15,7 @@ source: - path: "scripts/measure-hook-wallclock.js" - path: "scripts/run-staged-validators.js" - path: "scripts/run-staged-md-pipeline.js" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "git-hooks" @@ -282,6 +282,7 @@ budget directly. - [Git Hook Performance: Stages and Tooling](git-hook-performance-tooling.md) - [Cross-Platform Script Compatibility](../scripting/cross-platform-compatibility.md) - [JavaScript Code Quality](../scripting/javascript-code-quality.md) +- [Jest Hook Robustness](../scripting/jest-hook-robustness.md) ## References diff --git a/.llm/skills/performance/memory-reclamation.md b/.llm/skills/performance/memory-reclamation.md index 7f6e530a..1e38077b 100644 --- a/.llm/skills/performance/memory-reclamation.md +++ b/.llm/skills/performance/memory-reclamation.md @@ -7,7 +7,7 @@ created: "2026-05-04" updated: "2026-05-06" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "Runtime/Core/MessageBus/MessageBus.cs" - path: "Runtime/Core/MessageBus/IMessageBus.cs" @@ -15,7 +15,7 @@ source: - path: "Runtime/Core/Pooling/DxPools.cs" - path: "Tests/Runtime/MemoryReclaim/MemoryReclamationTests.cs" - path: "Tests/Editor/Contract/MessageBusInvariantTests.cs" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "memory" diff --git a/.llm/skills/performance/object-pooling-anti-patterns.md b/.llm/skills/performance/object-pooling-anti-patterns.md index 43f151bb..c210172b 100644 --- a/.llm/skills/performance/object-pooling-anti-patterns.md +++ b/.llm/skills/performance/object-pooling-anti-patterns.md @@ -7,10 +7,10 @@ created: "2026-01-21" updated: "2026-01-21" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "Runtime/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "memory" diff --git a/.llm/skills/performance/object-pooling-usage-examples.md b/.llm/skills/performance/object-pooling-usage-examples.md index 7f76dd82..e5e43d5c 100644 --- a/.llm/skills/performance/object-pooling-usage-examples.md +++ b/.llm/skills/performance/object-pooling-usage-examples.md @@ -7,10 +7,10 @@ created: "2026-01-21" updated: "2026-01-21" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "Runtime/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "memory" diff --git a/.llm/skills/performance/object-pooling-variations.md b/.llm/skills/performance/object-pooling-variations.md index 88eb7730..28efe80b 100644 --- a/.llm/skills/performance/object-pooling-variations.md +++ b/.llm/skills/performance/object-pooling-variations.md @@ -7,10 +7,10 @@ created: "2026-01-21" updated: "2026-01-21" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "Runtime/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "memory" diff --git a/.llm/skills/performance/object-pooling.md b/.llm/skills/performance/object-pooling.md index 7719a156..c0c8404f 100644 --- a/.llm/skills/performance/object-pooling.md +++ b/.llm/skills/performance/object-pooling.md @@ -7,12 +7,12 @@ created: "2026-01-21" updated: "2026-01-21" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "Runtime/Core/Pool/ObjectPool.cs" lines: "1-150" - path: "Runtime/Core/Messages/PooledMessage.cs" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "memory" diff --git a/.llm/skills/performance/sweep-gate-must-be-cheap.md b/.llm/skills/performance/sweep-gate-must-be-cheap.md index 7386a858..e0960dd5 100644 --- a/.llm/skills/performance/sweep-gate-must-be-cheap.md +++ b/.llm/skills/performance/sweep-gate-must-be-cheap.md @@ -7,14 +7,14 @@ created: "2026-05-05" updated: "2026-05-05" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "Runtime/Core/MessageBus/MessageBus.cs" - path: "Runtime/Core/Pooling/StopwatchClock.cs" - path: "Runtime/Core/Pooling/IDxMessagingClock.cs" - path: "Tests/Editor/Allocations/EmitGateClockReadIsRare.cs" - path: "Tests/Editor/Contract/EvictionSweepContractTests.cs" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "sweep" diff --git a/.llm/skills/scripting/cross-platform-compatibility.md b/.llm/skills/scripting/cross-platform-compatibility.md index 686e5549..2dc00a25 100644 --- a/.llm/skills/scripting/cross-platform-compatibility.md +++ b/.llm/skills/scripting/cross-platform-compatibility.md @@ -7,11 +7,11 @@ created: "2026-01-28" updated: "2026-01-28" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "scripts/" - path: ".github/workflows/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "cross-platform" @@ -216,6 +216,8 @@ Before merging scripts: - [Shell Best Practices](./shell-best-practices.md) - Shell-specific case sensitivity patterns - [PowerShell Best Practices](./powershell-best-practices.md) - PowerShell scripting patterns - [Test Coverage Requirements](../testing/comprehensive-test-coverage.md) - General test coverage requirements +- [Jest Hook Robustness](./jest-hook-robustness.md) +- [Let Tools Resolve Modules](./let-tools-resolve-modules.md) ## Changelog diff --git a/.llm/skills/scripting/integrity-gate-robustness.md b/.llm/skills/scripting/integrity-gate-robustness.md new file mode 100644 index 00000000..78c90b92 --- /dev/null +++ b/.llm/skills/scripting/integrity-gate-robustness.md @@ -0,0 +1,298 @@ +--- +title: "Integrity Gate Robustness" +id: "integrity-gate-robustness" +category: "scripting" +version: "1.1.0" +created: "2026-05-18" +updated: "2026-05-19" + +source: + repository: "Ambiguous-Interactive/DxMessaging" + files: + - path: "scripts/lib/node-modules-integrity.js" + - path: "scripts/lib/integrity-gate-with-recovery.js" + - path: "scripts/lib/path-classifier.js" + - path: "scripts/run-managed-jest.js" + - path: "scripts/run-managed-prettier.js" + - path: "scripts/run-managed-cspell.js" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" + +tags: + - "integrity" + - "auto-repair" + - "pre-commit" + - "pre-push" + - "windows" + - "cross-platform" + - "tooling" + +complexity: + level: "intermediate" + reasoning: "Requires understanding of partial-extract failure modes, npm ci semantics, and the path-classifier dispatch" + +impact: + performance: + rating: "low" + details: "Adds one fs.statSync per integrity target before tool invocation; sub-millisecond on healthy installs" + maintainability: + rating: "high" + details: "Centralizes the auto-repair decision and partial-extract detection that every managed wrapper shares" + testability: + rating: "high" + details: "Pure modules with explicit dependency injection; refusal cases all have direct unit tests" + +prerequisites: + - "Familiarity with pre-commit hook configuration" + - "Understanding of Jest 27+ test runner architecture" + - "Understanding of npm ci semantics (lockfile-driven reinstall)" + +dependencies: + packages: [] + skills: + - "jest-hook-robustness" + - "cross-platform-compatibility" + +applies_to: + languages: + - "JavaScript" + frameworks: + - "Jest" + - "Prettier" + - "cspell" + - "pre-commit" + versions: + node: ">=18.0" + npm: ">=7.0" + +aliases: + - "Partial extract detection" + - "Auto-repair gate" + +related: + - "jest-hook-robustness" + - "cross-platform-compatibility" + - "let-tools-resolve-modules" + +status: "stable" +--- + +# Integrity Gate Robustness + +> **One-line summary**: The managed wrappers (Jest, Prettier, cspell) probe +> `node_modules` integrity before invoking the underlying tool; on failure, +> they may auto-repair via `npm ci` only when the working copy is safe. + +## Overview + +Pre-push hooks in this repository must survive a partial `node_modules` +install. The integrity gate is the shared "probe -> auto-repair -> +re-probe" flow that every managed wrapper (`scripts/run-managed-jest.js`, +`scripts/run-managed-prettier.js`, `scripts/run-managed-cspell.js`) runs +BEFORE invoking the underlying tool, so a Jest/Prettier/cspell failure +that is really a partial extract is recovered automatically without +prompting the operator. + +## Solution + +1. Probe `node_modules` for the critical files in `INTEGRITY_TARGETS` + via `scripts/lib/node-modules-integrity.js`. On Windows, also scan + for zero-byte `*.node` native bindings. +1. If the probe fails, consult `isAutoRepairAllowed` (refuses + mid-rebase, with a dirty lockfile, or when + `DXMSG_HOOK_NO_AUTOREPAIR=1`). Refusal -> print banner and exit 1. +1. Otherwise run `npm ci --no-audit --no-fund`. On success, re-probe in + a fresh Node subprocess (defeats the parent's stat/module cache). + Subprocess probe ok -> proceed to tier dispatch. +1. Any failure prints the actionable repair banner and exits 1. + +## Why the gate exists + +A partial `npm install` (interrupted by long paths, antivirus, sleep, or +a network blip) can leave critical files like +`node_modules/jest-circus/build/runner.js` missing or zero-byte. The +next `npm install` reports "up to date" because the lockfile hash +matches and skips re-extraction, so Jest later emits +`testRunner option was not found` even though the JS-level resolver +appeared to succeed. The integrity gate +(`scripts/lib/node-modules-integrity.js`) closes that loop by probing +the on-disk critical-file list BEFORE Jest is invoked and calling +`npm ci` when a partial extract is detected. + +## State diagram + +```text +probe --(ok)----------------------> tier dispatch + | + (fail) -> auto-repair-allowed? + | + (refused / NO_AUTOREPAIR=1) -> banner + status=1 (or degraded) + | + (allowed) -> npm ci -> subprocess re-probe + | + (ok) -> tier dispatch + (fail) -> banner + status=1 +``` + +After the gate succeeds (initial probe or recovery), the wrapper +proceeds to the existing local -> isolated -> npm-exec -> npx cascade. + +## What auto-repair does NOT fix + +The gate intentionally refuses to run `npm ci` when the operator's +working copy could be silently overwritten. Refusal cases: + +- `npm` is not on PATH (`getNpmMajorVersion` returns `null`). +- `package-lock.json` has unstaged changes; `git diff --quiet` would + exit non-zero. Auto-recovery here would clobber lockfile edits. +- A rebase is in progress (`.git/rebase-merge` or `.git/rebase-apply` + exists). Touching `node_modules` mid-rebase could leave the working + copy in an inconsistent state. +- Antivirus is actively quarantining files. `npm ci` will run but the + next probe will still fail; in that case, the user must add an AV + exclusion for `node_modules/` (Defender, Symantec, etc.). +- Operator override via `DXMSG_HOOK_NO_AUTOREPAIR=1`. + +CI runners are deliberately NOT special-cased: `npm ci` is the correct +repair in CI too because the lockfile is committed. Operators who want +CI to fail rather than auto-repair set `DXMSG_HOOK_NO_AUTOREPAIR=1`. + +## Environment variables + +| Variable | Effect | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `DXMSG_HOOK_SKIP_INTEGRITY=1` | Bypass the integrity gate entirely. Tier dispatch runs without any pre-flight probe. | +| `DXMSG_HOOK_NO_AUTOREPAIR=1` | Still probe, but skip `npm ci` on failure. Proceed to tier dispatch with a degraded gate (banner is printed). | +| `DXMSG_HOOK_AGGRESSIVE_RECOVERY=1` | `rm -rf node_modules` before `npm ci`. Use when the partial extract has gone past simple `npm ci` recovery. | + +All three honor `isTruthyEnv` semantics from +`scripts/lib/jest-error-decoder.js`: `0`, `false`, `no`, `off`, and the +empty string are treated as falsy. + +## Path-classifier dispatch + +When Jest emits `MISSING_TEST_RUNNER` AFTER the gate has already passed +(rare but possible -- e.g. the captured path was healthy at probe time +but went bad between the probe and Jest's resolver), the dispatcher +classifies the captured runner path via +`scripts/lib/path-classifier.js`: + +- `"repo"` -> route to `npm ci` recovery (gated by `isAutoRepairAllowed`). +- `"isolated"` -> route to isolated cache reset (legacy behavior). +- `"unknown"` -> refuse to auto-repair; print banner only. + +This keeps tier-level recovery scoped to the directory the failure +actually came from. + +## Windows-only: zero-byte native binaries + +`findZeroByteNativeBinaries({ repoRoot })` scans `node_modules/**/*.node` +for size-zero files on Windows only (returns `[]` immediately on +Linux/macOS). The canonical failure: antivirus truncates a native +binding mid-write, leaving the JS probe passing but `require()` of the +native module crashing the parent process. The gate concatenates any +offenders to `missing[]` with `tool: "<native-binding>"`, +`reason: "zero-byte"` so the same downstream banner flow applies. + +## Resolver probe + +The file-only integrity probe is blind to the failure mode where +`node_modules/jest-circus/build/runner.js` is present on disk (probe OK) +but `require.resolve('jest-circus/runner')` THROWS at runtime. The +canonical Windows trigger is a missing or broken +`@unrs/resolver-binding-win32-x64-msvc` native binding: the JS file is +fine but the resolver chain cannot find it because the native binding +that backs `unrs-resolver` failed to load. + +`probeResolverHealth({ repoRoot })` (in +`scripts/lib/node-modules-integrity.js`) closes that gap. It spawns a +fresh Node subprocess and runs a layered probe: + +1. `require("unrs-resolver")` from the repo (falls back to + `require("jest-resolve")` which transitively pulls it in). Throws at + module load when the native binding is broken. +1. `new ResolverFactory({}).sync(repoRoot, spec)` for each + `DEFAULT_RESOLVER_SPECIFIERS` entry (currently + `["jest-circus/runner"]`). A half-loaded binding can survive + `require()` but throw here. +1. `Module.createRequire(repoRoot/package.json).resolve(spec)` -- legacy + belt-and-suspenders that catches missing peer deps and broken + `exports` maps the unrs-resolver layers do not surface as cleanly. + +Throws are reported as `{ specifier, error }` and merged into +`missing[]` with `tool: "<resolver>"`; the same `npm ci` recovery +path applies. After `npm ci`, the gate re-runs `probeResolverHealth` +(subprocess freshness is owned by that function). A contract test in +`scripts/__tests__/node-modules-integrity.test.js` pins the literal +`unrs-resolver` token in the inline script source so a future refactor +cannot regress this probe to Node-only resolution. + +## Gate caching + +`runIntegrityGateWithRecovery` memoizes its success verdict +per-repoRoot in a module-level `Map` for the lifetime of the parent +Node process. This amortizes the resolver-probe subprocess spawn +across the managed wrappers (`run-managed-jest`, +`run-managed-prettier`, `run-managed-cspell`) in a single hook: only +the first wrapper pays the spawn cost. Failure verdicts are NOT cached +because they carry side effects the next caller must observe fresh. +Tests use `__clearIntegrityGateCacheForTests()` to reset between cases. + +## Cross-platform path-separator policy + +User-facing log lines (`warnFn`, `console.warn`, `console.error`) in +the integrity-gate / managed-Jest code must emit POSIX-separator paths +even on Windows. The helpers live in +`scripts/lib/path-classifier.js`: + +- `toPosixPath(value)` -- pure separator swap (`\` -> `/`). Idempotent + on POSIX input. Maps `null` / `undefined` to `""` (no `"undefined"` + leak in log lines); coerces other non-string primitives via + `String(value)` and then swaps separators. Safe to use inside + template literals without runtime type narrowing. +- `toRepoPosixRelative(absPath, repoRoot)` -- POSIX-relative when + `absPath` lives under `repoRoot`; POSIX-absolute (via `toPosixPath`) + fallback otherwise. + +The contract is enforced by +`scripts/__tests__/cross-platform-path-handling.test.js`, which walks +the integrity-gate / managed-Jest source files and fails if any +`warnFn`/`console.warn`/`console.error` interpolation of a path-like +identifier (`*Path`, `*Dir`, `*Root`) is not wrapped in `toPosixPath` or +`toRepoPosixRelative`. The scope is scoped to the call sites that +participate in pre-push integrity recovery; widening the scope is a +deliberate addition to `SCAN_FILES` in that test. + +`formatIntegrityFailure` (in `scripts/lib/node-modules-integrity.js`) +POSIX-normalizes its `relPath` input before formatting, so any caller +that records a backslash-flavored relPath in `missing[]` still produces +a uniform single-line summary across platforms. + +## DXMSG_HOOK_NO_AUTOREPAIR interaction + +When `DXMSG_HOOK_NO_AUTOREPAIR=1` is set, the gate still probes +(file + resolver health). On failure, it short-circuits BEFORE `npm ci` +and prints the repair banner with an extra root cause +("auto-repair disabled by DXMSG_HOOK_NO_AUTOREPAIR=1 (operator +override)") plus two `Either:`-prefixed unset alternatives: + +- POSIX: `unset DXMSG_HOOK_NO_AUTOREPAIR` +- PowerShell: `Remove-Item Env:\DXMSG_HOOK_NO_AUTOREPAIR` + +The `Either:` prefix distinguishes the POSIX-or-PowerShell alternatives +from the numbered sequential repair steps that precede them. Tier +dispatch then proceeds in degraded mode so the underlying tool surfaces +its own final error. + +## See Also + +- [Jest Hook Robustness](./jest-hook-robustness.md) -- the + `--testRunner`-injection contract that the gate supplements. +- [Cross-Platform Script Compatibility](./cross-platform-compatibility.md) +- [Let Tools Resolve Modules](./let-tools-resolve-modules.md) + +## Changelog + +| Version | Date | Changes | +| ------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| 1.0.0 | 2026-05-18 | Initial split-off from `jest-hook-robustness.md`; documents the gate and auto-repair UX. | +| 1.1.0 | 2026-05-19 | Add resolver probe, cross-platform path-separator policy + contract test, and DXMSG_HOOK_NO_AUTOREPAIR banner hint with shell-specific unset. | diff --git a/.llm/skills/scripting/javascript-code-quality.md b/.llm/skills/scripting/javascript-code-quality.md index e2ff4ec2..7ddb1d99 100644 --- a/.llm/skills/scripting/javascript-code-quality.md +++ b/.llm/skills/scripting/javascript-code-quality.md @@ -7,11 +7,11 @@ created: "2026-01-30" updated: "2026-01-30" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "scripts/" - path: "scripts/__tests__/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "javascript" diff --git a/.llm/skills/scripting/jest-hook-robustness.md b/.llm/skills/scripting/jest-hook-robustness.md new file mode 100644 index 00000000..747c2a9e --- /dev/null +++ b/.llm/skills/scripting/jest-hook-robustness.md @@ -0,0 +1,212 @@ +--- +title: "Jest Hook Robustness" +id: "jest-hook-robustness" +category: "scripting" +version: "1.1.0" +created: "2026-05-18" +updated: "2026-05-18" + +source: + repository: "Ambiguous-Interactive/DxMessaging" + files: + - path: "scripts/run-managed-jest.js" + - path: "scripts/__tests__/run-managed-jest-no-injected-test-runner.test.js" + - path: "scripts/__tests__/no-testrunner-injection-policy.test.js" + - path: "scripts/doctor.js" + - path: ".pre-commit-config.yaml" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" + +tags: + - "jest" + - "pre-commit" + - "pre-push" + - "windows" + - "cross-platform" + - "tooling" + +complexity: + level: "intermediate" + reasoning: "Requires understanding of Jest's internal module resolver, pre-commit hook execution, and Windows-specific failure modes" + +impact: + performance: + rating: "none" + details: "Hook reliability only; no runtime cost" + maintainability: + rating: "critical" + details: "Prevents the recurring Windows-only pre-push failure that blocked development" + testability: + rating: "high" + details: "Two regression tests (narrow source-scan plus broad policy) plus doctor diagnostics" + +prerequisites: + - "Familiarity with pre-commit hook configuration" + - "Understanding of Jest 27+ test runner architecture" + +dependencies: + packages: [] + skills: + - "cross-platform-compatibility" + - "git-hook-performance" + +applies_to: + languages: + - "JavaScript" + frameworks: + - "Jest" + - "pre-commit" + versions: + jest: ">=27.0" + node: ">=18.0" + +aliases: + - "Managed Jest" + - "Pre-push Jest" + - "testRunner option was not found" + +related: + - "cross-platform-compatibility" + - "git-hook-performance" + - "let-tools-resolve-modules" + +status: "stable" +--- + +# Jest Hook Robustness + +> **One-line summary**: The `scripts/run-managed-jest.js` wrapper must never inject +> `--testRunner <absolute-path>`. Jest 27+ resolves `jest-circus` natively, and +> absolute-path injection breaks jest-config's runner validator on Windows. + +## Overview + +Pre-push hooks that run Jest in this repository go through +`scripts/run-managed-jest.js`. The wrapper validates the local install and +falls back to an isolated install if needed, but it MUST forward the resolver +decision to Jest itself. The failure that motivated this skill was a Windows +pre-push log (`pre-push.txt`) reporting `Validation Error: testRunner option +was not found` because the wrapper passed an absolute path to `jest-circus` +that jest-config's internal validator rejected on Windows drive-letter paths. + +## Solution + +1. Treat the contract "no `--testRunner <abs-path>` injection in + `run-managed-jest.js`" as load-bearing. Two regression tests pin it. +1. Before reporting a hook-adjacent change complete, run + `npm run preflight:pre-push`. +1. On `testRunner option was not found` or any `jest-circus` resolution + error, run `npm run doctor`; the wrapper self-heals once per run, and the + doctor surfaces the manual repair commands when self-heal fails. + +## The failure mode + +Symptom (from `pre-push.txt`): + +```text +Validation Error: + testRunner option was not found. + Make sure jest-circus is installed: https://www.npmjs.com/package/jest-circus +``` + +The wrapper had been passing an absolute path such as +`C:\Users\...\node_modules\jest-circus\build\runner.js` via `--testRunner`. +jest-config's internal resolver rejects that path on Windows because of +how it normalizes drive letters and slashes. Jest 27+ defaults to +`jest-circus` and resolves the bundled runner via its own resolver, +which is more reliable than any caller-side pre-resolution. + +## Partial-extract failure class + +The Windows `testRunner option was not found` error has a second root cause +that the `--testRunner`-injection-avoidance contract alone does not fix: +the repository's `node_modules/jest-circus/build/runner.js` (and similar +critical files) can be missing or zero-byte on disk after a partial +extract. The integrity gate +(`scripts/lib/node-modules-integrity.js`) closes that loop by probing +the on-disk critical-file list BEFORE Jest is invoked and calling +`npm ci` when a partial extract is detected. See +[Integrity Gate Robustness](./integrity-gate-robustness.md) for the +full state diagram, refusal rules, env-var matrix, and the path-classifier +dispatch that decides between `npm ci` and isolated cache reset. + +## The fix invariant + +`scripts/run-managed-jest.js` MUST NOT inject `--testRunner`. The contract is +enforced by two tests: + +- Narrow source-scan: + `scripts/__tests__/run-managed-jest-no-injected-test-runner.test.js` + greps the wrapper source for any internal `--testRunner` push and fails on + match. If the caller's `args` array contains a user-supplied `--testRunner`, + the wrapper forwards it unchanged (documented escape hatch). +- Broad policy: + `scripts/__tests__/no-testrunner-injection-policy.test.js` + scans every `scripts/*.js` and every hook entry in + `.pre-commit-config.yaml` for `--testRunner` literals. Any new script or + hook that pre-resolves a runner path fails the test. + +Both tests run under the `script-parser-tests` pre-push hook. + +## Agentic workflow + +Before reporting done for any change that touches +`.pre-commit-config.yaml`, `scripts/run-managed-jest.js`, +`scripts/validate-pre-commit-tooling.js`, +`scripts/validate-node-tooling.js`, `.github/workflows/*.yml`, or any +file gated by `script-parser-tests`, `script-tests`, or +`unity-contract-tests`, run `npm run preflight:pre-push`. For triage on +a fresh clone or a flaky workstation, run `npm run doctor` -- it prints +Node, npm, `jest-circus` install state, and the isolated cache +directory and reports the manual repair commands listed below. + +## Hook self-heal protocol + +On `testRunner option was not found` or any `jest-circus` resolution failure: + +1. `scripts/run-managed-jest.js` auto-retries once after clearing the + isolated managed cache or rebuilding it. The decoder reads the failing + stderr to decide which recovery to attempt. +1. If the retry fails, the wrapper banner prints the explicit repair + commands that match the manual repair procedure below. +1. Manual repair (only after the wrapper has already failed twice). The first + command is cross-platform (Linux, macOS, Windows CMD, Windows PowerShell); + run them in order: + 1. `node -e "require('fs').rmSync(require('path').join(require('os').tmpdir(), 'dxmessaging-managed-jest'), { recursive: true, force: true })"` + 1. `npm ci` + 1. `npm run preflight:pre-push` +1. Never bypass with `git commit --no-verify` or `git push --no-verify`. The + hook is the gate, not the obstacle. + +## Cross-platform note + +On Windows, prefer open-source PowerShell 7+ (`pwsh`); legacy `powershell` works +but is slower. Bash on macOS and Linux works directly. Internally, +`scripts/run-managed-jest.js` uses `spawnPlatformCommandSync` from +`scripts/lib/shell-command.js` so npm and Node shims resolve through the +Windows shell-shim rules consistently. See +[Cross-Platform Script Compatibility](./cross-platform-compatibility.md) for +the shared helper and case-sensitivity rules. + +## Adding a new Jest-backed hook + +1. Copy the `script-tests` (or `script-parser-tests`) block shape; both already + invoke `node scripts/run-managed-jest.js`. Do not introduce a new wrapper. +1. Add the new test file path to the `--runTestsByPath` list and to the + `files:` regex on the same hook entry. +1. `npm run preflight:pre-push` covers the new hook automatically via + `pre-commit run --hook-stage pre-push --all-files`. Run it before reporting + done. + +## See Also + +- [Integrity Gate Robustness](./integrity-gate-robustness.md) +- [Cross-Platform Script Compatibility](./cross-platform-compatibility.md) +- [Git Hook Performance Budget](../performance/git-hook-performance.md) +- [Let Tools Resolve Modules](./let-tools-resolve-modules.md) + +## Changelog + +| Version | Date | Changes | +| ------- | ---------- | ------------------------------------------------------------------------------------------------ | +| 1.0.0 | 2026-05-18 | Initial version after the pre-push.txt failure. | +| 1.1.0 | 2026-05-18 | Added integrity-gate auto-repair flow, state diagram, refusal rules, and environment-var matrix. | diff --git a/.llm/skills/scripting/let-tools-resolve-modules.md b/.llm/skills/scripting/let-tools-resolve-modules.md new file mode 100644 index 00000000..3c87093f --- /dev/null +++ b/.llm/skills/scripting/let-tools-resolve-modules.md @@ -0,0 +1,151 @@ +--- +title: "Let Tools Resolve Modules" +id: "let-tools-resolve-modules" +category: "scripting" +version: "1.0.0" +created: "2026-05-18" +updated: "2026-05-18" + +source: + repository: "Ambiguous-Interactive/DxMessaging" + files: + - path: "scripts/run-managed-jest.js" + - path: "scripts/__tests__/no-testrunner-injection-policy.test.js" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" + +tags: + - "cross-platform" + - "tooling" + - "module-resolution" + - "windows" + - "node" + - "jest" + +complexity: + level: "basic" + reasoning: "Simple meta-rule with a single decision point" + +impact: + performance: + rating: "none" + details: "Style and reliability rule; no runtime cost" + maintainability: + rating: "high" + details: "Prevents an entire class of Windows-only and pnpm-only tool failures" + testability: + rating: "medium" + details: "Heuristic is easy to grep for during review" + +prerequisites: + - "Understanding of Node module resolution" + +dependencies: + packages: [] + skills: + - "cross-platform-compatibility" + +applies_to: + languages: + - "JavaScript" + frameworks: + - "Jest" + - "ESLint" + - "Prettier" + - "TypeScript" + versions: + node: ">=18.0" + +aliases: + - "Bare specifier rule" + - "Do not pre-resolve" + +related: + - "jest-hook-robustness" + - "cross-platform-compatibility" + +status: "stable" +--- + +# Let Tools Resolve Modules + +> **One-line summary**: When a tool resolves modules internally (jest-config +> `testRunner`, ESLint resolver, Prettier plugin loader, TypeScript `--lib`), +> pass the bare specifier; do NOT pre-resolve with `require.resolve` or +> `path.join(REPO_ROOT, 'node_modules', ...)` and pass an absolute path. + +## Overview + +Most JavaScript tools ship their own resolver tuned for their internal layout +assumptions. Pre-resolving from outside bypasses those rules and breaks +unpredictably across platforms. + +## Solution + +1. Default to passing the bare specifier (`jest-circus`, `eslint-config-foo`, + `prettier-plugin-bar`). +1. Let the tool's resolver walk its own search paths. +1. Reserve absolute paths for the documented escape hatches where the caller + explicitly opts in. + +## Why + +Tool-specific resolution edge cases are tool-private and version-private: + +- Windows drive letters and backslash normalization. +- Symlinked `node_modules` (Yarn PnP, npm workspaces). +- pnpm's content-addressable store and virtual store shapes. +- npm workspace hoisting decisions per project layout. +- Jest's runner validation rejecting paths that fail its own `path.isAbsolute` + plus normalization sequence. + +Pre-resolving from outside a tool bypasses all of these. Windows is the most +common manifestation because path normalization differs the most there, but +pnpm-on-Linux trips the same class of failures. + +## Canonical example: the jest-circus footgun + +`scripts/run-managed-jest.js` used to inject +`--testRunner <abs-path-to-jest-circus>`. That absolute path failed +jest-config's runner validator on Windows even though the file existed at the +exact path supplied. See +[Jest Hook Robustness](./jest-hook-robustness.md) for the full failure story +and the two regression tests that pin the contract. + +## Other tools where the pattern applies + +- ESLint plugin and config paths: pass the package name; let ESLint resolve. +- Prettier `--plugin <pkg>`: pass the package name, not a built file path. +- TypeScript `--lib` and `--types`: pass library identifiers, not file paths. +- Dynamic `import()` of filesystem paths: use + `pathToFileURL(absPath).href` rather than passing a raw Windows + drive-letter path string to `import()`. + +## Boundary: when is an absolute path OK? + +When the caller explicitly opts in through a documented escape hatch. The +public `args` array forwarded to `run-managed-jest.js` is a supported escape +hatch: if a test author deliberately passes `--testRunner /abs/path/to/runner` +in argv, the wrapper forwards it unchanged. The rule applies to +INTERNAL injection by the wrapper, not to argv pass-through from a deliberate +caller. + +## Detection heuristic + +Any `scripts/*.js` line that computes a `--<flag>` value with `require.resolve` +or with `path.join(..., 'node_modules', ...)` and then passes the result to a +child tool is suspect. Default to the bare specifier. Reviewers should grep: + +```bash +grep -rnE "(--[A-Za-z]+\s*[\"'].*node_modules)|(require\\.resolve\\([^)]*\\).*--)" scripts/ +``` + +## See Also + +- [Jest Hook Robustness](./jest-hook-robustness.md) +- [Cross-Platform Script Compatibility](./cross-platform-compatibility.md) + +## Changelog + +| Version | Date | Changes | +| ------- | ---------- | ---------------------------------------------------------------------- | +| 1.0.0 | 2026-05-18 | Initial version generalizing the jest-circus testRunner injection bug. | diff --git a/.llm/skills/scripting/powershell-best-practices.md b/.llm/skills/scripting/powershell-best-practices.md index 45ff618f..6926b2a4 100644 --- a/.llm/skills/scripting/powershell-best-practices.md +++ b/.llm/skills/scripting/powershell-best-practices.md @@ -7,11 +7,11 @@ created: "2026-01-27" updated: "2026-01-28" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "scripts/" - path: ".github/workflows/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "powershell" diff --git a/.llm/skills/scripting/regex-documentation.md b/.llm/skills/scripting/regex-documentation.md index 724485ae..0696b121 100644 --- a/.llm/skills/scripting/regex-documentation.md +++ b/.llm/skills/scripting/regex-documentation.md @@ -7,11 +7,11 @@ created: "2026-01-29" updated: "2026-01-29" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "scripts/" - path: "docs/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "regex" diff --git a/.llm/skills/scripting/shell-best-practices.md b/.llm/skills/scripting/shell-best-practices.md index a24ed4e8..13dafa46 100644 --- a/.llm/skills/scripting/shell-best-practices.md +++ b/.llm/skills/scripting/shell-best-practices.md @@ -7,12 +7,12 @@ created: "2026-01-28" updated: "2026-01-28" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "scripts/" - path: ".github/workflows/" - path: ".husky/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "shell" diff --git a/.llm/skills/scripting/validation-patterns.md b/.llm/skills/scripting/validation-patterns.md index 82b60938..020cdefd 100644 --- a/.llm/skills/scripting/validation-patterns.md +++ b/.llm/skills/scripting/validation-patterns.md @@ -7,11 +7,11 @@ created: "2026-01-30" updated: "2026-03-17" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "scripts/validate-skills.js" - path: "scripts/__tests__/validate-skills-optional-fields.test.js" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "validation" diff --git a/.llm/skills/testing/allocation-coverage-required-for-dispatch.md b/.llm/skills/testing/allocation-coverage-required-for-dispatch.md index f873e85c..26e0215d 100644 --- a/.llm/skills/testing/allocation-coverage-required-for-dispatch.md +++ b/.llm/skills/testing/allocation-coverage-required-for-dispatch.md @@ -7,12 +7,12 @@ created: "2026-05-01" updated: "2026-05-01" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "Tests/Editor/Allocations/AllocationMatrixTests.cs" - path: "Tests/Runtime/TestUtilities/AllocationAssertions.cs" - path: "Tests/Runtime/TestUtilities/MessageScenarios.cs" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "testing" diff --git a/.llm/skills/testing/comprehensive-test-coverage.md b/.llm/skills/testing/comprehensive-test-coverage.md index f13797e9..683ac59f 100644 --- a/.llm/skills/testing/comprehensive-test-coverage.md +++ b/.llm/skills/testing/comprehensive-test-coverage.md @@ -7,11 +7,11 @@ created: "2026-01-22" updated: "2026-01-22" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "Tests/Runtime/Core/NominalTests.cs" - path: "Tests/Runtime/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "testing" diff --git a/.llm/skills/testing/data-driven-tests-sources.md b/.llm/skills/testing/data-driven-tests-sources.md index c1182788..6a3ab128 100644 --- a/.llm/skills/testing/data-driven-tests-sources.md +++ b/.llm/skills/testing/data-driven-tests-sources.md @@ -7,10 +7,10 @@ created: "2026-01-22" updated: "2026-01-22" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "Tests/Runtime/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "testing" diff --git a/.llm/skills/testing/data-driven-tests-usage.md b/.llm/skills/testing/data-driven-tests-usage.md index 0a7b7376..4317abb6 100644 --- a/.llm/skills/testing/data-driven-tests-usage.md +++ b/.llm/skills/testing/data-driven-tests-usage.md @@ -7,10 +7,10 @@ created: "2026-01-22" updated: "2026-01-22" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "Tests/Runtime/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "testing" diff --git a/.llm/skills/testing/inspector-overlay-invariants.md b/.llm/skills/testing/inspector-overlay-invariants.md index 4cb9e6f0..8656ea40 100644 --- a/.llm/skills/testing/inspector-overlay-invariants.md +++ b/.llm/skills/testing/inspector-overlay-invariants.md @@ -7,12 +7,12 @@ created: "2026-04-30" updated: "2026-04-30" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "Editor/CustomEditors/MessageAwareComponentFallbackEditor.cs" - path: "Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs" - path: "Tests/Editor/MessageAwareComponentFallbackEditorTests.cs" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "testing" diff --git a/.llm/skills/testing/leak-watcher-usage.md b/.llm/skills/testing/leak-watcher-usage.md index 66650cf9..98d68a6e 100644 --- a/.llm/skills/testing/leak-watcher-usage.md +++ b/.llm/skills/testing/leak-watcher-usage.md @@ -7,13 +7,13 @@ created: "2026-05-02" updated: "2026-05-02" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "Tests/Runtime/TestUtilities/LeakWatcher.cs" - path: "Tests/Runtime/Core/LeakWatcherSelfTests.cs" - path: "Runtime/Core/MessageBus/IMessageBus.cs" - path: "Tests/Runtime/Core/PublicSurfaceContractTests.cs" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "testing" diff --git a/.llm/skills/testing/lifecycle-edge-coverage.md b/.llm/skills/testing/lifecycle-edge-coverage.md index 46926050..b60235ed 100644 --- a/.llm/skills/testing/lifecycle-edge-coverage.md +++ b/.llm/skills/testing/lifecycle-edge-coverage.md @@ -7,14 +7,14 @@ created: "2026-05-02" updated: "2026-05-02" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "Tests/Runtime/Core/LifecycleEdgeCasesTests.cs" - path: "Tests/Runtime/Core/ReentrantEmissionExtendedTests.cs" - path: "Tests/Runtime/TestUtilities/LeakWatcher.cs" - path: "Runtime/Core/MessageBus/MessageBus.cs" - path: "Runtime/Core/DxMessagingStaticState.cs" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "testing" diff --git a/.llm/skills/testing/memory-reclaim-coverage.md b/.llm/skills/testing/memory-reclaim-coverage.md index 52a5086f..c8108420 100644 --- a/.llm/skills/testing/memory-reclaim-coverage.md +++ b/.llm/skills/testing/memory-reclaim-coverage.md @@ -7,13 +7,13 @@ created: "2026-05-04" updated: "2026-05-04" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "Tests/Runtime/MemoryReclaim/MemoryReclamationTests.cs" - path: "Tests/Runtime/TestUtilities/LeakWatcher.cs" - path: "Tests/Editor/Allocations/AllocationMatrixTests.cs" - path: "Tests/Editor/Contract/MessageBusInvariantTests.cs" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "testing" diff --git a/.llm/skills/testing/script-test-coverage.md b/.llm/skills/testing/script-test-coverage.md index 28e0ceca..39e3e01b 100644 --- a/.llm/skills/testing/script-test-coverage.md +++ b/.llm/skills/testing/script-test-coverage.md @@ -7,10 +7,10 @@ created: "2026-01-28" updated: "2026-01-28" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "scripts/__tests__/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "testing" diff --git a/.llm/skills/testing/single-thread-contract.md b/.llm/skills/testing/single-thread-contract.md index 376d8b1e..1231a78a 100644 --- a/.llm/skills/testing/single-thread-contract.md +++ b/.llm/skills/testing/single-thread-contract.md @@ -7,12 +7,12 @@ created: "2026-05-01" updated: "2026-05-01" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "Tests/Runtime/Core/SingleThreadContractTests.cs" - path: "Runtime/Core/MessageBus/MessageBus.cs" - path: "Runtime/Core/MessageHandler.cs" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "testing" diff --git a/.llm/skills/testing/test-code-quality.md b/.llm/skills/testing/test-code-quality.md index a9d3599b..15810fad 100644 --- a/.llm/skills/testing/test-code-quality.md +++ b/.llm/skills/testing/test-code-quality.md @@ -9,11 +9,11 @@ updated: "2026-01-30" status: stable source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "scripts/__tests__/validate-skills-tags.test.js" - path: "scripts/__tests__/validate-skills-optional-fields.test.js" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - testing diff --git a/.llm/skills/testing/test-coverage-data-driven.md b/.llm/skills/testing/test-coverage-data-driven.md index 917490cd..e8c0b773 100644 --- a/.llm/skills/testing/test-coverage-data-driven.md +++ b/.llm/skills/testing/test-coverage-data-driven.md @@ -7,10 +7,10 @@ created: "2026-01-22" updated: "2026-01-22" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "Tests/Runtime/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "testing" diff --git a/.llm/skills/testing/test-coverage-organization-assertions.md b/.llm/skills/testing/test-coverage-organization-assertions.md index 233d4dff..79467bd2 100644 --- a/.llm/skills/testing/test-coverage-organization-assertions.md +++ b/.llm/skills/testing/test-coverage-organization-assertions.md @@ -7,10 +7,10 @@ created: "2026-01-22" updated: "2026-01-22" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "Tests/Runtime/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "testing" diff --git a/.llm/skills/testing/test-coverage-scenario-categories.md b/.llm/skills/testing/test-coverage-scenario-categories.md index 757f00b1..a13f61e3 100644 --- a/.llm/skills/testing/test-coverage-scenario-categories.md +++ b/.llm/skills/testing/test-coverage-scenario-categories.md @@ -7,10 +7,10 @@ created: "2026-01-22" updated: "2026-01-22" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "Tests/Runtime/Core/NominalTests.cs" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "testing" diff --git a/.llm/skills/testing/test-coverage-unity-anti-patterns.md b/.llm/skills/testing/test-coverage-unity-anti-patterns.md index 4a7a57ad..9ce85cf4 100644 --- a/.llm/skills/testing/test-coverage-unity-anti-patterns.md +++ b/.llm/skills/testing/test-coverage-unity-anti-patterns.md @@ -7,10 +7,10 @@ created: "2026-01-22" updated: "2026-01-22" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "Tests/Runtime/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "testing" diff --git a/.llm/skills/testing/test-failure-investigation-procedure.md b/.llm/skills/testing/test-failure-investigation-procedure.md index 71d43663..19aaf072 100644 --- a/.llm/skills/testing/test-failure-investigation-procedure.md +++ b/.llm/skills/testing/test-failure-investigation-procedure.md @@ -7,10 +7,10 @@ created: "2026-01-22" updated: "2026-01-22" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "Tests/Runtime/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "testing" diff --git a/.llm/skills/testing/test-failure-investigation-root-causes.md b/.llm/skills/testing/test-failure-investigation-root-causes.md index 60a25d01..cf40ff79 100644 --- a/.llm/skills/testing/test-failure-investigation-root-causes.md +++ b/.llm/skills/testing/test-failure-investigation-root-causes.md @@ -7,10 +7,10 @@ created: "2026-01-22" updated: "2026-01-22" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "Tests/Runtime/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "testing" diff --git a/.llm/skills/testing/test-failure-investigation.md b/.llm/skills/testing/test-failure-investigation.md index 17b9b75a..f2fafe85 100644 --- a/.llm/skills/testing/test-failure-investigation.md +++ b/.llm/skills/testing/test-failure-investigation.md @@ -7,10 +7,10 @@ created: "2026-01-22" updated: "2026-01-22" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "Tests/Runtime/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "testing" diff --git a/.llm/skills/testing/test-production-code.md b/.llm/skills/testing/test-production-code.md index 45465fa5..2b364331 100644 --- a/.llm/skills/testing/test-production-code.md +++ b/.llm/skills/testing/test-production-code.md @@ -7,11 +7,11 @@ created: "2026-01-30" updated: "2026-01-30" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "scripts/__tests__/" - path: "scripts/" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "testing" diff --git a/.llm/skills/testing/tests-must-be-parameterized-by-message-kind.md b/.llm/skills/testing/tests-must-be-parameterized-by-message-kind.md index e38992c1..f09aaf43 100644 --- a/.llm/skills/testing/tests-must-be-parameterized-by-message-kind.md +++ b/.llm/skills/testing/tests-must-be-parameterized-by-message-kind.md @@ -7,12 +7,12 @@ created: "2026-05-01" updated: "2026-05-01" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "Tests/Runtime/TestUtilities/MessageScenario.cs" - path: "Tests/Runtime/TestUtilities/MessageScenarios.cs" - path: "Tests/Runtime/TestUtilities/ScenarioHarness.cs" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "testing" diff --git a/.llm/skills/unity/base-call-contract.md b/.llm/skills/unity/base-call-contract.md index 2ccde1c2..a14475b0 100644 --- a/.llm/skills/unity/base-call-contract.md +++ b/.llm/skills/unity/base-call-contract.md @@ -7,13 +7,13 @@ created: "2026-05-02" updated: "2026-05-03" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "Runtime/Unity/MessageAwareComponent.cs" - path: "SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs" - path: "Editor/Analyzers/BaseCallTypeScannerCore.cs" - path: "Editor/CustomEditors/MessageAwareComponentInspectorOverlay.cs" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "unity" diff --git a/.llm/skills/unity/devcontainer-cache-contract.md b/.llm/skills/unity/devcontainer-cache-contract.md index 7ec13b5f..6fff63b3 100644 --- a/.llm/skills/unity/devcontainer-cache-contract.md +++ b/.llm/skills/unity/devcontainer-cache-contract.md @@ -7,7 +7,7 @@ created: "2026-05-05" updated: "2026-05-05" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: ".devcontainer/cache-contract.sh" - path: ".devcontainer/devcontainer.json" @@ -15,7 +15,7 @@ source: - path: ".devcontainer/post-start.sh" - path: ".devcontainer/validate-caching.sh" - path: ".devcontainer/Dockerfile" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "devcontainer" diff --git a/.llm/skills/unity/headless-test-runner.md b/.llm/skills/unity/headless-test-runner.md index b8840d0c..d4b4e52f 100644 --- a/.llm/skills/unity/headless-test-runner.md +++ b/.llm/skills/unity/headless-test-runner.md @@ -7,14 +7,14 @@ created: "2026-05-05" updated: "2026-05-05" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "scripts/unity/run-tests.sh" - path: "scripts/unity/run-tests.ps1" - path: "scripts/unity/lib/asmdef-discovery.js" - path: "scripts/unity/lib/parse-test-results.py" - path: ".github/workflows-disabled/unity-tests.yml" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "unity" diff --git a/.llm/skills/unity/unity-ci-matrix.md b/.llm/skills/unity/unity-ci-matrix.md index 407d7ca7..c74a4234 100644 --- a/.llm/skills/unity/unity-ci-matrix.md +++ b/.llm/skills/unity/unity-ci-matrix.md @@ -7,12 +7,12 @@ created: "2026-05-05" updated: "2026-05-05" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: ".github/workflows-disabled/unity-tests.yml" - path: ".github/workflows-disabled/unity-il2cpp.yml" - path: ".github/workflows-disabled/unity-benchmarks.yml" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "unity" diff --git a/.llm/skills/unity/unity-license-bootstrap.md b/.llm/skills/unity/unity-license-bootstrap.md index d4e2983a..e2ecd68d 100644 --- a/.llm/skills/unity/unity-license-bootstrap.md +++ b/.llm/skills/unity/unity-license-bootstrap.md @@ -7,13 +7,13 @@ created: "2026-05-05" updated: "2026-05-05" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "scripts/unity/activate-license.sh" - path: "scripts/unity/run-tests.sh" - path: ".devcontainer/devcontainer.json" - path: ".github/workflows-disabled/unity-tests.yml" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "unity" diff --git a/.llm/skills/unity/unity-perf-test-isolation.md b/.llm/skills/unity/unity-perf-test-isolation.md index 334fd8a2..66d96c92 100644 --- a/.llm/skills/unity/unity-perf-test-isolation.md +++ b/.llm/skills/unity/unity-perf-test-isolation.md @@ -7,14 +7,14 @@ created: "2026-05-05" updated: "2026-05-05" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: "scripts/unity/lib/asmdef-discovery.js" - path: ".github/workflows-disabled/unity-tests.yml" - path: ".github/workflows-disabled/unity-benchmarks.yml" - path: "scripts/unity/run-tests.sh" - path: ".llm/context.md" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "unity" diff --git a/.llm/skills/unity/upm-test-harness.md b/.llm/skills/unity/upm-test-harness.md index dcc74d6c..15755e25 100644 --- a/.llm/skills/unity/upm-test-harness.md +++ b/.llm/skills/unity/upm-test-harness.md @@ -7,14 +7,14 @@ created: "2026-05-05" updated: "2026-05-05" source: - repository: "wallstop/DxMessaging" + repository: "Ambiguous-Interactive/DxMessaging" files: - path: ".unity-test-project/Packages/manifest.json" - path: ".unity-test-project/Packages/packages-lock.json" - path: ".unity-test-project/ProjectSettings/ProjectVersion.txt" - path: ".unity-test-project/Assets/Editor/TestRunnerBuilder.cs" - path: ".unity-test-project/Assets/Editor/WallstopStudios.DxMessaging.TestHarness.Editor.asmdef" - url: "https://github.com/wallstop/DxMessaging" + url: "https://github.com/Ambiguous-Interactive/DxMessaging" tags: - "unity" diff --git a/.lychee.toml b/.lychee.toml index 3dedd227..6caa4212 100644 --- a/.lychee.toml +++ b/.lychee.toml @@ -28,5 +28,8 @@ exclude = [ # GitHub Pages site - not deployed yet, will be available after first successful deploy "^https://wallstop\\.github\\.io/DxMessaging", # Game Programming Patterns site returns 415 (Unsupported Media Type) to automated clients despite valid content - "^https://gameprogrammingpatterns\\.com/" + "^https://gameprogrammingpatterns\\.com/", + # Unity Support (Zendesk + Cloudflare) returns 403 to non-browser clients regardless of User-Agent; + # links are user-facing documentation and stay valid in real browsers. + "^https?://support\\.unity\\.com/" ] diff --git a/.npmignore b/.npmignore index 1a566422..7bed6177 100644 --- a/.npmignore +++ b/.npmignore @@ -1,3 +1,4 @@ +# cspell:words lscache # npm package exclusions for com.wallstop-studios.dxmessaging # # This file works together with package.json "files" to control npm packaging: @@ -16,6 +17,12 @@ **/bin/ **/obj/ **/*.pdb +# C# Dev Kit per-project cache files (auto-regenerated; never publish). +**/*.lscache +**/*.lscache.meta +# JetBrains Rider per-user solution settings. +**/*.DotSettings.user +**/*.DotSettings.user.meta # ============================================================================= # Tooling Output Directories @@ -83,6 +90,9 @@ site.meta progress/ progress.meta +# Local operator runbooks are generated on demand and never packaged. +.operator-runbooks/ + # ============================================================================= # Node.js / JavaScript Tooling # ============================================================================= diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0c549f33..bd103e50 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -42,32 +42,48 @@ repos: - repo: local hooks: - # perf-allow[scans-the-world-with-files]: gated to package.json via files: regex; the bash entry reads exactly one SVG to keep the banner version in sync + # perf-allow[scans-the-world-with-files]: gated to package metadata plus test-marker source files; the sync script scans test files by design to keep the banner badge count in sync - id: sync-banner-version name: Sync banner version from package.json entry: bash scripts/sync-banner-version.sh language: system - files: '^package\.json$' + files: '^(package\.json|Tests/.*Tests?\.cs|SourceGenerators/.*Tests?\.cs|scripts/(sync-banner-version\.(ps1|js|sh)|.*\.(test|spec)\.js))$' pass_filenames: false stages: - pre-commit - description: Sync version from package.json to SVG banner (cosmetic, non-blocking). + description: Sync package version and rounded repository test count to the SVG banner. + # perf-allow[scans-the-world-with-files]: gated to banner sources plus test-marker files; validator scans those inputs to enforce no-drift invariants + - id: validate-banner + name: Validate banner SVG (encapsulation, sync, accessibility) + entry: node scripts/validate-banner.js + language: system + files: '^(docs/images/DxMessaging-banner\.svg|package\.json|Tests/.*Tests?\.cs|SourceGenerators/.*Tests?\.cs|scripts/(sync-banner-version\.(ps1|js|sh)|validate-banner\.js|.*\.(test|spec)\.js))$' + pass_filenames: false + stages: + - pre-commit + description: Enforce banner invariants - version-block byte equality with PS heredoc, badge encapsulation, feature labels, accessibility, no-drift. - repo: local hooks: - id: prettier name: Prettier (JSON, asmdef, asmref, YAML) - entry: >- - bash -c 'if [ -f node_modules/prettier/bin/prettier.cjs ]; then - exec node node_modules/prettier/bin/prettier.cjs --write "$@"; - else exec npx --yes --package=prettier@3.8.3 prettier --write "$@"; fi' -- + entry: node scripts/run-managed-prettier.js --write language: system files: '(?i)\.(json|asmdef|asmref|ya?ml)$' stages: - pre-commit + # Pinned npx fallback is `prettier@3.8.3` (kept in sync by the + # cspell/prettier-version-parity tests with package.json + # devDependencies). The managed wrapper at + # scripts/run-managed-prettier.js prefers the local devDependency + # (node_modules/prettier/bin/prettier.cjs) and falls back to the + # pinned npx install only on cold caches, with an integrity gate + # ahead of either branch. description: >- - Format staged JSON/YAML files in-process when local Prettier is - present, falling back to a pinned npx install only on cold caches. + Format staged JSON/YAML files via scripts/run-managed-prettier.js, + which gates on node_modules integrity, prefers local Prettier + (node_modules/prettier/bin/prettier.cjs) and falls back to a + pinned npx install (prettier@3.8.3) only on cold caches. Markdown files go through run-staged-md-pipeline. - repo: local @@ -335,6 +351,7 @@ repos: scripts/__tests__/prettier-version-parity.test.js scripts/__tests__/validate-skills-required-fields.test.js scripts/__tests__/validate-workflows.test.js + scripts/__tests__/validate-workflows-concurrency-and-labels.test.js scripts/__tests__/check-eol.test.js scripts/__tests__/quote-parser.test.js scripts/__tests__/shell-command.test.js @@ -350,15 +367,26 @@ repos: scripts/__tests__/detect-shell-redirection-antipattern.test.js scripts/__tests__/pre-commit-hook-stage-policy.test.js scripts/__tests__/run-managed-jest.test.js + scripts/__tests__/run-managed-jest-no-injected-test-runner.test.js + scripts/__tests__/no-testrunner-injection-policy.test.js + scripts/lib/__tests__/source-stripping.test.js scripts/__tests__/verify-managed-jest-fallback.test.js scripts/__tests__/run-staged-validators.test.js scripts/__tests__/run-staged-md-pipeline.test.js scripts/__tests__/hook-perf-budget.test.js scripts/__tests__/precommit-perf-score.test.js scripts/__tests__/precommit-yaml.test.js + scripts/__tests__/doctor.test.js + scripts/__tests__/llm-skills-jest-robustness-coverage.test.js + scripts/__tests__/preflight-pre-push-coverage.test.js + scripts/__tests__/path-classifier.test.js + scripts/__tests__/node-modules-integrity.test.js + scripts/__tests__/run-managed-cspell.test.js + scripts/__tests__/managed-prettier.test.js + scripts/__tests__/jest-error-decoder.test.js language: system pass_filenames: false - files: '^(\.gitattributes|CONTRIBUTING\.md|\.vscode/settings\.json|\.github/workflows/(llm-policy-check|pre-commit-tooling-check)\.yml|scripts/check-eol\.ps1|scripts/(check-eol|fix-eol|fix-md029-md051|fix-csharp-underscore-methods|validate-lychee-config|validate-skills|generate-skills-index|validate-workflows|update-llms-txt|validate-vscode-settings|validate-pre-commit-tooling|validate-node-tooling|validate-npm-meta|validate-changelog|run-managed-jest|run-managed-prettier|run-staged-validators|run-staged-md-pipeline|verify-managed-jest-fallback)\.js|scripts/lib/(quote-parser|eol-policy|shell-command|prettier-version|precommit-yaml|precommit-perf-score|staged-doc-formatters)\.js|scripts/__tests__/(check-eol|fix-md029-md051|fix-csharp-underscore-methods|validate-lychee-config|validate-skills-required-fields|validate-skills-llm-policy|generate-skills-index|prettier-version|prettier-version-parity|run-managed-prettier|cspell-version-parity|validate-workflows|validate-changelog|quote-parser|shell-command|update-llms-txt|validate-vscode-settings|validate-pre-commit-tooling|validate-node-tooling|validate-npm-meta|detect-shell-redirection-antipattern|pre-commit-hook-stage-policy|run-managed-jest|verify-managed-jest-fallback|run-staged-validators|run-staged-md-pipeline|hook-perf-budget|precommit-perf-score|precommit-yaml)\.test\.js)$' + files: '^(\.gitattributes|CONTRIBUTING\.md|\.vscode/settings\.json|\.github/workflows/(llm-policy-check|pre-commit-tooling-check)\.yml|scripts/check-eol\.ps1|scripts/(check-eol|fix-eol|fix-md029-md051|fix-csharp-underscore-methods|validate-lychee-config|validate-skills|generate-skills-index|validate-workflows|update-llms-txt|validate-vscode-settings|validate-pre-commit-tooling|validate-node-tooling|validate-npm-meta|validate-changelog|run-managed-jest|run-managed-prettier|run-managed-cspell|run-staged-validators|run-staged-md-pipeline|verify-managed-jest-fallback|doctor)\.js|scripts/lib/(quote-parser|eol-policy|shell-command|prettier-version|precommit-yaml|precommit-perf-score|staged-doc-formatters|source-stripping|jest-error-decoder|path-classifier|node-modules-integrity|integrity-gate-with-recovery|managed-prettier)\.js|scripts/__tests__/(check-eol|fix-md029-md051|fix-csharp-underscore-methods|validate-lychee-config|validate-skills-required-fields|validate-skills-llm-policy|generate-skills-index|prettier-version-parity|prettier-version|run-managed-prettier|cspell-version-parity|validate-workflows-concurrency-and-labels|validate-workflows|validate-changelog|quote-parser|shell-command|update-llms-txt|validate-vscode-settings|validate-pre-commit-tooling|validate-node-tooling|validate-npm-meta|detect-shell-redirection-antipattern|pre-commit-hook-stage-policy|run-managed-jest|verify-managed-jest-fallback|run-staged-validators|run-staged-md-pipeline|hook-perf-budget|precommit-perf-score|precommit-yaml|run-managed-jest-no-injected-test-runner|no-testrunner-injection-policy|doctor|llm-skills-jest-robustness-coverage|preflight-pre-push-coverage|path-classifier|node-modules-integrity|run-managed-cspell|managed-prettier|jest-error-decoder)\.test\.js|scripts/lib/__tests__/source-stripping\.test\.js)$' stages: - pre-push description: Fail fast on parser, npm-meta, and shell-safety regressions before push. Pre-push only; see .llm/skills/performance/git-hook-performance.md. @@ -388,16 +416,24 @@ repos: hooks: - id: cspell name: Check spelling - entry: >- - bash -c 'if [ -f node_modules/cspell/bin.mjs ]; then - exec node node_modules/cspell/bin.mjs --no-progress --no-summary "$@"; - else exec npx --yes cspell@10.0.0 --no-progress --no-summary "$@"; fi' -- + entry: node scripts/run-managed-cspell.js --no-progress --no-summary language: system files: '(?i)\.(md|markdown|cs|json|ya?ml|ps1|js)$' pass_filenames: true stages: - pre-push - description: Check spelling in changed files. Pre-push only; see .llm/skills/performance/git-hook-performance.md. + # Pinned npx fallback is `cspell@10.0.0` (kept in sync by the + # cspell-version-parity test with package.json devDependencies). + # The managed wrapper at scripts/run-managed-cspell.js prefers the + # local devDependency (node_modules/cspell/bin.mjs) and falls back + # to the pinned npx install only on cold caches, with an integrity + # gate ahead of either branch. + description: >- + Check spelling in changed files via scripts/run-managed-cspell.js, + which gates on node_modules integrity, prefers local cspell + (node_modules/cspell/bin.mjs) and falls back to the pinned npx + install (cspell@10.0.0) only on cold caches. Pre-push only; see + .llm/skills/performance/git-hook-performance.md. - repo: local hooks: diff --git a/CHANGELOG.md b/CHANGELOG.md index 6122f6a9..dbc79c9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,7 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `RegisterGlobalAcceptAll` (`HandleGlobalUntargeted`/`HandleGlobalTargeted`/`HandleGlobalBroadcast`) is intentionally NOT covered by this fix. The bus's global accept-all dispatch path prefreezes lazily per-entry inside the dispatch loop, so a sibling `MessageHandler` that removes another's global registration mid-emit causes the removed handler to be skipped on the in-flight emission. The behavior is pinned by `MutationPostProcessorAcrossHandlersTests.RemoveOtherGlobalAcceptAllAcrossHandlersDuringDispatch`; if a future change introduces upfront global-handler prefreeze, that test must be updated to expect the snapshot semantics that the per-kind paths already provide. - `DxMessagingStaticState.Reset` is now race-safe against deferred deregistrations. Previously, when a message-aware component was destroyed but its disable callback had not yet run (Unity defers Object.Destroy to end of frame) and Reset ran in between, the deferred token teardown would log spurious "Received over-deregistration of {type} for {handler}" errors against the user's Unity console. The bus now stamps each captured deregister closure with a generation counter and silently no-ops closures captured before a Reset. Applied uniformly across every register entry point (untargeted, targeted, broadcast, GlobalAcceptAll, and all three interceptor kinds). The same race-safety guarantee is now propagated to user-installed custom global buses via `MessageBus.BumpResetGeneration()`, which `DxMessagingStaticState.Reset` invokes on the active global bus when it differs from the built-in default; the custom bus's sinks are intentionally left intact to avoid clobbering state the user installed it to preserve. User code is unaffected except that previously-spurious error logs disappear. - `MessageRegistrationToken.RemoveRegistration(handle)` no longer leaks the staged registration entry, so a `Disable()`/`Enable()` cycle after `RemoveRegistration` no longer silently re-registers the removed handler. The fix also drops the matching metadata and call-count entries so diagnostic mode does not accumulate stale handles. -- Resolved [issue #204](https://github.com/wallstop/DxMessaging/issues/204) (build artifacts and orphaned `.meta` files leaking into the npm tarball) and prevented its regression: `scripts/validate-npm-meta.js` now runs `validateNoBuildArtifactsInTarball` (rejects `bin/`, `obj/`, `*.pdb`, `*.tmp`, `*.csproj.user`, `.vs/`, `.idea/`, `*.suo`, and `*.DotSettings.user` paths in the tarball) and `validatePublishedFilesArePairedWithMetas` (every shipped Unity-relevant file has its `.meta` neighbour and every shipped directory has its directory `.meta`), wired into `prepack` and the `validate-npm-meta` workflow so the next publish cannot reintroduce the regression. +- Resolved [issue #204](https://github.com/Ambiguous-Interactive/DxMessaging/issues/204) (build artifacts and orphaned `.meta` files leaking into the npm tarball) and prevented its regression: `scripts/validate-npm-meta.js` now runs `validateNoBuildArtifactsInTarball` (rejects `bin/`, `obj/`, `*.pdb`, `*.tmp`, `*.csproj.user`, `.vs/`, `.idea/`, `*.suo`, and `*.DotSettings.user` paths in the tarball) and `validatePublishedFilesArePairedWithMetas` (every shipped Unity-relevant file has its `.meta` neighbour and every shipped directory has its directory `.meta`), wired into `prepack` and the `validate-npm-meta` workflow so the next publish cannot reintroduce the regression. ## [2.2.0] @@ -90,7 +90,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Wiki synchronization workflow that automatically syncs documentation to GitHub Wiki - Documentation validation workflow that runs on pull requests and pushes - MkDocs build validation in pre-push hooks -- Searchable documentation site at <https://wallstop.github.io/DxMessaging/> +- Searchable documentation site at <https://ambiguous-interactive.github.io/DxMessaging/> - Theme-aware Mermaid diagrams with automatic light/dark mode switching for GitHub Pages - User-visible error messages when Mermaid diagrams fail to render diff --git a/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll b/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll index 7307d310..b5f27084 100644 Binary files a/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll and b/Editor/Analyzers/WallstopStudios.DxMessaging.Analyzer.dll differ diff --git a/Editor/Analyzers/WallstopStudios.DxMessaging.SourceGenerators.dll b/Editor/Analyzers/WallstopStudios.DxMessaging.SourceGenerators.dll index ed2299e2..5ef54fd4 100644 Binary files a/Editor/Analyzers/WallstopStudios.DxMessaging.SourceGenerators.dll and b/Editor/Analyzers/WallstopStudios.DxMessaging.SourceGenerators.dll differ diff --git a/README.md b/README.md index 1e302b85..92bf3ea6 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ </p> <p align="center"> - <a href="https://wallstop.github.io/DxMessaging/"> + <a href="https://ambiguous-interactive.github.io/DxMessaging/"> <img src="https://img.shields.io/badge/Full_Documentation-Visit_the_Docs_Site-2ea44f?style=for-the-badge" alt="Full Documentation" /> </a> </p> @@ -15,8 +15,8 @@ [![openupm](https://img.shields.io/npm/v/com.wallstop-studios.dxmessaging?label=openupm®istry_uri=https://package.openupm.com)](https://openupm.com/packages/com.wallstop-studios.dxmessaging/)<br/> [![Version](https://img.shields.io/npm/v/com.wallstop-studios.dxmessaging.svg)](https://www.npmjs.com/package/com.wallstop-studios.dxmessaging)<br/> [![Performance: OS-Specific Benchmarks](https://img.shields.io/badge/Performance-OS--specific-blueviolet.svg)](docs/architecture/performance.md)<br/> -[![Markdown Link Validity](https://github.com/wallstop/DxMessaging/actions/workflows/markdown-link-validity.yml/badge.svg)](https://github.com/wallstop/DxMessaging/actions/workflows/markdown-link-validity.yml)<br/> -[![Markdown Link Text Check](https://github.com/wallstop/DxMessaging/actions/workflows/markdown-link-text-check.yml/badge.svg)](https://github.com/wallstop/DxMessaging/actions/workflows/markdown-link-text-check.yml) +[![Markdown Link Validity](https://github.com/Ambiguous-Interactive/DxMessaging/actions/workflows/markdown-link-validity.yml/badge.svg)](https://github.com/Ambiguous-Interactive/DxMessaging/actions/workflows/markdown-link-validity.yml)<br/> +[![Markdown Link Text Check](https://github.com/Ambiguous-Interactive/DxMessaging/actions/workflows/markdown-link-text-check.yml/badge.svg)](https://github.com/Ambiguous-Interactive/DxMessaging/actions/workflows/markdown-link-text-check.yml) > **šŸ¤– AI Assistance Disclosure:** > @@ -90,7 +90,7 @@ You have data. You need to pass it around. That's the problem. DxMessaging provi ### The Three Message Types: Real-World Analogies -> šŸ’” _Diagrams below require Mermaid support. If they don't render, try viewing this file directly on [GitHub](https://github.com/wallstop/DxMessaging)._ +> šŸ’” _Diagrams below require Mermaid support. If they don't render, try viewing this file directly on [GitHub](https://github.com/Ambiguous-Interactive/DxMessaging)._ Each message type maps to a real-world communication pattern: @@ -192,7 +192,7 @@ openupm add com.wallstop-studios.dxmessaging ```bash # Unity Package Manager > Add package from git URL... -https://github.com/wallstop/DxMessaging.git +https://github.com/Ambiguous-Interactive/DxMessaging.git ``` See the [Install Guide](docs/getting-started/install.md) for all options including NPM scoped registries and local tarballs. @@ -672,8 +672,8 @@ public void TestAchievementSystem() { ## Documentation -- **[Documentation Site](https://wallstop.github.io/DxMessaging/)** - Full searchable documentation -- **[Wiki](https://github.com/wallstop/DxMessaging/wiki)** - Quick reference wiki +- **[Documentation Site](https://ambiguous-interactive.github.io/DxMessaging/)** - Full searchable documentation +- **[Wiki](https://github.com/Ambiguous-Interactive/DxMessaging/wiki)** - Quick reference wiki - **[Changelog](CHANGELOG.md)** - Version history ### Learn @@ -896,10 +896,10 @@ Created and maintained by [wallstop studios](https://wallstopstudios.com) ## Links -- [Package on GitHub](https://github.com/wallstop/DxMessaging) -- [Report Issues](https://github.com/wallstop/DxMessaging/issues) -- [Documentation Site](https://wallstop.github.io/DxMessaging/) -- [Wiki](https://github.com/wallstop/DxMessaging/wiki) +- [Package on GitHub](https://github.com/Ambiguous-Interactive/DxMessaging) +- [Report Issues](https://github.com/Ambiguous-Interactive/DxMessaging/issues) +- [Documentation Site](https://ambiguous-interactive.github.io/DxMessaging/) +- [Wiki](https://github.com/Ambiguous-Interactive/DxMessaging/wiki) ## AI Agent Integration diff --git a/Runtime/Unity/MessageAwareComponent.cs b/Runtime/Unity/MessageAwareComponent.cs index 171d9e95..48e98b27 100644 --- a/Runtime/Unity/MessageAwareComponent.cs +++ b/Runtime/Unity/MessageAwareComponent.cs @@ -159,7 +159,7 @@ protected virtual void OnEnable() Debug.LogError( $"[DxMessaging] {GetType().Name} appears to be missing a base.Awake() call; " + "no registration token exists, so this component will not receive messages. " - + "See https://github.com/wallstop/DxMessaging/blob/master/docs/reference/analyzers.md#dxmsg006-missing-base-call", + + "See https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/docs/reference/analyzers.md#dxmsg006-missing-base-call", this ); } diff --git a/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs index 9708e09c..e13fc8aa 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.Analyzer/Analyzers/MessageAwareComponentBaseCallAnalyzer.cs @@ -87,7 +87,7 @@ public sealed class MessageAwareComponentBaseCallAnalyzer : DiagnosticAnalyzer "'{0}' overrides MessageAwareComponent.{1} but does not call base.{1}(); the messaging system may not function correctly on this component."; private const string HelpLinkBase = - "https://github.com/wallstop/DxMessaging/blob/master/docs/reference/analyzers.md#"; + "https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/docs/reference/analyzers.md#"; internal static readonly ImmutableHashSet<string> GuardedMethodNames = ImmutableHashSet.Create( diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/MessageAwareComponentBaseCallAnalyzerTests.cs b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/MessageAwareComponentBaseCallAnalyzerTests.cs index f6c6f8f8..d04a6305 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/MessageAwareComponentBaseCallAnalyzerTests.cs +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.Tests/MessageAwareComponentBaseCallAnalyzerTests.cs @@ -35,7 +35,7 @@ string expectedAnchor ); string expectedLink = - $"https://github.com/wallstop/DxMessaging/blob/master/docs/reference/analyzers.md#{expectedAnchor}"; + $"https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/docs/reference/analyzers.md#{expectedAnchor}"; Assert.That( descriptor.HelpLinkUri, Is.EqualTo(expectedLink), diff --git a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.csproj b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.csproj index 568190b4..a9e21208 100644 --- a/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.csproj +++ b/SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators/WallstopStudios.DxMessaging.SourceGenerators.csproj @@ -74,6 +74,5 @@ SkipUnchangedFiles="true" Condition="Exists('$(UnityAssetsPluginsEditorWallstopDir)')" /> - <!-- Removed copy to Assets/Editor/Wallstop Studios as requested --> </Target> </Project> diff --git a/docs/architecture/comparisons.md b/docs/architecture/comparisons.md index 982c0d7d..b9c2420c 100644 --- a/docs/architecture/comparisons.md +++ b/docs/architecture/comparisons.md @@ -28,7 +28,7 @@ ## Performance Benchmarks -These sections are auto-updated by the PlayMode comparison benchmarks in the [Comparison Performance PlayMode tests](https://github.com/wallstop/DxMessaging/blob/master/Tests/Editor/Comparisons/ComparisonPerformanceTests.cs). Run the suite locally to refresh the tables. +These sections are auto-updated by the PlayMode comparison benchmarks in the [Comparison Performance PlayMode tests](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/Tests/Editor/Comparisons/ComparisonPerformanceTests.cs). Run the suite locally to refresh the tables. ### Comparisons (Windows) @@ -317,7 +317,7 @@ public class AchievementSystem #### What Problems It Solves -- [x] **Performance:** Zero allocations with struct-based messages (see [benchmarks](https://github.com/wallstop/DxMessaging/tree/master/Tests/Editor/Comparisons) for comparison data) +- [x] **Performance:** Zero allocations with struct-based messages (see [benchmarks](https://github.com/Ambiguous-Interactive/DxMessaging/tree/master/Tests/Editor/Comparisons) for comparison data) - [x] **DI integration:** First-class support for dependency injection - [x] **Async messaging:** Native async/await without blocking - [x] **Leak detection:** Analyzer catches forgotten subscriptions at compile-time diff --git a/docs/architecture/performance.md b/docs/architecture/performance.md index 9ee85070..1d5ba270 100644 --- a/docs/architecture/performance.md +++ b/docs/architecture/performance.md @@ -186,7 +186,7 @@ workflow gate. ## Historical PlayMode Benchmarks The sections below are auto-updated by the Unity PlayMode benchmark tests in -the [Performance PlayMode benchmark suite](https://github.com/wallstop/DxMessaging/blob/master/Tests/Editor/Benchmarks/PerformanceTests.cs). +the [Performance PlayMode benchmark suite](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/Tests/Editor/Benchmarks/PerformanceTests.cs). How it works: diff --git a/docs/concepts/interceptors-and-ordering.md b/docs/concepts/interceptors-and-ordering.md index b55617b3..99428f02 100644 --- a/docs/concepts/interceptors-and-ordering.md +++ b/docs/concepts/interceptors-and-ordering.md @@ -38,7 +38,7 @@ secondEvent.Emit(); // Both DoWork() and ProcessLater() execute - Ensures all listeners see a consistent view of the registration state - Makes debugging and reasoning about message flow easier -This snapshot behavior is extensively tested in the [Mutation During Emission tests](https://github.com/wallstop/DxMessaging/blob/master/Tests/Runtime/Core/MutationDuringEmissionTests.cs). +This snapshot behavior is extensively tested in the [Mutation During Emission tests](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/Tests/Runtime/Core/MutationDuringEmissionTests.cs). --- diff --git a/docs/concepts/message-types.md b/docs/concepts/message-types.md index 35e9f29f..0353f179 100644 --- a/docs/concepts/message-types.md +++ b/docs/concepts/message-types.md @@ -215,4 +215,4 @@ void OnAnyTookDamage(ref InstanceId source, ref TookDamage m) => Track(source, m ##### Try It - to [Quick Start](../getting-started/quick-start.md) -- Working example -- to [Mini Combat sample](https://github.com/wallstop/DxMessaging/blob/master/Samples~/Mini%20Combat/README.md) -- See all 3 types in action +- to [Mini Combat sample](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/Samples~/Mini%20Combat/README.md) -- See all 3 types in action diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index a1609f32..14e043fd 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -118,12 +118,12 @@ flowchart TD **Located in `Samples~/` directory** - Import via Unity Package Manager! -- **[Mini Combat](https://github.com/wallstop/DxMessaging/blob/master/Samples~/Mini%20Combat/README.md)** - Interactive combat demo with Heal/Damage messages +- **[Mini Combat](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/Samples~/Mini%20Combat/README.md)** - Interactive combat demo with Heal/Damage messages - Perfect first example to understand message flow - Shows Targeted and Broadcast messages in action - Complete working scene you can play with -- **[UI Buttons + Inspector](https://github.com/wallstop/DxMessaging/blob/master/Samples~/UI%20Buttons%20%2B%20Inspector/README.md)** - Interactive diagnostics demo +- **[UI Buttons + Inspector](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/Samples~/UI%20Buttons%20%2B%20Inspector/README.md)** - Interactive diagnostics demo - See the Inspector diagnostics in action - Explore message history and handler registrations - Great for debugging and understanding the system @@ -291,8 +291,8 @@ From [Comparisons](../architecture/comparisons.md): - [Compatibility](../reference/compatibility.md) - [End-to-End Example](../examples/end-to-end.md) - [Scene Transitions Example](../examples/end-to-end-scene-transitions.md) -- [Mini Combat Sample](https://github.com/wallstop/DxMessaging/blob/master/Samples~/Mini%20Combat/README.md) -- [UI Buttons + Inspector Sample](https://github.com/wallstop/DxMessaging/blob/master/Samples~/UI%20Buttons%20%2B%20Inspector/README.md) +- [Mini Combat Sample](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/Samples~/Mini%20Combat/README.md) +- [UI Buttons + Inspector Sample](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/Samples~/UI%20Buttons%20%2B%20Inspector/README.md) --- @@ -304,7 +304,7 @@ From [Comparisons](../architecture/comparisons.md): 1. 5 min: [Visual Guide](visual-guide.md) - Pictures & analogies 1. 10 min: [Getting Started](getting-started.md) - Deep dive 1. 5 min: [Quick Start](quick-start.md) - Hands-on code -1. 10 min: Try a [Sample](https://github.com/wallstop/DxMessaging/blob/master/Samples~/Mini%20Combat/README.md) - See it in action +1. 10 min: Try a [Sample](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/Samples~/Mini%20Combat/README.md) - See it in action **Want to go deep?** Continue with: diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index cd3c9464..2fab65cd 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -4,14 +4,14 @@ This page helps you install DxMessaging into a Unity 2021.3+ project using the U ## Quick Reference -| Method | Command/URL | Auto-Updates | -| ---------------------------- | ---------------------------------------------- | ------------ | -| **OpenUPM** (Recommended) | `openupm add com.wallstop-studios.dxmessaging` | Yes | -| **Git URL** | `https://github.com/wallstop/DxMessaging.git` | No | -| **NPM Scoped Registry** | Add registry + resolve | Yes | -| **From Releases** | Download .unitypackage | No | -| **From Source** | Clone/download zip | No | -| **Manual** (not recommended) | Edit manifest.json | No | +| Method | Command/URL | Auto-Updates | +| ---------------------------- | ---------------------------------------------------------- | ------------ | +| **OpenUPM** (Recommended) | `openupm add com.wallstop-studios.dxmessaging` | Yes | +| **Git URL** | `https://github.com/Ambiguous-Interactive/DxMessaging.git` | No | +| **NPM Scoped Registry** | Add registry + resolve | Yes | +| **From Releases** | Download npm `.tgz` | No | +| **From Source** | Clone/download zip | No | +| **Manual** (not recommended) | Edit manifest.json | No | ## Methods @@ -40,7 +40,7 @@ openupm add com.wallstop-studios.dxmessaging - Paste: ```text -https://github.com/wallstop/DxMessaging.git +https://github.com/Ambiguous-Interactive/DxMessaging.git ``` - Click Add. Unity imports the package and its analyzers/generators. @@ -48,7 +48,6 @@ https://github.com/wallstop/DxMessaging.git ### NPM Scoped Registry 1. Open Unity Package Manager -1. (Optional) Enable Pre-release packages to receive pre-release builds (RCs and betas) before they ship as stable 1. Open the Advanced Package Settings 1. Add an entry for a new "Scoped Registry" - Name: `NPM` @@ -60,11 +59,20 @@ Unity will notify you of version updates when using scoped registries. ### From Releases -Check out the latest [Releases](https://github.com/wallstop/DxMessaging/releases) to grab the Unity Package and import to your project. +Check out the latest [Releases](https://github.com/Ambiguous-Interactive/DxMessaging/releases) to download the npm `.tgz` package and checksum. Current releases do not include a `.unitypackage` asset. ### From Source -Grab a copy of this repo (either `git clone` [this repo](https://github.com/wallstop/DxMessaging) or [download a zip of the source](https://github.com/wallstop/DxMessaging/archive/refs/heads/master.zip)) and copy the contents to your project's `Assets` directory. +Embed the package under your Unity project's `Packages` directory, preserving +the package manifest and analyzer layout: + +```bash +git clone https://github.com/Ambiguous-Interactive/DxMessaging.git Packages/com.wallstop-studios.dxmessaging +``` + +For ZIP downloads, extract the repository contents into +`Packages/com.wallstop-studios.dxmessaging`. Do not copy the package into +`Assets`; that bypasses Unity Package Manager behavior. ### Manual - Manifest.json (not recommended) @@ -73,7 +81,7 @@ Grab a copy of this repo (either `git clone` [this repo](https://github.com/wall ```json { "dependencies": { - "com.wallstop-studios.dxmessaging": "https://github.com/wallstop/DxMessaging.git" + "com.wallstop-studios.dxmessaging": "https://github.com/Ambiguous-Interactive/DxMessaging.git" } } ``` diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index b5049e65..7b2c18cf 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -1,6 +1,6 @@ # Quick Start - Your First Message in 5 Minutes -[Back to Index](index.md) | [Getting Started](getting-started.md) | [Visual Guide](visual-guide.md) | [Samples](https://github.com/wallstop/DxMessaging/tree/master/Samples~) +[Back to Index](index.md) | [Getting Started](getting-started.md) | [Visual Guide](visual-guide.md) | [Samples](https://github.com/Ambiguous-Interactive/DxMessaging/tree/master/Samples~) --- @@ -15,7 +15,7 @@ Unity Package Manager -> Add package from git URL: ```text -https://github.com/wallstop/DxMessaging.git +https://github.com/Ambiguous-Interactive/DxMessaging.git ``` **Requirements:** Unity 2021.3+ | .NET Standard 2.1 | All render pipelines supported @@ -167,8 +167,8 @@ Registration cleanup is automatic. Messages are type-safe. - -> [Getting Started Guide](getting-started.md) (10 min) - Full explanation with examples - -> [Visual Guide](visual-guide.md) (5 min) - Pictures and analogies - **Try Real Examples** - - -> [Mini Combat sample](https://github.com/wallstop/DxMessaging/blob/master/Samples~/Mini%20Combat/README.md) - Working combat example - - -> [UI Buttons + Inspector sample](https://github.com/wallstop/DxMessaging/blob/master/Samples~/UI%20Buttons%20%2B%20Inspector/README.md) - See diagnostics in action + - -> [Mini Combat sample](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/Samples~/Mini%20Combat/README.md) - Working combat example + - -> [UI Buttons + Inspector sample](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/Samples~/UI%20Buttons%20%2B%20Inspector/README.md) - See diagnostics in action - **Go Deeper** - -> [Message Types](../concepts/message-types.md) (10 min) - When to use which type - -> [Common Patterns](../guides/patterns.md) (15 min) - Real-world solutions diff --git a/docs/guides/patterns.md b/docs/guides/patterns.md index c4308b4f..48b43204 100644 --- a/docs/guides/patterns.md +++ b/docs/guides/patterns.md @@ -1,6 +1,6 @@ # DxMessaging Patterns: Real-World Solutions -[Back to Index](../getting-started/index.md) | [Getting Started](../getting-started/getting-started.md) | [Message Types](../concepts/message-types.md) | [Samples](https://github.com/wallstop/DxMessaging/tree/master/Samples~) +[Back to Index](../getting-started/index.md) | [Getting Started](../getting-started/getting-started.md) | [Message Types](../concepts/message-types.md) | [Samples](https://github.com/Ambiguous-Interactive/DxMessaging/tree/master/Samples~) --- @@ -1064,8 +1064,8 @@ For SOA variables: ### Try Real Examples -- to [Mini Combat sample](https://github.com/wallstop/DxMessaging/blob/master/Samples~/Mini%20Combat/README.md) -- Working combat example -- to [UI Buttons + Inspector sample](https://github.com/wallstop/DxMessaging/blob/master/Samples~/UI%20Buttons%20%2B%20Inspector/README.md) -- Interactive diagnostics +- to [Mini Combat sample](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/Samples~/Mini%20Combat/README.md) -- Working combat example +- to [UI Buttons + Inspector sample](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/Samples~/UI%20Buttons%20%2B%20Inspector/README.md) -- Interactive diagnostics - to [End-to-End Example](../examples/end-to-end.md) -- Complete feature walkthrough ### Deep Dives diff --git a/docs/hooks.py b/docs/hooks.py index 6f4f572c..d7c1c766 100644 --- a/docs/hooks.py +++ b/docs/hooks.py @@ -17,7 +17,7 @@ from urllib.parse import quote # GitHub repository configuration -REPO_URL = "https://github.com/wallstop/DxMessaging" +REPO_URL = "https://github.com/Ambiguous-Interactive/DxMessaging" BRANCH = "master" # Patterns that indicate source file references (not doc-relative links) diff --git a/docs/images/DxMessaging-banner.svg b/docs/images/DxMessaging-banner.svg index 00f563ea..c0f0bcd1 100644 --- a/docs/images/DxMessaging-banner.svg +++ b/docs/images/DxMessaging-banner.svg @@ -1,27 +1,31 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 200" width="800" height="200" style="background:transparent" role="img" aria-label="DxMessaging - Synchronous Event Bus for Unity by Wallstop Studios"> - <title>DxMessaging - Synchronous Event Bus for Unity + + + DxMessaging: Unity messaging library + Unity messaging library for sending typed messages between game objects with zero-allocation dispatch. - - - - + + + + - + - - + + + - + - - + + @@ -29,91 +33,108 @@ - + + - + - + + + + - - - - - - - W + + + + + + + - + - DxMessaging - + DxMessaging + - Synchronous messaging. Zero allocations. Lifecycle-managed. - - - - - - - + Synchronous messaging. Zero allocations. Lifecycle managed. + + + + + + + + - šŸ“Ø - Messages - - - - šŸŽÆ - Targeted + ✨️ + Simple - - - šŸ“” - Broadcast + + + 🔁️ + Automatic - - - ⚔ - Zero-Alloc + + + 🛠️ + Dev-Friendly - - - šŸ”„ - Lifecycle + + + 🧪️ + 2600+ Tests - - - - + + + + + - - šŸŽ® Unity 2021.3+ + + 🎮️Unity 2021.3+ - - - - āœ… Zero Alloc + + + + ✅️Zero Alloc - - - - ⚔ High Perf + + + + ⚡️High Perf - + - - - v3.0.1 + + + v3.0.1 - - - - Wallstop Studios - - + + + Wallstop Studios diff --git a/docs/index.md b/docs/index.md index e65ebd73..7bc4631b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,7 +7,7 @@ description: High-performance type-safe messaging library for Unity **DxMessaging** is a high-performance, type-safe messaging library for Unity that provides a clean, decoupled communication pattern between game components. -**[Get Started](getting-started/index.md)** | [View on GitHub](https://github.com/wallstop/DxMessaging) +**[Get Started](getting-started/index.md)** | [View on GitHub](https://github.com/Ambiguous-Interactive/DxMessaging) ## Why DxMessaging? @@ -36,7 +36,7 @@ openupm add com.wallstop-studios.dxmessaging #### Or via Git URL ```text -https://github.com/wallstop/DxMessaging.git +https://github.com/Ambiguous-Interactive/DxMessaging.git ``` See the [Install Guide](getting-started/install.md) for all options including NPM scoped registries and local tarballs. diff --git a/docs/ops.meta b/docs/ops.meta new file mode 100644 index 00000000..b52d7eae --- /dev/null +++ b/docs/ops.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: eafff3b9288d1c3e183b1cb562177188 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/ops/ambiguous-release-migration.md b/docs/ops/ambiguous-release-migration.md new file mode 100644 index 00000000..3477eee1 --- /dev/null +++ b/docs/ops/ambiguous-release-migration.md @@ -0,0 +1,337 @@ +# Ambiguous Release Migration Operator Guide + +This guide tracks the human setup work behind the Ambiguous release migration +for DxMessaging. It is safe to commit because it contains only public +identifiers, checklist structure, and verification steps. + +Do not commit secrets, recovery codes, account screenshots, publisher account +identifiers, personal access tokens, one-time codes, private billing details, or +local execution notes. Use `npm run generate:ambiguous-release-runbook` for an +ignored local checklist only for non-sensitive execution notes, such as public +PR URLs, public release URLs, and dates when public verification was completed. + +## Public Identifiers + +| Surface | Public value | +| --------------------------------------- | ------------------------------------------------------------------------------------------- | +| GitHub repository | `Ambiguous-Interactive/DxMessaging` | +| Canonical repository URL | `https://github.com/Ambiguous-Interactive/DxMessaging` | +| GitHub Pages URL | `https://ambiguous-interactive.github.io/DxMessaging/` | +| Unity and npm package id | `com.wallstop-studios.dxmessaging` | +| Display name | `DxMessaging` | +| Minimum Unity version in `package.json` | `2021.3` | +| Release workflow | `.github/workflows/release.yml` | +| Documentation workflow | `.github/workflows/deploy-docs.yml` | +| npm package validation workflow | `.github/workflows/validate-npm-meta.yml` | +| Unity test workflow | `.github/workflows/unity-tests.yml` | +| Unity IL2CPP workflow | `.github/workflows/unity-il2cpp.yml` | +| Unity benchmark workflow | `.github/workflows/unity-benchmarks.yml` | +| Unity job concurrency group | `unity-pro-license` (single-seat license; `wallstop-organization-builds` reserved sentinel) | +| GitHub Pages environment | `github-pages` | +| OpenUPM package page | `https://openupm.com/packages/com.wallstop-studios.dxmessaging/` | + +## Transfer Inventory + +Before changing external settings, capture the public state that must survive +the migration. + +- [ ] Confirm `Ambiguous-Interactive/DxMessaging` is the intended canonical + repository slug and no target repository or fork network conflict blocks + the transfer. +- [ ] Confirm the default branch used for release and docs operations. Current + tracked links use `master`, while workflows also accept pushes to `main`. +- [ ] Confirm `package.json` still has `name` set to + `com.wallstop-studios.dxmessaging`, `displayName` set to `DxMessaging`, + and repository URL + `git+https://github.com/Ambiguous-Interactive/DxMessaging.git`. +- [ ] Record the latest public tag, latest GitHub Release, and latest npm and + OpenUPM package versions in the ignored local runbook, not in tracked + docs. +- [ ] Confirm maintainers who need post-transfer admin access are available for + GitHub, npm, OpenUPM, and Unity Publisher Portal actions. +- [ ] Confirm no release-critical workflow depends on a personal fork, personal + token, or old repository URL. + +## GitHub Repository Transfer + +Use GitHub repository settings for the transfer. GitHub documents that the +operator must have administrator access, the target owner must be valid, and the +target must not already have a repository with the same name or a fork in the +same network. + +- [ ] From the source repository, open **Settings** and use the repository + transfer control in the danger zone. +- [ ] Set the new owner to `Ambiguous-Interactive` and keep the repository name + `DxMessaging`. +- [ ] Read GitHub's transfer warnings before confirming. Pay specific attention + to Pages, protected branches, collaborators, issue assignments, and + Marketplace/action-name retirement warnings. +- [ ] After acceptance, open `https://github.com/Ambiguous-Interactive/DxMessaging` + directly and confirm the repository, issues, pull requests, tags, releases, + and Actions tabs are visible to maintainers. +- [ ] Confirm redirects from old public URLs are working, but do not keep old + slugs in tracked configuration or docs. +- [ ] Run `npm run validate:repo-identity` after tracked URL updates so stale + repository identity references are caught before release. + +Reference: [GitHub repository transfer documentation](https://docs.github.com/articles/about-repository-transfers). + +## Self-Hosted Unity Runner Setup + +Unity workflows target self-hosted Windows runners by labels only. No +dedicated runner group is provisioned; runners may live in the organization's +default runner group. Unity Pro is a single-seat license, so cross-workflow +serialization is provided by a shared `unity-pro-license` concurrency +group (`cancel-in-progress: false`), and within-workflow serialization by +`strategy.max-parallel: 1` on every Unity matrix. + +- Required runner labels on both Windows runners: `self-hosted`, `Windows`, + `RAM-64GB`. +- Additional speed marker applied only to `ELI-MACHINE`: `fast` (kept for + future opt-in hotfix dispatch; no job currently requests it). +- Every Unity-credential-using job declares the same job-level + `concurrency:` block: + + ```yaml + concurrency: + group: unity-pro-license + cancel-in-progress: false + ``` + + The legacy `wallstop-organization-builds` name remains a reserved + sentinel; the workflow validator hard-rejects it anywhere it appears + (workflow- or job-level, multi-line or scalar-shorthand form). + +- The three Unity matrix jobs (`unity-tests`, `il2cpp-tests`, `benchmarks`) + declare `strategy.max-parallel: 1` so matrix entries serialize internally + to the workflow run. Without `max-parallel: 1` the shared concurrency + group would evict matrix entries (the original incident pattern); the + validator enforces the combination. +- All four jobs declare the uniform `runs-on: [self-hosted, Windows, +RAM-64GB]` so either Windows machine can pick up any Unity job. The + `fast` label is no longer requested by any job. + +- [ ] In the `Ambiguous-Interactive` organization, open **Settings**, + **Actions**, then **Runners**. +- [ ] Confirm at least one online self-hosted Windows runner has the labels + `self-hosted`, `Windows`, and `RAM-64GB`. +- [ ] Confirm `ELI-MACHINE` retains its `fast` label for future opt-in use. +- [ ] Confirm each of `.github/workflows/unity-tests.yml`, + `.github/workflows/unity-il2cpp.yml`, + `.github/workflows/unity-benchmarks.yml`, and the `unity-checks` + job in `.github/workflows/release.yml` declares + `concurrency: { group: unity-pro-license, cancel-in-progress: false }` + and uses the static `runs-on: [self-hosted, Windows, RAM-64GB]`. +- [ ] Confirm the three matrix jobs (`unity-tests`, `il2cpp-tests`, + `benchmarks`) declare `strategy.max-parallel: 1`. `unity-checks` + has no matrix and therefore no `max-parallel`. +- [ ] Confirm the string `wallstop-organization-builds` does not appear + anywhere under `.github/workflows/*.yml` (it is reserved and the + validator hard-fails any reintroduction). +- [ ] Confirm `.github/workflows/stuck-job-watchdog.yml` is enabled. It + audits queued runs every 5 minutes and cancels and (where safe) + re-dispatches those that an idle runner could service, mitigating + the GitHub Actions dispatcher bug tracked at + . +- [ ] Confirm `.github/workflows/unstick-run.yml` is present for manual + one-click recovery of a single run id. Operators dispatch it from + the Actions tab with the stuck run's id when they cannot wait for + the watchdog cron, or when the watchdog is not yet on the default + branch (GitHub `schedule:` cron triggers fire only from the + default branch). +- [ ] Keep fork pull requests off self-hosted runners. The Unity workflows only + allow same-repository pull requests and protected branch pushes; do not + replace those guards with `pull_request_target`. +- [ ] Protect release tags before the first production release. The release + workflow also runs trusted Unity checks on `v*` tags, so `vX.Y.Z` tags + must be covered by GitHub rulesets or tag protection that require the + approved release process and reviewed release commit. + +Reference: [GitHub self-hosted runners documentation](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners). + +## GitHub Environments, Secrets, and Protections + +### Environments + +- [ ] Confirm the `github-pages` environment exists for + `.github/workflows/deploy-docs.yml`. +- [ ] Confirm `github-pages` allows deployments from the protected default + branch path used by the docs workflow. +- [ ] If a future npm publishing environment is added to `.github/workflows/release.yml`, + create the matching GitHub environment before adding it to npm Trusted + Publishing. The current tracked release workflow does not declare a + publish environment. +- [ ] If reviewers or wait timers are required by organization policy, configure + them in GitHub and record only the policy name in local notes. + +### Secrets + +The tracked workflows expose only secret names. Never write the values into a +tracked file or generated artifact. + +- [ ] Confirm Unity workflows can read the required secret names: + `UNITY_LICENSE`, `UNITY_SERIAL`, `UNITY_EMAIL`, and `UNITY_PASSWORD`. +- [ ] Confirm `.github/workflows/release.yml` does not require `NPM_TOKEN`; npm + publishing should use Trusted Publishing and OIDC. +- [ ] Confirm the release workflow grants `id-token: write` only to jobs that + need attestations or Trusted Publishing. +- [ ] Rotate any transfer-only token after migration. Prefer environment, + repository, or organization secrets only when OIDC is not supported. +- [ ] On self-hosted runners, treat environment secrets with the same care as + repository and organization secrets because the runner host is not a + clean hosted runner image. + +Reference: [GitHub environments documentation](https://docs.github.com/en/actions/reference/deployments-and-environments). + +### Branches and Tags + +- [ ] Protect the default branch used for release commits, currently `master` + unless maintainers intentionally migrate to `main`. +- [ ] Require pull request review before merging release changes. +- [ ] Require the checks that gate release readiness, at minimum script tests, + npm package validation, docs validation, repo identity validation, and the + Unity workflow checks that the organization expects before publishing. +- [ ] Keep force pushes and branch deletion disabled for protected branches. +- [ ] Restrict who can create or update `v*` release tags if GitHub rulesets are + available in the organization. +- [ ] Confirm release automation can create GitHub Releases and upload release + assets through `.github/workflows/release.yml`. + +## npm Ownership, Trusted Publishing, and Provenance + +The npm package name is `com.wallstop-studios.dxmessaging`. The release workflow +publishes the packed package from `.artifacts/release` with +`npm publish --provenance --access public` through npm `^11.5.1`. + +- [ ] In npm, confirm the package exists under the expected owner or maintainer + set and that active maintainers have two-factor authentication enabled. +- [ ] Remove maintainers who were only needed for transfer work. +- [ ] Configure npm Trusted Publishing for package + `com.wallstop-studios.dxmessaging` with: + `repository owner = Ambiguous-Interactive`, `repository name = DxMessaging`, + `workflow filename = release.yml`, and `environment = none` unless the + release workflow later declares a publish environment. +- [ ] Confirm the `publish` job in `.github/workflows/release.yml` has + `id-token: write` and does not read `NPM_TOKEN`. +- [ ] Confirm the npm package provenance view links back to + `Ambiguous-Interactive/DxMessaging` after the first Trusted Publishing + release. +- [ ] Run `npm run validate:npm-meta` before publishing to verify Unity `.meta` + files and package contents. +- [ ] Use `npm pack --json --dry-run --ignore-scripts` or + `npm run validate:npm-meta` for a non-publishing package check. + +References: + +- [npm Trusted Publishing documentation](https://docs.npmjs.com/trusted-publishers) +- [npm provenance documentation](https://docs.npmjs.com/generating-provenance-statements) + +## Semver Tag Release Flow + +`.github/workflows/release.yml` runs only on tags matching `vX.Y.Z`. It validates +that the tag exactly matches `package.json` version before packing, attesting, +creating or updating the GitHub Release, and publishing to npm. + +- [ ] Update `package.json` `version` to the intended public version. +- [ ] Update `CHANGELOG.md` for the user-facing release. +- [ ] Run `npm run test:scripts`, `npm run test:unity-contracts`, + `npm run validate:npm-meta`, `npm run validate:llms-txt`, + `npm run validate:repo-identity`, and `npm run validate:all`. +- [ ] Merge the release commit to the protected default branch. +- [ ] Create the release tag from the exact release commit: + `git tag -s vX.Y.Z` when signing is available, or the repository-approved + annotated tag method when signing is not available. +- [ ] Push only the intended release tag: `git push origin vX.Y.Z`. +- [ ] Confirm `.github/workflows/release.yml` starts on the tag and that the + `verify-tag`, `validate`, `unity-checks`, and `publish` jobs complete. +- [ ] Confirm the GitHub Release includes the `.tgz` package and `.sha256` + artifact. +- [ ] Confirm npm shows the new `com.wallstop-studios.dxmessaging` version with + provenance linked to the release workflow. + +## OpenUPM Metadata Update + +OpenUPM uses package metadata YAML and monitors versioned Git tags. The package +page for this package is +`https://openupm.com/packages/com.wallstop-studios.dxmessaging/`. + +- [ ] Find the OpenUPM metadata entry for `com.wallstop-studios.dxmessaging`. +- [ ] Confirm its `repoUrl` is `https://github.com/Ambiguous-Interactive/DxMessaging`. +- [ ] Confirm the metadata still points to the root package path if `package.json` + remains at the repository root. +- [ ] Confirm metadata values match `package.json`: package id + `com.wallstop-studios.dxmessaging`, display name `DxMessaging`, license + `MIT`, and Unity version `2021.3`. +- [ ] If any metadata changed, submit an OpenUPM metadata pull request using the + package name in the title, then link the public PR from local operator + notes only. +- [ ] After the PR merges, wait for OpenUPM indexing and confirm the package + page reports the latest `vX.Y.Z` tag without build issues. + +Reference: [OpenUPM package metadata documentation](https://openupm.com/docs/adding-upm-package.html). + +## Unity Asset Store UPM Onboarding + +Unity Asset Store UPM publishing is separate from npm and OpenUPM. It uses Unity +Publisher Portal enrollment, publisher verification, package validation, upload, +review, and Asset Store distribution. + +- [ ] Confirm the publisher account is enrolled or eligible for UPM publishing + before promising Asset Store availability. +- [ ] Confirm the namespace requested in Unity Publisher Portal is compatible + with package id `com.wallstop-studios.dxmessaging`. +- [ ] Validate the UPM package structure from the repository root, including + `package.json`, `Runtime/**`, `Editor/**`, `Samples~/**`, `README.md`, + `CHANGELOG.md`, `LICENSE.md`, and all required `.meta` files. +- [ ] Confirm public links in `package.json` resolve: + documentation `https://ambiguous-interactive.github.io/DxMessaging/`, + changelog `https://raw.githubusercontent.com/Ambiguous-Interactive/DxMessaging/master/CHANGELOG.md`, + and license `https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/LICENSE.md`. +- [ ] Use Unity's current UPM publishing tools or Publisher Portal upload flow + for validation and upload. Do not commit upload receipts, portal + screenshots, package signing output, or account-specific identifiers. +- [ ] Treat Asset Store package signing as Unity-controlled review output. Do + not modify package contents after validation without re-running validation + and upload. +- [ ] Record only the public listing URL in tracked follow-up issues or release + notes. + +References: + +- [Unity UPM publishing overview](https://support.unity.com/hc/en-us/articles/46563578188180-What-is-the-UPM-publishing-workflow-and-how-is-it-different) +- [Unity Asset Store UPM publishing page](https://assetstore.unity.com/publishing/upm-publishing) + +## Post-Transfer Verification + +Run these checks after the transfer and again after the first tagged release. + +- [ ] Fresh clone succeeds: + `git clone https://github.com/Ambiguous-Interactive/DxMessaging.git`. +- [ ] `git remote -v` uses `https://github.com/Ambiguous-Interactive/DxMessaging.git` + or the matching SSH remote for the same slug. +- [ ] `git fetch --tags --prune` returns the expected `vX.Y.Z` release tags. +- [ ] `npm run validate:repo-identity` passes. +- [ ] `npm run validate:npm-meta` passes. +- [ ] `npm run test:scripts` passes. +- [ ] `.github/workflows/deploy-docs.yml` deploys to + `https://ambiguous-interactive.github.io/DxMessaging/`. +- [ ] `.github/workflows/unity-tests.yml` can dispatch to self-hosted runners + labeled `self-hosted`, `Windows`, and `RAM-64GB` for same-repository + pull requests or protected branch pushes. Either Windows machine can + pick up any Unity job; cross-workflow license serialization is + enforced by the shared `unity-pro-license` concurrency group + (`cancel-in-progress: false`), and within-workflow matrix + serialization by `strategy.max-parallel: 1`. The legacy + `wallstop-organization-builds` group must never reappear in any + workflow. +- [ ] `.github/workflows/release.yml` succeeds for a real semver tag and does + not require `NPM_TOKEN`. +- [ ] npm, OpenUPM, GitHub Releases, and GitHub Pages all point to + `Ambiguous-Interactive/DxMessaging`. +- [ ] If Unity has approved and published the Asset Store UPM listing, confirm + the public listing points to `Ambiguous-Interactive/DxMessaging`. If Unity + approval is still pending, keep the approval state in Unity Publisher + Portal or the approved organization password manager instead of treating + the missing listing as a release blocker. +- [ ] Maintainers can recover GitHub, npm, OpenUPM, and Unity Publisher Portal + access without relying on private material committed to the repository. diff --git a/docs/ops/ambiguous-release-migration.md.meta b/docs/ops/ambiguous-release-migration.md.meta new file mode 100644 index 00000000..c1dbb9c5 --- /dev/null +++ b/docs/ops/ambiguous-release-migration.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 7fd77a8ad36180e7acb26cb1163695a8 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/ops/ci-and-github-settings.md b/docs/ops/ci-and-github-settings.md new file mode 100644 index 00000000..b0497f6a --- /dev/null +++ b/docs/ops/ci-and-github-settings.md @@ -0,0 +1,308 @@ +--- +title: CI and GitHub Settings +description: Runner, environment, secret, and branch protection setup for trusted releases +--- + +# CI and GitHub Settings + +This repository splits trust domains: + +- Licensed Unity jobs run only on Ambiguous self-hosted Windows runners. +- npm publishing runs on GitHub-hosted Ubuntu with OIDC Trusted Publishing. + +## Self-Hosted Unity Runners + +Licensed Unity jobs target self-hosted Windows runners by labels only. No +custom runner group is required; runners may live in the organization's +default runner group. + +- Labels (all required on each Unity runner): + - `self-hosted` + - `Windows` + - `RAM-64GB` +- Speed marker applied only to `ELI-MACHINE`: + - `fast` + +Unity Pro is a single-seat license: only one machine can be activated at +a time. All four Unity-credential-using jobs (`unity-tests`, +`il2cpp-tests`, `benchmarks`, and the `unity-checks` job in +`release.yml`) declare: + +```yaml +concurrency: + group: unity-pro-license + cancel-in-progress: false +``` + +so two licensed jobs cannot run simultaneously across the two Windows +machines and fight for the license. The previous single-slot +`wallstop-organization-builds` group is a reserved sentinel that the +workflow validator hard-rejects anywhere it appears; the new +`unity-pro-license` group serves the same serialization purpose under a +non-overloaded name. + +The three Unity matrix jobs (`unity-tests`, `il2cpp-tests`, `benchmarks`) +additionally declare `strategy.max-parallel: 1` so matrix entries serialize +internally to the workflow run and do not compete for the single +concurrency slot (the validator's `findMatrixConcurrencyEvictionViolations` +check enforces this combination). + +Per-runner Unity-cache safety is provided by each runner agent's exclusive +workspace - a single self-hosted agent only ever runs one job at a time, so +`.unity-test-project/Library` directories cannot collide. + +Runner routing is uniform across all four Unity-credential-using jobs: + +```yaml +runs-on: [self-hosted, Windows, RAM-64GB] +``` + +Both ELI-MACHINE and DAD-MACHINE are eligible to pick up any Unity job; +the `fast` label remains on ELI-MACHINE only for future opt-in hotfix +dispatch but no job requests it today. + +Lightweight matrix configuration jobs run on `ubuntu-latest` and remain +parallelizable. + +### Workflow-level vs job-level concurrency interaction + +The Unity workflows declare workflow-level `concurrency: { group: +${{ github.workflow }}-${{ github.ref }}, cancel-in-progress: true }` +so rapid same-branch pushes supersede the older workflow run. The +licensed Unity jobs inside each workflow separately declare a job-level +`concurrency: { group: unity-pro-license, cancel-in-progress: false }`. +The two groups operate at different scopes and do not interact directly. + +On rapid same-branch pushes, the workflow-level group cancels the +_older_ workflow run; any of its jobs that have not yet started will +never start, and any running step is sent SIGTERM. However, the +job-level `cancel-in-progress: false` on the license group prevents the +license-holding job itself from being preempted, so a job that has +already entered the `unity-pro-license` slot keeps running until it +completes; the new workflow's `unity-checks`/`unity-tests`/etc. job +then waits on the license slot until the old job releases it. This is +the intended behavior - it protects in-flight Unity activation and +asset import state from being torn down mid-run - but operators should +expect a brief gap between "newer push" and "newer Unity job actually +starts" while the older job drains. + +## Stuck-Job Recovery + +A known GitHub Actions dispatcher bug ([Community Discussion #186811](https://github.com/orgs/community/discussions/186811)) +causes self-hosted runners to report Online/Idle while `runner_id` stays +at 0 for 7+ minutes, leaving a queued job indefinitely stuck even when an +idle runner's labels are a superset of the job's requested labels. + +Two workflows together provide recovery: an auto-watchdog that audits +the queue on a cron schedule, and a manual one-click recovery workflow +for a single run id. + +### Auto-watchdog: `.github/workflows/stuck-job-watchdog.yml` + +Runs every 5 minutes on `ubuntu-latest`, lists queued workflow runs +older than 5 minutes (`MIN_QUEUE_AGE_SECONDS=300`), fetches the org +runner inventory (falling back to repo runners on 403), and identifies +the subset that are genuinely dispatcher-stuck. A run is considered +stuck only when ALL of the following hold: the run is `status: queued`, +no job in the run is `in_progress` (a run with an in-progress job is by +definition holding/using a runner, not dispatcher-stuck), at least one +job is queued, at least one idle runner's labels satisfy a queued job's +label requirements, the run's workflow file is not in the exclusion +list, and the run is not the watchdog's own run. + +Worst-case time-to-recover under the tightened thresholds is roughly +10 minutes (5-min cron interval + 300s queue-age threshold + cancel / +redispatch latency). + +For each genuinely-stuck run the watchdog `gh run cancel`s the run +(the documented recovery for a queued-only run; `gh run rerun --failed` +cannot rerun a run that never reached `failed` status - see cli/cli +issue #9221). For runs triggered by `push`, `schedule`, or +`workflow_dispatch` on a workflow that declares `workflow_dispatch:` +the watchdog then re-dispatches the workflow on the same `ref` via the +REST API. For `pull_request`-triggered runs, the watchdog only cancels +and writes a clear `GITHUB_STEP_SUMMARY` instruction asking the +operator to click "Re-run all jobs" in the GitHub UI - there is no +safe API path to re-trigger a `pull_request` run without pushing a +commit, so the watchdog does not attempt it. + +`release.yml` is excluded by default (`EXCLUDED_WORKFLOW_FILES= +("release.yml")`) so a spurious cancel cannot double-publish or break +attestation. Additional exclusions may be set via the +`WATCHDOG_EXCLUDED_WORKFLOWS` repository variable (whitespace-separated +list of workflow filenames). + +Cancel attempts are capped at 2 per run-id per 24 hours via a small +state file on the `watchdog-state` orphan branch. + +GitHub `schedule:` cron triggers only fire from the repository default +branch. Until `stuck-job-watchdog.yml` is on `master`, the cron is +INACTIVE and only manual `workflow_dispatch` from the Actions tab +works. After merge to `master` the cron resumes automatically and runs +every 5 minutes. + +### Manual one-click recovery: `.github/workflows/unstick-run.yml` + +`workflow_dispatch`-only workflow that targets a single explicit run id +rather than auto-scanning. Use this when: + +1. The watchdog is not yet on the default branch (see the cron caveat + above) and a job is stuck right now. +1. You want immediate recovery and do not want to wait for the next + cron tick or the queue-age threshold. + +Inputs: + +- `run_id` (required, string of digits): the GitHub Actions run id to + recover. Find it in the URL of the stuck run. +- `force_redispatch` (optional, boolean, default `false`): when true, + attempt REST `actions/workflows/{id}/dispatches` after cancel. Only + valid for `push` / `schedule` / `workflow_dispatch` events on a + branch where the workflow file declares `workflow_dispatch:`. +- `bypass_exclusion` (optional, boolean, default `false`): operate on a + run whose workflow file is in the exclusion list (e.g. `release.yml`). + Use deliberately. + +Behavior mirrors the watchdog's per-run logic: validates the run id is +a positive integer, confirms the run exists and is `queued` and older +than `MIN_AGE_SECONDS=30` (guards against accidental cancellation of +fresh runs), honors the same exclusion list unless `bypass_exclusion` +is true, then `gh run cancel`s and optionally REST-redispatches. It +does NOT touch the watchdog state branch and does NOT count against the +watchdog's per-run cancel cap. + +To invoke: repo Actions tab -> "Unstick Run" workflow -> "Run workflow" +dropdown -> select branch -> enter the run id. + +## Unity Workflows + +Active Unity workflows: + +- `.github/workflows/unity-tests.yml` +- `.github/workflows/unity-il2cpp.yml` +- `.github/workflows/unity-benchmarks.yml` +- `.github/workflows/release.yml` (`unity-checks` job) + +Unity test matrix: + +- `2021.3.45f1` +- `2022.3.45f1` +- `6000.0.32f1` +- `editmode` +- `playmode` + +IL2CPP and release checks default to `2022.3.45f1`. Benchmarks run on schedule +or manual dispatch only. + +## Licensed Job Guardrails + +Licensed Unity jobs intentionally skip: + +- pull requests from forks +- pushes to unprotected branches + +This is expected. Fork PRs should still run GitHub-hosted checks that do not +need Unity licenses or self-hosted runners. + +The workflows must not use `pull_request_target` to check out untrusted fork +code. + +## Required Unity Secrets + +Set secret names without documenting values: + +- `UNITY_LICENSE` +- `UNITY_SERIAL` +- `UNITY_EMAIL` +- `UNITY_PASSWORD` + +Personal/GameCI license flow uses `UNITY_LICENSE` plus account credentials. +Professional serial activation uses `UNITY_SERIAL` plus account credentials. +Do not record secret existence, rotation status, or account credential state in +tracked files or the local ignored runbook. Keep that security status in GitHub +environment settings or the approved organization password manager. + +## GitHub Environments + +Use environments if the organization wants release approvals. The current +workflow can run without an environment, but npm Trusted Publishing may be +configured with one. If an environment is used, configure npm with the exact +environment name. + +For each environment, verify: + +1. Required reviewers. +1. Wait timers. +1. Deployment branch rules. +1. Environment secret access through GitHub settings. +1. Whether the release workflow can request the environment from tag builds. + +## Branch and Tag Protection + +Protect the default branch and release tags: + +1. Require pull requests for `master` and `main` if both are active. +1. Require status checks for script tests, docs checks, package metadata, and + workflow validation. +1. Require signed tags or limit tag creation to release maintainers if the + organization supports it. +1. Protect `v*` tags from deletion or force updates. +1. Confirm release maintainers can create `vX.Y.Z` tags through the intended + process. + +## Cache Contract + +Unity Library caches must include: + +- `.unity-test-project/Packages/manifest.json` +- `.unity-test-project/Packages/packages-lock.json` +- `.unity-test-project/ProjectSettings/ProjectVersion.txt` + +Do not add broad `restore-keys` for Unity Library caches. + +## Verification + +Run: + +```bash +npm run test:unity-contracts +node scripts/validate-workflows.js +``` + +The validator hard-fails if any workflow reintroduces the reserved +`wallstop-organization-builds` group name (workflow-level or job-level, in +multi-line mapping, inline mapping, or scalar-shorthand form). + +Workflow-shape contract checklist: + +1. Confirm each of the four Unity-credential-using jobs (`unity-tests`, + `il2cpp-tests`, `benchmarks`, `unity-checks`) declares the job-level + `concurrency:` block with `group: unity-pro-license` and + `cancel-in-progress: false`. +1. Confirm the three Unity matrix jobs (`unity-tests`, `il2cpp-tests`, + `benchmarks`) declare `strategy.max-parallel: 1`. The `unity-checks` + release job has no matrix and therefore no `max-parallel`. +1. Confirm each of the four jobs declares the uniform static label set + `runs-on: [self-hosted, Windows, RAM-64GB]` so either Windows machine + can pick up any Unity job. +1. Confirm `wallstop-organization-builds` does not appear anywhere under + `.github/workflows/*.yml` (sentinel guard). +1. Confirm `.github/workflows/stuck-job-watchdog.yml` exists and is + enabled (queue auto-recovery for the GitHub Actions dispatcher bug). + Once merged to the default branch, the 5-minute cron fires + automatically. +1. Confirm `.github/workflows/unstick-run.yml` exists for manual + one-click recovery of a single run id (operator dispatches it from + the Actions tab with the stuck run's id). The workflow is + `workflow_dispatch`-only -- there is no cron, push, or PR trigger. + (See "Stuck-Job Recovery" subsection above for the operator runbook.) + +Trigger safe workflows after transfer: + +1. `workflow_dispatch` for Unity Tests with one Unity version and one mode. +1. `workflow_dispatch` for Unity IL2CPP. +1. `workflow_dispatch` for Unity Benchmarks if runner capacity allows it. +1. A same-repository pull request to confirm licensed checks land on a + Windows runner and serialize correctly (only one matrix entry running + at a time, no eviction messages). +1. A fork pull request dry run to confirm licensed checks skip. diff --git a/docs/ops/ci-and-github-settings.md.meta b/docs/ops/ci-and-github-settings.md.meta new file mode 100644 index 00000000..f9fd4594 --- /dev/null +++ b/docs/ops/ci-and-github-settings.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 93ac47b29a7346d8994f3a8915fc82ec +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/ops/github-transfer.md b/docs/ops/github-transfer.md new file mode 100644 index 00000000..6ff54e48 --- /dev/null +++ b/docs/ops/github-transfer.md @@ -0,0 +1,93 @@ +--- +title: GitHub Transfer +description: Manual steps for moving DxMessaging to Ambiguous-Interactive +--- + +# GitHub Transfer + +Use this checklist when transferring or verifying the repository under +`Ambiguous-Interactive/DxMessaging`. + +## Before Transfer + +1. Confirm the target organization has an owner who can accept transferred + repositories. +1. Confirm the target repository name remains `DxMessaging`. +1. Confirm the package ID remains `com.wallstop-studios.dxmessaging`. +1. Record public state in the local ignored runbook: + - current default branch + - latest version tag + - required workflow names + - public package pages +1. Do not record personal access tokens, recovery codes, private emails, + screenshots, or publisher account IDs. + +## Transfer + +1. Transfer the repository through GitHub repository settings. +1. Accept the transfer in the `Ambiguous-Interactive` organization. +1. Confirm the repository URL is + `https://github.com/Ambiguous-Interactive/DxMessaging`. +1. Confirm existing tags and GitHub Releases are still visible. +1. Confirm old links redirect, but do not rely on redirects in tracked files. + +GitHub states that webhooks, services, secrets, and deploy keys remain +associated with a transferred repository. Treat that as a starting point, not a +verification result. Recheck every automation binding after the transfer. + +## Repository Identity Surfaces + +After transfer, verify each surface points at the canonical repository: + +- `package.json`: + - `documentationUrl` + - `changelogUrl` + - `licensesUrl` + - `repository.url` + - `bugs.url` + - `homepage` +- `mkdocs.yml`: + - `site_url` + - `repo_name` + - `repo_url` + - `pymdownx.magiclink.user` + - `pymdownx.magiclink.repo` +- README badges and install URLs +- docs source links and GitHub sample links +- `.github/workflows/release-drafter.yml` repository guard +- `.github/release-drafter.yml` tag template +- `.github/dependabot.yml` assignees, reviewers, and ownership routing +- `scripts/update-llms-txt.js` and generated `llms.txt` +- `scripts/wiki/generate-wiki-sidebar.js` +- OpenUPM metadata +- npm Trusted Publishing binding + +Run: + +```bash +npm run validate:repo-identity +npm run validate:llms-txt +``` + +## Local Git Remotes + +Update local remotes after transfer: + +```bash +git remote set-url origin git@github.com:Ambiguous-Interactive/DxMessaging.git +git fetch --all --tags --prune +git remote -v +``` + +Use HTTPS instead of SSH if that is the maintainer's normal GitHub setup. + +## Failure Modes + +- GitHub Pages still publishes from the old organization URL. +- README badges point at the old repository. +- npm Trusted Publishing remains bound to the old repository. +- OpenUPM metadata still indexes the old repository. +- Dependabot still assigns or requests review from old-owner accounts. +- Release Drafter creates unprefixed tags while release workflow expects + `vX.Y.Z`. +- Maintainers rely on old redirects and miss stale links in package metadata. diff --git a/docs/ops/github-transfer.md.meta b/docs/ops/github-transfer.md.meta new file mode 100644 index 00000000..c6e410a8 --- /dev/null +++ b/docs/ops/github-transfer.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b86fd890d7bb4ab0a7b9fdbd06c07979 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/ops/index.md b/docs/ops/index.md new file mode 100644 index 00000000..9c63b725 --- /dev/null +++ b/docs/ops/index.md @@ -0,0 +1,14 @@ +# Operations + +Operational documents in this section describe repository, packaging, and +release setup that has to be performed by a human maintainer in external +systems. + +- [Ambiguous release migration](ambiguous-release-migration.md) +- [Release operations](release-operations.md) +- [GitHub transfer](github-transfer.md) +- [CI and GitHub settings](ci-and-github-settings.md) +- [npm release publishing](npm-release-publishing.md) +- [OpenUPM metadata](openupm-metadata.md) +- [Unity Asset Store UPM](unity-asset-store-upm.md) +- [Post-transfer verification](post-transfer-verification.md) diff --git a/docs/ops/index.md.meta b/docs/ops/index.md.meta new file mode 100644 index 00000000..e72c3b5c --- /dev/null +++ b/docs/ops/index.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: ec389f298247b2d2508fe9305a6dc07c +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/ops/npm-release-publishing.md b/docs/ops/npm-release-publishing.md new file mode 100644 index 00000000..606d7a00 --- /dev/null +++ b/docs/ops/npm-release-publishing.md @@ -0,0 +1,112 @@ +--- +title: npm Release Publishing +description: Manual setup for npm Trusted Publishing and tag-driven releases +--- + +# npm Release Publishing + +The package name is `com.wallstop-studios.dxmessaging`. + +The release workflow publishes from GitHub Actions using npm Trusted Publishing. +There is no `NPM_TOKEN` secret. Do not add one unless the release model is +changed and reviewed. + +## npm Package Access + +In npm, verify: + +1. The package exists and the current maintainers are correct. +1. Two-factor policy matches organization policy. +1. No stale maintainers remain from the transfer. +1. Package visibility is public. +1. Provenance is visible for versions published from GitHub Actions. + +Keep only non-sensitive verification notes in the local ignored runbook, such +as the public package URL and the date access was checked. Keep maintainer +account details, private npm account notes, recovery codes, tokens, and other +private account metadata in the provider console or approved organization +password manager. + +## Trusted Publishing Binding + +Configure npm Trusted Publishing for: + +- GitHub organization: `Ambiguous-Interactive` +- GitHub repository: `DxMessaging` +- Workflow: `.github/workflows/release.yml` +- Environment: only if the GitHub release job uses one + +Trusted Publishing uses OIDC. npm's current docs require an npm CLI that +supports trusted publishing; this workflow invokes `npm@^11.5.1` for publish. + +## Release Trigger + +Release only by pushing a strict semver tag that points at the reviewed release +commit. Use a signed tag when signing is available, or the repository-approved +annotated tag fallback when signing is not available: + +```bash +git checkout +git tag -s v3.0.2 + +# Approved fallback only when signed tags are unavailable: +git tag -a v3.0.2 -m "Release v3.0.2" +git push origin v3.0.2 +``` + +Before tagging, `package.json.version` must be `3.0.2`. The workflow rejects: + +- `3.0.2` +- `v3.0.2-rc.1` +- `v3.0.2` when `package.json.version` is still `3.0.1` + +There is no manual release dispatch. + +## Release Gates + +The workflow runs these checks before publishing: + +- `npm run test:scripts` +- `npm run test:unity-contracts` +- `npm run validate:npm-meta` +- `npm run validate:llms-txt` +- `npm run validate:repo-identity` +- `npm run validate:all` +- trusted Unity editmode release check on the Ambiguous Windows runner + +Run the same commands locally from a clean tracked state before tagging. If new +files are untracked, `validate:untracked-policy` fails by design. + +## Artifacts + +The release workflow creates: + +- npm `.tgz` +- `.sha256` checksum +- GitHub artifact attestation for the `.tgz` +- GitHub Release assets containing the `.tgz` and checksum +- npm package version published with provenance + +It does not create a `.unitypackage`. + +## Release Drafter + +Release Drafter creates draft release notes from pull requests and changelog +content. The tag template is `v$RESOLVED_VERSION`, matching the release +workflow. + +Current `release.yml` writes minimal generated release notes during publish. +If maintainers want rich Release Drafter notes to remain, copy the draft notes +into `CHANGELOG.md` or update the release workflow before the first production +release under Ambiguous. + +## Failure Modes + +- npm Trusted Publishing still points at the old GitHub repository. +- npm Trusted Publishing is configured for a GitHub environment that the + workflow does not use. +- A maintainer adds `NPM_TOKEN`, bypassing the OIDC model. +- The GitHub Release step fails after npm publish. The workflow creates or + updates the GitHub Release before npm publish to reduce that risk. +- Release assets are confused with Unity Asset Store uploads. The release + assets are npm tarballs, not `.unitypackage` files. diff --git a/docs/ops/npm-release-publishing.md.meta b/docs/ops/npm-release-publishing.md.meta new file mode 100644 index 00000000..d59b10c5 --- /dev/null +++ b/docs/ops/npm-release-publishing.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: e8c9f43fbb1746a18bc6e0ca14e9116a +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/ops/openupm-metadata.md b/docs/ops/openupm-metadata.md new file mode 100644 index 00000000..46f74d95 --- /dev/null +++ b/docs/ops/openupm-metadata.md @@ -0,0 +1,60 @@ +--- +title: OpenUPM Metadata +description: Manual checklist for OpenUPM package metadata after repository transfer +--- + +# OpenUPM Metadata + +OpenUPM indexes Git tags and package metadata to build Unity Package Manager +versions. DxMessaging must continue to publish as: + +- Package ID: `com.wallstop-studios.dxmessaging` +- Repository URL: `https://github.com/Ambiguous-Interactive/DxMessaging.git` + +## Metadata PR + +OpenUPM package metadata lives in the `openupm/openupm` repository. Use the +OpenUPM package add form or edit the package YAML directly. + +Verify the metadata includes: + +- `name: com.wallstop-studios.dxmessaging` +- display name `DxMessaging` +- repository URL under `Ambiguous-Interactive/DxMessaging` +- correct license +- supported Unity version matching `package.json` +- root package layout + +Open a PR if the metadata still points at the old repository. Use a public PR +description only. Keep only non-sensitive verification notes in the local +ignored runbook, such as the public OpenUPM PR URL. Keep npm account notes, +GitHub account notes, private review status, tokens, recovery codes, and other +private account metadata in the relevant provider console or approved +organization password manager. + +## Tag Requirements + +OpenUPM builds from version tags. The release process uses strict `vX.Y.Z` +tags. Confirm OpenUPM recognizes the next pushed tag after the metadata update. + +## Verification + +After the PR merges and the next release tag is pushed: + +1. Open `https://openupm.com/packages/com.wallstop-studios.dxmessaging/`. +1. Confirm the latest version matches `package.json.version`. +1. Confirm the source repository is `Ambiguous-Interactive/DxMessaging`. +1. Confirm version history includes the `vX.Y.Z` tag. +1. Install in a clean Unity project: + +```bash +openupm add com.wallstop-studios.dxmessaging +``` + +## Failure Modes + +- Metadata still points at the old repository. +- The package page updates but version history does not include the new tag. +- OpenUPM cannot detect `package.json` at the repository root. +- README or package metadata links use the old documentation URL. +- A release tag exists but does not match `package.json.version`. diff --git a/docs/ops/openupm-metadata.md.meta b/docs/ops/openupm-metadata.md.meta new file mode 100644 index 00000000..fa2d75af --- /dev/null +++ b/docs/ops/openupm-metadata.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 8704c1919d49412e94ed3a166a2e07ce +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/ops/post-transfer-verification.md b/docs/ops/post-transfer-verification.md new file mode 100644 index 00000000..fbb8f1ad --- /dev/null +++ b/docs/ops/post-transfer-verification.md @@ -0,0 +1,86 @@ +--- +title: Post-Transfer Verification +description: End-to-end checklist after moving repository and release ownership +--- + +# Post-Transfer Verification + +Run this after GitHub transfer, npm Trusted Publishing setup, OpenUPM metadata +updates, and Unity Asset Store account checks. + +## Repository + +1. Clone from `git@github.com:Ambiguous-Interactive/DxMessaging.git` or the + HTTPS equivalent. +1. Run `git fetch --all --tags --prune`. +1. Confirm default branch, tags, release drafts, and protected branches. +1. Confirm maintainers can open and review pull requests. +1. Confirm stale links are gone: + +```bash +npm run validate:repo-identity +``` + +## Validation + +Run from a clean tracked state: + +```bash +npm run test:scripts +npm run test:unity-contracts +npm run validate:npm-meta +npm run validate:llms-txt +npm run validate:all +node scripts/validate-workflows.js +``` + +If `validate:untracked-policy` fails, either commit intended files or add a +documented ignore rule. Do not ignore files that should be part of the release +change. + +## CI + +1. Trigger Unity Tests manually for one Unity version and one mode. +1. Trigger Unity IL2CPP manually. +1. Trigger Unity Benchmarks only when runner capacity is available. +1. Open a same-repository pull request and confirm licensed Unity checks run. +1. Open or simulate a fork pull request and confirm licensed Unity checks skip. +1. Confirm GitHub-hosted checks still run for fork PRs. + +## Release Dry Run + +There is no release dry-run workflow. Use local validation before tagging: + +```bash +npm run validate:npm-meta +npm pack --json --dry-run --ignore-scripts +``` + +Do not push a real `vX.Y.Z` tag until npm Trusted Publishing, runner access, +GitHub Release permissions, and OpenUPM metadata are all verified. + +## Public Distribution + +After the first Ambiguous release: + +1. GitHub Release contains `.tgz` and `.sha256` assets. +1. npm page shows the new version and provenance. +1. OpenUPM page shows the new version. +1. Git URL install works from a clean Unity project. +1. npm scoped registry install works from a clean Unity project. +1. Documentation site resolves at + `https://ambiguous-interactive.github.io/DxMessaging/`. +1. README badges point at `Ambiguous-Interactive/DxMessaging`. +1. Unity Asset Store public listing URL is recorded locally, if applicable. + +## Local Runbook Closeout + +In `.operator-runbooks/ambiguous-release-setup.md`, record only: + +- public URLs +- public PR or issue links +- dates when public verification was completed +- non-sensitive next actions + +Do not record secrets, account screenshots, publisher identifiers, recovery +codes, private reviewer messages, or private account metadata. diff --git a/docs/ops/post-transfer-verification.md.meta b/docs/ops/post-transfer-verification.md.meta new file mode 100644 index 00000000..51511d22 --- /dev/null +++ b/docs/ops/post-transfer-verification.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 13df6cda1ef144e78823a58b0664b855 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/ops/release-operations.md b/docs/ops/release-operations.md new file mode 100644 index 00000000..fa547449 --- /dev/null +++ b/docs/ops/release-operations.md @@ -0,0 +1,114 @@ +--- +title: Release Operations +description: Operator checklist for repository transfer, trusted releases, OpenUPM, and Unity Asset Store onboarding +--- + +# Release Operations + +This section is for maintainers doing account, repository, registry, and store +work for DxMessaging. It is not user-facing package documentation. Keep only +non-sensitive execution notes in `.operator-runbooks/`; keep private account, +security, publisher, and approval status in the provider console or approved +organization password manager. + +Canonical public identifiers: + +- GitHub repository: `Ambiguous-Interactive/DxMessaging` +- Package ID: `com.wallstop-studios.dxmessaging` +- Documentation site: `https://ambiguous-interactive.github.io/DxMessaging/` +- Release workflow: `.github/workflows/release.yml` +- Unity workflow concurrency group: every Unity-credential-using job + shares `concurrency.group: unity-pro-license` with + `cancel-in-progress: false` (single-seat Unity Pro license; only one + job may hold the license at a time). The three matrix jobs + (`unity-tests`, `il2cpp-tests`, `benchmarks`) additionally declare + `strategy.max-parallel: 1` so matrix entries serialize internally and + do not compete for the shared license slot (which would otherwise + evict each other under GitHub's 1-running + 1-pending limit). The + legacy name `wallstop-organization-builds` remains a reserved sentinel + that the validator hard-rejects anywhere in `.github/workflows/*.yml`. +- Unity runner labels: uniform static `runs-on: [self-hosted, Windows, +RAM-64GB]` across all four Unity-credential-using jobs, so either + ELI-MACHINE or DAD-MACHINE can pick up any Unity job. The `fast` + marker remains on ELI-MACHINE for a future opt-in hotfix dispatch but + no currently-active workflow requests it. +- Stuck-job watchdog: `.github/workflows/stuck-job-watchdog.yml` runs + every 5 minutes to detect and recover from the known GitHub Actions + self-hosted dispatcher bug (Community Discussion #186811) where a + queued run never receives an Online/Idle runner. The watchdog + excludes `release.yml` from auto-cancellation to protect attestation + and publishing flows. For immediate one-click recovery of a single + stuck run, operators dispatch `.github/workflows/unstick-run.yml` + from the Actions tab with the stuck run id (it bypasses the cron + wait and the queue-age threshold). Note that GitHub `schedule:` cron + triggers fire only from the repository default branch, so the + watchdog cron is INACTIVE until `stuck-job-watchdog.yml` reaches + `master`; until then, use `unstick-run.yml` or the watchdog's manual + `workflow_dispatch` trigger. + +Tracked pages: + +- [GitHub Transfer](github-transfer.md) +- [CI and GitHub Settings](ci-and-github-settings.md) +- [npm Release Publishing](npm-release-publishing.md) +- [OpenUPM Metadata](openupm-metadata.md) +- [Unity Asset Store UPM](unity-asset-store-upm.md) +- [Post-Transfer Verification](post-transfer-verification.md) + +## Local Operator Runbook + +Generate an ignored local checklist for non-sensitive execution notes: + +```bash +npm run generate:ambiguous-release-runbook +``` + +The command writes `.operator-runbooks/ambiguous-release-setup.md`. The file is +gitignored and excluded from npm packages. Generation refuses to overwrite an +existing runbook; use `node scripts/generate-ambiguous-release-runbook.js --force` +only after preserving local notes. + +Do not store secrets, tokens, recovery codes, screenshots, publisher +identifiers, private account metadata, private contact details, or publisher +portal notes in tracked files or this local runbook. Keep secret values and +publisher-only records in the appropriate provider consoles or approved +organization password manager. + +## Release Model + +The release trigger is a pushed tag named `vX.Y.Z`. The tag must exactly match +`package.json.version` with a leading `v`. For example, package version `3.0.1` +must be released from tag `v3.0.1`. + +There is no manual `workflow_dispatch` release path. A tag such as `3.0.1` or +`v3.0.1-rc.1` does not pass the release verifier. + +The release workflow performs these gates: + +1. Verify the semver tag and package version. +1. Run script tests, Unity workflow contract tests, npm package validation, + `llms.txt` validation, repository identity validation, and `validate:all`. +1. Pack the npm tarball and write a `.sha256` checksum. +1. Attest the packed `.tgz` with GitHub artifact attestations. +1. Run the trusted Unity release check on the Ambiguous self-hosted Windows + runner. +1. Create or update the GitHub Release with the `.tgz` and checksum. +1. Publish to npm with Trusted Publishing and provenance. + +Release assets are currently npm `.tgz` plus `.sha256`. The workflow does not +build or upload a `.unitypackage`. + +## Public References + +- GitHub repository transfer docs: + +- npm Trusted Publishing: + +- npm provenance: + +- OpenUPM package metadata: + +- Unity Asset Store publishing: + +- Unity package standards: + diff --git a/docs/ops/release-operations.md.meta b/docs/ops/release-operations.md.meta new file mode 100644 index 00000000..4572cf68 --- /dev/null +++ b/docs/ops/release-operations.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 56a5a8b16c9447f99f59ccfdd5de3721 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/ops/unity-asset-store-upm.md b/docs/ops/unity-asset-store-upm.md new file mode 100644 index 00000000..1bc8ea02 --- /dev/null +++ b/docs/ops/unity-asset-store-upm.md @@ -0,0 +1,88 @@ +--- +title: Unity Asset Store UPM +description: Manual onboarding checklist for Unity Asset Store UPM publishing +--- + +# Unity Asset Store UPM + +Unity Asset Store UPM publishing is separate from npm and OpenUPM. npm +provenance and GitHub artifact attestations do not replace Unity-controlled +package signing or Asset Store review. + +Unity's public materials describe UPM publishing on the Asset Store as an +early-access workflow. Treat UPM Asset Store publishing as conditional until the +Ambiguous publisher account is approved for that workflow. + +## Publisher Account Setup + +Verify in the Unity publisher account: + +1. Publisher profile is active. +1. Organization verification requirements are complete. +1. Any required identity, domain, tax, or business verification is complete. +1. The account has access to UPM publishing tools if using UPM submission. +1. Maintainers who submit packages have the needed role. + +Do not commit publisher account IDs, screenshots, tax details, DUNS numbers, or +private review messages. + +## Package Preparation + +DxMessaging is a UPM package with package ID +`com.wallstop-studios.dxmessaging`. Before submission, verify: + +- `package.json` metadata is current. +- `README.md`, `CHANGELOG.md`, `LICENSE.md`, and third-party notices are + included in the npm/UPM package. +- Samples under `Samples~/` import correctly. +- Unity versions match the supported matrix. +- Dependencies are documented and minimal. +- No build artifacts, IDE files, local runbooks, `.llm`, `.github`, scripts, + tests, devcontainer files, or Unity test harness files ship in the package. +- Every shipped Unity-relevant path has a paired `.meta` file. + +Run: + +```bash +npm run validate:npm-meta +npm pack --dry-run +``` + +## Submission Path + +If Ambiguous has UPM Asset Store early access: + +1. Install Unity's UPM publishing tooling from Unity's official channel. +1. Validate the package with Unity's tooling. +1. Upload the UPM package through the UPM publishing workflow. +1. Complete Asset Store metadata, screenshots, compatibility, and review fields. +1. Submit for review. + +If Ambiguous does not have UPM Asset Store early access: + +1. Do not claim Asset Store UPM availability in package docs. +1. Continue publishing through npm and OpenUPM. +1. Track Unity approval status in Unity Publisher Portal or the approved + organization password manager. +1. Decide separately whether a `.unitypackage` fallback is worth maintaining. + +## Signing and Provenance + +Unity package signing is controlled by Unity's publishing pipeline. It is +independent from: + +- npm Trusted Publishing provenance +- GitHub artifact attestations +- OpenUPM indexing + +Do not describe npm or GitHub provenance as Unity Asset Store signing. + +## Failure Modes + +- The publisher account is not approved for UPM publishing. +- Package metadata links point to the old GitHub organization. +- Asset Store submission asks for documentation included offline, while the + npm package excludes `docs/**`. +- A `.unitypackage` is expected, but the release workflow only creates `.tgz`. +- Unity rejects unnecessary dependencies or files. +- Private publisher identifiers leak into tracked docs. diff --git a/docs/ops/unity-asset-store-upm.md.meta b/docs/ops/unity-asset-store-upm.md.meta new file mode 100644 index 00000000..309c38e7 --- /dev/null +++ b/docs/ops/unity-asset-store-upm.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 351d04f393b64333885362d43f658a44 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index 9f077403..8b50ac25 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -298,5 +298,5 @@ public class UI : MessageAwareComponent { - to [Quick Reference](quick-reference.md) -- API cheat sheet - to [API Reference](reference.md) -- Complete API documentation -- to [Mini Combat sample](https://github.com/wallstop/DxMessaging/blob/master/Samples~/Mini%20Combat/README.md) -- See concepts in action +- to [Mini Combat sample](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/Samples~/Mini%20Combat/README.md) -- See concepts in action - to [Patterns](../guides/patterns.md) -- Real-world usage patterns diff --git a/docs/reference/quick-reference.md b/docs/reference/quick-reference.md index 75da18aa..f3a14acc 100644 --- a/docs/reference/quick-reference.md +++ b/docs/reference/quick-reference.md @@ -106,7 +106,7 @@ public sealed class DamageSystem : IStartable, IDisposable } ``` -Tip: Define `ZENJECT_PRESENT`, `VCONTAINER_PRESENT`, or `REFLEX_PRESENT` to enable the optional shims under [Runtime/Unity/Integrations](https://github.com/wallstop/DxMessaging/tree/master/Runtime/Unity/Integrations) that bind the builder automatically for those containers. +Tip: Define `ZENJECT_PRESENT`, `VCONTAINER_PRESENT`, or `REFLEX_PRESENT` to enable the optional shims under [Runtime/Unity/Integrations](https://github.com/Ambiguous-Interactive/DxMessaging/tree/master/Runtime/Unity/Integrations) that bind the builder automatically for those containers. ## Interceptors and post-processors diff --git a/docs/reference/reference.md b/docs/reference/reference.md index d052ec21..e955fcd1 100644 --- a/docs/reference/reference.md +++ b/docs/reference/reference.md @@ -355,11 +355,11 @@ public abstract class MessageAwareComponent : MessagingComponent For deeper exploration, browse the source code: -| Component | Source | -| --------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| Message Bus Interface | [IMessageBus.cs](https://github.com/wallstop/DxMessaging/blob/master/Runtime/Core/MessageBus/IMessageBus.cs) | -| Message Bus | [MessageBus.cs](https://github.com/wallstop/DxMessaging/blob/master/Runtime/Core/MessageBus/MessageBus.cs) | -| Message Handler | [MessageHandler.cs](https://github.com/wallstop/DxMessaging/blob/master/Runtime/Core/MessageHandler.cs) | -| Registration Token | [MessageRegistrationToken.cs](https://github.com/wallstop/DxMessaging/blob/master/Runtime/Core/MessageRegistrationToken.cs) | -| Emit Helpers | [MessageExtensions.cs](https://github.com/wallstop/DxMessaging/blob/master/Runtime/Core/Extensions/MessageExtensions.cs) | -| Attributes | [Attributes/](https://github.com/wallstop/DxMessaging/tree/master/Runtime/Core/Attributes) | +| Component | Source | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| Message Bus Interface | [IMessageBus.cs](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/Runtime/Core/MessageBus/IMessageBus.cs) | +| Message Bus | [MessageBus.cs](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/Runtime/Core/MessageBus/MessageBus.cs) | +| Message Handler | [MessageHandler.cs](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/Runtime/Core/MessageHandler.cs) | +| Registration Token | [MessageRegistrationToken.cs](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/Runtime/Core/MessageRegistrationToken.cs) | +| Emit Helpers | [MessageExtensions.cs](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/Runtime/Core/Extensions/MessageExtensions.cs) | +| Attributes | [Attributes/](https://github.com/Ambiguous-Interactive/DxMessaging/tree/master/Runtime/Core/Attributes) | diff --git a/docs/reference/troubleshooting.md b/docs/reference/troubleshooting.md index 18ec8e13..e5a701eb 100644 --- a/docs/reference/troubleshooting.md +++ b/docs/reference/troubleshooting.md @@ -74,5 +74,5 @@ Diagnostics overhead - to [Listening Patterns](../concepts/listening-patterns.md) -- Verify you're listening correctly - to [Message Types](../concepts/message-types.md) -- Ensure you're using the right type - **Examples** - - to [Mini Combat sample](https://github.com/wallstop/DxMessaging/blob/master/Samples~/Mini%20Combat/README.md) -- See working code + - to [Mini Combat sample](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/Samples~/Mini%20Combat/README.md) -- See working code - to [Common Patterns](../guides/patterns.md) -- Real-world solutions diff --git a/docs/runbooks.meta b/docs/runbooks.meta new file mode 100644 index 00000000..48b72e42 --- /dev/null +++ b/docs/runbooks.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a9a9c57dad89be42118e7b98c0f0b5d0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/runbooks/windows-partial-extract.md b/docs/runbooks/windows-partial-extract.md new file mode 100644 index 00000000..323d8469 --- /dev/null +++ b/docs/runbooks/windows-partial-extract.md @@ -0,0 +1,237 @@ +# Windows partial node_modules extract + +This runbook covers the Windows-specific failure mode where `npm install` +extracts only some of the files in a package, leaving the package directory +present but unusable. The pre-push hooks surface this as opaque "module not +found" or "testRunner option was not found" errors. The integrity gate in +`scripts/lib/node-modules-integrity.js` auto-repairs the most common case, +but operator action is required when auto-repair is refused or fails. + +## Symptoms + +The canonical symptom is a pre-push log that looks like (verbatim from +`pre-push.txt`): + +```text +Validation Error: + + testRunner option was not found. + Make sure jest-circus is installed: https://www.npmjs.com/package/jest-circus +``` + +The error appears even though `node_modules/jest-circus` is present on +disk. Other symptoms include: + +- `cspell` failing with `Cannot find module 'cspell/dist/...'` when + `node_modules/cspell/bin.mjs` is present but other internal files are + missing. +- `prettier` failing with `Cannot find package` for an internal dependency + even though `node_modules/prettier/index.cjs` and + `node_modules/prettier/bin/prettier.cjs` are present. +- `npm install` reporting `up to date` repeatedly while the broken file + remains broken. + +## Root cause + +`npm` writes packages by extracting their tarballs into `node_modules` +incrementally. If the install is interrupted - antivirus quarantine, sleep +transition, network blip, long-path limit (260 chars on default Windows +NTFS), or the user closing the shell - some files are written and the rest +are not. The lockfile hash, however, is computed from the manifest, not +the extracted contents, so a subsequent `npm install` sees a matching +lockfile entry and skips re-extraction (the "up to date" message). + +The repository's pre-push hooks invoke tools that need files deeper inside +the package than `npm install` looks at. The integrity gate enumerates the +load-bearing critical files (see `INTEGRITY_TARGETS` in +`scripts/lib/node-modules-integrity.js`) and probes each one for both +presence and non-zero size before any tool is invoked. + +## Auto-repair behavior + +When `scripts/run-managed-jest.js`, `scripts/run-managed-prettier.js`, or +`scripts/run-managed-cspell.js` is invoked: + +1. The integrity gate runs first. It probes every file in + `INTEGRITY_TARGETS` for existence and non-zero size. +1. If the probe is OK, the wrapper continues to its normal tier dispatch + (local devDependency, isolated managed cache, npm exec, npx). +1. If the probe fails, the gate consults `isAutoRepairAllowed`. Refusal + cases are listed below. +1. If auto-repair is allowed, the gate runs `npm ci --no-audit --no-fund` + against the repo root. +1. After `npm ci` succeeds, the gate spawns a fresh `node -e` subprocess + that re-runs `probeIntegrity`. The subprocess is needed because the + parent process still has the previous (broken) module + stat cache + entries; a same-process re-probe would falsely report failure. +1. If the subprocess probe is OK, the wrapper proceeds. If it still + fails, the wrapper prints the actionable repair banner and exits with + status 1. + +The gate emits no `--testRunner` injection at any point; the contract +documented in +`.llm/skills/scripting/jest-hook-robustness.md` remains load-bearing. + +## When auto-repair is refused + +The gate refuses to run `npm ci` in any of these states: + +| Refusal reason | How to fix | +| ---------------------------------------- | ---------------------------------------------------------------------- | +| `npm` is not on PATH | Open a shell with Node + npm initialized; re-run the hook. | +| `package-lock.json` has unstaged changes | Stage or stash the lockfile edits, then re-run. | +| Mid-rebase (`.git/rebase-merge` exists) | Finish the rebase (`git rebase --continue` or `--abort`), then re-run. | +| Mid-rebase (`.git/rebase-apply` exists) | Finish the rebase, then re-run. | +| `DXMSG_HOOK_NO_AUTOREPAIR=1` is set | Either unset the variable or run `npm ci` manually. | + +## Manual recovery (cross-platform) + +If auto-repair is refused or fails, run these commands in order. They +work on Linux, macOS, Windows CMD, and Windows PowerShell: + +```bash +node -e "require('fs').rmSync(require('path').join(require('os').tmpdir(), 'dxmessaging-managed-jest'), { recursive: true, force: true })" +npm ci +node scripts/validate-node-tooling.js +npm run preflight:pre-push +``` + +The first command clears the isolated managed-Jest cache under the OS +temp dir. The second reinstalls the repo's `node_modules` from the +lockfile. The third verifies the install is complete. The fourth runs +the full pre-push gauntlet. + +## Aggressive recovery + +When the partial extract has persisted across multiple `npm ci` +invocations (rare, usually antivirus-driven), use the aggressive flag: + +```bash +DXMSG_HOOK_AGGRESSIVE_RECOVERY=1 node scripts/run-managed-jest.js --version +``` + +This deletes `node_modules` outright before invoking `npm ci`. Use it +only when the normal path has failed; the rm-rf step adds 30-60s to the +hook on a healthy install. + +## Long-path mitigation (Windows) + +The default Windows NTFS path limit is 260 characters. Nested +`node_modules` trees can blow past this. Two mitigations: + +1. Enable long-path support globally: + + ```powershell + New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force + ``` + + Reboot required. + +1. Move the repo closer to the drive root. A repo at + `D:\Code\dxmessaging` rarely hits the limit; one at + `C:\Users\\Documents\GitHub\organization\sub\dxmessaging` may. + +## Missing or broken `unrs-resolver` native binding + +Newer versions of the dev-tool resolver chain (`unrs-resolver`) ship a +platform-specific native binding -- on Windows it is +`@unrs/resolver-binding-win32-x64-msvc`. When the binding is missing or +truncated (commonly during a partial install or after an AV scan), the +JS file `node_modules/jest-circus/build/runner.js` is present on disk +but `require.resolve('jest-circus/runner')` throws at runtime with +`Failed to load native binding`. The file-based integrity probe is +blind to this; the resolver probe added in +`scripts/lib/node-modules-integrity.js` catches it. + +Symptoms: + +- `Failed to load native binding: @unrs/resolver-binding-win32-x64-msvc` + in the pre-push log even though `node_modules/jest-circus/build/runner.js` + exists and is non-zero. +- `Cannot find module 'unrs-resolver/resolver.win32-x64-msvc.node'`. + +Manual recovery: + +```powershell +# PowerShell +npm rebuild @unrs/resolver-binding-win32-x64-msvc +# If rebuild doesn't fix it, blow away node_modules entirely +Remove-Item -Recurse -Force node_modules +npm install +``` + +```bash +# Git Bash / WSL +npm rebuild @unrs/resolver-binding-win32-x64-msvc +# Or, aggressive: +rm -rf node_modules && npm install +``` + +## `DXMSG_HOOK_NO_AUTOREPAIR=1` shell carry-over + +If you set `DXMSG_HOOK_NO_AUTOREPAIR=1` in a previous session and forgot +to unset it, the integrity gate will detect the partial extract but +SKIP `npm ci` even when the repair is safe. The gate banner now +explicitly tells you when this is the cause; to re-enable auto-repair: + +```bash +# POSIX (bash, zsh) +unset DXMSG_HOOK_NO_AUTOREPAIR +``` + +```powershell +# PowerShell +Remove-Item Env:\DXMSG_HOOK_NO_AUTOREPAIR +``` + +To verify it is unset: + +```bash +echo "${DXMSG_HOOK_NO_AUTOREPAIR:-}" # POSIX +``` + +```powershell +"$env:DXMSG_HOOK_NO_AUTOREPAIR" # PowerShell +``` + +If the variable is set globally (Windows -> Environment Variables panel, +or `~/.bashrc`, `~/.zshrc`, PowerShell `$PROFILE`), edit the source and +re-open your shell. + +## Doubled paths and junctions on Windows + +Windows junctions or accidental path doubling (for example +`C:\foo\Packages\Packages\com.wallstop-studios.dxmessaging`) can confuse +Jest's resolver and the `unrs-resolver` cache. If the integrity probe +keeps reporting the same file as missing after a successful `npm ci`, +re-clone into a flat path: + +```powershell +git clone https://github.com/Ambiguous-Interactive/DxMessaging C:\dev\dxmessaging +cd C:\dev\dxmessaging +npm ci +``` + +## Antivirus exclusions + +Windows Defender, Symantec, and Sophos have all been observed +quarantining `*.node` native binaries during package install. Symptoms: + +- `findZeroByteNativeBinaries` (in + `scripts/lib/node-modules-integrity.js`) reports a non-empty list. +- `npm ci` completes but the next probe still fails. + +Mitigation: add an exclusion for the repo's `node_modules` directory in +the AV control panel. The repo path is local development only; an +exclusion does not weaken production security. + +## See also + +- `.llm/skills/scripting/jest-hook-robustness.md` for the hook-side + contract and the `--testRunner` injection ban. +- `scripts/lib/node-modules-integrity.js` for the canonical + `INTEGRITY_TARGETS` list. +- `scripts/lib/integrity-gate-with-recovery.js` for the gate's + probe-repair-reprobe flow. +- `scripts/doctor.js` for the read-only diagnostic that surfaces the + same probe results without attempting any repair. diff --git a/docs/runbooks/windows-partial-extract.md.meta b/docs/runbooks/windows-partial-extract.md.meta new file mode 100644 index 00000000..4a59c092 --- /dev/null +++ b/docs/runbooks/windows-partial-extract.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 911574139a4138f1d453cb96fce01c3b +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/llms.txt b/llms.txt index 00c00bfa..d2e0feec 100644 --- a/llms.txt +++ b/llms.txt @@ -8,8 +8,8 @@ DxMessaging is a high-performance messaging library for Unity (v2021.3+) that re **Version:** 3.0.1 **License:** MIT -**Repository:** https://github.com/wallstop/DxMessaging -**Documentation:** https://wallstop.github.io/DxMessaging/ +**Repository:** https://github.com/Ambiguous-Interactive/DxMessaging +**Documentation:** https://ambiguous-interactive.github.io/DxMessaging/ ## Quick Facts @@ -119,62 +119,62 @@ public class HealthDisplay : MessageAwareComponent ### Getting Started -- [Overview](https://wallstop.github.io/DxMessaging/getting-started/overview/) -- [Installation](https://wallstop.github.io/DxMessaging/getting-started/install/) -- [Quick Start](https://wallstop.github.io/DxMessaging/getting-started/quick-start/) -- [Visual Guide](https://wallstop.github.io/DxMessaging/getting-started/visual-guide/) +- [Overview](https://ambiguous-interactive.github.io/DxMessaging/getting-started/overview/) +- [Installation](https://ambiguous-interactive.github.io/DxMessaging/getting-started/install/) +- [Quick Start](https://ambiguous-interactive.github.io/DxMessaging/getting-started/quick-start/) +- [Visual Guide](https://ambiguous-interactive.github.io/DxMessaging/getting-started/visual-guide/) ### Concepts -- [Mental Model](https://wallstop.github.io/DxMessaging/concepts/mental-model/) - Core philosophy and design principles -- [Message Types](https://wallstop.github.io/DxMessaging/concepts/message-types/) - Untargeted, Targeted, Broadcast -- [Listening Patterns](https://wallstop.github.io/DxMessaging/concepts/listening-patterns/) -- [Targeting & Context](https://wallstop.github.io/DxMessaging/concepts/targeting-and-context/) -- [Interceptors & Ordering](https://wallstop.github.io/DxMessaging/concepts/interceptors-and-ordering/) +- [Mental Model](https://ambiguous-interactive.github.io/DxMessaging/concepts/mental-model/) - Core philosophy and design principles +- [Message Types](https://ambiguous-interactive.github.io/DxMessaging/concepts/message-types/) - Untargeted, Targeted, Broadcast +- [Listening Patterns](https://ambiguous-interactive.github.io/DxMessaging/concepts/listening-patterns/) +- [Targeting & Context](https://ambiguous-interactive.github.io/DxMessaging/concepts/targeting-and-context/) +- [Interceptors & Ordering](https://ambiguous-interactive.github.io/DxMessaging/concepts/interceptors-and-ordering/) ### Guides -- [Patterns](https://wallstop.github.io/DxMessaging/guides/patterns/) - Best practices and common patterns -- [Unity Integration](https://wallstop.github.io/DxMessaging/guides/unity-integration/) -- [Testing](https://wallstop.github.io/DxMessaging/guides/testing/) - Testing strategies for message-based systems -- [Diagnostics](https://wallstop.github.io/DxMessaging/guides/diagnostics/) - Inspector tools and debugging -- [Memory Reclamation](https://wallstop.github.io/DxMessaging/guides/memory-reclamation/) - Idle eviction, Trim API, occupancy counters -- [Migration Guide](https://wallstop.github.io/DxMessaging/guides/migration-guide/) +- [Patterns](https://ambiguous-interactive.github.io/DxMessaging/guides/patterns/) - Best practices and common patterns +- [Unity Integration](https://ambiguous-interactive.github.io/DxMessaging/guides/unity-integration/) +- [Testing](https://ambiguous-interactive.github.io/DxMessaging/guides/testing/) - Testing strategies for message-based systems +- [Diagnostics](https://ambiguous-interactive.github.io/DxMessaging/guides/diagnostics/) - Inspector tools and debugging +- [Memory Reclamation](https://ambiguous-interactive.github.io/DxMessaging/guides/memory-reclamation/) - Idle eviction, Trim API, occupancy counters +- [Migration Guide](https://ambiguous-interactive.github.io/DxMessaging/guides/migration-guide/) ### Architecture -- [Design & Architecture](https://wallstop.github.io/DxMessaging/architecture/design-and-architecture/) -- [Performance](https://wallstop.github.io/DxMessaging/architecture/performance/) - Benchmarks (10-17M ops/sec) -- [Comparisons](https://wallstop.github.io/DxMessaging/architecture/comparisons/) - vs Events, UnityEvents, other buses +- [Design & Architecture](https://ambiguous-interactive.github.io/DxMessaging/architecture/design-and-architecture/) +- [Performance](https://ambiguous-interactive.github.io/DxMessaging/architecture/performance/) - Benchmarks (10-17M ops/sec) +- [Comparisons](https://ambiguous-interactive.github.io/DxMessaging/architecture/comparisons/) - vs Events, UnityEvents, other buses ### Advanced Topics -- [Emit Shorthands](https://wallstop.github.io/DxMessaging/advanced/emit-shorthands/) -- [Message Bus Providers](https://wallstop.github.io/DxMessaging/advanced/message-bus-providers/) -- [Registration Builders](https://wallstop.github.io/DxMessaging/advanced/registration-builders/) -- [Runtime Configuration](https://wallstop.github.io/DxMessaging/advanced/runtime-configuration/) +- [Emit Shorthands](https://ambiguous-interactive.github.io/DxMessaging/advanced/emit-shorthands/) +- [Message Bus Providers](https://ambiguous-interactive.github.io/DxMessaging/advanced/message-bus-providers/) +- [Registration Builders](https://ambiguous-interactive.github.io/DxMessaging/advanced/registration-builders/) +- [Runtime Configuration](https://ambiguous-interactive.github.io/DxMessaging/advanced/runtime-configuration/) ### Integrations -- [Zenject](https://wallstop.github.io/DxMessaging/integrations/zenject/) - Extenject/Zenject DI integration -- [VContainer](https://wallstop.github.io/DxMessaging/integrations/vcontainer/) - VContainer DI integration -- [Reflex](https://wallstop.github.io/DxMessaging/integrations/reflex/) - Reflex DI integration +- [Zenject](https://ambiguous-interactive.github.io/DxMessaging/integrations/zenject/) - Extenject/Zenject DI integration +- [VContainer](https://ambiguous-interactive.github.io/DxMessaging/integrations/vcontainer/) - VContainer DI integration +- [Reflex](https://ambiguous-interactive.github.io/DxMessaging/integrations/reflex/) - Reflex DI integration ### Reference -- [Quick Reference](https://wallstop.github.io/DxMessaging/reference/quick-reference/) -- [Runtime Settings](https://wallstop.github.io/DxMessaging/reference/runtime-settings/) - DxMessagingRuntimeSettings asset and diagnostic API -- [FAQ](https://wallstop.github.io/DxMessaging/reference/faq/) -- [Glossary](https://wallstop.github.io/DxMessaging/reference/glossary/) -- [Troubleshooting](https://wallstop.github.io/DxMessaging/reference/troubleshooting/) +- [Quick Reference](https://ambiguous-interactive.github.io/DxMessaging/reference/quick-reference/) +- [Runtime Settings](https://ambiguous-interactive.github.io/DxMessaging/reference/runtime-settings/) - DxMessagingRuntimeSettings asset and diagnostic API +- [FAQ](https://ambiguous-interactive.github.io/DxMessaging/reference/faq/) +- [Glossary](https://ambiguous-interactive.github.io/DxMessaging/reference/glossary/) +- [Troubleshooting](https://ambiguous-interactive.github.io/DxMessaging/reference/troubleshooting/) ## Key Files -- [README.md](https://github.com/wallstop/DxMessaging/blob/master/README.md) - 30-second pitch, mental models, quick start -- [CHANGELOG.md](https://github.com/wallstop/DxMessaging/blob/master/CHANGELOG.md) - Version history -- [CONTRIBUTING.md](https://github.com/wallstop/DxMessaging/blob/master/CONTRIBUTING.md) - Contribution guidelines -- [package.json](https://github.com/wallstop/DxMessaging/blob/master/package.json) - Package manifest -- [.llm/context.md](https://github.com/wallstop/DxMessaging/blob/master/.llm/context.md) - Repository guidelines for AI agents +- [README.md](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/README.md) - 30-second pitch, mental models, quick start +- [CHANGELOG.md](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/CHANGELOG.md) - Version history +- [CONTRIBUTING.md](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/CONTRIBUTING.md) - Contribution guidelines +- [package.json](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/package.json) - Package manifest +- [.llm/context.md](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/.llm/context.md) - Repository guidelines for AI agents ## Development @@ -216,8 +216,8 @@ npx cspell "**/*" This repository includes comprehensive AI agent guidance in the `.llm/` directory: -- **[.llm/context.md](https://github.com/wallstop/DxMessaging/blob/master/.llm/context.md)** - Repository guidelines, coding standards, testing policies -- **[.llm/skills/](https://github.com/wallstop/DxMessaging/tree/master/.llm/skills)** - 157+ specialized skill documents covering: +- **[.llm/context.md](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/.llm/context.md)** - Repository guidelines, coding standards, testing policies +- **[.llm/skills/](https://github.com/Ambiguous-Interactive/DxMessaging/tree/master/.llm/skills)** - 161+ specialized skill documents covering: - **documentation/** - **github-actions/** - **packaging/** @@ -242,7 +242,7 @@ This repository includes comprehensive AI agent guidance in the `.llm/` director ### Wrong Message Type **Problem:** Used Broadcast when Targeted was needed -**Solution:** See [Mental Model](https://wallstop.github.io/DxMessaging/concepts/mental-model/) for type selection guidance +**Solution:** See [Mental Model](https://ambiguous-interactive.github.io/DxMessaging/concepts/mental-model/) for type selection guidance ### Performance Issues @@ -257,7 +257,7 @@ This repository includes comprehensive AI agent guidance in the `.llm/` director - **Registration:** O(1) add/remove with backing dictionary - **Priority Ordering:** Stable sort on registration -See [Performance Documentation](https://wallstop.github.io/DxMessaging/architecture/performance/) for detailed benchmarks. +See [Performance Documentation](https://ambiguous-interactive.github.io/DxMessaging/architecture/performance/) for detailed benchmarks. ## Examples @@ -293,18 +293,18 @@ Demonstrates debugging tools: ## Support & Community -- **Issues:** https://github.com/wallstop/DxMessaging/issues -- **Discussions:** https://github.com/wallstop/DxMessaging/discussions +- **Issues:** https://github.com/Ambiguous-Interactive/DxMessaging/issues +- **Discussions:** https://github.com/Ambiguous-Interactive/DxMessaging/discussions - **Email:** wallstop@wallstopstudios.com - **OpenUPM:** https://openupm.com/packages/com.wallstop-studios.dxmessaging/ ## License -MIT License - see [LICENSE.md](https://github.com/wallstop/DxMessaging/blob/master/LICENSE.md) +MIT License - see [LICENSE.md](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/LICENSE.md) Copyright (c) 2017-2026 Wallstop Studios --- -**Last Updated:** 2026-05-07 +**Last Updated:** 2026-05-19 **Generated by:** scripts/update-llms-txt.js using package.json v3.0.1 and .llm/skills metadata diff --git a/mkdocs.yml b/mkdocs.yml index 5d1706ce..d55e91d8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,9 +1,9 @@ site_name: DxMessaging Documentation -site_url: https://wallstop.github.io/DxMessaging/ +site_url: https://ambiguous-interactive.github.io/DxMessaging/ site_description: High-performance type-safe messaging library for Unity site_author: Wallstop Studios -repo_name: wallstop/DxMessaging -repo_url: https://github.com/wallstop/DxMessaging +repo_name: Ambiguous-Interactive/DxMessaging +repo_url: https://github.com/Ambiguous-Interactive/DxMessaging edit_uri: edit/master/docs/ copyright: Copyright © 2017-2026 Wallstop Studios @@ -95,7 +95,7 @@ markdown_extensions: - pymdownx.keys - pymdownx.magiclink: repo_url_shorthand: true - user: wallstop + user: Ambiguous-Interactive repo: DxMessaging - pymdownx.mark - pymdownx.smartsymbols @@ -199,6 +199,16 @@ nav: - Examples: - End-to-End: examples/end-to-end.md - Scene Transitions: examples/end-to-end-scene-transitions.md + - Operations: + - ops/index.md + - Ambiguous Release Migration: ops/ambiguous-release-migration.md + - Release Operations: ops/release-operations.md + - GitHub Transfer: ops/github-transfer.md + - CI and GitHub Settings: ops/ci-and-github-settings.md + - npm Release Publishing: ops/npm-release-publishing.md + - OpenUPM Metadata: ops/openupm-metadata.md + - Unity Asset Store UPM: ops/unity-asset-store-upm.md + - Post-Transfer Verification: ops/post-transfer-verification.md - Reference: - reference/reference.md - Quick Reference: reference/quick-reference.md diff --git a/package.json b/package.json index b821bb14..d4b4ebf1 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,9 @@ "displayName": "DxMessaging", "description": "Synchronous Event Bus for Unity", "unity": "2021.3", - "documentationUrl": "https://wallstop.github.io/DxMessaging/", - "changelogUrl": "https://raw.githubusercontent.com/wallstop/DxMessaging/master/CHANGELOG.md", - "licensesUrl": "https://github.com/wallstop/DxMessaging/blob/master/LICENSE.md", + "documentationUrl": "https://ambiguous-interactive.github.io/DxMessaging/", + "changelogUrl": "https://raw.githubusercontent.com/Ambiguous-Interactive/DxMessaging/master/CHANGELOG.md", + "licensesUrl": "https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/LICENSE.md", "keywords": [ "messaging", "message", @@ -22,13 +22,13 @@ "license": "MIT", "repository": { "type": "git", - "url": "git+https://github.com/wallstop/DxMessaging.git" + "url": "git+https://github.com/Ambiguous-Interactive/DxMessaging.git" }, "bugs": { - "url": "https://github.com/wallstop/DxMessaging/issues" + "url": "https://github.com/Ambiguous-Interactive/DxMessaging/issues" }, "author": "wallstop studios (https://wallstopstudios.com)", - "homepage": "https://wallstop.github.io/DxMessaging/", + "homepage": "https://ambiguous-interactive.github.io/DxMessaging/", "main": "README.md", "files": [ "Editor/**", @@ -55,10 +55,11 @@ "SourceGenerators.meta" ], "scripts": { + "postinstall": "node scripts/postinstall.js", "test": "node scripts/run-managed-jest.js", "test:scripts": "node scripts/run-managed-jest.js", "test:llms-txt": "node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/update-llms-txt.test.js", - "test:unity-contracts": "node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/unity-test-harness-contract.test.js scripts/__tests__/unity-runner-script-contract.test.js scripts/__tests__/unity-perf-baseline-script-contract.test.js scripts/__tests__/devcontainer-cache-contract.test.js scripts/__tests__/unity-workflow-shape.test.js scripts/__tests__/unity-perf-isolation.test.js scripts/__tests__/claude-permissions-contract.test.js scripts/__tests__/llm-skills-unity-coverage.test.js", + "test:unity-contracts": "node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/unity-test-harness-contract.test.js scripts/__tests__/unity-runner-script-contract.test.js scripts/__tests__/unity-perf-baseline-script-contract.test.js scripts/__tests__/devcontainer-cache-contract.test.js scripts/__tests__/unity-workflow-shape.test.js scripts/__tests__/unity-perf-isolation.test.js scripts/__tests__/claude-permissions-contract.test.js scripts/__tests__/llm-skills-unity-coverage.test.js scripts/__tests__/validate-workflows-concurrency-and-labels.test.js", "test:watch": "node scripts/run-managed-jest.js --watch", "test:coverage": "node scripts/run-managed-jest.js --coverage", "format:md": "node scripts/run-managed-prettier.js --write \"**/*.{md,markdown}\"", @@ -70,23 +71,32 @@ "check:package-json-format": "node scripts/run-managed-prettier.js --check package.json", "check:prettier:hooks": "node scripts/run-managed-prettier.js --check \"**/*.{md,markdown,json,asmdef,asmref,yml,yaml}\"", "check:cspell:scripts": "npx --yes cspell@10.0.0 --no-progress --no-summary \"scripts/**/*.js\"", + "check:workflow-cspell": "npx --yes cspell@10.0.0 --no-progress --no-summary \".github/workflows/**/*.yml\" \".github/workflows/**/*.yaml\"", + "check:banner-sync": "node scripts/validate-banner.js", + "sync:banner": "bash scripts/sync-banner-version.sh", "check:yaml": "npm run format:yaml:check && pre-commit run yamllint --all-files", "lint:markdown": "markdownlint-cli2 \"**/*.md\" \"**/*.markdown\"", + "generate:ambiguous-release-runbook": "node scripts/generate-ambiguous-release-runbook.js", "update:llms-txt": "node scripts/update-llms-txt.js", "check:llms-txt": "node scripts/update-llms-txt.js --check", - "validate:all": "npm run validate:node-tooling && npm run validate:pre-commit-tooling && npm run validate:npm-meta && npm run validate:changelog:coverage && npm run validate:runtime-settings-docs && npm run validate:no-plan-vocabulary && npm run validate:untracked-policy", + "validate:all": "npm run validate:node-tooling && npm run validate:pre-commit-tooling && npm run validate:npm-meta && npm run validate:repo-identity && npm run validate:changelog:coverage && npm run validate:runtime-settings-docs && npm run validate:no-plan-vocabulary && npm run validate:untracked-policy && npm run validate:workflows", "validate:changelog": "node scripts/validate-changelog.js", "validate:changelog:coverage": "node scripts/validate-changelog.js --check-coverage", "validate:hook-markdown": "pre-commit run --hook-stage pre-commit run-staged-md-pipeline --files README.md", + "validate:llm-markdown": "node scripts/validate-docs-ascii.js --paths .llm && node scripts/validate-doc-code-patterns.js --paths .llm && node scripts/validate-docs-prose.js --paths .llm", "validate:llms-txt": "npm run test:llms-txt && npm run check:llms-txt", "validate:no-plan-vocabulary": "node scripts/validate-no-plan-vocabulary.js", "validate:node-tooling": "node scripts/validate-node-tooling.js", "validate:npm-meta": "node scripts/validate-npm-meta.js --check", "validate:pre-commit-tooling": "node scripts/validate-pre-commit-tooling.js", + "validate:repo-identity": "node scripts/validate-repo-identity.js --check", "validate:runtime-settings-docs": "node scripts/validate-runtime-settings-docs.js", "validate:untracked-policy": "node scripts/validate-untracked-policy.js", "validate:vscode-settings": "node scripts/validate-vscode-settings.js", - "preflight:pre-commit": "npm run validate:node-tooling && npm run validate:hook-markdown && npm run check:package-json-format && npm run check:prettier:hooks && npm run validate:pre-commit-tooling && npm run check:cspell:scripts && npm run check:yaml && node scripts/generate-skills-index.js --check && npm run validate:npm-meta && npm run validate:changelog:coverage && pre-commit run --hook-stage pre-push script-parser-tests --all-files && npm run validate:runtime-settings-docs && npm run validate:no-plan-vocabulary && npm run validate:untracked-policy", + "validate:workflows": "node scripts/validate-workflows.js", + "preflight:pre-commit": "npm run validate:node-tooling && npm run validate:hook-markdown && npm run validate:llm-markdown && npm run check:package-json-format && npm run check:prettier:hooks && npm run validate:pre-commit-tooling && npm run check:cspell:scripts && npm run check:workflow-cspell && npm run validate:workflows && npm run check:yaml && npm run check:banner-sync && node scripts/generate-skills-index.js --check && npm run validate:npm-meta && npm run validate:repo-identity && npm run validate:changelog:coverage && pre-commit run --hook-stage pre-push script-parser-tests --all-files && npm run validate:runtime-settings-docs && npm run validate:no-plan-vocabulary && npm run validate:untracked-policy", + "preflight:pre-push": "npm run preflight:pre-commit && pre-commit run --hook-stage pre-push --all-files", + "doctor": "node scripts/doctor.js", "prepack": "node scripts/validate-npm-meta.js --check" }, "devDependencies": { diff --git a/scripts/__tests__/cross-platform-path-handling.test.js b/scripts/__tests__/cross-platform-path-handling.test.js new file mode 100644 index 00000000..17841cfa --- /dev/null +++ b/scripts/__tests__/cross-platform-path-handling.test.js @@ -0,0 +1,291 @@ +/** + * @fileoverview Cross-platform path-separator regression suite. + * + * The Windows developer hit `npm run preflight:pre-push` failures three + * times in a row because production code emitted backslash-separated paths + * in user-facing log lines (warnFn, console.warn) and tests asserted on + * forward-slash substrings. Each time the failure was patched in one site + * and the class popped up elsewhere. + * + * This file is the class-level pin: + * 1. The unit-level invariants for `toPosixPath` / `toRepoPosixRelative` + * (idempotency on POSIX, conversion on Windows, defensive non-string + * handling). The detailed `toPosixPath` / `toRepoPosixRelative` tests + * live in path-classifier.test.js; this file adds the cross-module + * contracts that path-classifier alone cannot pin. + * 2. `formatIntegrityFailure` POSIX-normalizes its `relPath` input + * regardless of how the caller spells it. + * 3. A policy contract test that walks every production script and fails + * if a `warnFn` / `console.warn` / `console.error` interpolates a + * variable whose name ends in `Path` (or `path`) WITHOUT wrapping it + * in `toPosixPath(` or `toRepoPosixRelative(`. This is the "self- + * preventing-class" gate; an allowlist captures the few legitimate + * exceptions (paths that are already POSIX-normalized at their source, + * or paths intentionally surfaced in their platform-native form). + * + * The policy test runs in <100ms on the existing scripts/ tree; it is safe + * to keep in the pre-push battery. + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const { + toPosixPath, + toRepoPosixRelative, +} = require("../lib/path-classifier"); +const { + formatIntegrityFailure, +} = require("../lib/node-modules-integrity"); + +const REPO_ROOT = path.resolve(__dirname, "..", ".."); +const SCRIPTS_ROOT = path.resolve(__dirname, ".."); + +describe("cross-platform path handling", () => { + describe("toPosixPath", () => { + test("is idempotent on POSIX input", () => { + expect(toPosixPath("a/b/c")).toBe("a/b/c"); + expect(toPosixPath("/abs/path")).toBe("/abs/path"); + }); + + test("converts Windows separators to POSIX", () => { + expect(toPosixPath("D:\\Code\\foo")).toBe("D:/Code/foo"); + expect(toPosixPath("a\\b\\c.txt")).toBe("a/b/c.txt"); + }); + + test("null / undefined map to empty string (no 'undefined' leak)", () => { + // Regression pin: a previous shape returned the original + // null/undefined unchanged, which interpolated as the literal + // string "undefined" in warnFn template literals. + expect(toPosixPath(null)).toBe(""); + expect(toPosixPath(undefined)).toBe(""); + expect(`runner ${toPosixPath(undefined)}`).toBe("runner "); + }); + + test("coerces non-string primitives via String() and swaps separators", () => { + expect(toPosixPath(7)).toBe("7"); + }); + }); + + describe("toRepoPosixRelative", () => { + test("emits POSIX form for inside-repo paths", () => { + const repo = path.resolve("/repo"); + expect(toRepoPosixRelative(path.join(repo, "node_modules", "x.js"), repo)) + .toBe("node_modules/x.js"); + }); + + test("falls back to POSIX-absolute for outside-repo paths", () => { + const repo = path.resolve("/repo"); + const outside = path.resolve("/elsewhere/x.js"); + const out = toRepoPosixRelative(outside, repo); + expect(out).toBe(toPosixPath(outside)); + expect(out.includes("\\")).toBe(false); + }); + }); + + describe("formatIntegrityFailure POSIX contract", () => { + test("POSIX-normalizes backslash relPath in the output", () => { + const result = { + ok: false, + missing: [ + { + tool: "x", + relPath: "node_modules\\foo\\bar.js", + reason: "missing", + }, + ], + }; + const formatted = formatIntegrityFailure(result); + expect(formatted).toContain("node_modules/foo/bar.js"); + expect(formatted).not.toContain("\\"); + }); + + test("idempotent on already-POSIX relPath", () => { + const result = { + ok: false, + missing: [ + { + tool: "x", + relPath: "node_modules/foo/bar.js", + reason: "missing", + }, + ], + }; + expect(formatIntegrityFailure(result)).toContain("node_modules/foo/bar.js"); + }); + }); + + describe("policy: warn/error paths must be POSIX-normalized", () => { + // Allow-list keyed by absolute file path. Each entry is a Set of + // 1-indexed line numbers where a `warnFn|console.warn|console.error` + // interpolation of a *Path-suffixed identifier is intentionally + // NOT wrapped in toPosixPath / toRepoPosixRelative. Reasons: + // - the value is already known to be POSIX at the source, or + // - the call site is a metadata diagnostic for an internal + // tester where the platform-native form is more debuggable. + // + // Each allowlist entry includes a short reason. Adding a new + // entry is the explicit "I know what I'm doing" gesture; the + // default behavior is to fail the test until the call site is + // wrapped. + const ALLOWLIST = Object.create(null); + // No allowlist entries today. The fix in this branch normalized + // every call site; any future allowlist addition should also + // file an issue tracking why the wrap is undesirable. + + // Matches `(warnFn|console.warn|console.error|logFn)( ... ${VAR} ... )` + // where VAR ends in a path-like suffix. The "between the open paren + // and the ${...}" body uses `[\s\S]*?` (lazy any-character) instead + // of `[^)]*`; the previous `[^)]*` stopped at the FIRST `)` and + // silently failed to scan call sites that contained a nested call + // before the interpolation (e.g. `warnFn(getMsg() + \`${runnerPath}\`)`). + // The lazy quantifier lets the match cross nested parens but still + // anchors to the first `${...}` after the call's opening paren. + // + // We DELIBERATELY scope the heuristic narrowly to known path-like + // identifier suffixes. The character-class alternation now includes + // lowercase `root` (e.g. `repo_root`, `nodeModulesRoot`) so the + // policy gate catches snake_case Root identifiers too. Broadening to + // "all variables" would explode the false-positive surface; the + // suffix-based filter catches the realistic class of regressions + // (someone names a local `runnerPath`, `nodeModulesPath`, + // `repoRoot`, `repo_root`, etc.). + const PATH_VAR_RE = + /(warnFn|console\.(?:warn|error|log)|logFn)\s*\([\s\S]*?\$\{([A-Za-z_][A-Za-z0-9_.]*?(?:Path|path|PATH|Dir|dir|Root|root))\}/; + + // Scope: only the files that participate in the managed-Jest / + // integrity-gate / resolver-health flow. Other scripts (the docs + // generators, the EOL checker, the wiki transformer) emit + // repo-relative paths derived from path.relative(), which are + // already POSIX on the only platforms where they run, and broaden + // the false-positive surface unnecessarily. + // + // The scope is intentionally NARROW: it covers the call sites the + // Windows developer actually hit (and the sites where adding a + // new path interpolation is most likely to regress). Expanding + // the scope is a deliberate decision; do it by adding paths + // here, not by widening the regex. + const SCAN_FILES = [ + path.join(SCRIPTS_ROOT, "run-managed-jest.js"), + path.join(SCRIPTS_ROOT, "run-managed-prettier.js"), + path.join(SCRIPTS_ROOT, "run-managed-cspell.js"), + path.join(SCRIPTS_ROOT, "verify-managed-jest-fallback.js"), + path.join(SCRIPTS_ROOT, "lib", "integrity-gate-with-recovery.js"), + path.join(SCRIPTS_ROOT, "lib", "node-modules-integrity.js"), + path.join(SCRIPTS_ROOT, "lib", "path-classifier.js"), + path.join(SCRIPTS_ROOT, "lib", "jest-error-decoder.js"), + path.join(SCRIPTS_ROOT, "lib", "managed-prettier.js"), + path.join(SCRIPTS_ROOT, "lib", "shell-command.js"), + ]; + + test("PATH_VAR_RE catches nested-paren call before the interpolation (regression)", () => { + // The previous `[^)]*` body stopped at the first `)` and missed + // call sites whose argument was a function call. The lazy + // `[\s\S]*?` form crosses nested parens. + const sample = "warnFn(getMsg() + `runner ${runnerPath}`);"; + const match = PATH_VAR_RE.exec(sample); + expect(match).not.toBeNull(); + expect(match[2]).toBe("runnerPath"); + }); + + test("PATH_VAR_RE catches lowercase snake_case `root` suffix (regression)", () => { + // The previous character class only included `Root`; lowercase + // `root` (e.g. `repo_root`) slipped through and could regress + // a call site without tripping the policy gate. + const sample = "console.warn(`repo at ${repo_root}`);"; + const match = PATH_VAR_RE.exec(sample); + expect(match).not.toBeNull(); + expect(match[2]).toBe("repo_root"); + }); + + test("PATH_VAR_RE does NOT fire when interpolation is wrapped in toPosixPath", () => { + // `${toPosixPath(runnerPath)}` is a function call inside the + // template hole, not a bare identifier. The regex requires + // ${IDENT} with IDENT being a contiguous identifier ending in + // a path-like suffix; `toPosixPath(runnerPath` is not a + // contiguous identifier (the `(` breaks it). Therefore the + // regex returns null, which IS the correct policy outcome: + // wrapped call sites do not need to be allowlisted. + const sample = "warnFn(`runner ${toPosixPath(runnerPath)}`);"; + const match = PATH_VAR_RE.exec(sample); + expect(match).toBeNull(); + }); + + test("PATH_VAR_RE: walker's same-line wrap detection clears wrapped offenders", () => { + // Belt-and-suspenders: a hypothetical line that interpolates a + // *bare* `${runnerPath}` while ALSO containing + // `toPosixPath(runnerPath` elsewhere on the same line (e.g. a + // log that captures both forms) should be cleared by the + // walker's `normalized.test(line)` check. The regex still + // fires, but the walker treats the line as compliant. + const sample = + "warnFn(`runner=${runnerPath}, normalized=${toPosixPath(runnerPath)}`);"; + const match = PATH_VAR_RE.exec(sample); + expect(match).not.toBeNull(); + const escapedInterp = match[2].replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const normalized = new RegExp( + "(?:toPosixPath|toRepoPosixRelative)\\(\\s*" + escapedInterp + "\\s*" + ); + expect(normalized.test(sample)).toBe(true); + }); + + test("integrity-gate / managed-Jest scripts wrap path interpolations in warn/error logs with toPosixPath/toRepoPosixRelative", () => { + const offenders = []; + for (const abs of SCAN_FILES) { + if (!fs.existsSync(abs)) { + throw new Error( + "cross-platform-path-handling SCAN_FILES references missing file: " + abs + ); + } + const content = fs.readFileSync(abs, "utf8"); + const lines = content.split(/\r?\n/); + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + const match = PATH_VAR_RE.exec(line); + if (!match) { + continue; + } + // If the line already routes the interpolated value + // through one of the normalizers, it's fine. We check + // the SAME line because the interpolation is on the + // same line as the regex match. + const interpolated = match[2]; + const escapedInterp = interpolated.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const normalized = new RegExp( + "(?:toPosixPath|toRepoPosixRelative)\\(\\s*" + escapedInterp + "\\s*" + ); + if (normalized.test(line)) { + continue; + } + // Check the allowlist. + const fileEntries = ALLOWLIST[abs]; + if (fileEntries && fileEntries.has(i + 1)) { + continue; + } + offenders.push({ + file: toRepoPosixRelative(abs, REPO_ROOT), + line: i + 1, + identifier: interpolated, + text: line.trim(), + }); + } + } + if (offenders.length > 0) { + const detail = offenders + .map( + (o) => + ` ${o.file}:${o.line} $\{${o.identifier}\} -> ${o.text}` + ) + .join("\n"); + throw new Error( + "Found path-like log interpolations not wrapped in toPosixPath / toRepoPosixRelative.\n" + + "Wrap each occurrence, or (with justification) add an allowlist entry in\n" + + "scripts/__tests__/cross-platform-path-handling.test.js.\n\n" + + detail + ); + } + }); + }); +}); diff --git a/scripts/__tests__/cross-platform-path-handling.test.js.meta b/scripts/__tests__/cross-platform-path-handling.test.js.meta new file mode 100644 index 00000000..bc5e22f9 --- /dev/null +++ b/scripts/__tests__/cross-platform-path-handling.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: aa3f689175d4a0642b4dca51f36b9570 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/doctor.test.js b/scripts/__tests__/doctor.test.js new file mode 100644 index 00000000..2133fca6 --- /dev/null +++ b/scripts/__tests__/doctor.test.js @@ -0,0 +1,1403 @@ +/** + * @fileoverview Tests for scripts/doctor.js -- the agentic preflight gate. + * + * Each section function in doctor.js accepts a dependency-injection options + * bag so these tests can drive every probe with deterministic fakes + * (synthetic fs, synthetic spawnSync, synthetic YAML). Real fs/child_process + * are not touched. + */ + +"use strict"; + +const path = require("path"); + +const { + STALE_CACHE_DAYS, + STALE_CACHE_MS, + KNOWN_STATUSES, + aggregateStatus, + statusRank, + statusToExitCode, + checkNodeModulesFreshness, + checkIsolatedJestCache, + checkEolPolicy, + checkPreCommitConfig, + checkHookPerfBudget, + checkCrossPlatformSanity, + checkWorkingTreeState, + probeToolVersions, + runDoctor, + shouldUseColor, + decorateStatusLabel, + formatHeaderBanner, + formatSection, + formatFooter, + main, +} = require("../doctor"); + +const precommitYaml = require("../lib/precommit-yaml"); +const precommitPerfScore = require("../lib/precommit-perf-score"); + +function noopRequire() { + return {}; +} + +describe("doctor.js status helpers", () => { + test("statusRank orders fail > warn > ok", () => { + expect(statusRank("fail")).toBeGreaterThan(statusRank("warn")); + expect(statusRank("warn")).toBeGreaterThan(statusRank("ok")); + }); + + test("aggregateStatus returns the worst section status", () => { + const swallow = () => {}; + expect(aggregateStatus([{ status: "ok" }, { status: "ok" }], { warn: swallow })).toBe("ok"); + expect(aggregateStatus([{ status: "ok" }, { status: "warn" }], { warn: swallow })).toBe("warn"); + expect(aggregateStatus([{ status: "warn" }, { status: "fail" }], { warn: swallow })).toBe("fail"); + expect(aggregateStatus([], { warn: swallow })).toBe("ok"); + }); + + test("aggregateStatus treats unrecognized statuses as fail and logs a warning (M4)", () => { + const warnings = []; + const result = aggregateStatus( + [ + { name: "weird", status: "maybe" }, + { name: "fine", status: "ok" }, + ], + { warn: (msg) => warnings.push(msg) } + ); + expect(result).toBe("fail"); + expect(warnings.length).toBe(1); + expect(warnings[0]).toContain("'weird'"); + expect(warnings[0]).toContain("'maybe'"); + expect(warnings[0]).toContain("treating as 'fail'"); + }); + + test("aggregateStatus accepts a default warn sink (smoke test, swallow stderr)", () => { + // No explicit warn override -> the function reaches for the default + // sink (process.stderr.write). We do not assert on stderr; we only + // care that the call still returns the fail-safe status. + const originalWrite = process.stderr.write; + process.stderr.write = () => true; + try { + const result = aggregateStatus([{ name: "x", status: "unknown" }]); + expect(result).toBe("fail"); + } finally { + process.stderr.write = originalWrite; + } + }); + + test("statusToExitCode returns 1 only for fail", () => { + expect(statusToExitCode("ok")).toBe(0); + expect(statusToExitCode("warn")).toBe(0); + expect(statusToExitCode("fail")).toBe(1); + }); + + test("STALE_CACHE_MS matches STALE_CACHE_DAYS", () => { + expect(STALE_CACHE_MS).toBe(STALE_CACHE_DAYS * 24 * 60 * 60 * 1000); + }); +}); + +describe("checkNodeModulesFreshness", () => { + // Step 10: checkNodeModulesFreshness now consumes probeIntegrity from + // scripts/lib/node-modules-integrity.js as the file-existence + size + // layer. Tests inject a no-op `probeIntegrityFn` so the existing fake-fs + // fixtures remain authoritative for the legacy `requiredFiles` path. + const noopProbeIntegrity = () => ({ ok: true, missing: [] }); + + test("returns ok when every tool resolves, exists, and loads", () => { + const toolSpecs = [ + { + name: "prettier", + requiredFiles: ["node_modules/prettier/index.cjs"], + load: "require", + entry: "node_modules/prettier/index.cjs", + }, + { + name: "markdownlint-cli2", + requiredFiles: ["node_modules/markdownlint-cli2/markdownlint-cli2.mjs"], + load: "import", + entry: "node_modules/markdownlint-cli2/markdownlint-cli2.mjs", + }, + { + name: "cspell", + requiredFiles: ["node_modules/cspell/bin.mjs"], + }, + { + name: "jest-circus", + requiredFiles: [], + load: "resolve", + entry: "jest-circus/runner", + }, + ]; + + const section = checkNodeModulesFreshness({ + probeIntegrityFn: noopProbeIntegrity, + toolSpecs, + existsSyncFn: () => true, + requireResolveFn: () => "/repo/node_modules/jest-circus/build/runner.js", + requireFn: noopRequire, + repoRoot: "/repo", + }); + + expect(section.name).toBe("node_modules freshness"); + expect(section.status).toBe("ok"); + expect(section.lines.some((line) => line.includes("ok prettier"))).toBe(true); + expect(section.lines.some((line) => line.includes("ok jest-circus"))).toBe(true); + }); + + test("returns fail when one tool's required file is missing", () => { + const toolSpecs = [ + { + name: "prettier", + requiredFiles: ["node_modules/prettier/index.cjs"], + load: "require", + entry: "node_modules/prettier/index.cjs", + }, + { + name: "missing-tool", + requiredFiles: ["node_modules/missing-tool/bin.js"], + }, + ]; + + const section = checkNodeModulesFreshness({ + probeIntegrityFn: noopProbeIntegrity, + toolSpecs, + existsSyncFn: (absPath) => !absPath.includes("missing-tool"), + requireFn: noopRequire, + requireResolveFn: () => "/repo/node_modules/jest-circus/build/runner.js", + repoRoot: "/repo", + }); + + expect(section.status).toBe("fail"); + const text = section.lines.join("\n"); + expect(text).toContain("FAIL missing-tool"); + expect(text).toContain("missing required file"); + expect(text).toContain("npm ci"); + }); + + test("returns fail when require throws", () => { + const toolSpecs = [ + { + name: "prettier", + requiredFiles: ["node_modules/prettier/index.cjs"], + load: "require", + entry: "node_modules/prettier/index.cjs", + }, + ]; + + const section = checkNodeModulesFreshness({ + probeIntegrityFn: noopProbeIntegrity, + toolSpecs, + existsSyncFn: () => true, + requireFn: () => { + throw new Error("transitive dep broken"); + }, + requireResolveFn: () => "/path", + repoRoot: "/repo", + }); + + expect(section.status).toBe("fail"); + expect(section.lines.join("\n")).toContain("transitive dep broken"); + }); + + test("handles tool-specs without load/entry (m5: cspell, jest bare)", () => { + // A spec that only has requiredFiles (no load mode, no entry) should + // still succeed when those files exist. This is the cspell/jest-bare + // shape -- the spec contributes a presence check only. + const toolSpecs = [ + { + name: "cspell", + requiredFiles: ["node_modules/cspell/bin.mjs"], + }, + { + name: "jest", + requiredFiles: [ + "node_modules/jest/package.json", + "node_modules/jest/bin/jest.js", + ], + }, + ]; + + const section = checkNodeModulesFreshness({ + probeIntegrityFn: noopProbeIntegrity, + toolSpecs, + existsSyncFn: () => true, + requireFn: noopRequire, + requireResolveFn: () => "/should/not/be/called", + repoRoot: "/repo", + }); + + expect(section.status).toBe("ok"); + const text = section.lines.join("\n"); + expect(text).toContain("ok cspell"); + expect(text).toContain("ok jest"); + // No resolve/required/loaded lines because there is no entry. + expect(text).not.toContain("resolved:"); + expect(text).not.toContain("required:"); + expect(text).not.toContain("loaded:"); + }); + + test("returns fail when resolve-mode lookup throws", () => { + const toolSpecs = [ + { + name: "jest-circus", + requiredFiles: [], + load: "resolve", + entry: "jest-circus/runner", + }, + ]; + + const section = checkNodeModulesFreshness({ + probeIntegrityFn: noopProbeIntegrity, + toolSpecs, + existsSyncFn: () => true, + requireFn: noopRequire, + requireResolveFn: () => { + throw new Error("Cannot find module 'jest-circus/runner'"); + }, + repoRoot: "/repo", + }); + + expect(section.status).toBe("fail"); + expect(section.lines.join("\n")).toContain("Cannot find module"); + }); +}); + +describe("checkIsolatedJestCache", () => { + function makeStat(mtimeMs) { + return { mtimeMs }; + } + + test("returns ok with no-cache message when cache root does not exist", () => { + const section = checkIsolatedJestCache({ + existsSyncFn: () => false, + readdirSyncFn: () => { + throw new Error("should not read when root missing"); + }, + statSyncFn: () => { + throw new Error("should not stat"); + }, + cacheRoot: "/tmp/dxmessaging-managed-jest", + nowMs: 1_700_000_000_000, + }); + + expect(section.status).toBe("ok"); + const text = section.lines.join("\n"); + expect(text).toContain("No isolated managed-Jest cache yet"); + expect(text).toContain("Manual reset"); + }); + + test("returns ok when cache root exists but is empty", () => { + const section = checkIsolatedJestCache({ + existsSyncFn: () => true, + readdirSyncFn: () => [], + statSyncFn: () => makeStat(0), + createRequireFn: () => ({ resolve: () => "/never" }), + cacheRoot: "/tmp/dxmessaging-managed-jest", + nowMs: 1_700_000_000_000, + }); + + expect(section.status).toBe("ok"); + expect(section.lines.join("\n")).toContain("contains no install dirs"); + }); + + test("returns warn for stale entries (>30 days mtime)", () => { + const now = 1_700_000_000_000; + const fortyDaysAgo = now - 40 * 24 * 60 * 60 * 1000; + + // existsSyncFn must return true for the cache root, the package.json + // inside the install dir, and the resolved runner path. + const existsSyncFn = jest.fn(() => true); + const readdirSyncFn = () => [ + { name: "jest_30.3.0", isDirectory: () => true }, + ]; + const statSyncFn = () => makeStat(fortyDaysAgo); + const createRequireFn = () => ({ + resolve: () => "/tmp/dxmessaging-managed-jest/jest_30.3.0/node_modules/jest-circus/build/runner.js", + }); + + const section = checkIsolatedJestCache({ + existsSyncFn, + readdirSyncFn, + statSyncFn, + createRequireFn, + cacheRoot: "/tmp/dxmessaging-managed-jest", + nowMs: now, + }); + + expect(section.status).toBe("warn"); + const text = section.lines.join("\n"); + expect(text).toContain("STALE"); + expect(text).toContain("age=40d"); + }); + + test("returns fail when jest-circus/runner cannot be resolved from the install dir", () => { + const now = 1_700_000_000_000; + const recentMs = now - 1000; + + const section = checkIsolatedJestCache({ + existsSyncFn: () => true, + readdirSyncFn: () => [{ name: "jest_30.3.0", isDirectory: () => true }], + statSyncFn: () => makeStat(recentMs), + createRequireFn: () => ({ + resolve: () => { + throw new Error("Cannot find module 'jest-circus/runner'"); + }, + }), + cacheRoot: "/tmp/dxmessaging-managed-jest", + nowMs: now, + }); + + expect(section.status).toBe("fail"); + expect(section.lines.join("\n")).toContain("Cannot find module 'jest-circus/runner'"); + }); + + test("returns fail when readdirSync throws (m4)", () => { + // Simulates a read failure mid-flight (EACCES, EIO, etc.). The + // section should report fail and surface the underlying error + // message; it must not silently treat the cache as empty. + const section = checkIsolatedJestCache({ + existsSyncFn: () => true, + readdirSyncFn: () => { + const err = new Error("EACCES: permission denied"); + err.code = "EACCES"; + throw err; + }, + statSyncFn: () => makeStat(0), + createRequireFn: () => ({ resolve: () => "/never" }), + cacheRoot: "/tmp/dxmessaging-managed-jest", + nowMs: 1_700_000_000_000, + }); + + expect(section.status).toBe("fail"); + const text = section.lines.join("\n"); + expect(text).toContain("Could not read cache root"); + expect(text).toContain("EACCES: permission denied"); + expect(text).toContain("Manual reset"); + }); + + test("ignores non-directory entries under cache root", () => { + const section = checkIsolatedJestCache({ + existsSyncFn: () => true, + readdirSyncFn: () => [ + { name: "stray-file.txt", isDirectory: () => false }, + ], + statSyncFn: () => makeStat(Date.now()), + createRequireFn: () => ({ resolve: () => "/never" }), + cacheRoot: "/tmp/dxmessaging-managed-jest", + nowMs: Date.now(), + }); + + expect(section.status).toBe("ok"); + expect(section.lines.join("\n")).toContain("contains no install dirs"); + }); +}); + +describe("checkEolPolicy", () => { + test("returns ok and reports policy counts consistent with the shared policy module", () => { + const realPolicy = require("../lib/eol-policy"); + const section = checkEolPolicy({ policy: realPolicy }); + + expect(section.status).toBe("ok"); + const text = section.lines.join("\n"); + expect(text).toContain(`CRLF extensions (${realPolicy.crlfExts.size})`); + expect(text).toContain(`LF extensions (${realPolicy.lfExts.size})`); + const expectedTotal = realPolicy.crlfExts.size + realPolicy.lfExts.size; + expect(text).toContain(`total tracked extensions: ${expectedTotal}`); + }); + + test("returns fail when an extension appears in both crlfExts and lfExts", () => { + const conflictingPolicy = { + crlfExts: new Set([".cs", ".js"]), + lfExts: new Set([".js", ".md"]), + }; + const section = checkEolPolicy({ policy: conflictingPolicy }); + expect(section.status).toBe("fail"); + expect(section.lines.join("\n")).toContain(".js"); + }); +}); + +describe("checkPreCommitConfig", () => { + const sampleConfig = [ + "repos:", + " - repo: local", + " hooks:", + " - id: alpha", + " entry: echo alpha", + " language: system", + " stages:", + " - pre-commit", + " - id: beta", + " entry: echo beta", + " language: system", + " stages:", + " - pre-push", + " - id: gamma", + " entry: echo gamma", + " language: system", + " stages:", + " - pre-commit", + " - pre-push", + "", + ].join("\n"); + + function makeReadFile(map) { + return (filePath) => { + if (Object.prototype.hasOwnProperty.call(map, filePath)) { + return map[filePath]; + } + throw new Error(`unexpected read: ${filePath}`); + }; + } + + test("counts total hooks and pre-push subset, verifies preflight coverage script", () => { + const packageJson = JSON.stringify({ + scripts: { + "preflight:pre-push": + "npm run preflight:pre-commit && pre-commit run --hook-stage pre-push --all-files", + }, + }); + + const section = checkPreCommitConfig({ + readFileSyncFn: makeReadFile({ + "/repo/.pre-commit-config.yaml": sampleConfig, + "/repo/package.json": packageJson, + }), + parsePrecommitYaml: precommitYaml, + runCommandFn: () => ({ + status: 0, + error: null, + stdout: "pre-commit 4.0.0", + stderr: "", + }), + configPath: "/repo/.pre-commit-config.yaml", + packageJsonPath: "/repo/package.json", + }); + + expect(section.status).toBe("ok"); + const text = section.lines.join("\n"); + expect(text).toContain("Parsed 3 hook block(s)"); + expect(text).toContain("pre-push hooks (2): beta, gamma"); + expect(text).toContain("pre-commit --version: pre-commit 4.0.0"); + expect(text).toContain("preflight:pre-push script present"); + expect(text).toContain( + "invokes 'pre-commit run --hook-stage pre-push --all-files'" + ); + }); + + test("returns fail when preflight:pre-push is missing", () => { + const packageJson = JSON.stringify({ scripts: {} }); + + const section = checkPreCommitConfig({ + readFileSyncFn: makeReadFile({ + "/repo/.pre-commit-config.yaml": sampleConfig, + "/repo/package.json": packageJson, + }), + parsePrecommitYaml: precommitYaml, + runCommandFn: () => ({ status: 1, error: null, stdout: "", stderr: "" }), + configPath: "/repo/.pre-commit-config.yaml", + packageJsonPath: "/repo/package.json", + }); + + expect(section.status).toBe("fail"); + expect(section.lines.join("\n")).toContain("preflight:pre-push is missing"); + }); + + test("returns fail when preflight:pre-push lacks the all-files invocation", () => { + const packageJson = JSON.stringify({ + scripts: { + "preflight:pre-push": "echo not-really", + }, + }); + + const section = checkPreCommitConfig({ + readFileSyncFn: makeReadFile({ + "/repo/.pre-commit-config.yaml": sampleConfig, + "/repo/package.json": packageJson, + }), + parsePrecommitYaml: precommitYaml, + runCommandFn: () => ({ status: 1, error: null, stdout: "", stderr: "" }), + configPath: "/repo/.pre-commit-config.yaml", + packageJsonPath: "/repo/package.json", + }); + + expect(section.status).toBe("fail"); + expect(section.lines.join("\n")).toContain( + "does not invoke 'pre-commit run --hook-stage pre-push --all-files'" + ); + }); + + test("fails when pre-commit is not on PATH (ENOENT)", () => { + // Without `pre-commit` on PATH, `npm run preflight:pre-push` cannot + // run its `pre-commit run --hook-stage pre-push --all-files` step. + // The doctor must surface this as a hard failure -- it directly + // contradicts the doctor's premise that "this branch is safe to push". + const packageJson = JSON.stringify({ + scripts: { + "preflight:pre-push": + "npm run preflight:pre-commit && pre-commit run --hook-stage pre-push --all-files", + }, + }); + + const section = checkPreCommitConfig({ + readFileSyncFn: makeReadFile({ + "/repo/.pre-commit-config.yaml": sampleConfig, + "/repo/package.json": packageJson, + }), + parsePrecommitYaml: precommitYaml, + runCommandFn: () => ({ + status: null, + error: { code: "ENOENT", message: "ENOENT" }, + stdout: "", + stderr: "", + }), + configPath: "/repo/.pre-commit-config.yaml", + packageJsonPath: "/repo/package.json", + }); + + expect(section.status).toBe("fail"); + const text = section.lines.join("\n"); + expect(text).toContain("pre-commit --version: not on PATH (ENOENT)"); + expect(text).toContain("preflight:pre-push cannot run"); + expect(text).toContain("pip install pre-commit"); + }); + + test("fails when pre-commit --version errors with a non-ENOENT reason", () => { + const packageJson = JSON.stringify({ + scripts: { + "preflight:pre-push": + "npm run preflight:pre-commit && pre-commit run --hook-stage pre-push --all-files", + }, + }); + + const section = checkPreCommitConfig({ + readFileSyncFn: makeReadFile({ + "/repo/.pre-commit-config.yaml": sampleConfig, + "/repo/package.json": packageJson, + }), + parsePrecommitYaml: precommitYaml, + runCommandFn: () => ({ + status: 2, + error: null, + stdout: "", + stderr: "some other failure", + }), + configPath: "/repo/.pre-commit-config.yaml", + packageJsonPath: "/repo/package.json", + }); + + expect(section.status).toBe("fail"); + expect(section.lines.join("\n")).toContain("pre-commit --version: not available"); + }); + + test("accepts a pre-resolved preCommitVersionResult and does NOT spawn", () => { + const packageJson = JSON.stringify({ + scripts: { + "preflight:pre-push": + "npm run preflight:pre-commit && pre-commit run --hook-stage pre-push --all-files", + }, + }); + + let spawned = 0; + const section = checkPreCommitConfig({ + readFileSyncFn: makeReadFile({ + "/repo/.pre-commit-config.yaml": sampleConfig, + "/repo/package.json": packageJson, + }), + parsePrecommitYaml: precommitYaml, + runCommandFn: () => { + spawned += 1; + return { status: 1, error: null, stdout: "", stderr: "" }; + }, + preCommitVersionResult: { + status: 0, + error: null, + stdout: "pre-commit 3.6.0", + stderr: "", + }, + configPath: "/repo/.pre-commit-config.yaml", + packageJsonPath: "/repo/package.json", + }); + + expect(spawned).toBe(0); + expect(section.status).toBe("ok"); + expect(section.lines.join("\n")).toContain("pre-commit --version: pre-commit 3.6.0"); + }); +}); + +describe("checkHookPerfBudget", () => { + test("returns ok when scoreConfig reports a score within budget", () => { + const section = checkHookPerfBudget({ + readFileSyncFn: () => "irrelevant -- scoreConfigFn is mocked", + scoreConfigFn: () => ({ + totalScore: 5, + perHookScores: [], + allowList: [], + rejections: [], + perHookViolations: [], + }), + budget: 10, + perHookCeiling: 3, + configPath: "/repo/.pre-commit-config.yaml", + }); + + expect(section.status).toBe("ok"); + expect(section.lines.join("\n")).toContain("score: 5 (budget=10"); + }); + + test("returns fail when score exceeds the budget", () => { + const section = checkHookPerfBudget({ + readFileSyncFn: () => "irrelevant", + scoreConfigFn: () => ({ + totalScore: 99, + perHookScores: [], + allowList: [], + rejections: [], + perHookViolations: [], + }), + budget: 10, + perHookCeiling: 3, + configPath: "/repo/.pre-commit-config.yaml", + }); + + expect(section.status).toBe("fail"); + expect(section.lines.join("\n")).toContain("exceeds whole-pipeline budget"); + }); + + test("returns fail when per-hook ceiling is violated", () => { + const section = checkHookPerfBudget({ + readFileSyncFn: () => "irrelevant", + scoreConfigFn: () => ({ + totalScore: 5, + perHookScores: [], + allowList: [], + rejections: [], + perHookViolations: [ + { id: "bad-hook", score: 5, ceiling: 3 }, + ], + }), + budget: 10, + perHookCeiling: 3, + configPath: "/repo/.pre-commit-config.yaml", + }); + + expect(section.status).toBe("fail"); + expect(section.lines.join("\n")).toContain("bad-hook"); + }); + + test("returns fail when scoreConfig reports rejected perf-allow directives", () => { + const section = checkHookPerfBudget({ + readFileSyncFn: () => "irrelevant", + scoreConfigFn: () => ({ + totalScore: 5, + perHookScores: [], + allowList: [], + rejections: [ + { id: "noisy", startLine: 12, error: "reason too short" }, + ], + perHookViolations: [], + }), + budget: 10, + perHookCeiling: 3, + configPath: "/repo/.pre-commit-config.yaml", + }); + + expect(section.status).toBe("fail"); + expect(section.lines.join("\n")).toContain("reason too short"); + }); + + test("integration: live precommit-perf-score.scoreConfig consumed correctly", () => { + // The doctor reuses the EXISTING scoreConfig (per the deliverable + // constraints). This integration smoke-test runs the real scorer + // against a tiny synthetic config to prove the wiring. + const tinyConfig = [ + "repos:", + " - repo: local", + " hooks:", + " - id: cheap", + " entry: echo hi", + " language: system", + " stages:", + " - pre-commit", + ].join("\n"); + + const section = checkHookPerfBudget({ + readFileSyncFn: () => tinyConfig, + scoreConfigFn: precommitPerfScore.scoreConfig, + budget: precommitPerfScore.PERF_BUDGET, + perHookCeiling: precommitPerfScore.PER_HOOK_CEILING, + configPath: "/synthetic/.pre-commit-config.yaml", + }); + + expect(section.status).toBe("ok"); + }); +}); + +describe("checkCrossPlatformSanity", () => { + test("reports the injected platform and LF package.json", () => { + const calls = []; + const runCommandFn = (cmd) => { + calls.push(cmd); + if (cmd === "pwsh") { + return { status: 0, stdout: "PowerShell 7.4.0\n", error: null }; + } + if (cmd === "bash") { + return { + status: 0, + stdout: "GNU bash, version 5.2.15(1)-release\n", + error: null, + }; + } + return { status: 1, error: null, stdout: "", stderr: "" }; + }; + + const section = checkCrossPlatformSanity({ + platformFn: () => "linux", + runCommandFn, + readFileSyncFn: () => + "{\n \"name\": \"clean\"\n}\n", + packageJsonPath: "/repo/package.json", + shellEnv: "/bin/bash", + }); + + expect(section.status).toBe("ok"); + const text = section.lines.join("\n"); + expect(text).toContain("process.platform: linux"); + expect(text).toContain("PowerShell 7.4.0"); + expect(text).toContain("bash, version 5.2.15"); + expect(text).toContain("package.json line endings: LF"); + expect(calls).toEqual(["pwsh", "bash"]); + }); + + test("gracefully handles pwsh missing on Linux (does not fail)", () => { + const section = checkCrossPlatformSanity({ + platformFn: () => "linux", + runCommandFn: (cmd) => { + if (cmd === "pwsh") { + return { status: null, error: { code: "ENOENT" }, stdout: "", stderr: "" }; + } + if (cmd === "bash") { + return { status: 0, stdout: "GNU bash, version 5.2\n", error: null }; + } + return { status: 1, error: null, stdout: "", stderr: "" }; + }, + readFileSyncFn: () => "{\n}\n", + packageJsonPath: "/repo/package.json", + shellEnv: "/bin/bash", + }); + + expect(section.status).toBe("ok"); + expect(section.lines.join("\n")).toContain("pwsh: not installed"); + }); + + test("flags pwsh ENOENT as warn on win32", () => { + const section = checkCrossPlatformSanity({ + platformFn: () => "win32", + runCommandFn: (cmd) => { + if (cmd === "pwsh") { + return { status: null, error: { code: "ENOENT" }, stdout: "", stderr: "" }; + } + if (cmd === "bash") { + return { status: 0, stdout: "GNU bash, version 5.2\n", error: null }; + } + return { status: 1, error: null, stdout: "", stderr: "" }; + }, + readFileSyncFn: () => "{}\n", + packageJsonPath: "/repo/package.json", + shellEnv: "C:\\Windows\\System32\\cmd.exe", + }); + + expect(section.status).toBe("warn"); + }); + + test("fails when package.json has CRLF line endings", () => { + const section = checkCrossPlatformSanity({ + platformFn: () => "linux", + runCommandFn: () => ({ status: 0, stdout: "x\n", error: null }), + readFileSyncFn: () => "{\r\n \"name\": \"crlf\"\r\n}\r\n", + packageJsonPath: "/repo/package.json", + shellEnv: "/bin/bash", + }); + + expect(section.status).toBe("fail"); + expect(section.lines.join("\n")).toContain("CRLF line endings"); + }); + + test("fails when package.json cannot be read", () => { + const section = checkCrossPlatformSanity({ + platformFn: () => "linux", + runCommandFn: () => ({ status: 0, stdout: "x\n", error: null }), + readFileSyncFn: () => { + throw new Error("ENOENT: package.json"); + }, + packageJsonPath: "/repo/package.json", + shellEnv: "/bin/bash", + }); + + expect(section.status).toBe("fail"); + expect(section.lines.join("\n")).toContain("Could not read"); + }); +}); + +describe("checkWorkingTreeState", () => { + function makeRun(stdout, overrides = {}) { + return () => ({ + status: 0, + error: null, + stdout, + stderr: "", + ...overrides, + }); + } + + test("returns ok on a clean tree (empty porcelain)", () => { + const section = checkWorkingTreeState({ runCommandFn: makeRun("") }); + expect(section.status).toBe("ok"); + expect(section.lines.join("\n")).toContain("Working tree is clean"); + }); + + test("returns fail listing untracked paths (validate-untracked-policy gate)", () => { + // Mirrors the exact case the adversarial reviewer caught: an untracked + // path under scripts/ that validate-untracked-policy would reject. + const section = checkWorkingTreeState({ + runCommandFn: makeRun("?? scripts/foo.js\n"), + }); + expect(section.status).toBe("fail"); + const text = section.lines.join("\n"); + expect(text).toContain("1 untracked-and-unignored path(s)"); + expect(text).toContain("?? scripts/foo.js"); + expect(text).toContain("Remediation:"); + expect(text).toContain(".gitignore"); + expect(text).toContain("git add -N"); + }); + + test("returns warn for modified-but-not-staged paths", () => { + // " M" -> worktree change, no staged change. preflight does NOT fail + // on these, but the doctor warns so the operator notices the drift. + const section = checkWorkingTreeState({ + runCommandFn: makeRun(" M scripts/foo.js\n"), + }); + expect(section.status).toBe("warn"); + const text = section.lines.join("\n"); + expect(text).toContain("1 modified-but-unstaged path(s)"); + expect(text).toContain(" M scripts/foo.js"); + }); + + test("returns ok when only staged changes are present", () => { + // "M " -> staged modification, worktree matches index. The push will + // carry this; nothing to flag. + const section = checkWorkingTreeState({ + runCommandFn: makeRun("M scripts/foo.js\n"), + }); + expect(section.status).toBe("ok"); + const text = section.lines.join("\n"); + expect(text).toContain("1 staged path(s)"); + expect(text).toContain("M scripts/foo.js"); + }); + + test("includes staged + unstaged info under a FAIL untracked diagnostic", () => { + const porcelain = [ + "?? new.js", + " M modified.js", + "M staged.js", + ].join("\n") + "\n"; + const section = checkWorkingTreeState({ + runCommandFn: makeRun(porcelain), + }); + expect(section.status).toBe("fail"); + const text = section.lines.join("\n"); + expect(text).toContain("?? new.js"); + expect(text).toContain(" M modified.js"); + expect(text).toContain("M staged.js"); + }); + + test("fails when git is not on PATH (ENOENT)", () => { + const section = checkWorkingTreeState({ + runCommandFn: () => ({ + status: null, + error: { code: "ENOENT", message: "ENOENT" }, + stdout: "", + stderr: "", + }), + }); + expect(section.status).toBe("fail"); + expect(section.lines.join("\n")).toContain("git not found on PATH"); + }); + + test("fails when git status exits non-zero", () => { + const section = checkWorkingTreeState({ + runCommandFn: () => ({ + status: 128, + error: null, + stdout: "", + stderr: "fatal: not a git repository", + }), + }); + expect(section.status).toBe("fail"); + const text = section.lines.join("\n"); + expect(text).toContain("git status exited with status 128"); + expect(text).toContain("not a git repository"); + }); + + test("fails when spawn returns a non-ENOENT error", () => { + const section = checkWorkingTreeState({ + runCommandFn: () => ({ + status: null, + error: new Error("ETXTBSY: text file busy"), + stdout: "", + stderr: "", + }), + }); + expect(section.status).toBe("fail"); + expect(section.lines.join("\n")).toContain("ETXTBSY"); + }); + + test("treats intent-to-add (` A` after git add -N) as warn, NOT fail", () => { + // `git add -N path` produces " A path" in porcelain output. The + // important distinction is that validate-untracked-policy's underlying + // command (`git ls-files --others --exclude-standard`) does NOT list + // intent-to-add files, so they are NOT a hard failure. Treating them + // as modified-but-unstaged matches that semantic. + const section = checkWorkingTreeState({ + runCommandFn: makeRun(" A scripts/new.js\n"), + }); + expect(section.status).toBe("warn"); + }); +}); + +describe("probeToolVersions", () => { + test("spawns one --version probe per tool and returns them keyed by name", () => { + const calls = []; + const runCommandFn = (cmd, args) => { + calls.push({ cmd, args }); + return { status: 0, error: null, stdout: `${cmd}-version-output`, stderr: "" }; + }; + const probes = probeToolVersions({ runCommandFn }); + expect(probes.npm.stdout).toBe("npm-version-output"); + expect(probes.preCommit.stdout).toBe("pre-commit-version-output"); + expect(probes.pwsh.stdout).toBe("pwsh-version-output"); + expect(probes.bash.stdout).toBe("bash-version-output"); + // Exactly one spawn per tool. + expect(calls.map((c) => c.cmd)).toEqual(["npm", "pre-commit", "pwsh", "bash"]); + }); +}); + +describe("KNOWN_STATUSES", () => { + test("exposes the canonical status vocabulary", () => { + expect(KNOWN_STATUSES).toEqual(["ok", "warn", "fail"]); + }); +}); + +describe("runDoctor aggregator", () => { + test("aggregates section statuses; any fail yields exit code 1", () => { + // We cannot trivially inject all six section deps via runDoctor() + // without effectively duplicating the section logic in tests, so we + // exercise the aggregator pathway with overrides that force one + // section to fail by triggering a failure condition that depends only + // on the injected deps. + const overrides = { + checkNodeModulesFreshness: { + toolSpecs: [ + { + name: "always-missing", + requiredFiles: ["node_modules/always-missing/index.js"], + }, + ], + existsSyncFn: () => false, + requireFn: noopRequire, + requireResolveFn: () => null, + repoRoot: "/repo", + }, + checkIsolatedJestCache: { + existsSyncFn: () => false, + readdirSyncFn: () => [], + statSyncFn: () => ({ mtimeMs: 0 }), + cacheRoot: "/never", + nowMs: 0, + }, + checkEolPolicy: { + policy: { crlfExts: new Set(), lfExts: new Set() }, + }, + checkPreCommitConfig: { + readFileSyncFn: (filePath) => { + if (filePath.endsWith(".pre-commit-config.yaml")) { + return "repos: []\n"; + } + return JSON.stringify({ + scripts: { + "preflight:pre-push": + "npm run preflight:pre-commit && pre-commit run --hook-stage pre-push --all-files", + }, + }); + }, + parsePrecommitYaml: precommitYaml, + runCommandFn: () => ({ status: 0, stdout: "pre-commit 4.0.0", error: null }), + }, + checkHookPerfBudget: { + readFileSyncFn: () => "anything", + scoreConfigFn: () => ({ + totalScore: 0, + perHookScores: [], + allowList: [], + rejections: [], + perHookViolations: [], + }), + budget: 10, + perHookCeiling: 3, + }, + checkCrossPlatformSanity: { + platformFn: () => "linux", + runCommandFn: () => ({ status: 0, stdout: "x\n", error: null }), + readFileSyncFn: () => "{}\n", + shellEnv: "/bin/bash", + }, + checkWorkingTreeState: { + runCommandFn: () => ({ status: 0, stdout: "", error: null, stderr: "" }), + }, + }; + + const report = runDoctor({ overrides }); + expect(report.overall).toBe("fail"); + expect(report.status).toBe(1); + const failingSection = report.sections.find((s) => s.status === "fail"); + expect(failingSection).toBeTruthy(); + expect(failingSection.name).toBe("node_modules freshness"); + }); + + test("aggregates to ok when every section is ok", () => { + const overrides = { + checkNodeModulesFreshness: { + toolSpecs: [], + existsSyncFn: () => true, + requireFn: noopRequire, + requireResolveFn: () => "/x", + repoRoot: "/repo", + }, + checkIsolatedJestCache: { + existsSyncFn: () => false, + readdirSyncFn: () => [], + statSyncFn: () => ({ mtimeMs: 0 }), + cacheRoot: "/never", + nowMs: 0, + }, + checkEolPolicy: { + policy: { crlfExts: new Set([".cs"]), lfExts: new Set([".js"]) }, + }, + checkPreCommitConfig: { + readFileSyncFn: (filePath) => { + if (filePath.endsWith(".pre-commit-config.yaml")) { + return "repos: []\n"; + } + return JSON.stringify({ + scripts: { + "preflight:pre-push": + "npm run preflight:pre-commit && pre-commit run --hook-stage pre-push --all-files", + }, + }); + }, + parsePrecommitYaml: precommitYaml, + runCommandFn: () => ({ status: 0, stdout: "pre-commit 4.0.0", error: null }), + }, + checkHookPerfBudget: { + readFileSyncFn: () => "anything", + scoreConfigFn: () => ({ + totalScore: 0, + perHookScores: [], + allowList: [], + rejections: [], + perHookViolations: [], + }), + budget: 10, + perHookCeiling: 3, + }, + checkCrossPlatformSanity: { + platformFn: () => "linux", + runCommandFn: () => ({ status: 0, stdout: "x\n", error: null }), + readFileSyncFn: () => "{}\n", + shellEnv: "/bin/bash", + }, + checkWorkingTreeState: { + runCommandFn: () => ({ status: 0, stdout: "", error: null, stderr: "" }), + }, + }; + + const report = runDoctor({ overrides }); + expect(report.overall).toBe("ok"); + expect(report.status).toBe(0); + }); + + test("threads versionProbes into pre-commit + cross-platform sections without re-spawning (M1)", () => { + // Sentinel results that the sections SHOULD consume from + // versionProbes. If they ignored versionProbes and spawned their own + // probes, runCommandFn (which we set up to throw) would be invoked + // for those tools. + const versionProbes = { + npm: { status: 0, error: null, stdout: "11.99.0", stderr: "" }, + preCommit: { status: 0, error: null, stdout: "pre-commit SENTINEL-PC", stderr: "" }, + pwsh: { status: 0, error: null, stdout: "PowerShell SENTINEL-PWSH\n", stderr: "" }, + bash: { status: 0, error: null, stdout: "GNU bash, version SENTINEL-BASH\n", stderr: "" }, + }; + + const throwOnSpawn = (cmd) => { + // The aggregator passes the versionProbes-derived results into + // sections; sections must not re-spawn. The only spawn allowed + // through this fixture is git status inside checkWorkingTreeState + // (its own runCommandFn is independently injected below). + throw new Error(`Unexpected spawn for ${cmd} -- versionProbes should have been used`); + }; + + const overrides = { + checkNodeModulesFreshness: { + toolSpecs: [], + existsSyncFn: () => true, + requireFn: noopRequire, + requireResolveFn: () => "/x", + repoRoot: "/repo", + }, + checkIsolatedJestCache: { + existsSyncFn: () => false, + readdirSyncFn: () => [], + statSyncFn: () => ({ mtimeMs: 0 }), + cacheRoot: "/never", + nowMs: 0, + }, + checkEolPolicy: { + policy: { crlfExts: new Set([".cs"]), lfExts: new Set([".js"]) }, + }, + checkPreCommitConfig: { + readFileSyncFn: (filePath) => { + if (filePath.endsWith(".pre-commit-config.yaml")) { + return "repos: []\n"; + } + return JSON.stringify({ + scripts: { + "preflight:pre-push": + "npm run preflight:pre-commit && pre-commit run --hook-stage pre-push --all-files", + }, + }); + }, + parsePrecommitYaml: precommitYaml, + runCommandFn: throwOnSpawn, + }, + checkHookPerfBudget: { + readFileSyncFn: () => "anything", + scoreConfigFn: () => ({ + totalScore: 0, + perHookScores: [], + allowList: [], + rejections: [], + perHookViolations: [], + }), + budget: 10, + perHookCeiling: 3, + }, + checkCrossPlatformSanity: { + platformFn: () => "linux", + runCommandFn: throwOnSpawn, + readFileSyncFn: () => "{}\n", + shellEnv: "/bin/bash", + }, + checkWorkingTreeState: { + runCommandFn: () => ({ status: 0, stdout: "", error: null, stderr: "" }), + }, + }; + + const report = runDoctor({ overrides, versionProbes }); + expect(report.overall).toBe("ok"); + + const preCommitSection = report.sections.find((s) => s.name === "pre-commit config"); + expect(preCommitSection.lines.join("\n")).toContain("SENTINEL-PC"); + + const sanitySection = report.sections.find((s) => s.name === "cross-platform sanity"); + const sanityText = sanitySection.lines.join("\n"); + expect(sanityText).toContain("SENTINEL-PWSH"); + expect(sanityText).toContain("SENTINEL-BASH"); + }); +}); + +describe("doctor presentation helpers", () => { + test("shouldUseColor suppresses color when CI is truthy", () => { + expect(shouldUseColor({ envCi: "1", isTTY: true })).toBe(false); + expect(shouldUseColor({ envCi: "true", isTTY: true })).toBe(false); + expect(shouldUseColor({ envCi: "", isTTY: true })).toBe(true); + expect(shouldUseColor({ envCi: "0", isTTY: true })).toBe(true); + expect(shouldUseColor({ envCi: null, isTTY: false })).toBe(false); + }); + + test("shouldUseColor honors NO_COLOR even on a TTY (m7)", () => { + // NO_COLOR is the highest-priority opt-out per https://no-color.org/. + expect(shouldUseColor({ envNoColor: "1", isTTY: true })).toBe(false); + expect(shouldUseColor({ envNoColor: "true", isTTY: true })).toBe(false); + // Falsy NO_COLOR has no effect; falls through to other rules. + expect(shouldUseColor({ envNoColor: "0", isTTY: true })).toBe(true); + expect(shouldUseColor({ envNoColor: "", isTTY: true })).toBe(true); + }); + + test("shouldUseColor honors FORCE_COLOR even without a TTY (m7)", () => { + // FORCE_COLOR overrides isTTY=false (e.g. when piping through tee). + expect(shouldUseColor({ envForceColor: "1", isTTY: false })).toBe(true); + expect(shouldUseColor({ envForceColor: "true", isTTY: false })).toBe(true); + // NO_COLOR still wins over FORCE_COLOR (the more restrictive opt-out). + expect( + shouldUseColor({ envNoColor: "1", envForceColor: "1", isTTY: false }) + ).toBe(false); + // CI is overridden by FORCE_COLOR. + expect( + shouldUseColor({ envCi: "1", envForceColor: "1", isTTY: false }) + ).toBe(true); + }); + + test("decorateStatusLabel includes ANSI only when useColor=true", () => { + const colorized = decorateStatusLabel("fail", true); + expect(colorized).toMatch(/\x1b\[/); + expect(colorized).toContain("[FAIL]"); + const plain = decorateStatusLabel("fail", false); + expect(plain).toBe("[FAIL]"); + }); + + test("formatHeaderBanner contains repo/node/os fields and uses ASCII box drawing", () => { + const banner = formatHeaderBanner({ + repoRoot: "/repo", + nodeVersion: "v24.0.0", + platform: "linux", + arch: "x64", + shellEnv: "/bin/bash", + npmVersion: "11.0.0", + }); + expect(banner).toContain("Repo: /repo"); + expect(banner).toContain("Node: v24.0.0"); + expect(banner).toContain("npm: 11.0.0"); + expect(banner).toContain("OS: linux/x64"); + // ASCII-only. + for (let i = 0; i < banner.length; i += 1) { + const code = banner.charCodeAt(i); + const ok = code === 0x0a || (code >= 0x20 && code <= 0x7e); + if (!ok) { + throw new Error( + `Header banner contains non-ASCII codepoint 0x${code.toString(16)} at index ${i}` + ); + } + } + }); + + test("formatSection and formatFooter produce printable text", () => { + const section = { name: "x", status: "ok", lines: [" ok body"] }; + const text = formatSection(section, false); + expect(text).toContain("[ OK ] x"); + expect(text).toContain("ok body"); + + const footer = formatFooter([section], "ok", false); + expect(footer).toContain("Summary:"); + expect(footer).toContain("Overall: [ OK ]"); + }); +}); + +describe("doctor main()", () => { + test("main returns the runDoctor status code and writes output via writeFn", () => { + const writes = []; + const writeFn = (text) => writes.push(text); + const runDoctorFn = () => ({ + status: 0, + overall: "ok", + sections: [{ name: "fake", status: "ok", lines: [" ok placeholder"] }], + }); + const runCommandFn = () => ({ status: 0, stdout: "11.0.0", error: null }); + const probeToolVersionsFn = () => ({ + npm: { status: 0, error: null, stdout: "11.0.0", stderr: "" }, + preCommit: { status: 0, error: null, stdout: "pre-commit 4.0.0", stderr: "" }, + pwsh: { status: 0, error: null, stdout: "PowerShell 7.4.0\n", stderr: "" }, + bash: { status: 0, error: null, stdout: "GNU bash, version 5.2\n", stderr: "" }, + }); + + const exitCode = main({ + writeFn, + envCi: "1", + envNoColor: undefined, + envForceColor: undefined, + isTTY: false, + runDoctorFn, + runCommandFn, + probeToolVersionsFn, + }); + + expect(exitCode).toBe(0); + const joined = writes.join(""); + expect(joined).toContain("DxMessaging doctor"); + expect(joined).toContain("[ OK ] fake"); + expect(joined).toContain("Overall: [ OK ]"); + }); + + test("main returns 1 when runDoctor fails", () => { + const writes = []; + const writeFn = (text) => writes.push(text); + const runDoctorFn = () => ({ + status: 1, + overall: "fail", + sections: [{ name: "fake", status: "fail", lines: [" FAIL bad"] }], + }); + const runCommandFn = () => ({ status: 0, stdout: "11.0.0", error: null }); + const probeToolVersionsFn = () => ({ + npm: { status: 0, error: null, stdout: "11.0.0", stderr: "" }, + preCommit: { status: 0, error: null, stdout: "pre-commit 4.0.0", stderr: "" }, + pwsh: { status: 0, error: null, stdout: "PowerShell 7.4.0\n", stderr: "" }, + bash: { status: 0, error: null, stdout: "GNU bash, version 5.2\n", stderr: "" }, + }); + + const exitCode = main({ + writeFn, + envCi: "1", + envNoColor: undefined, + envForceColor: undefined, + isTTY: false, + runDoctorFn, + runCommandFn, + probeToolVersionsFn, + }); + + expect(exitCode).toBe(1); + expect(writes.join("")).toContain("Overall: [FAIL]"); + }); + + test("main calls probeToolVersionsFn exactly once (M1)", () => { + const writes = []; + let probeCalls = 0; + const probeToolVersionsFn = () => { + probeCalls += 1; + return { + npm: { status: 0, error: null, stdout: "11.0.0", stderr: "" }, + preCommit: { status: 0, error: null, stdout: "pre-commit 4.0.0", stderr: "" }, + pwsh: { status: 0, error: null, stdout: "PowerShell 7.4.0\n", stderr: "" }, + bash: { status: 0, error: null, stdout: "GNU bash, version 5.2\n", stderr: "" }, + }; + }; + const runDoctorFn = () => ({ + status: 0, + overall: "ok", + sections: [{ name: "fake", status: "ok", lines: [] }], + }); + + main({ + writeFn: (text) => writes.push(text), + envCi: "1", + envNoColor: undefined, + envForceColor: undefined, + isTTY: false, + runDoctorFn, + runCommandFn: () => ({ status: 0, stdout: "", error: null }), + probeToolVersionsFn, + }); + + expect(probeCalls).toBe(1); + }); +}); + +describe("doctor file lives next to other scripts", () => { + test("doctor.js is located at scripts/doctor.js relative to repo root", () => { + // Defensive: the hook regex and the preflight script entry both + // assume the doctor lives at scripts/doctor.js. If a refactor moves + // it, the failure should surface here, not at push time. + const expected = path.resolve(__dirname, "../doctor.js"); + const required = require.resolve("../doctor"); + expect(required).toBe(expected); + }); +}); diff --git a/scripts/__tests__/doctor.test.js.meta b/scripts/__tests__/doctor.test.js.meta new file mode 100644 index 00000000..8086f8bf --- /dev/null +++ b/scripts/__tests__/doctor.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 57fc2dc45f09a9c5c8de37998cb601cb +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/generate-ambiguous-release-runbook.test.js b/scripts/__tests__/generate-ambiguous-release-runbook.test.js new file mode 100644 index 00000000..bc9b6b2d --- /dev/null +++ b/scripts/__tests__/generate-ambiguous-release-runbook.test.js @@ -0,0 +1,251 @@ +"use strict"; + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + OUTPUT_RELATIVE_PATH, + TRACKED_RUNBOOK_SOURCES, + generateRunbook, + generateRunbookContent +} = require("../generate-ambiguous-release-runbook"); + +const ROOT_DIR = path.resolve(__dirname, "../.."); + +function readRepoFile(relativePath) { + return fs.readFileSync(path.join(ROOT_DIR, relativePath), "utf8"); +} + +describe("generate-ambiguous-release-runbook", () => { + test("generates the local runbook at the expected ignored path", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dx-runbook-")); + try { + const outputPath = generateRunbook({ rootDir: tempRoot }); + + expect(outputPath).toBe(path.join(tempRoot, OUTPUT_RELATIVE_PATH)); + expect(fs.readFileSync(outputPath, "utf8")).toBe(generateRunbookContent()); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + test("does not clobber an existing local runbook by default", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dx-runbook-")); + const outputPath = path.join(tempRoot, OUTPUT_RELATIVE_PATH); + const existingContent = "local operator notes\n"; + + try { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, existingContent, "utf8"); + + expect(() => generateRunbook({ rootDir: tempRoot })).toThrow(/--force/); + expect(fs.readFileSync(outputPath, "utf8")).toBe(existingContent); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + test("does not clobber if the runbook appears during a non-force write", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dx-runbook-")); + const outputPath = path.join(tempRoot, OUTPUT_RELATIVE_PATH); + const existingContent = "created by another process\n"; + const writeFileSyncSpy = jest.spyOn(fs, "writeFileSync"); + + try { + writeFileSyncSpy.mockImplementationOnce((targetPath, content, options) => { + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, existingContent, "utf8"); + + const error = new Error("file already exists"); + error.code = "EEXIST"; + throw error; + }); + + expect(() => generateRunbook({ rootDir: tempRoot })).toThrow(/--force/); + expect(fs.readFileSync(outputPath, "utf8")).toBe(existingContent); + expect(writeFileSyncSpy).toHaveBeenCalledWith( + outputPath, + generateRunbookContent(), + expect.objectContaining({ flag: "wx" }) + ); + } finally { + writeFileSyncSpy.mockRestore(); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + test("overwrites an existing local runbook when force is explicit", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dx-runbook-")); + const outputPath = path.join(tempRoot, OUTPUT_RELATIVE_PATH); + + try { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, "local operator notes\n", "utf8"); + + expect(generateRunbook({ rootDir: tempRoot, force: true })).toBe(outputPath); + expect(fs.readFileSync(outputPath, "utf8")).toBe(generateRunbookContent()); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + test("local runbook points to and includes tracked operator docs", () => { + const content = generateRunbookContent(); + + expect(TRACKED_RUNBOOK_SOURCES).toEqual([ + { + title: "Ambiguous release migration operator guide", + relativePath: "docs/ops/ambiguous-release-migration.md" + }, + { + title: "Release operations", + relativePath: "docs/ops/release-operations.md" + }, + { + title: "GitHub transfer", + relativePath: "docs/ops/github-transfer.md" + }, + { + title: "CI and GitHub settings", + relativePath: "docs/ops/ci-and-github-settings.md" + }, + { + title: "npm release publishing", + relativePath: "docs/ops/npm-release-publishing.md" + }, + { + title: "OpenUPM metadata", + relativePath: "docs/ops/openupm-metadata.md" + }, + { + title: "Unity Asset Store UPM", + relativePath: "docs/ops/unity-asset-store-upm.md" + }, + { + title: "Post-transfer verification", + relativePath: "docs/ops/post-transfer-verification.md" + } + ]); + expect(content).toContain("Tracked source documents to follow:"); + expect(content).toContain("`docs/ops/ambiguous-release-migration.md`"); + expect(content).toContain("`docs/ops/release-operations.md`"); + expect(content).toContain("`docs/ops/unity-asset-store-upm.md`"); + expect(content).toContain("## Public Verification Notes"); + expect(content).toContain("## Public Follow-Up Links"); + expect(content).toContain("## Non-Sensitive Next Actions"); + expect(content).not.toContain("## Included:"); + expect(content).not.toContain("## Ambiguous Release Migration Operator Guide"); + }); + + test("tracked operator docs cover every required migration area", () => { + const content = [ + readRepoFile("docs/ops/ambiguous-release-migration.md"), + readRepoFile("docs/ops/release-operations.md"), + readRepoFile("docs/ops/github-transfer.md"), + readRepoFile("docs/ops/ci-and-github-settings.md"), + readRepoFile("docs/ops/npm-release-publishing.md"), + readRepoFile("docs/ops/openupm-metadata.md"), + readRepoFile("docs/ops/unity-asset-store-upm.md"), + readRepoFile("docs/ops/post-transfer-verification.md") + ].join("\n"); + const requiredPhrases = [ + "## GitHub Repository Transfer", + "## Self-Hosted Unity Runner Setup", + "## GitHub Environments, Secrets, and Protections", + "### Branches and Tags", + "## npm Ownership, Trusted Publishing, and Provenance", + "## Semver Tag Release Flow", + "## OpenUPM Metadata Update", + "## Unity Asset Store UPM Onboarding", + "## Post-Transfer Verification", + "tag protection", + "Dependabot" + ]; + + for (const phrase of requiredPhrases) { + expect(content).toContain(phrase); + } + }); + + test("tracked operator docs preserve exact public release identifiers", () => { + const content = [ + readRepoFile("docs/ops/ambiguous-release-migration.md"), + readRepoFile("docs/ops/release-operations.md"), + readRepoFile("docs/ops/ci-and-github-settings.md"), + readRepoFile("docs/ops/npm-release-publishing.md") + ].join("\n"); + const requiredIdentifiers = [ + "Ambiguous-Interactive/DxMessaging", + "https://github.com/Ambiguous-Interactive/DxMessaging", + "https://ambiguous-interactive.github.io/DxMessaging/", + "com.wallstop-studios.dxmessaging", + ".github/workflows/release.yml", + ".github/workflows/deploy-docs.yml", + ".github/workflows/validate-npm-meta.yml", + ".github/workflows/unity-tests.yml", + ".github/workflows/unity-il2cpp.yml", + ".github/workflows/unity-benchmarks.yml", + "RAM-64GB", + "github-pages", + "vX.Y.Z" + ]; + + for (const identifier of requiredIdentifiers) { + expect(content).toContain(identifier); + } + }); + + test("tracked and generated docs forbid sensitive local operator material", () => { + const content = [ + generateRunbookContent(), + readRepoFile("docs/ops/ambiguous-release-migration.md"), + readRepoFile("docs/ops/ci-and-github-settings.md"), + readRepoFile("docs/ops/github-transfer.md"), + readRepoFile("docs/ops/npm-release-publishing.md"), + readRepoFile("docs/ops/openupm-metadata.md"), + readRepoFile("docs/ops/post-transfer-verification.md"), + readRepoFile("docs/ops/release-operations.md"), + readRepoFile("docs/ops/unity-asset-store-upm.md") + ].join("\n"); + + expect(content).toContain("Do not paste secrets, account screenshots"); + expect(content).toContain("tracked files or this local runbook"); + expect(content).toContain("Keep only non-sensitive verification notes"); + expect(content).toContain("Do not commit secrets"); + expect(content).not.toMatch(/keep maintainer account details/i); + expect(content).not.toMatch(/private notes/i); + expect(content).not.toMatch(/private npm account notes in the local ignored runbook/i); + expect(content).not.toMatch(/account notes.*local ignored runbook/i); + expect(content).not.toMatch(/private status.*local ignored runbook/i); + expect(content).not.toMatch(/record secret existence.*local ignored runbook/i); + expect(content).not.toMatch(/last rotated.*local ignored runbook/i); + expect(content).not.toMatch(/approval status.*local ignored runbook/i); + expect(content).not.toMatch(/approval state.*local ignored runbook/i); + expect(content).not.toMatch(/review state.*tracked follow-up/i); + expect(content).not.toMatch(/release draft url/i); + expect(content).not.toMatch(/open release draft/i); + expect(content).not.toMatch(/secret availability/i); + expect(content).not.toMatch(/pass\/fail state/i); + expect(content).not.toMatch(/ghp_[A-Za-z0-9_]+/); + expect(content).not.toMatch(/npm_[A-Za-z0-9_]+/); + expect(content).not.toMatch(/publisher id:\s*\S+/i); + expect(content).not.toMatch(/local status:\s*\S+/i); + }); + + test("generated runbook directory is gitignored with one-line rationale", () => { + const gitignore = readRepoFile(".gitignore"); + + expect(gitignore).toContain( + "# Local operator runbooks may contain environment-specific execution notes.\n.operator-runbooks/" + ); + }); + + test("generated runbook directory is excluded from npm packages", () => { + const npmignore = readRepoFile(".npmignore"); + const packageJson = JSON.parse(readRepoFile("package.json")); + + expect(npmignore).toContain(".operator-runbooks/"); + expect(packageJson.files).not.toContain(".operator-runbooks/**"); + }); +}); diff --git a/scripts/__tests__/generate-ambiguous-release-runbook.test.js.meta b/scripts/__tests__/generate-ambiguous-release-runbook.test.js.meta new file mode 100644 index 00000000..e99be995 --- /dev/null +++ b/scripts/__tests__/generate-ambiguous-release-runbook.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: e9dcab3b99cbd7643890baf996b42a49 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/generate-skills-index.test.js b/scripts/__tests__/generate-skills-index.test.js index 8b4118cf..0f0d82c7 100644 --- a/scripts/__tests__/generate-skills-index.test.js +++ b/scripts/__tests__/generate-skills-index.test.js @@ -10,8 +10,6 @@ "use strict"; -const childProcess = require("child_process"); - const { applyBrandCapitalization, categoryToTitle, @@ -20,7 +18,6 @@ const { BRAND_NAMES, } = require('../generate-skills-index.js'); const { normalizeToLf } = require('../lib/quote-parser'); -const { toShellCommand, spawnPlatformCommandSync } = require('../lib/shell-command'); describe("generate-skills-index", () => { describe("BRAND_NAMES mapping", () => { @@ -379,136 +376,141 @@ describe("generate-skills-index", () => { }); describe("formatWithPrettier", () => { - test("passes base command to provided spawn implementation", () => { - const spawnSyncMock = jest.fn(() => ({ - status: 0, - stdout: "formatted output", - stderr: "", - })); + test("prefers local in-process Prettier when available", async () => { + const localPrettier = { + resolveConfig: jest.fn(async () => ({ tabWidth: 4 })), + getFileInfo: jest.fn(async () => ({ ignored: false })), + format: jest.fn(async () => "formatted in-process"), + }; + const loadLocalPrettierFn = jest.fn(() => localPrettier); + const runBundledNpxCommandFn = jest.fn(); + + const result = await formatWithPrettier("raw input", { + loadLocalPrettierFn, + runBundledNpxCommandFn, + indexPath: "/repo/.llm/skills/index.md", + }); - const result = formatWithPrettier("raw input", spawnSyncMock); - - expect(result).toBe("formatted output"); - expect(spawnSyncMock).toHaveBeenCalledTimes(1); - const [command, args, options] = spawnSyncMock.mock.calls[0]; - expect(command).toBe("npx"); - expect(args[0]).toBe("--yes"); - expect(args[1].startsWith("--package=prettier@")).toBe(true); - expect(args[2]).toBe("prettier"); - expect(args).toContain("--stdin-filepath"); - expect(options).toEqual( + expect(result).toBe("formatted in-process"); + // Use stringMatching with a regex that accepts both POSIX and + // Windows separators so the test runs identically on every + // platform. The production code uses platform-native path.join, + // which emits "\" on Windows and "/" on POSIX. + expect(loadLocalPrettierFn).toHaveBeenCalledWith( expect.objectContaining({ - input: "raw input", - encoding: "utf8", - cwd: expect.any(String), + localPrettierModulePath: expect.stringMatching( + /node_modules[\\/]prettier[\\/]index\.cjs$/ + ), + localPrettierBinPath: expect.stringMatching( + /node_modules[\\/]prettier[\\/]bin[\\/]prettier\.cjs$/ + ), + }) + ); + expect(localPrettier.resolveConfig).toHaveBeenCalledWith( + "/repo/.llm/skills/index.md", + { editorconfig: true } + ); + expect(localPrettier.getFileInfo).toHaveBeenCalledWith( + "/repo/.llm/skills/index.md", + { resolveConfig: false } + ); + expect(localPrettier.format).toHaveBeenCalledWith( + "raw input", + expect.objectContaining({ + tabWidth: 4, + filepath: "/repo/.llm/skills/index.md", }) ); + expect(runBundledNpxCommandFn).not.toHaveBeenCalled(); }); - test("delegates platform resolution to spawn helper when wrapper enforces win32", () => { - const spawnSyncMock = jest.fn(() => ({ - status: 0, - stdout: "formatted output", - stderr: "", - })); - - const win32Wrapper = (command, args, options) => - spawnPlatformCommandSync(command, args, options, spawnSyncMock, "win32"); + test("returns original content when local Prettier marks index file ignored", async () => { + const localPrettier = { + resolveConfig: jest.fn(async () => ({})), + getFileInfo: jest.fn(async () => ({ ignored: true })), + format: jest.fn(async () => "should not run"), + }; - const result = formatWithPrettier("raw input", win32Wrapper); + const result = await formatWithPrettier("raw input", { + loadLocalPrettierFn: () => localPrettier, + runBundledNpxCommandFn: jest.fn(), + indexPath: "/repo/.llm/skills/index.md", + }); - expect(result).toBe("formatted output"); - expect(spawnSyncMock).toHaveBeenCalledTimes(1); - expect(spawnSyncMock).toHaveBeenCalledWith( - "npx.cmd", - expect.arrayContaining(["--yes", "prettier", "--stdin-filepath"]), - expect.objectContaining({ - input: "raw input", - encoding: "utf8", - cwd: expect.any(String), - shell: true, - windowsHide: true, - }) - ); + expect(result).toBe("raw input"); + expect(localPrettier.format).not.toHaveBeenCalled(); }); - test("delegates platform resolution to spawn helper when wrapper enforces non-win32", () => { - const spawnSyncMock = jest.fn(() => ({ + test("falls back to bundled npm npx-cli when local Prettier is unavailable", async () => { + const runBundledNpxCommandFn = jest.fn(() => ({ status: 0, - stdout: "formatted output", + stdout: "formatted fallback", stderr: "", })); - const linuxWrapper = (command, args, options) => - spawnPlatformCommandSync(command, args, options, spawnSyncMock, "linux"); - - const result = formatWithPrettier("raw input", linuxWrapper); - - expect(result).toBe("formatted output"); - expect(spawnSyncMock).toHaveBeenCalledTimes(1); - - const [command, args, options] = spawnSyncMock.mock.calls[0]; - expect(command).toBe("npx"); - expect(args).toEqual(expect.arrayContaining(["--yes", "prettier", "--stdin-filepath"])); - expect(options).toEqual( - expect.objectContaining({ + const result = await formatWithPrettier("raw input", { + loadLocalPrettierFn: () => null, + runBundledNpxCommandFn, + getPinnedPrettierSpecFn: () => "prettier@3.8.3", + indexPath: "/repo/.llm/skills/index.md", + repoRoot: "/repo", + }); + + expect(result).toBe("formatted fallback"); + expect(runBundledNpxCommandFn).toHaveBeenCalledWith( + [ + "--yes", + "--package=prettier@3.8.3", + "prettier", + "--stdin-filepath", + "/repo/.llm/skills/index.md", + ], + { + cwd: "/repo", input: "raw input", encoding: "utf8", - cwd: expect.any(String), - }) + } ); - expect(options.shell).toBeUndefined(); - expect(options.windowsHide).toBeUndefined(); }); - test("throws when prettier execution fails", () => { - const spawnSyncMock = jest.fn(() => ({ - status: 1, - stdout: "", - stderr: "boom", - })); - - expect(() => formatWithPrettier("raw", spawnSyncMock)).toThrow("Prettier failed: boom"); + test("throws clear fallback error when bundled npx prettier exits non-zero", async () => { + await expect( + formatWithPrettier("raw", { + loadLocalPrettierFn: () => null, + runBundledNpxCommandFn: () => ({ + status: 1, + stdout: "", + stderr: "boom", + }), + }) + ).rejects.toThrow("Prettier fallback via bundled npm npx-cli.js failed: boom"); }); - test("rethrows child process launch errors", () => { - const launchError = new Error("spawn failed"); - const spawnSyncMock = jest.fn(() => ({ - error: launchError, - status: null, - stdout: "", - stderr: "", - })); - - expect(() => formatWithPrettier("raw", spawnSyncMock)).toThrow("spawn failed"); + test("surfaces actionable error when bundled npm npx-cli is missing", async () => { + await expect( + formatWithPrettier("raw", { + loadLocalPrettierFn: () => null, + runBundledNpxCommandFn: () => { + throw new Error("Unable to locate npm's npx-cli.js next to the active Node executable."); + }, + }) + ).rejects.toThrow("Unable to locate npm's npx-cli.js"); }); - test("default invocation resolves npx via platform-aware spawn helper", () => { - const spawnSyncSpy = jest - .spyOn(childProcess, "spawnSync") - .mockReturnValue({ status: 0, stdout: "formatted output", stderr: "" }); - - const result = formatWithPrettier("raw input"); - - const expectedOptions = { - input: "raw input", - encoding: "utf8", - cwd: expect.any(String), - }; - - if (process.platform === "win32") { - expectedOptions.shell = true; - expectedOptions.windowsHide = true; - } - - expect(result).toBe("formatted output"); - expect(spawnSyncSpy).toHaveBeenCalledWith( - toShellCommand("npx"), - expect.arrayContaining(["--yes", "prettier", "--stdin-filepath"]), - expect.objectContaining(expectedOptions) - ); + test("rethrows child process launch errors from bundled npx fallback", async () => { + const launchError = new Error("spawn failed"); - spawnSyncSpy.mockRestore(); + await expect( + formatWithPrettier("raw", { + loadLocalPrettierFn: () => null, + runBundledNpxCommandFn: () => ({ + error: launchError, + status: null, + stdout: "", + stderr: "", + }), + }) + ).rejects.toThrow("spawn failed"); }); }); diff --git a/scripts/__tests__/jest-error-decoder.test.js b/scripts/__tests__/jest-error-decoder.test.js new file mode 100644 index 00000000..7936818d --- /dev/null +++ b/scripts/__tests__/jest-error-decoder.test.js @@ -0,0 +1,353 @@ +/** + * @fileoverview Tests for scripts/lib/jest-error-decoder.js. + */ + +"use strict"; + +const { + PATTERNS, + decodeJestStderr, + formatRepairBanner, + isTruthyEnv, +} = require("../lib/jest-error-decoder"); + +describe("jest-error-decoder", () => { + test("PATTERNS is frozen and contains the four expected kinds in most-specific-first order", () => { + expect(Object.isFrozen(PATTERNS)).toBe(true); + const kinds = PATTERNS.map((entry) => entry.kind); + // Order is precedence: MISSING_TEST_RUNNER (most specific) wins over + // the broader "Cannot find module" patterns when both would match. + // PARTIAL_NODE_MODULES_INSTALL is last because its sentinel regex + // never matches naturally — it is emitted synthetically by the + // integrity gate when auto-repair has been exhausted. + expect(kinds).toEqual([ + "MISSING_TEST_RUNNER", + "CORRUPT_ISOLATED_CACHE", + "MISSING_LOCAL_JEST", + "PARTIAL_NODE_MODULES_INSTALL", + ]); + expect(PATTERNS.length).toBe(4); + expect(PATTERNS[PATTERNS.length - 1].kind).toBe("PARTIAL_NODE_MODULES_INSTALL"); + for (const entry of PATTERNS) { + expect(Object.isFrozen(entry)).toBe(true); + expect(Object.isFrozen(entry.rootCauses)).toBe(true); + expect(Object.isFrozen(entry.repairCommands)).toBe(true); + expect(Object.isFrozen(entry.selfHeal)).toBe(true); + } + }); + + test("MISSING_TEST_RUNNER advertises BOTH isolatedCacheReset and npmCi selfHeal flags", () => { + // The Windows failure that motivated this rewrite was caused by a + // partial extract of the repo's node_modules — npm ci is the right + // recovery, NOT isolated-cache reset. The original entry only set + // isolatedCacheReset; we now expose both so the runtime tier + // dispatcher can pick the correct channel based on the resolved + // runner path's containing tree. + const missingTestRunner = PATTERNS.find((p) => p.kind === "MISSING_TEST_RUNNER"); + expect(missingTestRunner).toBeTruthy(); + expect(missingTestRunner.selfHeal.isolatedCacheReset).toBe(true); + expect(missingTestRunner.selfHeal.npmCi).toBe(true); + expect(missingTestRunner.selfHeal.retryOnce).toBe(true); + }); + + test("PARTIAL_NODE_MODULES_INSTALL is the lowest-priority entry and only matches the integrity-gate sentinel", () => { + const sentinel = PATTERNS[PATTERNS.length - 1]; + expect(sentinel.kind).toBe("PARTIAL_NODE_MODULES_INSTALL"); + // The regex must NOT match ambient Jest stderr; only the explicit + // synthetic sentinel emitted by the gate's banner-print path. + expect(sentinel.regex.test("Cannot find module 'jest-circus/runner'")).toBe(false); + expect(sentinel.regex.test("Module /tmp/x/runner.js in the testRunner option was not found.")).toBe(false); + expect(sentinel.regex.test("__INTEGRITY_GATE_FAILURE__")).toBe(true); + // Decoding the sentinel string returns this entry. + const decoded = decodeJestStderr("__INTEGRITY_GATE_FAILURE__"); + expect(decoded).not.toBeNull(); + expect(decoded.kind).toBe("PARTIAL_NODE_MODULES_INSTALL"); + expect(decoded.selfHeal.npmCi).toBe(false); + expect(decoded.selfHeal.retryOnce).toBe(false); + }); + + test("PATTERNS cannot be mutated by consumers", () => { + // Strict mode (this file uses "use strict") makes mutation of a frozen + // array throw. The throw is incidental to the invariant we care about + // (mutation must not stick), so we test the invariant directly using + // Jest's `toThrow` matcher and then re-read PATTERNS. + expect(() => { + PATTERNS[0].rootCauses.push("malicious"); + }).toThrow(); + expect(PATTERNS[0].rootCauses.includes("malicious")).toBe(false); + }); + + test("decoded result does NOT expose internal regex (dead data)", () => { + const decoded = decodeJestStderr("Cannot find module 'jest/bin/jest.js'"); + expect(decoded).not.toBeNull(); + // The internal regex is an implementation detail; consumers should + // not depend on it, and we now omit it from the returned object. + expect(Object.prototype.hasOwnProperty.call(decoded, "regex")).toBe(false); + }); + + test("decodeJestStderr accepts Buffer input and converts to UTF-8 internally", () => { + const decoded = decodeJestStderr( + Buffer.from("Cannot find module 'jest/bin/jest.js'", "utf8") + ); + expect(decoded).not.toBeNull(); + expect(decoded.kind).toBe("MISSING_LOCAL_JEST"); + }); + + test("decodes the Windows MISSING_TEST_RUNNER stderr verbatim", () => { + const stderr = + "Module D:\\Code\\Packages\\Packages\\com.wallstop-studios.dxmessaging\\node_modules\\jest-circus\\build\\runner.js in the testRunner option was not found."; + const decoded = decodeJestStderr(stderr); + expect(decoded).not.toBeNull(); + expect(decoded.kind).toBe("MISSING_TEST_RUNNER"); + expect(decoded.capturedMatch).toBeTruthy(); + expect(decoded.capturedMatch[1]).toContain( + "D:\\Code\\Packages\\Packages\\com.wallstop-studios.dxmessaging" + ); + expect(decoded.capturedMatch[1]).toContain("jest-circus"); + expect(decoded.capturedMatch[1]).toContain("runner.js"); + }); + + test("decodes 'Cannot find module jest-circus/runner' as CORRUPT_ISOLATED_CACHE", () => { + const decoded = decodeJestStderr( + "Error: Cannot find module 'jest-circus/runner'" + ); + expect(decoded).not.toBeNull(); + expect(decoded.kind).toBe("CORRUPT_ISOLATED_CACHE"); + }); + + test("decodes 'Cannot find module jest/bin/jest.js' as MISSING_LOCAL_JEST", () => { + const decoded = decodeJestStderr("Cannot find module 'jest/bin/jest.js'"); + expect(decoded).not.toBeNull(); + expect(decoded.kind).toBe("MISSING_LOCAL_JEST"); + }); + + test("decodes 'Error: Cannot find module jest' as MISSING_LOCAL_JEST", () => { + const decoded = decodeJestStderr("Error: Cannot find module 'jest'"); + expect(decoded).not.toBeNull(); + expect(decoded.kind).toBe("MISSING_LOCAL_JEST"); + }); + + test("MISSING_LOCAL_JEST regex anchors to line start and is not triggered by mid-line module identifiers", () => { + // The previous regex matched "Cannot find module 'jest'" anywhere in + // the stream, so a short module identifier whose own error text ended + // with that substring would false-positive. Anchoring to line start + // (with optional "Error:" prefix) prevents this. + const stderr = "babylonia: Cannot find module 'jest'"; + expect(decodeJestStderr(stderr)).toBeNull(); + }); + + test("MISSING_LOCAL_JEST does NOT match jest-circus / jest-cli false positives", () => { + // The (?![\w-]) suffix prevents matching neighbors of "jest" that are + // themselves valid module names. jest-circus is covered separately + // by the CORRUPT_ISOLATED_CACHE pattern. + const stderrCircus = "Cannot find module 'jest-circus'"; + const decodedCircus = decodeJestStderr(stderrCircus); + // jest-circus matches CORRUPT_ISOLATED_CACHE (the more-specific pattern). + expect(decodedCircus).not.toBeNull(); + expect(decodedCircus.kind).toBe("CORRUPT_ISOLATED_CACHE"); + + // jest-cli does not match any pattern (not jest-circus, not bare jest). + expect(decodeJestStderr("Cannot find module 'jest-cli'")).toBeNull(); + }); + + test("MISSING_LOCAL_JEST matches 'Error: Cannot find module jest/bin/jest.js' (canonical Node error)", () => { + const decoded = decodeJestStderr( + "Error: Cannot find module 'jest/bin/jest.js'" + ); + expect(decoded).not.toBeNull(); + expect(decoded.kind).toBe("MISSING_LOCAL_JEST"); + }); + + test("pattern order is precedence (most specific first): MISSING_TEST_RUNNER beats MISSING_LOCAL_JEST when both could match", () => { + // A stderr containing both signals — the precise "testRunner option + // was not found" sentence AND a "Cannot find module 'jest'" line — + // must decode to MISSING_TEST_RUNNER because that pattern is more + // specific and is listed earlier in PATTERNS. + const stderr = + "Module /tmp/foo/runner.js in the testRunner option was not found.\n" + + "Cannot find module 'jest'"; + const decoded = decodeJestStderr(stderr); + expect(decoded).not.toBeNull(); + expect(decoded.kind).toBe("MISSING_TEST_RUNNER"); + }); + + test("returns null for unrelated stderr", () => { + expect(decodeJestStderr("some random unrelated error")).toBeNull(); + }); + + test("decodeJestStderr handles null, undefined, and empty input defensively", () => { + expect(decodeJestStderr(null)).toBeNull(); + expect(decodeJestStderr(undefined)).toBeNull(); + expect(decodeJestStderr("")).toBeNull(); + expect(decodeJestStderr(123)).toBeNull(); + }); + + test("formatRepairBanner(null) returns empty string", () => { + expect(formatRepairBanner(null)).toBe(""); + expect(formatRepairBanner(undefined)).toBe(""); + }); + + test("formatRepairBanner contains every repair command and the skill reference", () => { + const decoded = decodeJestStderr( + "Module /tmp/whatever/runner.js in the testRunner option was not found." + ); + const banner = formatRepairBanner(decoded); + expect(typeof banner).toBe("string"); + expect(banner.length).toBeGreaterThan(0); + for (const command of decoded.repairCommands) { + expect(banner).toContain(command); + } + expect(banner).toContain(decoded.skillRef); + expect(banner).toContain(decoded.summary); + expect(banner).toContain(decoded.kind); + }); + + test("formatRepairBanner uses only ASCII (no Unicode box-drawing characters)", () => { + for (const pattern of PATTERNS) { + const decoded = { + kind: pattern.kind, + regex: pattern.regex, + summary: pattern.summary, + rootCauses: pattern.rootCauses, + repairCommands: pattern.repairCommands, + skillRef: pattern.skillRef, + selfHeal: pattern.selfHeal, + capturedMatch: null, + }; + const banner = formatRepairBanner(decoded); + // Every codepoint must be in basic ASCII (printable + newline). + // No Unicode box-drawing (U+2500..U+257F), no smart quotes, no + // BMP glyphs above 0x7F. + for (let index = 0; index < banner.length; index += 1) { + const codePoint = banner.charCodeAt(index); + const isAllowedAscii = + codePoint === 0x0a || // \n + (codePoint >= 0x20 && codePoint <= 0x7e); + if (!isAllowedAscii) { + throw new Error( + `Banner for ${pattern.kind} contains non-ASCII codepoint 0x${codePoint.toString(16)} at index ${index}.` + ); + } + } + // Spot-check: the box characters we DO use are present. + expect(banner).toContain("=".repeat(64)); + expect(banner).toContain("-".repeat(64)); + } + }); + + test("formatRepairBanner color option respects injected env and isTTY", () => { + const decoded = decodeJestStderr( + "Module /tmp/whatever/runner.js in the testRunner option was not found." + ); + + // Case 1: CI=truthy forces uncolored output even when caller asks for color. + const ciBanner = formatRepairBanner(decoded, { + color: true, + env: { CI: "true" }, + isTTY: true, + }); + expect(ciBanner.includes("\x1b[31m")).toBe(false); + expect(ciBanner.includes("\x1b[0m")).toBe(false); + + // Case 2: not a TTY -> uncolored regardless of color flag. + const noTtyBanner = formatRepairBanner(decoded, { + color: true, + env: {}, + isTTY: false, + }); + expect(noTtyBanner.includes("\x1b[31m")).toBe(false); + + // Case 3: TTY + no CI + color:true -> colored. + const colorBanner = formatRepairBanner(decoded, { + color: true, + env: {}, + isTTY: true, + }); + expect(colorBanner.includes("\x1b[31m")).toBe(true); + expect(colorBanner.includes("\x1b[0m")).toBe(true); + + // Case 4: TTY + no CI + color:false (default) -> uncolored. + const noColorBanner = formatRepairBanner(decoded, { + color: false, + env: {}, + isTTY: true, + }); + expect(noColorBanner.includes("\x1b[31m")).toBe(false); + }); + + test("formatRepairBanner treats CI=0/false/no/off as falsy (does not suppress color)", () => { + const decoded = decodeJestStderr( + "Module /tmp/whatever/runner.js in the testRunner option was not found." + ); + + // The previous Boolean(env.CI) check treated CI="0" as truthy because + // "0" is a non-empty string. isTruthyEnv treats it as falsy, which + // matches typical shell-script truthiness intuition. + for (const falsyValue of ["0", "false", "no", "off", "", " "]) { + const banner = formatRepairBanner(decoded, { + color: true, + env: { CI: falsyValue }, + isTTY: true, + }); + expect(banner.includes("\x1b[31m")).toBe(true); + } + }); + + test("formatRepairBanner falls back to process.stderr.isTTY (not stdout) by default", () => { + // The banner is written to stderr, so the TTY gate must reflect stderr. + // We verify by spying on process.stderr.isTTY and asserting the + // formatter consults it when `isTTY` is not passed explicitly. + const decoded = decodeJestStderr( + "Module /tmp/whatever/runner.js in the testRunner option was not found." + ); + const originalStderrIsTty = process.stderr.isTTY; + const originalStdoutIsTty = process.stdout.isTTY; + try { + // Force stderr=TTY, stdout=non-TTY. Colored output proves the + // formatter looked at stderr (not stdout). + Object.defineProperty(process.stderr, "isTTY", { + configurable: true, + value: true, + }); + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: false, + }); + const banner = formatRepairBanner(decoded, { + color: true, + env: {}, + }); + expect(banner.includes("\x1b[31m")).toBe(true); + } finally { + Object.defineProperty(process.stderr, "isTTY", { + configurable: true, + value: originalStderrIsTty, + }); + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: originalStdoutIsTty, + }); + } + }); +}); + +describe("isTruthyEnv", () => { + test.each([ + [null, false], + [undefined, false], + ["", false], + [" ", false], + ["0", false], + ["false", false], + ["FALSE", false], + ["No", false], + ["off", false], + ["1", true], + ["true", true], + ["yes", true], + ["on", true], + ["arbitrary", true], + ])("isTruthyEnv(%j) -> %s", (input, expected) => { + expect(isTruthyEnv(input)).toBe(expected); + }); +}); diff --git a/scripts/__tests__/jest-error-decoder.test.js.meta b/scripts/__tests__/jest-error-decoder.test.js.meta new file mode 100644 index 00000000..7db166f8 --- /dev/null +++ b/scripts/__tests__/jest-error-decoder.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 2e3a21fb493f4a74a8316d7d15b18ce8 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/llm-skills-jest-robustness-coverage.test.js b/scripts/__tests__/llm-skills-jest-robustness-coverage.test.js new file mode 100644 index 00000000..de9f8fc0 --- /dev/null +++ b/scripts/__tests__/llm-skills-jest-robustness-coverage.test.js @@ -0,0 +1,140 @@ +/** + * @fileoverview Contract test for the Jest-hook-robustness skill coverage. + * + * Phase 4 adds two LLM skill pages (jest-hook-robustness.md and + * let-tools-resolve-modules.md) plus context.md edits that link to them and + * surface the new preflight:pre-push and doctor commands. We lock the file + * paths, link presence, and underlying npm script shape here so any silent + * rename, deletion, or accidental wipe fails loudly. + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const REPO_ROOT = path.resolve(__dirname, "..", ".."); +const SKILLS_DIR = path.join(REPO_ROOT, ".llm", "skills"); +const CONTEXT_PATH = path.join(REPO_ROOT, ".llm", "context.md"); +const PACKAGE_JSON_PATH = path.join(REPO_ROOT, "package.json"); + +const REQUIRED_SKILL_RELATIVE_PATHS = [ + "scripting/integrity-gate-robustness.md", + "scripting/jest-hook-robustness.md", + "scripting/let-tools-resolve-modules.md", +]; + +// Phrases that anchor each skill's content. If a skill is silently wiped or +// rewritten without these terms, the human-prose intent has been lost. +const SKILL_CONTENT_ANCHORS = { + "scripting/integrity-gate-robustness.md": [ + "INTEGRITY_TARGETS", + "findZeroByteNativeBinaries", + "DXMSG_HOOK_NO_AUTOREPAIR", + ], + "scripting/jest-hook-robustness.md": [ + "testRunner", + ], + "scripting/let-tools-resolve-modules.md": [ + "resolve", + ], +}; + +describe(".llm/skills jest-hook-robustness coverage", () => { + test.each(REQUIRED_SKILL_RELATIVE_PATHS.map((rel) => [rel]))( + "skill page exists: %s", + (relativePath) => { + const absPath = path.join(SKILLS_DIR, relativePath); + expect(fs.existsSync(absPath)).toBe(true); + } + ); + + test.each(REQUIRED_SKILL_RELATIVE_PATHS.map((rel) => [rel]))( + "%s is non-trivial (>1 KB)", + (relativePath) => { + const absPath = path.join(SKILLS_DIR, relativePath); + if (!fs.existsSync(absPath)) { + throw new Error( + `Skill ${relativePath} missing -- Phase 4 must create it.` + ); + } + const stats = fs.statSync(absPath); + expect(stats.size).toBeGreaterThan(1024); + } + ); + + test.each(REQUIRED_SKILL_RELATIVE_PATHS.map((rel) => [rel]))( + "%s begins with a `# ` heading or frontmatter on the first non-blank line", + (relativePath) => { + const absPath = path.join(SKILLS_DIR, relativePath); + if (!fs.existsSync(absPath)) { + throw new Error( + `Skill ${relativePath} missing -- Phase 4 must create it.` + ); + } + const content = fs.readFileSync(absPath, "utf8"); + const firstNonBlank = content + .split(/\r?\n/) + .find((line) => line.trim().length > 0); + expect(firstNonBlank).toMatch(/^(#\s|---\s*$)/); + } + ); + + test.each(REQUIRED_SKILL_RELATIVE_PATHS.map((rel) => [rel]))( + "%s contains its content anchor phrases (content not silently wiped)", + (relativePath) => { + const absPath = path.join(SKILLS_DIR, relativePath); + if (!fs.existsSync(absPath)) { + throw new Error( + `Skill ${relativePath} missing -- cannot check anchors.` + ); + } + const anchors = SKILL_CONTENT_ANCHORS[relativePath]; + if (!Array.isArray(anchors) || anchors.length === 0) { + throw new Error( + `No content anchors registered for ${relativePath}; add at least one to SKILL_CONTENT_ANCHORS.` + ); + } + const content = fs.readFileSync(absPath, "utf8"); + for (const anchor of anchors) { + expect(content).toContain(anchor); + } + } + ); + + test(".llm/context.md links to each new skill by file path", () => { + const context = fs.readFileSync(CONTEXT_PATH, "utf8"); + for (const relativePath of REQUIRED_SKILL_RELATIVE_PATHS) { + const re = new RegExp( + `skills/${relativePath.replace(/\./g, "\\.")}` + ); + expect(context).toMatch(re); + } + }); + + test(".llm/context.md references npm run preflight:pre-push", () => { + const context = fs.readFileSync(CONTEXT_PATH, "utf8"); + expect(context).toContain("npm run preflight:pre-push"); + }); + + test(".llm/context.md references npm run doctor", () => { + const context = fs.readFileSync(CONTEXT_PATH, "utf8"); + expect(context).toContain("npm run doctor"); + }); + + test("package.json scripts.preflight:pre-push is a non-empty string", () => { + const pkg = JSON.parse(fs.readFileSync(PACKAGE_JSON_PATH, "utf8")); + expect(pkg.scripts).toBeDefined(); + const value = pkg.scripts["preflight:pre-push"]; + expect(typeof value).toBe("string"); + expect(value.trim().length).toBeGreaterThan(0); + }); + + test("package.json scripts.doctor is a non-empty string", () => { + const pkg = JSON.parse(fs.readFileSync(PACKAGE_JSON_PATH, "utf8")); + expect(pkg.scripts).toBeDefined(); + const value = pkg.scripts.doctor; + expect(typeof value).toBe("string"); + expect(value.trim().length).toBeGreaterThan(0); + }); +}); diff --git a/scripts/__tests__/llm-skills-jest-robustness-coverage.test.js.meta b/scripts/__tests__/llm-skills-jest-robustness-coverage.test.js.meta new file mode 100644 index 00000000..705192e5 --- /dev/null +++ b/scripts/__tests__/llm-skills-jest-robustness-coverage.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 7372ddeb89cc50765cac211c4ed500f7 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/managed-prettier.test.js b/scripts/__tests__/managed-prettier.test.js new file mode 100644 index 00000000..ec5c31a8 --- /dev/null +++ b/scripts/__tests__/managed-prettier.test.js @@ -0,0 +1,149 @@ +"use strict"; + +const path = require("path"); + +const { + MISSING_BUNDLED_NPX_CLI_MESSAGE, + resolveBundledNpxCliPath, + runBundledNpxCommand, + loadLocalPrettier +} = require("../lib/managed-prettier"); + +describe("managed-prettier", () => { + test("resolveBundledNpxCliPath returns bundled npx-cli.js when present", () => { + const execPath = path.join(path.sep, "opt", "node", "bin", "node"); + const expected = path.join(path.dirname(execPath), "node_modules", "npm", "bin", "npx-cli.js"); + + const resolved = resolveBundledNpxCliPath({ + execPath, + existsSyncFn: (candidatePath) => candidatePath === expected + }); + + expect(resolved).toBe(expected); + }); + + test("resolveBundledNpxCliPath returns null when bundled npx-cli.js is missing", () => { + const resolved = resolveBundledNpxCliPath({ + execPath: path.join(path.sep, "opt", "node", "bin", "node"), + existsSyncFn: () => false + }); + + expect(resolved).toBeNull(); + }); + + test("runBundledNpxCommand invokes Node with the bundled npx CLI", () => { + const execPath = String.raw`C:\node\node.exe`; + const npxCliPath = String.raw`C:\node\node_modules\npm\bin\npx-cli.js`; + const runCommandFn = jest.fn(() => ({ status: 0 })); + + const result = runBundledNpxCommand(["--yes", "prettier", "--check", "README.md"], { + execPath, + resolveBundledNpxCliPathFn: () => npxCliPath, + runCommandFn, + cwd: path.join(path.sep, "repo") + }); + + expect(result).toEqual({ status: 0 }); + expect(runCommandFn).toHaveBeenCalledWith( + execPath, + [npxCliPath, "--yes", "prettier", "--check", "README.md"], + expect.objectContaining({ + cwd: path.join(path.sep, "repo"), + encoding: "utf8" + }) + ); + }); + + test("runBundledNpxCommand fails closed when bundled npx CLI cannot be resolved", () => { + expect(() => + runBundledNpxCommand(["--yes", "prettier", "--check", "README.md"], { + resolveBundledNpxCliPathFn: () => null, + runCommandFn: jest.fn() + }) + ).toThrow(MISSING_BUNDLED_NPX_CLI_MESSAGE); + }); + + describe("loadLocalPrettier integrity probe (Step 7)", () => { + const fakeModulePath = "/repo/node_modules/prettier/index.cjs"; + const fakeBinPath = "/repo/node_modules/prettier/bin/prettier.cjs"; + + test("returns the require()'d module when both module and bin are present and non-empty", () => { + const requireFn = jest.fn(() => ({ format: () => "" })); + const result = loadLocalPrettier({ + localPrettierModulePath: fakeModulePath, + localPrettierBinPath: fakeBinPath, + existsSyncFn: () => true, + statSyncFn: () => ({ size: 100 }), + requireFn + }); + expect(requireFn).toHaveBeenCalledWith(fakeModulePath); + expect(result).toEqual({ format: expect.any(Function) }); + }); + + test("returns null when localPrettierBinPath is zero-byte (size 0)", () => { + const requireFn = jest.fn(); + const result = loadLocalPrettier({ + localPrettierModulePath: fakeModulePath, + localPrettierBinPath: fakeBinPath, + existsSyncFn: () => true, + statSyncFn: (abs) => (abs === fakeBinPath ? { size: 0 } : { size: 100 }), + requireFn + }); + expect(result).toBeNull(); + expect(requireFn).not.toHaveBeenCalled(); + }); + + test("returns null when localPrettierModulePath is zero-byte (size 0)", () => { + const requireFn = jest.fn(); + const result = loadLocalPrettier({ + localPrettierModulePath: fakeModulePath, + localPrettierBinPath: fakeBinPath, + existsSyncFn: () => true, + statSyncFn: (abs) => (abs === fakeModulePath ? { size: 0 } : { size: 100 }), + requireFn + }); + expect(result).toBeNull(); + expect(requireFn).not.toHaveBeenCalled(); + }); + + test("preserves the 'bin without module' error when index.cjs is absent but bin is present", () => { + const requireFn = jest.fn(); + expect(() => + loadLocalPrettier({ + localPrettierModulePath: fakeModulePath, + localPrettierBinPath: fakeBinPath, + existsSyncFn: (abs) => abs === fakeBinPath, + statSyncFn: () => ({ size: 100 }), + requireFn + }) + ).toThrow(/devDependency layout is unexpected/); + }); + + test("returns null when neither path exists (clean missing-install case)", () => { + const result = loadLocalPrettier({ + localPrettierModulePath: fakeModulePath, + localPrettierBinPath: fakeBinPath, + existsSyncFn: () => false, + statSyncFn: () => ({ size: 100 }), + requireFn: jest.fn() + }); + expect(result).toBeNull(); + }); + + test("require errors that look like missing dependencies return null (preserved behavior)", () => { + const requireFn = jest.fn(() => { + const err = new Error("Cannot find module 'transitive-dep'"); + err.code = "MODULE_NOT_FOUND"; + throw err; + }); + const result = loadLocalPrettier({ + localPrettierModulePath: fakeModulePath, + localPrettierBinPath: fakeBinPath, + existsSyncFn: () => true, + statSyncFn: () => ({ size: 100 }), + requireFn + }); + expect(result).toBeNull(); + }); + }); +}); \ No newline at end of file diff --git a/scripts/__tests__/managed-prettier.test.js.meta b/scripts/__tests__/managed-prettier.test.js.meta new file mode 100644 index 00000000..04992d1d --- /dev/null +++ b/scripts/__tests__/managed-prettier.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: bae8312af104e8e4980f32369dfbd831 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/no-testrunner-injection-policy.test.js b/scripts/__tests__/no-testrunner-injection-policy.test.js new file mode 100644 index 00000000..13eecfa2 --- /dev/null +++ b/scripts/__tests__/no-testrunner-injection-policy.test.js @@ -0,0 +1,807 @@ +/** + * @fileoverview Repository-wide policy guards against re-introducing + * `--testRunner` injection (and its env-driven cousins) into any hook, + * workflow, package field, or Jest configuration file. + * + * Phase 2 of the Jest-driven pre-push hardening plan. The single-file guard + * at run-managed-jest-no-injected-test-runner.test.js pins the wrapper + * source; this file closes EVERY other ingress vector: + * + * Policy 1 — `.pre-commit-config.yaml` hook entries/args/bash bodies. + * Policy 2 — `.github/workflows/*.{yml,yaml}` run-block bodies. + * Policy 3 — `package.json` `jest.testRunner` field (recursively, including + * nested `projects[*]` and `overrides` shapes). + * Policy 4 — `jest.config.*` / `.jestrc*` files at repo root and scripts/. + * Policy 5 — Absolute `--config` paths in hooks and workflows. + * Policy 6 — Env-driven `JEST_TEST_RUNNER` / `JEST_CONFIG` references, + * with a coarse unstripped-source fallback that catches variable- + * assignment indirection (false-positives allow-listed per file). + * Policy 7 — Literal `--testRunner` in any scripts/**\/*.js file. + * Policy 8 — PERMITTED_PATHS sanity: every allow-list entry resolves. + * + * Limitations (documented, not bugs): + * - Source-scan policies cannot detect runtime indirection. If code reads a + * forbidden flag from a non-obvious source (e.g. a file, an environment + * variable looked up via `process.env[dynamicKey]`, or a network call), + * the static scan will not see it. The narrow guard's structural + * "invocationArgs.push() must not reference a runner-path identifier" is + * the in-depth defense against the most realistic injection shape. + * + * Background: + * On Windows, jest-config's runner validator has been observed to reject + * absolute paths that `require.resolve("jest-circus/runner")` and + * `fs.existsSync` both report as valid. Re-introducing `--testRunner` + * anywhere in the invocation chain (CLI flag, env var, jest config block) + * reopens that failure surface. These policies make the regression loud at + * pre-push time. + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const { stripJsCommentsAndStrings } = require("../lib/source-stripping"); + +const REPO_ROOT = path.resolve(__dirname, "..", ".."); + +/** + * Files that legitimately reference `--testRunner`, `JEST_TEST_RUNNER`, + * or `JEST_CONFIG` because they document the policy, decode the failure + * mode, forward a caller-supplied flag unchanged, or assert this exact + * regression guard. Every other reference is forbidden. + * + * Paths are stored as repo-relative POSIX-style strings (`path.join` + * canonicalizes per platform on read). + */ +const PERMITTED_PATHS = new Set([ + path.join("scripts", "run-managed-jest.js"), + path.join("scripts", "__tests__", "run-managed-jest.test.js"), + path.join("scripts", "__tests__", "run-managed-jest-no-injected-test-runner.test.js"), + path.join("scripts", "__tests__", "no-testrunner-injection-policy.test.js"), + path.join("scripts", "__tests__", "jest-error-decoder.test.js"), + path.join("scripts", "lib", "jest-error-decoder.js"), + // Note: scripts/lib/__tests__/source-stripping.test.js intentionally + // omitted; the state-machine tokenizer correctly blanks the test's many + // `--testRunner` string literals so Policy 7 sees no residue. + path.join(".github", "workflows", "pre-commit-tooling-check.yml"), +]); + +const WALK_SKIP_DIRS = new Set([ + "node_modules", + ".git", + ".venv", + "__pycache__", + "Temp", +]); + +const JEST_CONFIG_CANDIDATES = [ + "jest.config.js", + "jest.config.cjs", + "jest.config.mjs", + "jest.config.ts", + "jest.config.json", + ".jestrc", + ".jestrc.json", + ".jestrc.js", +]; + +const JEST_CONFIG_SEARCH_DIRS = ["", "scripts"]; + +const STRIP_FOR_EXTENSIONS = new Set([".js", ".cjs", ".mjs", ".ts"]); + +const WORKFLOW_DIR = path.join(REPO_ROOT, ".github", "workflows"); +const PRE_COMMIT_CONFIG_PATH = path.join(REPO_ROOT, ".pre-commit-config.yaml"); +const PACKAGE_JSON_PATH = path.join(REPO_ROOT, "package.json"); + +function toRepoRelative(absolutePath) { + return path.relative(REPO_ROOT, absolutePath); +} + +function isPermitted(repoRelativePath) { + return PERMITTED_PATHS.has(repoRelativePath); +} + +function readUtf8(absolutePath) { + return fs.readFileSync(absolutePath, "utf8"); +} + +function listFiles(absoluteDir, predicate) { + const out = []; + if (!fs.existsSync(absoluteDir)) { + return out; + } + + const stack = [absoluteDir]; + while (stack.length > 0) { + const dir = stack.pop(); + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (error) { + continue; + } + + for (const entry of entries) { + if (entry.isDirectory()) { + if (WALK_SKIP_DIRS.has(entry.name)) { + continue; + } + stack.push(path.join(dir, entry.name)); + continue; + } + + if (!entry.isFile()) { + continue; + } + + const abs = path.join(dir, entry.name); + if (predicate(abs)) { + out.push(abs); + } + } + } + + return out; +} + +function formatViolationLines(violations) { + return violations + .map(({ file, line, snippet }) => { + const where = line === null || line === undefined ? "" : `:${line}`; + const text = snippet === undefined ? "" : `: ${snippet.trim()}`; + return ` ${file}${where}${text}`; + }) + .join("\n"); +} + +/** + * Find every hook block in `.pre-commit-config.yaml`. Returns an array of + * `{ idLine, idLineNumber, lines, blockStartLineNumber }` where `lines` is + * the inclusive range from the hook's `- id:` line down to the line before + * the next sibling hook (or end-of-file). We deliberately work line-by-line + * because the file embeds folded scalars and bash one-liners that a YAML + * parser would normalize away. + */ +function readPreCommitHookBlocks() { + const rawLines = readUtf8(PRE_COMMIT_CONFIG_PATH).split("\n"); + const blocks = []; + + let current = null; + for (let i = 0; i < rawLines.length; i++) { + const line = rawLines[i]; + const idMatch = /^(\s*)-\s+id:\s*(.+?)\s*$/.exec(line); + if (idMatch) { + if (current) { + blocks.push(current); + } + current = { + indent: idMatch[1].length, + id: idMatch[2].trim(), + startLineNumber: i + 1, + lines: [{ lineNumber: i + 1, text: line }], + }; + continue; + } + + if (!current) { + continue; + } + + // Stop appending to the current block when we hit a sibling or shallower + // structural line that begins a new hook list element at the same depth. + const siblingMatch = /^(\s*)-\s+id:\s*/.exec(line); + if (siblingMatch && siblingMatch[1].length === current.indent) { + blocks.push(current); + current = { + indent: siblingMatch[1].length, + id: /^(\s*)-\s+id:\s*(.+?)\s*$/.exec(line)[2].trim(), + startLineNumber: i + 1, + lines: [{ lineNumber: i + 1, text: line }], + }; + continue; + } + + current.lines.push({ lineNumber: i + 1, text: line }); + } + + if (current) { + blocks.push(current); + } + + return blocks; +} + +/** + * Inspect a hook block and return all lines that mention the literal + * `--testRunner`. Used for Policy 1. + */ +function findTestRunnerLinesInHookBlock(hookBlock) { + return hookBlock.lines + .filter(({ text }) => text.includes("--testRunner")) + .map(({ lineNumber, text }) => ({ + file: ".pre-commit-config.yaml", + line: lineNumber, + snippet: text, + })); +} + +/** + * Inspect a hook block's `entry:` and `args:` content for `jest` indicators + * (either `run-managed-jest.js` or the bare `jest` invocation token). The + * hook must invoke Jest for Policy 1 to apply; pure-prettier or linter + * hooks are out of scope. + */ +function hookInvokesJest(hookBlock) { + const joined = hookBlock.lines.map((entry) => entry.text).join("\n"); + return /run-managed-jest\.js/.test(joined) || /\bjest\b/i.test(joined); +} + +/** + * Read a workflow file and return all `run:` block bodies along with the + * line number where the body begins. Supports: + * - Single-line: `run: echo hi` + * - Literal: `run: |` (followed by indented lines) + * - Folded: `run: >-` (followed by indented lines) + * + * The returned `body` is the full multi-line string of the block content. + */ +function extractWorkflowRunBlocks(workflowPath) { + const rawText = readUtf8(workflowPath); + const rawLines = rawText.split("\n"); + const blocks = []; + + for (let i = 0; i < rawLines.length; i++) { + const line = rawLines[i]; + // Allow an optional leading list-element dash (`- run: ...`) which is + // the canonical shape inside `steps:` blocks. Without this the + // inline-run case missed every step body in `steps: - run: ...` + // workflows. + const inlineMatch = /^(\s*)(?:-\s+)?run:\s*(.+?)\s*$/.exec(line); + const blockMatch = /^(\s*)(?:-\s+)?run:\s*([|>][+-]?)?\s*$/.exec(line); + + if (inlineMatch && !/^([|>])[+-]?$/.test(inlineMatch[2])) { + blocks.push({ + file: workflowPath, + startLine: i + 1, + body: inlineMatch[2], + bodyStartLine: i + 1, + lines: [{ lineNumber: i + 1, text: inlineMatch[2] }], + }); + continue; + } + + if (blockMatch) { + const indent = blockMatch[1].length; + const bodyLines = []; + let j = i + 1; + while (j < rawLines.length) { + const candidate = rawLines[j]; + if (candidate.trim() === "") { + bodyLines.push({ lineNumber: j + 1, text: candidate }); + j++; + continue; + } + const leading = candidate.length - candidate.trimStart().length; + if (leading <= indent) { + break; + } + bodyLines.push({ lineNumber: j + 1, text: candidate }); + j++; + } + blocks.push({ + file: workflowPath, + startLine: i + 1, + body: bodyLines.map((bl) => bl.text).join("\n"), + bodyStartLine: i + 2, + lines: bodyLines, + }); + i = j - 1; + } + } + + return blocks; +} + +function listWorkflowFiles() { + return listFiles(WORKFLOW_DIR, (abs) => /\.(yml|yaml)$/i.test(abs)); +} + +/** + * Walk `--config ` and `--config=` occurrences in a multi-line + * string and return each value's text along with its 1-based line number. + * + * Handles quoted values: `--config="/abs/path"`, `--config '/abs/path'`, + * `--config="/abs/path"`. Lines whose first non-whitespace character is `#` + * are treated as YAML comments and skipped (avoids false positives on tips + * like `# Hint: use --config /abs/path`). + */ +function findConfigFlagValues(text, baseLineNumber) { + const violations = []; + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + // Skip YAML comment lines so a doc-tip like `# use --config /abs` + // does not produce a false-positive violation. We only skip lines + // that begin with `#` after optional whitespace; inline comments + // (`... # ...`) are not handled here because the policy intentionally + // catches comment-disguised flag values when they appear after real + // YAML content. + if (/^\s*#/.test(line)) { + continue; + } + + // `--config=value` form, including quoted values: + // --config=foo + // --config="foo" + // --config='foo' + // The capture group strips surrounding quotes from the value. + const equalRe = /--config=(?:"([^"]*)"|'([^']*)'|([^\s'"`]+))/g; + let match; + while ((match = equalRe.exec(line)) !== null) { + const value = match[1] !== undefined + ? match[1] + : match[2] !== undefined + ? match[2] + : match[3]; + violations.push({ + value, + lineNumber: (baseLineNumber || 1) + i, + snippet: line, + }); + } + // `--config value` form (next whitespace-separated token), with + // optional surrounding quotes. + const spaceRe = /--config\s+(?:"([^"]*)"|'([^']*)'|([^\s'"`]+))/g; + while ((match = spaceRe.exec(line)) !== null) { + const value = match[1] !== undefined + ? match[1] + : match[2] !== undefined + ? match[2] + : match[3]; + if (typeof value !== "string" || value.length === 0) { + continue; + } + violations.push({ + value, + lineNumber: (baseLineNumber || 1) + i, + snippet: line, + }); + } + } + return violations; +} + +function isAbsoluteConfigPath(value) { + if (typeof value !== "string" || value.length === 0) { + return false; + } + if (value.startsWith("/")) { + return true; + } + if (/^[A-Za-z]:[\\/]/.test(value)) { + return true; + } + if (value.startsWith("\\\\")) { + return true; + } + if (value.startsWith("~/") || value === "~") { + return true; + } + if (value.startsWith("$HOME") || value.startsWith("${HOME}")) { + return true; + } + return false; +} + +/** + * Recursively walk `scripts/` and return every `*.js` file (including tests). + */ +function listScriptJsFiles() { + return listFiles(path.join(REPO_ROOT, "scripts"), (abs) => abs.endsWith(".js")); +} + +function findLineNumbersContaining(text, needles) { + const lines = text.split("\n"); + const hits = []; + for (let i = 0; i < lines.length; i++) { + for (const needle of needles) { + if (lines[i].includes(needle)) { + hits.push({ lineNumber: i + 1, snippet: lines[i], needle }); + break; + } + } + } + return hits; +} + +/** + * Walk a parsed JSON config tree and collect every path where the `testRunner` + * key appears (e.g. `jest.testRunner`, `jest.projects[0].testRunner`, + * `jest.overrides.unit.testRunner`). Returns an array of + * `{ path, value }` records suitable for inclusion in an error message. + * + * The walk is bounded: it descends into plain objects and arrays only, and + * skips primitive leaves. `__proto__`-style keys are not treated specially + * because the input is parsed JSON, which yields plain own-property keys. + */ +function findTestRunnerKeysRecursive(node, currentPath) { + const found = []; + if (node === null || typeof node !== "object") { + return found; + } + + if (Array.isArray(node)) { + for (let i = 0; i < node.length; i++) { + const childPath = `${currentPath}[${i}]`; + found.push(...findTestRunnerKeysRecursive(node[i], childPath)); + } + return found; + } + + for (const key of Object.keys(node)) { + const value = node[key]; + const childPath = currentPath ? `${currentPath}.${key}` : key; + if (key === "testRunner") { + found.push({ path: childPath, value }); + } + if (value !== null && typeof value === "object") { + found.push(...findTestRunnerKeysRecursive(value, childPath)); + } + } + + return found; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("--testRunner injection policy (repo-wide)", () => { + test("Policy 1: no '--testRunner' literal in pre-commit hook entry/args/bash bodies", () => { + const blocks = readPreCommitHookBlocks(); + const offenders = []; + + for (const block of blocks) { + if (!hookInvokesJest(block)) { + continue; + } + + const violations = findTestRunnerLinesInHookBlock(block); + for (const v of violations) { + offenders.push({ + file: ".pre-commit-config.yaml", + line: v.line, + snippet: `[hook=${block.id}] ${v.snippet}`, + }); + } + } + + if (offenders.length > 0) { + throw new Error( + "Policy 1 violation: pre-commit hook(s) reference '--testRunner'.\n" + + "Jest 27+ resolves jest-circus internally; injecting --testRunner " + + "via a hook entry/args/bash body re-introduces the Windows runner " + + "validator failure. Remove the flag.\n\nOffending lines:\n" + + formatViolationLines(offenders) + ); + } + }); + + test("Policy 2: no '--testRunner' literal in workflow run-block bodies", () => { + const offenders = []; + for (const workflowPath of listWorkflowFiles()) { + const repoRelative = toRepoRelative(workflowPath); + if (isPermitted(repoRelative)) { + continue; + } + + const blocks = extractWorkflowRunBlocks(workflowPath); + for (const block of blocks) { + for (const bl of block.lines) { + if (bl.text.includes("--testRunner")) { + offenders.push({ + file: repoRelative, + line: bl.lineNumber, + snippet: bl.text, + }); + } + } + } + } + + if (offenders.length > 0) { + throw new Error( + "Policy 2 violation: workflow run-block(s) reference '--testRunner'.\n" + + "Workflows must invoke jest via scripts/run-managed-jest.js without " + + "the --testRunner flag. Remove the flag.\n\nOffending lines:\n" + + formatViolationLines(offenders) + ); + } + }); + + test("Policy 3: package.json must not configure jest.testRunner (recursively)", () => { + const pkg = JSON.parse(readUtf8(PACKAGE_JSON_PATH)); + const jestField = pkg && typeof pkg === "object" ? pkg.jest : undefined; + + if (jestField === undefined || jestField === null || typeof jestField !== "object") { + return; + } + + // Recursive: catches `jest.testRunner`, `jest.projects[*].testRunner`, + // and any future nested shape Jest may accept. + const hits = findTestRunnerKeysRecursive(jestField, "jest"); + + if (hits.length > 0) { + const formatted = hits + .map((h) => ` ${h.path} = ${JSON.stringify(h.value)}`) + .join("\n"); + throw new Error( + "Policy 3 violation: package.json sets testRunner under the " + + "jest field (possibly nested under projects[*] or similar).\n" + + "Jest 27+ defaults to jest-circus; do not configure testRunner.\n\n" + + "Offending keys:\n" + formatted + ); + } + }); + + test("Policy 4: no jest config file sets a testRunner key", () => { + const offenders = []; + + for (const dir of JEST_CONFIG_SEARCH_DIRS) { + for (const name of JEST_CONFIG_CANDIDATES) { + const abs = path.join(REPO_ROOT, dir, name); + if (!fs.existsSync(abs)) { + continue; + } + const repoRelative = toRepoRelative(abs); + const ext = path.extname(abs).toLowerCase(); + const raw = readUtf8(abs); + + if (ext === ".json" || name === ".jestrc") { + let parsed; + try { + parsed = JSON.parse(raw); + } catch (error) { + // Treat parse failure as a soft offense: a broken + // config blocks Jest entirely; still report cleanly. + offenders.push({ + file: repoRelative, + line: null, + snippet: `JSON parse failed: ${error.message}`, + }); + continue; + } + if (parsed !== null && typeof parsed === "object") { + const hits = findTestRunnerKeysRecursive(parsed, ""); + for (const hit of hits) { + offenders.push({ + file: repoRelative, + line: null, + snippet: `${hit.path} = ${JSON.stringify(hit.value)}`, + }); + } + } + continue; + } + + let scanText = raw; + if (STRIP_FOR_EXTENSIONS.has(ext)) { + scanText = stripJsCommentsAndStrings(raw); + } + + const lines = scanText.split("\n"); + for (let i = 0; i < lines.length; i++) { + if (/\btestRunner\b/.test(lines[i])) { + offenders.push({ + file: repoRelative, + line: i + 1, + snippet: lines[i], + }); + } + } + } + } + + if (offenders.length > 0) { + throw new Error( + "Policy 4 violation: jest config file(s) set 'testRunner'.\n" + + "Remove the testRunner key; Jest 27+ resolves jest-circus " + + "internally.\n\nOffending entries:\n" + + formatViolationLines(offenders) + ); + } + }); + + test("Policy 5: no absolute '--config' paths in hooks or workflows", () => { + const offenders = []; + + // .pre-commit-config.yaml — scan every line; --config in hooks should + // be relative to the repo root. + const preCommitText = readUtf8(PRE_COMMIT_CONFIG_PATH); + const preCommitConfigHits = findConfigFlagValues(preCommitText, 1); + for (const hit of preCommitConfigHits) { + if (isAbsoluteConfigPath(hit.value)) { + offenders.push({ + file: ".pre-commit-config.yaml", + line: hit.lineNumber, + snippet: hit.snippet, + }); + } + } + + // Workflow files — same scan. + for (const workflowPath of listWorkflowFiles()) { + const repoRelative = toRepoRelative(workflowPath); + if (isPermitted(repoRelative)) { + continue; + } + const text = readUtf8(workflowPath); + const hits = findConfigFlagValues(text, 1); + for (const hit of hits) { + if (isAbsoluteConfigPath(hit.value)) { + offenders.push({ + file: repoRelative, + line: hit.lineNumber, + snippet: hit.snippet, + }); + } + } + } + + if (offenders.length > 0) { + throw new Error( + "Policy 5 violation: absolute '--config ' detected.\n" + + "Absolute config paths break cross-platform reproducibility and " + + "are a vector for the --testRunner injection footgun. Use a " + + "repo-relative path.\n\nOffending lines:\n" + + formatViolationLines(offenders) + ); + } + }); + + test("Policy 6: no env-driven 'JEST_TEST_RUNNER' or 'JEST_CONFIG' references outside allow-list", () => { + const offenders = []; + const needles = ["JEST_TEST_RUNNER", "JEST_CONFIG"]; + + // scripts/**/*.js: scan BOTH stripped and unstripped source. + // + // Stripped scan catches direct references in code + // (e.g. `process.env.JEST_TEST_RUNNER = "..."`). + // Unstripped fallback catches variable-assignment indirection + // (e.g. `const k = "JEST_TEST_RUNNER"; process.env[k] = "...";`). + // The fallback may produce false positives in legitimate string- + // handling code; allow-list any such file via PERMITTED_PATHS. + for (const abs of listScriptJsFiles()) { + const repoRelative = toRepoRelative(abs); + if (isPermitted(repoRelative)) { + continue; + } + const raw = readUtf8(abs); + const stripped = stripJsCommentsAndStrings(raw); + const seen = new Set(); + + const strippedHits = findLineNumbersContaining(stripped, needles); + for (const hit of strippedHits) { + const key = `${hit.lineNumber}::${hit.needle}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + offenders.push({ + file: repoRelative, + line: hit.lineNumber, + snippet: hit.snippet, + }); + } + + // Coarse fallback: catches the string even when assigned to a + // variable; may produce false positives in legitimate string- + // handling code; allow-list any such file via PERMITTED_PATHS. + const rawHits = findLineNumbersContaining(raw, needles); + for (const hit of rawHits) { + const key = `${hit.lineNumber}::${hit.needle}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + offenders.push({ + file: repoRelative, + line: hit.lineNumber, + snippet: hit.snippet, + }); + } + } + + // .pre-commit-config.yaml — search raw text (YAML has no code/comment + // distinction we care about here; any literal occurrence is a vector). + const preCommitHits = findLineNumbersContaining(readUtf8(PRE_COMMIT_CONFIG_PATH), needles); + for (const hit of preCommitHits) { + offenders.push({ + file: ".pre-commit-config.yaml", + line: hit.lineNumber, + snippet: hit.snippet, + }); + } + + // .github/workflows/*.{yml,yaml} + for (const workflowPath of listWorkflowFiles()) { + const repoRelative = toRepoRelative(workflowPath); + if (isPermitted(repoRelative)) { + continue; + } + const hits = findLineNumbersContaining(readUtf8(workflowPath), needles); + for (const hit of hits) { + offenders.push({ + file: repoRelative, + line: hit.lineNumber, + snippet: hit.snippet, + }); + } + } + + if (offenders.length > 0) { + throw new Error( + "Policy 6 violation: env-driven JEST_TEST_RUNNER / JEST_CONFIG " + + "references detected outside the allow-list.\n" + + "These env vars are the back-door equivalent of the --testRunner " + + "flag and re-introduce the same Windows runner-validator failure.\n\n" + + "Offending lines:\n" + + formatViolationLines(offenders) + ); + } + }); + + test("Policy 7: no '--testRunner' literal in any scripts/**/*.js after stripping", () => { + const offenders = []; + + for (const abs of listScriptJsFiles()) { + const repoRelative = toRepoRelative(abs); + if (isPermitted(repoRelative)) { + continue; + } + const stripped = stripJsCommentsAndStrings(readUtf8(abs)); + const lines = stripped.split("\n"); + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes("--testRunner")) { + offenders.push({ + file: repoRelative, + line: i + 1, + snippet: lines[i], + }); + } + } + } + + if (offenders.length > 0) { + throw new Error( + "Policy 7 violation: '--testRunner' literal detected in script " + + "source after stripping comments and string literals.\n" + + "Forward caller-supplied --testRunner via the input args array; " + + "do not embed the flag in any wrapper or helper.\n\n" + + "Offending lines:\n" + + formatViolationLines(offenders) + ); + } + }); + + test("Policy 8: PERMITTED_PATHS sanity — every allow-list entry resolves to an existing file", () => { + const missing = []; + for (const repoRelative of PERMITTED_PATHS) { + const abs = path.join(REPO_ROOT, repoRelative); + if (!fs.existsSync(abs)) { + missing.push(repoRelative); + } + } + + if (missing.length > 0) { + throw new Error( + "Policy 8 violation: PERMITTED_PATHS contains entries that do " + + "not point to existing files. Either delete the dead entry or " + + "add an inline comment noting 'allow-list slot for future file' " + + "immediately above the path in this test file.\n\nMissing entries:\n" + + missing.map((p) => ` ${p}`).join("\n") + ); + } + }); +}); diff --git a/scripts/__tests__/no-testrunner-injection-policy.test.js.meta b/scripts/__tests__/no-testrunner-injection-policy.test.js.meta new file mode 100644 index 00000000..e6ceeb26 --- /dev/null +++ b/scripts/__tests__/no-testrunner-injection-policy.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 82519085991c7f3fc47f2886305b38d2 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/node-modules-integrity.test.js b/scripts/__tests__/node-modules-integrity.test.js new file mode 100644 index 00000000..feaad850 --- /dev/null +++ b/scripts/__tests__/node-modules-integrity.test.js @@ -0,0 +1,807 @@ +/** + * @fileoverview Tests for scripts/lib/node-modules-integrity.js. + * + * The integrity library is the single source of truth for the + * "are critical node_modules files present + non-zero?" check that the + * managed Jest/Prettier/cspell wrappers gate on before running anything. + * These tests pin INTEGRITY_TARGETS shape, the probe semantics, the + * subprocess re-probe contract, and the Windows-only zero-byte native + * scan. + */ + +"use strict"; + +const path = require("path"); + +const { + INTEGRITY_TARGETS, + probeIntegrity, + probeIntegrityInSubprocess, + findZeroByteNativeBinaries, + formatIntegrityFailure, + probeResolverHealth, +} = require("../lib/node-modules-integrity"); +const { toPosixPath } = require("../lib/path-classifier"); + +describe("INTEGRITY_TARGETS", () => { + test("is frozen and immutable end-to-end", () => { + expect(Object.isFrozen(INTEGRITY_TARGETS)).toBe(true); + for (const target of INTEGRITY_TARGETS) { + expect(Object.isFrozen(target)).toBe(true); + expect(Object.isFrozen(target.files)).toBe(true); + for (const file of target.files) { + expect(Object.isFrozen(file)).toBe(true); + } + } + }); + + test("contains entries for prettier, markdownlint-cli2, cspell, jest, jest-circus", () => { + const tools = INTEGRITY_TARGETS.map((target) => target.tool); + expect(tools).toEqual( + expect.arrayContaining([ + "prettier", + "markdownlint-cli2", + "cspell", + "jest", + "jest-circus", + ]) + ); + expect(tools.length).toBe(5); + }); + + test("every file entry has a relPath and a numeric minBytes", () => { + for (const target of INTEGRITY_TARGETS) { + expect(target.files.length).toBeGreaterThan(0); + for (const file of target.files) { + expect(typeof file.relPath).toBe("string"); + expect(file.relPath.length).toBeGreaterThan(0); + expect(file.relPath.startsWith("node_modules/")).toBe(true); + expect(typeof file.minBytes).toBe("number"); + expect(file.minBytes).toBeGreaterThan(0); + } + } + }); + + test("jest-circus entry includes the runner.js + index.js + jestAdapterInit.js triad (verified on-disk)", () => { + const jestCircus = INTEGRITY_TARGETS.find((t) => t.tool === "jest-circus"); + expect(jestCircus).toBeTruthy(); + const relPaths = jestCircus.files.map((f) => f.relPath); + expect(relPaths).toEqual( + expect.arrayContaining([ + "node_modules/jest-circus/build/runner.js", + "node_modules/jest-circus/build/index.js", + "node_modules/jest-circus/build/jestAdapterInit.js", + ]) + ); + }); + + test("cannot be mutated by consumers", () => { + expect(() => { + INTEGRITY_TARGETS.push({ tool: "malicious" }); + }).toThrow(); + expect(INTEGRITY_TARGETS.length).toBe(5); + }); +}); + +describe("probeIntegrity", () => { + const fakeTargets = [ + { + tool: "alpha", + files: [ + { relPath: "node_modules/alpha/index.js", minBytes: 1 }, + { relPath: "node_modules/alpha/bin/alpha.js", minBytes: 1 }, + ], + }, + { + tool: "beta", + files: [{ relPath: "node_modules/beta/bin.mjs", minBytes: 1 }], + }, + ]; + + test("returns ok=true with empty missing[] when every file is present and non-empty", () => { + const result = probeIntegrity({ + repoRoot: "/repo", + existsSyncFn: () => true, + statSyncFn: () => ({ size: 100 }), + targets: fakeTargets, + }); + + expect(result.ok).toBe(true); + expect(result.missing).toEqual([]); + }); + + test("flags a missing file via existsSync=false", () => { + const result = probeIntegrity({ + repoRoot: "/repo", + // POSIX-normalize before substring check so the fixture is + // platform-agnostic (on Windows, path.join uses "\"). + existsSyncFn: (abs) => !toPosixPath(abs).endsWith("alpha.js"), + statSyncFn: () => ({ size: 100 }), + targets: fakeTargets, + }); + + expect(result.ok).toBe(false); + expect(result.missing).toEqual([ + { tool: "alpha", relPath: "node_modules/alpha/bin/alpha.js", reason: "missing" }, + ]); + }); + + test("flags a missing file via statSync ENOENT", () => { + const result = probeIntegrity({ + repoRoot: "/repo", + existsSyncFn: () => true, + statSyncFn: (abs) => { + if (toPosixPath(abs).endsWith("bin.mjs")) { + const err = new Error("ENOENT: file gone"); + err.code = "ENOENT"; + throw err; + } + return { size: 100 }; + }, + targets: fakeTargets, + }); + + expect(result.ok).toBe(false); + expect(result.missing.find((m) => m.tool === "beta")).toEqual({ + tool: "beta", + relPath: "node_modules/beta/bin.mjs", + reason: "missing", + }); + }); + + test("flags zero-byte file as empty (existsSync true, size 0)", () => { + const result = probeIntegrity({ + repoRoot: "/repo", + existsSyncFn: () => true, + statSyncFn: (abs) => (toPosixPath(abs).endsWith("index.js") ? { size: 0 } : { size: 100 }), + targets: fakeTargets, + }); + + expect(result.ok).toBe(false); + expect(result.missing).toEqual([ + { tool: "alpha", relPath: "node_modules/alpha/index.js", reason: "empty" }, + ]); + }); + + test("throws when repoRoot is missing", () => { + expect(() => probeIntegrity({ targets: fakeTargets })).toThrow( + /repoRoot/ + ); + expect(() => probeIntegrity({ repoRoot: "", targets: fakeTargets })).toThrow(); + }); + + test("default targets reference INTEGRITY_TARGETS when none provided", () => { + // Spy: statSyncFn is invoked for each file in INTEGRITY_TARGETS. + const seenPaths = new Set(); + probeIntegrity({ + repoRoot: "/repo", + existsSyncFn: () => true, + statSyncFn: (abs) => { + seenPaths.add(abs); + return { size: 100 }; + }, + }); + const totalFiles = INTEGRITY_TARGETS.reduce( + (sum, target) => sum + target.files.length, + 0 + ); + expect(seenPaths.size).toBe(totalFiles); + }); + + test("non-ENOENT stat errors map to 'empty' (defense-in-depth)", () => { + const result = probeIntegrity({ + repoRoot: "/repo", + existsSyncFn: () => true, + statSyncFn: () => { + const err = new Error("EACCES"); + err.code = "EACCES"; + throw err; + }, + targets: [{ tool: "alpha", files: [{ relPath: "node_modules/alpha/index.js", minBytes: 1 }] }], + }); + + expect(result.ok).toBe(false); + expect(result.missing[0].reason).toBe("empty"); + }); +}); + +describe("probeIntegrityInSubprocess", () => { + test("invokes spawnSyncFn with node -e and parses stdout JSON", () => { + let capturedCommand; + let capturedArgs; + const spawnSyncFn = (command, args) => { + capturedCommand = command; + capturedArgs = args; + return { + status: 0, + error: null, + stdout: JSON.stringify({ ok: true, missing: [] }), + stderr: "", + }; + }; + + const result = probeIntegrityInSubprocess({ + repoRoot: "/repo", + execPath: "/usr/bin/node", + spawnSyncFn, + }); + + expect(capturedCommand).toBe("/usr/bin/node"); + expect(Array.isArray(capturedArgs)).toBe(true); + expect(capturedArgs[0]).toBe("-e"); + expect(typeof capturedArgs[1]).toBe("string"); + expect(capturedArgs[1]).toContain("probeIntegrity"); + expect(capturedArgs[1]).toContain('"/repo"'); + expect(result).toEqual({ ok: true, missing: [] }); + }); + + test("spawns the child with cwd set to repoRoot", () => { + // Defense in depth: an inline script that prints JSON to stdout + // does not strictly require a cwd, but pinning it to repoRoot + // prevents any future addition that relies on relative paths + // (or a Node policy file lookup) from drifting. + let capturedOptions; + const spawnSyncFn = (command, args, options) => { + capturedOptions = options; + return { + status: 0, + error: null, + stdout: JSON.stringify({ ok: true, missing: [] }), + stderr: "", + }; + }; + probeIntegrityInSubprocess({ + repoRoot: "/repo", + execPath: "/usr/bin/node", + spawnSyncFn, + }); + expect(capturedOptions).toEqual( + expect.objectContaining({ cwd: "/repo" }) + ); + }); + + test("inline subprocess script opts into strict mode", () => { + // Cosmetic consistency with the parent file's "use strict"; pin + // it so a future inline-script rewrite cannot silently drop it. + let capturedArgs; + probeIntegrityInSubprocess({ + repoRoot: "/repo", + execPath: "/usr/bin/node", + spawnSyncFn: (_command, args) => { + capturedArgs = args; + return { + status: 0, + error: null, + stdout: JSON.stringify({ ok: true, missing: [] }), + stderr: "", + }; + }, + }); + expect(capturedArgs[1]).toMatch(/^"use strict";/); + }); + + test("returns a synthetic failure when subprocess prints malformed JSON", () => { + const result = probeIntegrityInSubprocess({ + repoRoot: "/repo", + spawnSyncFn: () => ({ status: 0, error: null, stdout: "not json", stderr: "" }), + }); + + expect(result.ok).toBe(false); + expect(result.missing).toEqual([ + expect.objectContaining({ + tool: "(subprocess)", + reason: expect.stringContaining("JSON parse failed"), + }), + ]); + }); + + test("returns a synthetic failure when subprocess exits non-zero", () => { + const result = probeIntegrityInSubprocess({ + repoRoot: "/repo", + spawnSyncFn: () => ({ + status: 1, + error: null, + stdout: "", + stderr: "Error: blah", + }), + }); + + expect(result.ok).toBe(false); + expect(result.missing[0].reason).toContain("exit=1"); + expect(result.missing[0].reason).toContain("Error: blah"); + }); + + test("returns a synthetic failure when spawn itself errors", () => { + const result = probeIntegrityInSubprocess({ + repoRoot: "/repo", + spawnSyncFn: () => ({ + status: null, + error: { message: "ENOENT" }, + stdout: "", + stderr: "", + }), + }); + + expect(result.ok).toBe(false); + expect(result.missing[0].reason).toContain("spawn failed"); + }); + + test("throws when repoRoot is missing", () => { + expect(() => probeIntegrityInSubprocess({})).toThrow(/repoRoot/); + }); + + test("merges zeroByteNativeBinaries into missing[] when subprocess reports them", () => { + // The first-pass in-process gate scans for AV-truncated *.node + // binaries on Windows; the subprocess re-probe (after npm ci) must + // do the same, otherwise a truncated native binding mid-rewrite + // would falsely report ok. We fake a subprocess JSON shape that + // includes the zeroByteNativeBinaries field and assert the parent + // surfaces it under missing[] using the same + // shape as the in-process gate. + const subprocessJson = JSON.stringify({ + ok: true, + missing: [], + zeroByteNativeBinaries: [ + "node_modules/fake-native-binding/build/Release/binding.node", + ], + }); + const result = probeIntegrityInSubprocess({ + repoRoot: "/repo", + spawnSyncFn: () => ({ + status: 0, + error: null, + stdout: subprocessJson, + stderr: "", + }), + }); + expect(result.ok).toBe(false); + expect(result.missing).toEqual([ + expect.objectContaining({ + tool: "", + relPath: "node_modules/fake-native-binding/build/Release/binding.node", + reason: "zero-byte", + }), + ]); + }); + + test("preserves ok=true when subprocess emits empty zeroByteNativeBinaries", () => { + // Backwards-compat: a subprocess JSON shape that includes the + // zeroByteNativeBinaries field but with no offenders must not flip + // ok=true to ok=false. This pins the desired behavior for the + // common (healthy) case after npm ci recovery. + const subprocessJson = JSON.stringify({ + ok: true, + missing: [], + zeroByteNativeBinaries: [], + }); + const result = probeIntegrityInSubprocess({ + repoRoot: "/repo", + spawnSyncFn: () => ({ + status: 0, + error: null, + stdout: subprocessJson, + stderr: "", + }), + }); + expect(result).toEqual({ ok: true, missing: [] }); + }); + + test("inline subprocess script calls findZeroByteNativeBinaries on win32 only", () => { + // Pin the inline script wiring so a future rewrite cannot silently + // drop the Windows zero-byte scan from the re-probe. + let capturedArgs; + probeIntegrityInSubprocess({ + repoRoot: "/repo", + execPath: "/usr/bin/node", + spawnSyncFn: (_command, args) => { + capturedArgs = args; + return { + status: 0, + error: null, + stdout: JSON.stringify({ + ok: true, + missing: [], + zeroByteNativeBinaries: [], + }), + stderr: "", + }; + }, + }); + expect(capturedArgs[1]).toContain("findZeroByteNativeBinaries"); + expect(capturedArgs[1]).toContain('"win32"'); + expect(capturedArgs[1]).toContain("zeroByteNativeBinaries"); + }); + + test("end-to-end: invokes the real child process and produces a parseable result", () => { + // Integration smoke against the real repo. This exercises the full + // wiring: parent serializes inline script, child requires the + // integrity module + calls probeIntegrity, parent parses JSON. + const realRepoRoot = path.resolve(__dirname, "..", ".."); + const result = probeIntegrityInSubprocess({ repoRoot: realRepoRoot }); + expect(typeof result.ok).toBe("boolean"); + expect(Array.isArray(result.missing)).toBe(true); + // On a healthy repo the result should be ok; we assert the shape + // rather than ok=true so the test is robust across mid-implementation + // states. + if (!result.ok) { + for (const entry of result.missing) { + expect(entry).toEqual( + expect.objectContaining({ + tool: expect.any(String), + relPath: expect.any(String), + reason: expect.any(String), + }) + ); + } + } + }); +}); + +describe("findZeroByteNativeBinaries", () => { + test("returns [] on Linux/macOS without walking", () => { + let walked = false; + const result = findZeroByteNativeBinaries({ + repoRoot: "/repo", + readdirSyncFn: () => { + walked = true; + return []; + }, + statSyncFn: () => ({ size: 0 }), + platform: "linux", + }); + expect(result).toEqual([]); + expect(walked).toBe(false); + }); + + test("returns [] when skip=true even on Windows", () => { + let walked = false; + const result = findZeroByteNativeBinaries({ + repoRoot: "/repo", + readdirSyncFn: () => { + walked = true; + return []; + }, + statSyncFn: () => ({ size: 0 }), + platform: "win32", + skip: true, + }); + expect(result).toEqual([]); + expect(walked).toBe(false); + }); + + test("returns offenders on Windows when *.node files have size 0", () => { + // Fake one-level fs: node_modules has a child "native" with a 0-byte + // index.node and a healthy 100-byte sibling.node. + // + // The fixture is keyed in POSIX form so the test runs identically on + // Linux and Windows. The injected readdirSyncFn / statSyncFn + // normalize the (platform-native) absolute path the production code + // hands them before fixture lookup. + const tree = { + "/repo/node_modules": [ + { name: "native", isDirectory: () => true, isFile: () => false }, + ], + "/repo/node_modules/native": [ + { name: "index.node", isDirectory: () => false, isFile: () => true }, + { name: "sibling.node", isDirectory: () => false, isFile: () => true }, + ], + }; + const sizes = { + "/repo/node_modules/native/index.node": 0, + "/repo/node_modules/native/sibling.node": 100, + }; + const result = findZeroByteNativeBinaries({ + repoRoot: "/repo", + platform: "win32", + readdirSyncFn: (dir) => tree[toPosixPath(dir)] || [], + statSyncFn: (abs) => { + const key = toPosixPath(abs); + return { size: sizes[key] !== undefined ? sizes[key] : 100 }; + }, + }); + + expect(result).toEqual(["node_modules/native/index.node"]); + }); + + test("respects maxDepth to keep the scan bounded", () => { + const visited = new Set(); + const tree = (dir) => { + visited.add(dir); + return [ + { name: "deeper", isDirectory: () => true, isFile: () => false }, + ]; + }; + findZeroByteNativeBinaries({ + repoRoot: "/repo", + platform: "win32", + readdirSyncFn: tree, + statSyncFn: () => ({ size: 0 }), + maxDepth: 2, + }); + // Depth budget 2 means at most 3 readdir calls (root, level1, level2). + expect(visited.size).toBeLessThanOrEqual(3); + }); +}); + +describe("formatIntegrityFailure", () => { + test("produces a stable single-line format", () => { + const formatted = formatIntegrityFailure({ + ok: false, + missing: [ + { tool: "jest-circus", relPath: "node_modules/jest-circus/build/runner.js", reason: "missing" }, + ], + }); + expect(formatted).toBe( + "Integrity probe failed: missing node_modules/jest-circus/build/runner.js (missing) for jest-circus" + ); + // Single line - no embedded newlines. + expect(formatted.includes("\n")).toBe(false); + }); + + test("reports the count of remaining offenders when more than one is missing", () => { + const formatted = formatIntegrityFailure({ + ok: false, + missing: [ + { tool: "jest-circus", relPath: "a", reason: "missing" }, + { tool: "jest-circus", relPath: "b", reason: "missing" }, + { tool: "prettier", relPath: "c", reason: "empty" }, + ], + }); + expect(formatted).toContain("2 more"); + }); + + test("handles empty / malformed input defensively", () => { + expect(formatIntegrityFailure(null)).toContain("no detail available"); + expect(formatIntegrityFailure({})).toContain("no detail available"); + expect(formatIntegrityFailure({ ok: true, missing: [] })).toContain("no detail available"); + }); + + test("POSIX-normalizes Windows-flavored relPath in the output", () => { + const formatted = formatIntegrityFailure({ + ok: false, + missing: [ + { + tool: "jest-circus", + relPath: "node_modules\\jest-circus\\build\\runner.js", + reason: "missing", + }, + ], + }); + expect(formatted).toContain("node_modules/jest-circus/build/runner.js"); + expect(formatted).not.toContain("\\"); + }); +}); + +describe("probeResolverHealth", () => { + test("returns ok when all specifiers resolve (subprocess emits ok:true)", () => { + const spawnSyncFn = jest.fn(() => ({ + status: 0, + stdout: JSON.stringify({ ok: true, failures: [] }), + stderr: "", + })); + const result = probeResolverHealth({ + repoRoot: "/repo", + spawnSyncFn, + specifiers: ["jest-circus/runner"], + }); + expect(result).toEqual({ ok: true, failures: [] }); + expect(spawnSyncFn).toHaveBeenCalledTimes(1); + const [, args, opts] = spawnSyncFn.mock.calls[0]; + expect(args[0]).toBe("-e"); + // Verify the inline script encodes both repoRoot and specifiers + // via JSON.stringify (defense against injection). + expect(args[1]).toContain('JSON.stringify'); + expect(args[1]).toContain('"/repo"'); + expect(args[1]).toContain('"jest-circus/runner"'); + expect(opts.cwd).toBe("/repo"); + }); + + test("surfaces failures emitted by the subprocess JSON payload", () => { + const spawnSyncFn = jest.fn(() => ({ + status: 0, + stdout: JSON.stringify({ + ok: false, + failures: [ + { + specifier: "jest-circus/runner", + error: "Failed to load native binding: @unrs/resolver-binding-win32-x64-msvc", + }, + ], + }), + stderr: "", + })); + const result = probeResolverHealth({ + repoRoot: "/repo", + spawnSyncFn, + }); + expect(result.ok).toBe(false); + expect(result.failures).toHaveLength(1); + expect(result.failures[0].specifier).toBe("jest-circus/runner"); + expect(result.failures[0].error).toContain("native binding"); + }); + + test("returns synthetic failure when subprocess exits non-zero", () => { + const spawnSyncFn = jest.fn(() => ({ + status: 1, + stdout: "", + stderr: "node: boom", + })); + const result = probeResolverHealth({ + repoRoot: "/repo", + spawnSyncFn, + }); + expect(result.ok).toBe(false); + expect(result.failures).toHaveLength(1); + expect(result.failures[0].specifier).toBe(""); + expect(result.failures[0].error).toContain("exit=1"); + expect(result.failures[0].error).toContain("boom"); + }); + + test("returns synthetic failure when subprocess emits malformed JSON", () => { + const spawnSyncFn = jest.fn(() => ({ + status: 0, + stdout: "not-json", + stderr: "", + })); + const result = probeResolverHealth({ + repoRoot: "/repo", + spawnSyncFn, + }); + expect(result.ok).toBe(false); + expect(result.failures).toHaveLength(1); + expect(result.failures[0].specifier).toBe(""); + expect(result.failures[0].error).toContain("malformed probe output"); + }); + + test("returns synthetic failure when subprocess returns null result", () => { + const spawnSyncFn = jest.fn(() => null); + const result = probeResolverHealth({ + repoRoot: "/repo", + spawnSyncFn, + }); + expect(result.ok).toBe(false); + expect(result.failures[0].error).toContain("spawn returned null"); + }); + + test("returns synthetic failure when spawn itself throws", () => { + const spawnSyncFn = jest.fn(() => { + throw new Error("ENOENT: node not found"); + }); + const result = probeResolverHealth({ + repoRoot: "/repo", + spawnSyncFn, + }); + expect(result.ok).toBe(false); + expect(result.failures[0].error).toContain("spawn threw"); + expect(result.failures[0].error).toContain("ENOENT"); + }); + + test("returns synthetic failure when stdout is empty (ok exit)", () => { + const spawnSyncFn = jest.fn(() => ({ status: 0, stdout: "", stderr: "" })); + const result = probeResolverHealth({ + repoRoot: "/repo", + spawnSyncFn, + }); + expect(result.ok).toBe(false); + expect(result.failures[0].error).toContain("empty stdout"); + }); + + test("throws when repoRoot is missing", () => { + expect(() => probeResolverHealth({})).toThrow(/repoRoot/); + expect(() => probeResolverHealth({ repoRoot: "" })).toThrow(); + }); + + test("end-to-end: probes the real repo successfully", () => { + // Spawns a real Node subprocess against this repo's actual root. + // If the real jest-circus install is healthy (it must be, since the + // test runner is jest-circus), this returns ok:true. + const result = probeResolverHealth({ + repoRoot: path.resolve(__dirname, "../.."), + }); + expect(result.ok).toBe(true); + expect(result.failures).toEqual([]); + }); + + test("surfaces unrs-resolver load failures emitted by the subprocess (C1 regression)", () => { + // The Windows failure mode: the subprocess reports that + // require("unrs-resolver") threw because the native binding is + // missing or corrupt. The parent must propagate that failure with + // ok=false and the unrs-resolver specifier preserved. + const spawnSyncFn = jest.fn(() => ({ + status: 0, + stdout: JSON.stringify({ + ok: false, + failures: [ + { + specifier: "unrs-resolver", + error: "Cannot find native binding. (@unrs/resolver-binding-win32-x64-msvc)", + }, + ], + }), + stderr: "", + })); + const result = probeResolverHealth({ + repoRoot: "/repo", + spawnSyncFn, + }); + expect(result.ok).toBe(false); + expect(result.failures).toHaveLength(1); + expect(result.failures[0].specifier).toBe("unrs-resolver"); + expect(result.failures[0].error).toContain("native binding"); + }); + + test("contract: inline script source MUST mention unrs-resolver (load-bearing invariant)", () => { + // The whole point of the resolver probe is to exercise unrs-resolver + // (and via fallback, jest-resolve) — Node's own require.resolve will + // happily succeed on a Windows install with a broken native binding + // because the JS files are present on disk. This test pins the + // contract by inspecting the inline script source the parent hands + // to spawnSync, so a future refactor that accidentally strips the + // unrs-resolver load chain will fail loudly here instead of silently + // shipping a no-op probe to the Windows developer. + let capturedArgs; + probeResolverHealth({ + repoRoot: "/repo", + spawnSyncFn: (_command, args) => { + capturedArgs = args; + return { + status: 0, + stdout: JSON.stringify({ ok: true, failures: [] }), + stderr: "", + }; + }, + }); + expect(capturedArgs).toBeTruthy(); + expect(capturedArgs[0]).toBe("-e"); + const script = capturedArgs[1]; + expect(typeof script).toBe("string"); + // The literal "unrs-resolver" token MUST appear; this is the + // load-bearing invariant the user reviewer flagged. + expect(script).toContain("unrs-resolver"); + // ResolverFactory + sync must also appear: the probe must + // INSTANTIATE the factory and CALL sync(), not just require the + // module (a half-loaded binding can survive require but throw at + // sync time). + expect(script).toContain("ResolverFactory"); + expect(script).toContain("sync"); + // jest-resolve fallback is wired in so a repo whose tree does not + // directly list unrs-resolver still exercises the failure surface. + expect(script).toContain("jest-resolve"); + }); + + test("end-to-end: simulated broken native binding via SKIP_UNRS_RESOLVER_FALLBACK is surfaced", () => { + // The real Windows failure happens BEFORE napi-postinstall's + // auto-fallback (which itself is a partial mitigation on the + // Windows machines that hit this class of bug). We can simulate + // the post-fallback failure deterministically on Linux by setting + // SKIP_UNRS_RESOLVER_FALLBACK=1 and removing the native binding + // from node_modules/@unrs/. The simpler integration approach: + // construct a synthetic repoRoot where unrs-resolver MUST throw on + // load. We approximate by pointing repoRoot at a directory whose + // package.json does not resolve unrs-resolver -- the probe should + // then push a 'unrs-resolver' failure with MODULE_NOT_FOUND. + const os = require("os"); + const fs = require("fs"); + const tmpRepo = fs.mkdtempSync(path.join(os.tmpdir(), "resolver-probe-fixture-")); + try { + // Bare repo: package.json + empty node_modules. No unrs-resolver + // installed -> the probe's repoRequire('unrs-resolver') throws + // MODULE_NOT_FOUND, which the probe surfaces as a failure with + // specifier 'unrs-resolver'. + fs.writeFileSync( + path.join(tmpRepo, "package.json"), + JSON.stringify({ name: "fixture", version: "0.0.0" }) + ); + fs.mkdirSync(path.join(tmpRepo, "node_modules")); + const result = probeResolverHealth({ repoRoot: tmpRepo }); + expect(result.ok).toBe(false); + const unrsFailure = result.failures.find( + (f) => f.specifier === "unrs-resolver" + ); + expect(unrsFailure).toBeTruthy(); + expect(unrsFailure.error).toMatch(/MODULE_NOT_FOUND|Cannot find module/); + } finally { + fs.rmSync(tmpRepo, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/__tests__/node-modules-integrity.test.js.meta b/scripts/__tests__/node-modules-integrity.test.js.meta new file mode 100644 index 00000000..70192785 --- /dev/null +++ b/scripts/__tests__/node-modules-integrity.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c18c3e292cae06f7d95b3b3206195b60 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/path-classifier.test.js b/scripts/__tests__/path-classifier.test.js new file mode 100644 index 00000000..2e6fc37f --- /dev/null +++ b/scripts/__tests__/path-classifier.test.js @@ -0,0 +1,254 @@ +/** + * @fileoverview Tests for scripts/lib/path-classifier.js. + * + * The classifier is a pure module used by the integrity gate and the tier + * dispatcher to decide whether a captured runner path belongs to the + * repository's node_modules tree, the isolated managed-Jest cache, or + * neither. The boundary helpers (`normalizeForPathComparison`, + * `isPathInsideDirectory`) were previously defined inside + * `scripts/run-managed-jest.js`; these tests now own them as the canonical + * specification. + */ + +"use strict"; + +const path = require("path"); + +const { + PATH_CLASS_REPO, + PATH_CLASS_ISOLATED, + PATH_CLASS_UNKNOWN, + normalizeForPathComparison, + isPathInsideDirectory, + classifyCapturedPath, + toPosixPath, + toRepoPosixRelative, +} = require("../lib/path-classifier"); + +describe("normalizeForPathComparison", () => { + test("returns an absolute, resolved path for a relative input", () => { + const result = normalizeForPathComparison("./foo"); + expect(path.isAbsolute(result)).toBe(true); + }); + + test("idempotent: normalizing twice yields the same value", () => { + const first = normalizeForPathComparison(__dirname); + const second = normalizeForPathComparison(first); + expect(second).toBe(first); + }); + + test("does not throw on a nonexistent path", () => { + const phantom = path.join(__dirname, "this-path-does-not-exist-1234567890"); + expect(() => normalizeForPathComparison(phantom)).not.toThrow(); + }); +}); + +describe("isPathInsideDirectory", () => { + test("returns true when filePath is the directory itself", () => { + expect(isPathInsideDirectory(__dirname, __dirname)).toBe(true); + }); + + test("returns true for a descendant path", () => { + const child = path.join(__dirname, "fake-child.js"); + expect(isPathInsideDirectory(child, __dirname)).toBe(true); + }); + + test("returns false for a sibling path", () => { + const sibling = path.join(path.dirname(__dirname), "sibling.js"); + expect(isPathInsideDirectory(sibling, __dirname)).toBe(false); + }); + + test("returns false for the parent directory", () => { + expect(isPathInsideDirectory(path.dirname(__dirname), __dirname)).toBe(false); + }); +}); + +describe("classifyCapturedPath", () => { + const repoNodeModules = path.resolve("/repo/node_modules"); + const isolatedCacheRoot = path.resolve("/tmp/dxmessaging-managed-jest"); + + test("returns 'unknown' for null/undefined input", () => { + expect(classifyCapturedPath(null, { repoNodeModules, isolatedCacheRoot })).toBe( + PATH_CLASS_UNKNOWN + ); + expect(classifyCapturedPath(undefined, { repoNodeModules, isolatedCacheRoot })).toBe( + PATH_CLASS_UNKNOWN + ); + }); + + test("returns 'unknown' for empty / non-string input", () => { + expect(classifyCapturedPath("", { repoNodeModules, isolatedCacheRoot })).toBe( + PATH_CLASS_UNKNOWN + ); + expect(classifyCapturedPath(42, { repoNodeModules, isolatedCacheRoot })).toBe( + PATH_CLASS_UNKNOWN + ); + expect(classifyCapturedPath({}, { repoNodeModules, isolatedCacheRoot })).toBe( + PATH_CLASS_UNKNOWN + ); + }); + + test("returns 'repo' for a path under repoNodeModules", () => { + const result = classifyCapturedPath( + path.join(repoNodeModules, "jest-circus", "build", "runner.js"), + { repoNodeModules, isolatedCacheRoot } + ); + expect(result).toBe(PATH_CLASS_REPO); + }); + + test("returns 'isolated' for a path under isolatedCacheRoot", () => { + const result = classifyCapturedPath( + path.join( + isolatedCacheRoot, + "jest_30.3.0", + "node_modules", + "jest-circus", + "build", + "runner.js" + ), + { repoNodeModules, isolatedCacheRoot } + ); + expect(result).toBe(PATH_CLASS_ISOLATED); + }); + + test("returns 'unknown' for a path outside both trees", () => { + const outside = path.resolve("/somewhere/else/runner.js"); + const result = classifyCapturedPath(outside, { repoNodeModules, isolatedCacheRoot }); + expect(result).toBe(PATH_CLASS_UNKNOWN); + }); + + test("handles Windows-style backslashes when the boundary is also Windows-style", () => { + // Constructing a Windows-shape repo node_modules requires platform- + // specific path-resolution. We can still verify the classifier handles + // backslash inputs without throwing and falls into the correct + // bucket on the current platform via path.resolve normalization. + const winishRepo = path.resolve("D:/Code/Packages/com.wallstop-studios.dxmessaging/node_modules".replace(/\\/g, "/")); + const winishCapture = + "D:\\Code\\Packages\\com.wallstop-studios.dxmessaging\\node_modules\\jest-circus\\build\\runner.js"; + + // On POSIX, path.resolve does not collapse backslashes inside the + // captured string; classification therefore returns "unknown" because + // the captured path does not normalize under repoNodeModules. The + // test asserts the function is stable (no throw, returns a string in + // the known vocabulary), not a specific bucket. + const result = classifyCapturedPath(winishCapture, { + repoNodeModules: winishRepo, + isolatedCacheRoot, + }); + expect([PATH_CLASS_REPO, PATH_CLASS_ISOLATED, PATH_CLASS_UNKNOWN]).toContain(result); + }); + + test("repoNodeModules wins precedence when both bounds match", () => { + // Construct a synthetic case where the isolatedCacheRoot is a parent + // of repoNodeModules (artificial but proves the precedence rule). + const overlap = path.resolve("/shared"); + const repo = path.join(overlap, "node_modules"); + const isolated = overlap; + const captured = path.join(repo, "jest-circus", "runner.js"); + const result = classifyCapturedPath(captured, { + repoNodeModules: repo, + isolatedCacheRoot: isolated, + }); + expect(result).toBe(PATH_CLASS_REPO); + }); + + test("missing bounds: returns 'unknown' when both bounds are absent", () => { + expect(classifyCapturedPath("/some/path", {})).toBe(PATH_CLASS_UNKNOWN); + expect( + classifyCapturedPath("/some/path", { repoNodeModules: "", isolatedCacheRoot: null }) + ).toBe(PATH_CLASS_UNKNOWN); + }); +}); + +describe("re-export through run-managed-jest", () => { + test("scripts/run-managed-jest.js re-exports normalizeForPathComparison and isPathInsideDirectory", () => { + const wrapper = require("../run-managed-jest"); + expect(typeof wrapper.normalizeForPathComparison).toBe("function"); + expect(typeof wrapper.isPathInsideDirectory).toBe("function"); + // Functional smoke: the re-exports must behave identically. + expect(wrapper.normalizeForPathComparison(__dirname)).toBe( + normalizeForPathComparison(__dirname) + ); + expect(wrapper.isPathInsideDirectory(__filename, __dirname)).toBe(true); + }); + + test("scripts/run-managed-jest.js re-exports toPosixPath and toRepoPosixRelative", () => { + const wrapper = require("../run-managed-jest"); + expect(typeof wrapper.toPosixPath).toBe("function"); + expect(typeof wrapper.toRepoPosixRelative).toBe("function"); + expect(wrapper.toPosixPath("a\\b\\c")).toBe("a/b/c"); + }); +}); + +describe("toPosixPath", () => { + test("converts Windows-style backslashes to forward slashes", () => { + expect(toPosixPath("D:\\Code\\foo\\bar.js")).toBe("D:/Code/foo/bar.js"); + }); + + test("is idempotent on POSIX input", () => { + expect(toPosixPath("/usr/local/bin")).toBe("/usr/local/bin"); + expect(toPosixPath("a/b/c")).toBe("a/b/c"); + expect(toPosixPath(toPosixPath("a\\b\\c"))).toBe("a/b/c"); + }); + + test("returns empty string for null / undefined (no 'undefined' leak)", () => { + // Regression: when this helper was inlined into warnFn/console.warn + // template literals, returning the original undefined produced the + // literal string "undefined" in log output. Mapping to "" keeps the + // interpolation safe even when the upstream value was unset. + expect(toPosixPath(null)).toBe(""); + expect(toPosixPath(undefined)).toBe(""); + expect(`runner ${toPosixPath(undefined)}`).toBe("runner "); + expect(`runner ${toPosixPath(null)}`).toBe("runner "); + }); + + test("coerces non-null primitives via String() with separator swap", () => { + expect(toPosixPath(42)).toBe("42"); + expect(toPosixPath(true)).toBe("true"); + // Object coercion goes through Object#toString -> "[object Object]"; + // no backslashes there, so the result is the coerced string verbatim. + expect(toPosixPath({ a: 1 })).toBe("[object Object]"); + }); + + test("handles mixed-separator input", () => { + expect(toPosixPath("a\\b/c\\d")).toBe("a/b/c/d"); + }); + + test("returns empty string unchanged (no separators present)", () => { + expect(toPosixPath("")).toBe(""); + }); +}); + +describe("toRepoPosixRelative", () => { + test("produces POSIX-relative path for an input inside repoRoot", () => { + const repo = path.resolve("/repo"); + const inside = path.join(repo, "node_modules", "foo", "bar.js"); + expect(toRepoPosixRelative(inside, repo)).toBe( + "node_modules/foo/bar.js" + ); + }); + + test("produces POSIX-absolute path for an input outside repoRoot", () => { + const repo = path.resolve("/repo"); + const outside = path.resolve("/elsewhere/foo.js"); + const result = toRepoPosixRelative(outside, repo); + // Outside-of-repo paths fall back to POSIX-absolute form: any + // backslashes are converted, and the input is preserved as-is. + expect(result).toBe(toPosixPath(outside)); + expect(result.includes("\\")).toBe(false); + }); + + test("returns non-string inputs unchanged", () => { + expect(toRepoPosixRelative(null, "/repo")).toBe(null); + expect(toRepoPosixRelative("/repo/foo.js", null)).toBe("/repo/foo.js"); + expect(toRepoPosixRelative(42, "/repo")).toBe(42); + }); + + test("repoRoot itself maps to a POSIX-absolute fallback (empty relative)", () => { + const repo = path.resolve("/repo"); + // path.relative(repo, repo) === ""; toRepoPosixRelative treats that + // as "outside" (no useful relative form) and emits the POSIX + // fallback, which is the repo root itself. + expect(toRepoPosixRelative(repo, repo)).toBe(toPosixPath(repo)); + }); +}); diff --git a/scripts/__tests__/path-classifier.test.js.meta b/scripts/__tests__/path-classifier.test.js.meta new file mode 100644 index 00000000..2ecd24d1 --- /dev/null +++ b/scripts/__tests__/path-classifier.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b9f37c0cbc87c14a7ea00b8a20f34a6f +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/preflight-pre-push-coverage.test.js b/scripts/__tests__/preflight-pre-push-coverage.test.js new file mode 100644 index 00000000..bcdb4476 --- /dev/null +++ b/scripts/__tests__/preflight-pre-push-coverage.test.js @@ -0,0 +1,187 @@ +/** + * @fileoverview Static enforcement that `npm run preflight:pre-push` covers + * every hook declared at `stages: pre-push` in `.pre-commit-config.yaml`. + * + * Phase 5 of the doctor / preflight rollout. Phase 3 added the chained + * `preflight:pre-commit` + `pre-commit run --hook-stage pre-push --all-files` + * script. This test locks the coverage so that: + * + * - If a contributor adds a new pre-push hook to .pre-commit-config.yaml + * and forgets to widen `preflight:pre-push`, the static test fails with + * a pointer to the missing hook id. + * - The "bulk" form (`--all-files`) is treated as covering everything -- + * that is the canonical shape today, so the coverage assertion succeeds + * by recognizing that single substring. + * - The chain calls `preflight:pre-commit` first, so the fast preflight + * runs before the full one and aborts on the cheapest possible failure. + * + * If a hook truly must be skipped from the bulk run, add its id to + * `PREFLIGHT_EXEMPT_HOOKS` below WITH a cited reason. Every exempt entry is + * sanity-checked against `.pre-commit-config.yaml` to catch dead allow-list + * entries (a hook that was renamed or removed must be removed here too). + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { normalizeToLf } = require("../lib/quote-parser"); +const { + findAllHookBlocks, + extractStagesFromHookBlock, +} = require("../lib/precommit-yaml"); + +/** + * Allow-list of pre-push hook ids that are intentionally NOT invoked by + * `npm run preflight:pre-push`. Each entry must cite a reason. Empty for + * now -- the current preflight script covers every pre-push hook via the + * bulk `--all-files` invocation, so no exemptions are needed. + * + * Format: { id: "", reason: "" } + */ +const PREFLIGHT_EXEMPT_HOOKS = []; + +const REPO_ROOT = path.resolve(__dirname, "../.."); +const CONFIG_PATH = path.join(REPO_ROOT, ".pre-commit-config.yaml"); +const PACKAGE_JSON_PATH = path.join(REPO_ROOT, "package.json"); + +const BULK_PRE_PUSH_TOKENS = [ + /\bpre-commit\s+run\b/, + /--hook-stage\s+pre-push\b/, + /--all-files\b/, +]; + +function escapeRegex(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function scriptHasBulkPrePushInvocation(script) { + return BULK_PRE_PUSH_TOKENS.every((rx) => rx.test(script)); +} + +function loadConfigLines() { + const raw = fs.readFileSync(CONFIG_PATH, "utf8"); + return normalizeToLf(raw).split("\n"); +} + +function loadPackageJson() { + const raw = fs.readFileSync(PACKAGE_JSON_PATH, "utf8"); + return JSON.parse(raw); +} + +function getPrePushHookIds(configLines) { + const blocks = findAllHookBlocks(configLines); + const ids = []; + for (const block of blocks) { + const stages = extractStagesFromHookBlock(block); + if (stages.includes("pre-push")) { + ids.push(block.id); + } + } + return ids; +} + +function getAllHookIds(configLines) { + const blocks = findAllHookBlocks(configLines); + return blocks.map((block) => block.id); +} + +describe("preflight:pre-push static coverage", () => { + test("preflight:pre-push npm script exists and is a non-empty string", () => { + const pkg = loadPackageJson(); + expect(pkg.scripts).toBeDefined(); + const value = pkg.scripts["preflight:pre-push"]; + expect(typeof value).toBe("string"); + expect(value.trim().length).toBeGreaterThan(0); + }); + + test("preflight:pre-push chain calls preflight:pre-commit first", () => { + // The fast preflight gate is intentionally first in the chain so a + // cheap failure (e.g., yamllint, formatter, a sub-second validator) + // surfaces before the multi-minute pre-push sweep begins. If a + // future refactor reorders these, the agent loses its fast-feedback + // signal and pre-push doctor reports become misleading. + const pkg = loadPackageJson(); + const script = pkg.scripts["preflight:pre-push"]; + expect(script).toContain("npm run preflight:pre-commit"); + + const preCommitIndex = script.indexOf("npm run preflight:pre-commit"); + const prePushIndex = script.indexOf("pre-commit run --hook-stage pre-push"); + expect(preCommitIndex).toBeGreaterThanOrEqual(0); + expect(prePushIndex).toBeGreaterThan(preCommitIndex); + }); + + test("preflight:pre-push covers every pre-push hook in .pre-commit-config.yaml", () => { + const configLines = loadConfigLines(); + const prePushIds = getPrePushHookIds(configLines); + expect(prePushIds.length).toBeGreaterThan(0); + + const pkg = loadPackageJson(); + const script = pkg.scripts["preflight:pre-push"]; + + // Bulk form: `pre-commit run --hook-stage pre-push --all-files` runs + // every hook whose stages: includes pre-push, so coverage is + // automatic. This is the canonical shape today. The detector matches + // the three required tokens independently so flag-order variations + // (e.g., `--show-diff-on-failure` inserted anywhere) still resolve. + if (scriptHasBulkPrePushInvocation(script)) { + // Bulk form covers everything; nothing further to assert here. + // The bulk form is the canonical shape and the explicit-form + // branch below only fires if a future refactor switches to + // per-hook invocations. + return; + } + + const exemptIds = new Set(PREFLIGHT_EXEMPT_HOOKS.map((entry) => entry.id)); + const missing = []; + + for (const hookId of prePushIds) { + if (exemptIds.has(hookId)) { + continue; + } + + // Match `pre-commit run --hook-stage pre-push ` allowing + // optional flags (e.g., --all-files, --files ) between + // the stage flag and the hook id, but the hook id must appear + // as a whole token following the stage flag. + // Escape the hook id so any regex metacharacters (`.`, `+`, etc.) + // that future hook ids might use are matched literally rather + // than as regex syntax. Today's ids are kebab/alnum, but the + // protection is the whole point of this audit fence. + const escapedId = escapeRegex(hookId); + const explicitPattern = new RegExp( + `pre-commit\\s+run\\s+(?:--[\\w-]+(?:\\s+\\S+)?\\s+)*--hook-stage\\s+pre-push\\s+(?:--[\\w-]+(?:\\s+\\S+)?\\s+)*${escapedId}(?:\\s|$|&|;)` + ); + if (!explicitPattern.test(script)) { + missing.push(hookId); + } + } + + expect(missing).toEqual([]); + }); + + test("PREFLIGHT_EXEMPT_HOOKS entries reference real hook ids", () => { + const configLines = loadConfigLines(); + const allIds = new Set(getAllHookIds(configLines)); + + for (const entry of PREFLIGHT_EXEMPT_HOOKS) { + expect(typeof entry.id).toBe("string"); + expect(entry.id.length).toBeGreaterThan(0); + expect(typeof entry.reason).toBe("string"); + expect(entry.reason.trim().length).toBeGreaterThan(0); + // Dead-entry guard: if a hook was renamed or removed, force the + // allow-list to be updated rather than silently masking a real + // gap in coverage. + expect(allIds.has(entry.id)).toBe(true); + } + }); + + test("at least one pre-push hook exists (sanity)", () => { + // Defensive: if the parser silently returns zero hooks (e.g., a + // breaking change to precommit-yaml.js), the coverage assertion + // above would vacuously pass. Keep an explicit floor here. + const configLines = loadConfigLines(); + const prePushIds = getPrePushHookIds(configLines); + expect(prePushIds.length).toBeGreaterThanOrEqual(5); + }); +}); diff --git a/scripts/__tests__/preflight-pre-push-coverage.test.js.meta b/scripts/__tests__/preflight-pre-push-coverage.test.js.meta new file mode 100644 index 00000000..06f2c701 --- /dev/null +++ b/scripts/__tests__/preflight-pre-push-coverage.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c86fb47f1378573f8d4ea3ea3ab8469e +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/prettier-version-parity.test.js b/scripts/__tests__/prettier-version-parity.test.js index 2dada14c..85e74d9a 100644 --- a/scripts/__tests__/prettier-version-parity.test.js +++ b/scripts/__tests__/prettier-version-parity.test.js @@ -60,16 +60,22 @@ function findHookBlockText(content, hookId) { function findPinnedPrettierFallbackVersion(blockText) { if (!blockText) return null; - // Match the inlined `npx --yes --package=prettier@` form. The - // hook entry is wrapped across multiple lines via a folded YAML scalar, - // so allow any whitespace (including newlines) between tokens. - const re = /--package=prettier@(\d+\.\d+\.\d+(?:[-+][\w.]+)?)/; + // The hook entry routes through scripts/run-managed-prettier.js, which + // reads the pinned fallback spec from scripts/lib/prettier-version.js. + // The block-level parity check matches any `prettier@` mention + // (typically in the hook description / comment); the + // FALLBACK_PRETTIER_SPEC test below is the canonical pin. + const re = /prettier@(\d+\.\d+\.\d+(?:[-+][\w.]+)?)/; const match = blockText.match(re); return match ? match[1] : null; } function findLocalPrettierBinReference(blockText) { if (!blockText) return null; + // The managed wrapper script reads LOCAL_PRETTIER_BIN at this path; the + // hook block must reference it either inline (legacy) or in the + // description so a future audit reading the hook block alone can + // confirm the local-bin contract. return /\bnode_modules\/prettier\/bin\/prettier\.cjs\b/.test(blockText); } diff --git a/scripts/__tests__/run-managed-cspell.test.js b/scripts/__tests__/run-managed-cspell.test.js new file mode 100644 index 00000000..31f7c36d --- /dev/null +++ b/scripts/__tests__/run-managed-cspell.test.js @@ -0,0 +1,189 @@ +/** + * @fileoverview Tests for scripts/run-managed-cspell.js (Step 8). + * + * Mirrors the run-managed-prettier shape: integrity gate first, then local- + * tier preferred when healthy, then npx fallback on cold caches. Version + * pin is sourced from package.json#devDependencies.cspell. + */ + +"use strict"; + +const path = require("path"); + +const { + REPO_ROOT, + LOCAL_CSPELL_BIN, + FALLBACK_CSPELL_SPEC, + normalizeVersion, + getPinnedCspellSpec, + runNpxCspell, + runManagedCspell, +} = require("../run-managed-cspell"); + +describe("run-managed-cspell", () => { + // The integrity gate caches its "ok" verdict per-repoRoot to amortize + // the resolver-probe subprocess spawn across the managed wrappers in a + // single hook. Multiple tests below share the same REPO_ROOT; without + // a per-test reset, a previous test's success verdict would + // short-circuit later tests' gate-failure assertions. + const { + __clearIntegrityGateCacheForTests, + } = require("../lib/integrity-gate-with-recovery"); + beforeEach(() => { + __clearIntegrityGateCacheForTests(); + }); + + test("normalizeVersion strips ^/~ prefixes", () => { + expect(normalizeVersion("10.0.0")).toBe("10.0.0"); + expect(normalizeVersion("^10.0.0")).toBe("10.0.0"); + expect(normalizeVersion("~10.0.0")).toBe("10.0.0"); + expect(normalizeVersion("not-a-version")).toBe(null); + expect(normalizeVersion(null)).toBe(null); + }); + + test("getPinnedCspellSpec reads version from package.json devDependencies", () => { + const readFileSyncFn = jest.fn(() => + JSON.stringify({ devDependencies: { cspell: "10.1.2" } }) + ); + expect(getPinnedCspellSpec(readFileSyncFn)).toBe("cspell@10.1.2"); + }); + + test("getPinnedCspellSpec falls back to static spec when package.json is invalid", () => { + const readFileSyncFn = jest.fn(() => "not-json"); + expect(getPinnedCspellSpec(readFileSyncFn)).toBe(FALLBACK_CSPELL_SPEC); + }); + + test("getPinnedCspellSpec falls back when devDependencies.cspell is missing", () => { + const readFileSyncFn = jest.fn(() => JSON.stringify({ devDependencies: {} })); + expect(getPinnedCspellSpec(readFileSyncFn)).toBe(FALLBACK_CSPELL_SPEC); + }); + + test("runNpxCspell invokes bundled npx with the pinned package spec", () => { + const runBundledNpxCommandFn = jest.fn(() => ({ status: 0, error: null })); + const result = runNpxCspell(["--no-progress", "README.md"], "cspell@10.0.0", { + runBundledNpxCommandFn, + }); + expect(result).toEqual({ status: 0, error: null }); + expect(runBundledNpxCommandFn).toHaveBeenCalledWith( + [ + "--yes", + "--package=cspell@10.0.0", + "cspell", + "--no-progress", + "README.md", + ], + expect.objectContaining({ + cwd: REPO_ROOT, + stdio: "inherit", + }) + ); + }); + + test("runNpxCspell returns launch error object when bundled npx resolver throws", () => { + const err = new Error("missing npx-cli.js"); + const result = runNpxCspell(["--check", "x"], "cspell@10.0.0", { + runBundledNpxCommandFn: () => { + throw err; + }, + }); + expect(result).toEqual({ status: null, error: err }); + }); + + test("runManagedCspell prefers local cspell when present", () => { + const runLocalCspellFn = jest.fn(() => ({ status: 0, error: null })); + const runNpxCspellFn = jest.fn(); + + const result = runManagedCspell(["--no-progress", "x.md"], { + envFn: () => ({ DXMSG_HOOK_SKIP_INTEGRITY: "1" }), + existsSyncFn: () => true, + runLocalCspellFn, + runNpxCspellFn, + }); + + expect(result).toEqual({ status: 0, error: null }); + expect(runLocalCspellFn).toHaveBeenCalledWith(["--no-progress", "x.md"]); + expect(runNpxCspellFn).not.toHaveBeenCalled(); + }); + + test("runManagedCspell falls back to npx when local cspell is missing", () => { + const runLocalCspellFn = jest.fn(); + const runNpxCspellFn = jest.fn(() => ({ status: 0, error: null })); + + const result = runManagedCspell(["--no-progress", "x.md"], { + envFn: () => ({ DXMSG_HOOK_SKIP_INTEGRITY: "1" }), + existsSyncFn: () => false, + runLocalCspellFn, + runNpxCspellFn, + }); + + expect(result).toEqual({ status: 0, error: null }); + expect(runNpxCspellFn).toHaveBeenCalledWith(["--no-progress", "x.md"]); + expect(runLocalCspellFn).not.toHaveBeenCalled(); + }); + + test("integrity gate runs BEFORE cspell tier dispatch", () => { + const probeIntegrityFn = jest.fn(() => ({ + ok: false, + missing: [{ tool: "cspell", relPath: "node_modules/cspell/bin.mjs", reason: "missing" }], + })); + const probeIntegrityInSubprocessFn = jest.fn(() => ({ ok: true, missing: [] })); + const attemptNpmCiRecoveryFn = jest.fn(() => ({ status: 0, error: null })); + const isAutoRepairAllowedFn = jest.fn(() => ({ allowed: true, reason: null })); + const runLocalCspellFn = jest.fn(() => ({ status: 0, error: null })); + + const result = runManagedCspell(["x.md"], { + envFn: () => ({}), + probeIntegrityFn, + probeIntegrityInSubprocessFn, + attemptNpmCiRecoveryFn, + isAutoRepairAllowedFn, + existsSyncFn: () => true, + runLocalCspellFn, + runNpxCspellFn: jest.fn(), + }); + + expect(probeIntegrityFn).toHaveBeenCalledTimes(1); + expect(isAutoRepairAllowedFn).toHaveBeenCalledTimes(1); + expect(attemptNpmCiRecoveryFn).toHaveBeenCalledTimes(1); + expect(probeIntegrityInSubprocessFn).toHaveBeenCalledTimes(1); + expect(probeIntegrityInSubprocessFn.mock.invocationCallOrder[0]).toBeLessThan( + runLocalCspellFn.mock.invocationCallOrder[0] + ); + expect(result).toEqual({ status: 0, error: null }); + }); + + test("integrity gate failure returns status=1 without invoking cspell", () => { + const probeIntegrityFn = jest.fn(() => ({ + ok: false, + missing: [{ tool: "cspell", relPath: "x", reason: "missing" }], + })); + const probeIntegrityInSubprocessFn = jest.fn(() => ({ + ok: false, + missing: [{ tool: "cspell", relPath: "x", reason: "missing" }], + })); + const attemptNpmCiRecoveryFn = jest.fn(() => ({ status: 0, error: null })); + const isAutoRepairAllowedFn = jest.fn(() => ({ allowed: true, reason: null })); + const runLocalCspellFn = jest.fn(); + const printActionableRepairBannerFn = jest.fn(); + + const result = runManagedCspell(["x.md"], { + envFn: () => ({}), + probeIntegrityFn, + probeIntegrityInSubprocessFn, + attemptNpmCiRecoveryFn, + isAutoRepairAllowedFn, + existsSyncFn: () => true, + runLocalCspellFn, + runNpxCspellFn: jest.fn(), + printActionableRepairBannerFn, + }); + + expect(runLocalCspellFn).not.toHaveBeenCalled(); + expect(printActionableRepairBannerFn).toHaveBeenCalledTimes(1); + expect(result).toEqual({ status: 1, error: null }); + }); + + test("LOCAL_CSPELL_BIN points at node_modules/cspell/bin.mjs", () => { + expect(LOCAL_CSPELL_BIN).toBe(path.join(REPO_ROOT, "node_modules", "cspell", "bin.mjs")); + }); +}); diff --git a/scripts/__tests__/run-managed-cspell.test.js.meta b/scripts/__tests__/run-managed-cspell.test.js.meta new file mode 100644 index 00000000..a6a161e6 --- /dev/null +++ b/scripts/__tests__/run-managed-cspell.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9389eb8c2709ba10cd374c9bd4566063 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/run-managed-jest-no-injected-test-runner.test.js b/scripts/__tests__/run-managed-jest-no-injected-test-runner.test.js new file mode 100644 index 00000000..52e0fd6e --- /dev/null +++ b/scripts/__tests__/run-managed-jest-no-injected-test-runner.test.js @@ -0,0 +1,124 @@ +/** + * @fileoverview Static-source regression guard for the managed Jest wrapper. + * + * Root cause history: + * On Windows, jest-config's runner validator has been observed to reject + * absolute paths that `require.resolve("jest-circus/runner")` and + * `fs.existsSync` both report as valid, producing: + * "Module in the testRunner option was not found. + * is: " + * + * The original mitigation in `scripts/run-managed-jest.js` injected + * `--testRunner ` into Jest's argv. This created the exact + * failure surface above. Removing the injection (Jest 27+ defaults to + * `jest-circus` and resolves its bundled runner internally) eliminates the + * entire category of failures. + * + * This test pins the policy at the source level. It scans the wrapper source + * for any code that would push `--testRunner` into the Jest invocation + * arguments. Caller-provided `--testRunner` is still forwarded unchanged via + * the input `args` array (which is appended wholesale), so detection of + * argv-construction patterns will not produce false positives. + * + * If you have a legitimate need to inject `--testRunner` (e.g., a sandbox + * harness that cannot use Jest's default resolver), DO NOT relax this guard: + * route the path through the caller-supplied `args` array instead, which + * remains a supported public contract. + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const { stripJsCommentsAndStrings } = require("../lib/source-stripping"); + +const REPO_ROOT = path.resolve(__dirname, "..", ".."); +const RUN_MANAGED_JEST_PATH = path.join(REPO_ROOT, "scripts", "run-managed-jest.js"); + +describe("run-managed-jest source: forbidden --testRunner injection patterns", () => { + let source; + let strippedSource; + + beforeAll(() => { + source = fs.readFileSync(RUN_MANAGED_JEST_PATH, "utf8"); + strippedSource = stripJsCommentsAndStrings(source); + }); + + test("source file is readable and non-trivial", () => { + expect(typeof source).toBe("string"); + expect(source.length).toBeGreaterThan(1000); + }); + + test("source does not push '--testRunner' onto an invocation args array", () => { + // Matches any *.push("--testRunner", ...) where the literal would + // survive comment/string stripping only if it was actual code. + // After stripping all string literals the substring "--testRunner" + // cannot legally remain in code, so any match is a violation. + const violations = strippedSource + .split("\n") + .map((line, index) => ({ line, lineNumber: index + 1 })) + .filter(({ line }) => line.includes("--testRunner")); + + if (violations.length > 0) { + const formatted = violations + .map(({ line, lineNumber }) => ` ${RUN_MANAGED_JEST_PATH}:${lineNumber}: ${line.trim()}`) + .join("\n"); + throw new Error( + "Forbidden --testRunner code reference detected after stripping " + + "comments and string literals. Jest 27+ resolves its bundled " + + "jest-circus runner internally; injecting absolute paths is a " + + "known Windows failure mode (jest-config runner validator " + + "rejection). Forward caller-supplied --testRunner via the " + + "input args array instead.\n\nOffending lines:\n" + formatted + ); + } + }); + + test("source does not construct an invocationArgs push for --testRunner via concatenation", () => { + // Defense-in-depth: catch attempts to hide the literal by splitting + // it across concatenation (e.g., "--test" + "Runner"). After string + // stripping, no such concatenation can produce the option, so this + // test primarily documents intent and asserts the stripped source is + // free of test-runner-flag concatenations. + const suspiciousConcatenation = /testRunner\s*['"]?\s*\+/i; + expect(suspiciousConcatenation.test(strippedSource)).toBe(false); + }); + + test("invocationArgs.push call sites do not reference a runner path identifier", () => { + // Find all occurrences of `invocationArgs.push(...)` and ensure none + // of them reference a known runner-path identifier as their first + // argument. This is the structural guard: even if the literal were + // obfuscated, pushing a *resolved jest-circus path* would still need + // to reference one of these identifiers somewhere in the call. + const forbiddenIdentifiers = [ + "resolvedRunnerPath", + "jestRunnerPath", + "circusRunnerPath", + ]; + const pushCallRegex = /invocationArgs\.push\s*\(([^)]*)\)/g; + const violations = []; + let match; + while ((match = pushCallRegex.exec(strippedSource)) !== null) { + const callArgs = match[1]; + for (const id of forbiddenIdentifiers) { + const idRegex = new RegExp(`\\b${id}\\b`); + if (idRegex.test(callArgs)) { + violations.push({ callArgs: callArgs.trim(), id }); + } + } + } + + if (violations.length > 0) { + const formatted = violations + .map((v) => ` invocationArgs.push(${v.callArgs}) referenced forbidden identifier '${v.id}'`) + .join("\n"); + throw new Error( + "invocationArgs.push() call references a runner-path identifier. " + + "This re-introduces the Jest 30 / Windows '--testRunner' injection " + + "failure mode. Forward caller-supplied --testRunner via the input " + + "args array instead.\n\n" + formatted + ); + } + }); +}); diff --git a/scripts/__tests__/run-managed-jest-no-injected-test-runner.test.js.meta b/scripts/__tests__/run-managed-jest-no-injected-test-runner.test.js.meta new file mode 100644 index 00000000..ccb1798b --- /dev/null +++ b/scripts/__tests__/run-managed-jest-no-injected-test-runner.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 2103d83c5f6a217439ca6413b8cdd020 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/run-managed-jest.test.js b/scripts/__tests__/run-managed-jest.test.js index 8a14c4d0..f0124e25 100644 --- a/scripts/__tests__/run-managed-jest.test.js +++ b/scripts/__tests__/run-managed-jest.test.js @@ -13,7 +13,9 @@ const { FALLBACK_JEST_SPEC, ISOLATED_JEST_CACHE_ROOT, getPinnedFallbackJestSpec, + getDefaultIsolatedJestRunnerPath, getIsolatedJestPaths, + resolveIsolatedJestRunnerPath, hasCliOption, buildNodePathEnv, prepareIsolatedFallbackJest, @@ -24,20 +26,41 @@ const { hasHealthyLocalJestInstall, runIsolatedFallbackJest, runManagedJest, + runLocalJest, + runCommandCapturingStderr, + attemptIsolatedCacheReset, + attemptNpmCiRecovery, + printActionableRepairBanner, } = require("../run-managed-jest.js"); describe("run-managed-jest", () => { let existsSyncSpy; let spawnSyncSpy; + let originalSkipIntegrity; beforeEach(() => { existsSyncSpy = jest.spyOn(fs, "existsSync"); spawnSyncSpy = jest.spyOn(childProcess, "spawnSync"); + // The integrity gate (Step 5) runs before tier dispatch and consults + // fs.existsSync + fs.statSync. The legacy tier-fallback tests in + // this describe block intentionally mock `existsSync` to return + // false (simulating a missing local jest), which would also tell + // the gate "no INTEGRITY_TARGETS files exist" and short-circuit the + // wrapper to status=1. Skip the gate here so those tests continue + // to exercise the tier-fallback contract; a dedicated describe + // block below covers the gate's own behavior. + originalSkipIntegrity = process.env.DXMSG_HOOK_SKIP_INTEGRITY; + process.env.DXMSG_HOOK_SKIP_INTEGRITY = "1"; }); afterEach(() => { existsSyncSpy.mockRestore(); spawnSyncSpy.mockRestore(); + if (originalSkipIntegrity === undefined) { + delete process.env.DXMSG_HOOK_SKIP_INTEGRITY; + } else { + process.env.DXMSG_HOOK_SKIP_INTEGRITY = originalSkipIntegrity; + } }); test.each([ @@ -117,6 +140,50 @@ describe("run-managed-jest", () => { expect(isCommandUnavailable(value)).toBe(expected); }); + test("resolveIsolatedJestRunnerPath prefers module-resolution output", () => { + const installDir = path.join(ISOLATED_JEST_CACHE_ROOT, "jest_30.3.0"); + const packageJsonPath = path.join(installDir, "package.json"); + const resolvedRunnerPath = path.join( + installDir, + "node_modules", + "jest-circus", + "build", + "runner.mjs" + ); + const existsSyncFn = jest.fn( + (targetPath) => targetPath === packageJsonPath || targetPath === resolvedRunnerPath + ); + const resolveFn = jest.fn(() => resolvedRunnerPath); + const createRequireFn = jest.fn(() => ({ resolve: resolveFn })); + + const runnerPath = resolveIsolatedJestRunnerPath(installDir, { + existsSyncFn, + createRequireFn, + }); + + expect(runnerPath).toBe(resolvedRunnerPath); + expect(createRequireFn).toHaveBeenCalledWith(packageJsonPath); + expect(resolveFn).toHaveBeenCalledWith("jest-circus/runner"); + }); + + test("resolveIsolatedJestRunnerPath falls back to legacy path when resolution fails", () => { + const installDir = path.join(ISOLATED_JEST_CACHE_ROOT, "jest_30.3.0"); + const packageJsonPath = path.join(installDir, "package.json"); + const legacyRunnerPath = getDefaultIsolatedJestRunnerPath(installDir); + const existsSyncFn = jest.fn( + (targetPath) => targetPath === packageJsonPath || targetPath === legacyRunnerPath + ); + + const runnerPath = resolveIsolatedJestRunnerPath(installDir, { + existsSyncFn, + createRequireFn: () => { + throw new Error("resolution unavailable"); + }, + }); + + expect(runnerPath).toBe(legacyRunnerPath); + }); + test("prepareIsolatedFallbackJest reuses cached isolated binary when available", () => { const jestSpec = "jest@30.3.0"; const { jestBinPath, jestRunnerPath } = getIsolatedJestPaths(jestSpec); @@ -204,6 +271,113 @@ describe("run-managed-jest", () => { ); }); + test("prepareIsolatedFallbackJest deletes installDir before reinstall when cached runner is missing", () => { + // Idempotency invariant (Step 3): half-populated isolated cache dirs + // must be torn down before re-install so npm install cannot short- + // circuit on a stale lockfile / inconsistent manifest. + const jestSpec = "jest@30.3.0"; + const { installDir, packageJsonPath, jestBinPath, jestRunnerPath } = getIsolatedJestPaths(jestSpec); + // Pretend the install dir exists but the runner is missing (bin OK). + const existingPaths = new Set([installDir, jestBinPath]); + let runnerInstalled = false; + + const existsSyncFn = jest.fn((targetPath) => existingPaths.has(targetPath)); + const rmSyncFn = jest.fn((target) => { + // After rm, the install dir and bin are gone. + existingPaths.delete(target); + existingPaths.delete(jestBinPath); + existingPaths.delete(packageJsonPath); + }); + const mkdirSyncFn = jest.fn(() => existingPaths.add(installDir)); + const writeFileSyncFn = jest.fn((target) => existingPaths.add(target)); + const runCommandFn = jest.fn(() => { + existingPaths.add(jestBinPath); + existingPaths.add(jestRunnerPath); + runnerInstalled = true; + return { status: 0, error: null }; + }); + + const result = prepareIsolatedFallbackJest(jestSpec, { + existsSyncFn, + mkdirSyncFn, + writeFileSyncFn, + rmSyncFn, + runCommandFn, + warnFn: jest.fn(), + }); + + expect(rmSyncFn).toHaveBeenCalledTimes(1); + expect(rmSyncFn).toHaveBeenCalledWith( + path.resolve(installDir), + expect.objectContaining({ recursive: true, force: true }) + ); + expect(mkdirSyncFn).toHaveBeenCalledWith(installDir, { recursive: true }); + expect(runnerInstalled).toBe(true); + expect(result).toEqual({ jestBinPath, jestRunnerPath, cacheHit: false }); + }); + + test("prepareIsolatedFallbackJest refuses to rm when sanitized path traverses outside cache root", () => { + // sanitizeCacheKey("..") returns ".." which path.resolve maps to the + // parent of ISOLATED_JEST_CACHE_ROOT (typically the OS temp dir). + // The defensive validator must refuse to rm in that case. + const poisonedSpec = ".."; + const { installDir, jestBinPath, jestRunnerPath } = getIsolatedJestPaths(poisonedSpec); + const existingPaths = new Set([installDir]); + + const existsSyncFn = jest.fn((targetPath) => existingPaths.has(targetPath)); + const rmSyncFn = jest.fn(); + const mkdirSyncFn = jest.fn(() => existingPaths.add(installDir)); + const writeFileSyncFn = jest.fn(); + // runCommandFn is invoked because rm is refused, but the cache then + // skips through to install — we make install succeed so the function + // returns rather than dies. We only care that rm was refused. + const runCommandFn = jest.fn(() => { + existingPaths.add(jestBinPath); + existingPaths.add(jestRunnerPath); + return { status: 0, error: null }; + }); + const warnFn = jest.fn(); + + prepareIsolatedFallbackJest(poisonedSpec, { + existsSyncFn, + mkdirSyncFn, + writeFileSyncFn, + rmSyncFn, + runCommandFn, + warnFn, + }); + + expect(rmSyncFn).not.toHaveBeenCalled(); + expect( + warnFn.mock.calls.some((call) => + String(call[0]).includes("Refusing to rm partial isolated fallback install dir") + ) + ).toBe(true); + }); + + test("prepareIsolatedFallbackJest skips rm on cache-hit (both bin and runner present)", () => { + const jestSpec = "jest@30.3.0"; + const { jestBinPath, jestRunnerPath } = getIsolatedJestPaths(jestSpec); + const existsSyncFn = jest.fn( + (targetPath) => targetPath === jestBinPath || targetPath === jestRunnerPath + ); + const rmSyncFn = jest.fn(); + const runCommandFn = jest.fn(); + + const result = prepareIsolatedFallbackJest(jestSpec, { + existsSyncFn, + mkdirSyncFn: jest.fn(), + writeFileSyncFn: jest.fn(), + rmSyncFn, + runCommandFn, + warnFn: jest.fn(), + }); + + expect(result).toEqual({ jestBinPath, jestRunnerPath, cacheHit: true }); + expect(rmSyncFn).not.toHaveBeenCalled(); + expect(runCommandFn).not.toHaveBeenCalled(); + }); + test("prepareIsolatedFallbackJest reports unavailable isolated fallback when install fails", () => { const warnFn = jest.fn(); const result = prepareIsolatedFallbackJest("jest@30.3.0", { @@ -220,7 +394,7 @@ describe("run-managed-jest", () => { ).toBe(true); }); - test("runIsolatedFallbackJest injects isolated testRunner when caller did not provide one", () => { + test("runIsolatedFallbackJest does not inject --testRunner when caller did not provide one", () => { const jestSpec = "jest@30.3.0"; const { jestBinPath, jestRunnerPath } = getIsolatedJestPaths(jestSpec); const runCommandFn = jest.fn(() => ({ status: 0, error: null })); @@ -243,8 +417,6 @@ describe("run-managed-jest", () => { jestBinPath, true, expect.objectContaining({ - testRunnerPath: jestRunnerPath, - testRunnerInjected: true, callerProvidedTestRunner: false, nodePathOverride: expect.stringContaining( path.join(ISOLATED_JEST_CACHE_ROOT, "jest_30.3.0", "node_modules") @@ -253,12 +425,7 @@ describe("run-managed-jest", () => { ); expect(runCommandFn).toHaveBeenCalledWith( process.execPath, - [ - jestBinPath, - "--testRunner", - jestRunnerPath, - "--version", - ], + [jestBinPath, "--version"], expect.objectContaining({ env: expect.objectContaining({ NODE_PATH: expect.stringContaining( @@ -307,8 +474,6 @@ describe("run-managed-jest", () => { jestBinPath, false, expect.objectContaining({ - testRunnerPath: null, - testRunnerInjected: false, callerProvidedTestRunner: true, nodePathOverride: expect.stringContaining( path.join(ISOLATED_JEST_CACHE_ROOT, "jest_30.3.0", "node_modules") @@ -332,7 +497,7 @@ describe("run-managed-jest", () => { expect(runCommandFn).not.toHaveBeenCalled(); }); - test("runIsolatedFallbackJest returns null when runner injection is required but unavailable", () => { + test("runIsolatedFallbackJest returns null when isolated runner is unavailable and caller provided no override", () => { const jestSpec = "jest@30.3.0"; const runCommandFn = jest.fn(); const warnFn = jest.fn(); @@ -378,6 +543,136 @@ describe("run-managed-jest", () => { expect(result).toBe(true); }); + test("hasHealthyLocalJestInstall returns false when tryLoadModule throws even though existsSync is true", () => { + const localRunnerPath = path.join(REPO_ROOT, "node_modules", "jest-circus", "build", "runner.js"); + const tryLoadModuleFn = jest.fn(() => false); + const result = hasHealthyLocalJestInstall( + () => localRunnerPath, + () => true, + tryLoadModuleFn + ); + expect(result).toBe(false); + expect(tryLoadModuleFn).toHaveBeenCalledWith("jest-circus/runner"); + }); + + test("runLocalJest does not inject --testRunner when validation passes", () => { + const resolvedPath = path.join(REPO_ROOT, "node_modules", "jest-circus", "build", "runner.js"); + const runCommandFn = jest.fn(() => ({ status: 0, error: null })); + const warnFn = jest.fn(); + + const result = runLocalJest(["--version"], { + moduleResolver: () => resolvedPath, + tryLoadModuleFn: () => true, + existsSyncFn: () => true, + runCommandFn, + warnFn, + }); + + expect(result).toEqual({ status: 0, error: null }); + expect(runCommandFn).toHaveBeenCalledWith( + process.execPath, + [LOCAL_JEST_BIN, "--version"] + ); + const invocationArgs = runCommandFn.mock.calls[0][1]; + expect(invocationArgs).not.toContain("--testRunner"); + }); + + test("runLocalJest does not inject --testRunner when the caller already provided one", () => { + const userRunnerPath = path.join(REPO_ROOT, "custom-runner.js"); + const runCommandFn = jest.fn(() => ({ status: 0, error: null })); + const moduleResolver = jest.fn(); + const tryLoadModuleFn = jest.fn(); + + const result = runLocalJest(["--testRunner", userRunnerPath, "--version"], { + moduleResolver, + tryLoadModuleFn, + existsSyncFn: () => true, + runCommandFn, + warnFn: () => {}, + }); + + expect(result).toEqual({ status: 0, error: null }); + expect(moduleResolver).not.toHaveBeenCalled(); + expect(tryLoadModuleFn).not.toHaveBeenCalled(); + expect(runCommandFn).toHaveBeenCalledWith( + process.execPath, + [LOCAL_JEST_BIN, "--testRunner", userRunnerPath, "--version"] + ); + }); + + test("runLocalJest returns null when load-validation of jest-circus/runner fails", () => { + const resolvedPath = path.join(REPO_ROOT, "node_modules", "jest-circus", "build", "runner.js"); + const runCommandFn = jest.fn(); + const warnFn = jest.fn(); + + const result = runLocalJest(["--version"], { + moduleResolver: () => resolvedPath, + tryLoadModuleFn: () => false, + existsSyncFn: () => true, + runCommandFn, + warnFn, + }); + + expect(result).toBeNull(); + expect(runCommandFn).not.toHaveBeenCalled(); + expect(warnFn.mock.calls.some((call) => String(call[0]).includes("failed load validation"))).toBe(true); + }); + + test("runManagedJest cascades local → isolated → npm exec when earlier tiers return null", () => { + // Regression coverage for the full fallback cascade: gate fails, then + // isolated fallback prep fails, then npm exec succeeds. This is the + // exact code path exercised when a hook runs in an environment with a + // corrupted local node_modules and no /tmp write access for the + // isolated cache. + const npmExecResult = { status: 0, error: null }; + const runLocalJestFn = jest.fn(() => null); + const runIsolatedFallbackJestFn = jest.fn(() => null); + const runNpmExecJestFn = jest.fn(() => npmExecResult); + const runNpxJestFn = jest.fn(); + const printLocalJestFallbackWarningFn = jest.fn(); + + existsSyncSpy.mockReturnValue(true); + + const result = runManagedJest(["--version"], { + hasHealthyLocalJestInstallFn: () => true, + getNpmMajorVersionFn: () => 10, + runLocalJestFn, + runIsolatedFallbackJestFn, + runNpmExecJestFn, + runNpxJestFn, + printLocalJestFallbackWarningFn, + }); + + expect(result).toEqual(npmExecResult); + expect(runLocalJestFn).toHaveBeenCalledTimes(1); + expect(runIsolatedFallbackJestFn).toHaveBeenCalledTimes(1); + expect(runNpmExecJestFn).toHaveBeenCalledTimes(1); + expect(runNpxJestFn).not.toHaveBeenCalled(); + }); + + test("runManagedJest falls through to isolated/npm-exec when runLocalJest returns null", () => { + const isolatedResult = { status: 0, error: null }; + const runLocalJestFn = jest.fn(() => null); + const runIsolatedFallbackJestFn = jest.fn(() => isolatedResult); + const runNpmExecJestFn = jest.fn(); + const printLocalJestFallbackWarningFn = jest.fn(); + + existsSyncSpy.mockReturnValue(true); + + const result = runManagedJest(["--version"], { + hasHealthyLocalJestInstallFn: () => true, + runLocalJestFn, + runIsolatedFallbackJestFn, + runNpmExecJestFn, + printLocalJestFallbackWarningFn, + }); + + expect(result).toEqual(isolatedResult); + expect(runLocalJestFn).toHaveBeenCalledWith(["--version"]); + expect(runIsolatedFallbackJestFn).toHaveBeenCalledWith(["--version"]); + expect(runNpmExecJestFn).not.toHaveBeenCalled(); + }); + test("resolveLocalModule resolves local jest-circus runner from repository dependencies", () => { const resolvedPath = resolveLocalModule("jest-circus/runner"); expect(typeof resolvedPath).toBe("string"); @@ -386,16 +681,32 @@ describe("run-managed-jest", () => { test("runManagedJest uses local jest when installed", () => { existsSyncSpy.mockReturnValue(true); - spawnSyncSpy.mockReturnValue({ status: 0 }); + spawnSyncSpy.mockReturnValue({ status: 0, stderr: Buffer.from("") }); + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); - const result = runManagedJest(["--version"]); + try { + const result = runManagedJest(["--version"]); - expect(result).toEqual({ status: 0, error: null }); - expect(spawnSyncSpy).toHaveBeenCalledWith( - process.execPath, - [LOCAL_JEST_BIN, "--version"], - expect.objectContaining({ cwd: REPO_ROOT, stdio: "inherit" }) - ); + expect(result).toEqual( + expect.objectContaining({ status: 0, error: null, stderr: "" }) + ); + expect(spawnSyncSpy).toHaveBeenCalledWith( + process.execPath, + [LOCAL_JEST_BIN, "--version"], + expect.objectContaining({ + cwd: REPO_ROOT, + stdio: ["inherit", "inherit", "pipe"], + }) + ); + // Regression guard: never inject --testRunner with a hardcoded + // jest-circus runner path. Jest 27+ resolves its bundled default + // runner reliably; injecting absolute paths has caused Windows + // failures ("Module ... in the testRunner option was not found"). + const invocationArgs = spawnSyncSpy.mock.calls[0][1]; + expect(invocationArgs).not.toContain("--testRunner"); + } finally { + warnSpy.mockRestore(); + } }); test("runManagedJest uses npm exec fallback when local jest is missing and npm>=7", () => { @@ -403,11 +714,13 @@ describe("run-managed-jest", () => { const pinnedFallbackJestSpec = getPinnedFallbackJestSpec(); spawnSyncSpy .mockReturnValueOnce({ status: 0, stdout: "11.11.0\n", stderr: "" }) - .mockReturnValueOnce({ status: 0 }); + .mockReturnValueOnce({ status: 0, stderr: Buffer.from("") }); const result = runManagedJest(["--runTestsByPath", "scripts/__tests__/alpha.test.js"]); - expect(result).toEqual({ status: 0, error: null }); + expect(result).toEqual( + expect.objectContaining({ status: 0, error: null, stderr: "" }) + ); expect(spawnSyncSpy).toHaveBeenNthCalledWith( 1, toShellCommand("npm"), @@ -426,7 +739,10 @@ describe("run-managed-jest", () => { "--runTestsByPath", "scripts/__tests__/alpha.test.js", ], - expect.objectContaining({ cwd: REPO_ROOT, stdio: "inherit" }) + expect.objectContaining({ + cwd: REPO_ROOT, + stdio: ["inherit", "inherit", "pipe"], + }) ); }); @@ -435,7 +751,7 @@ describe("run-managed-jest", () => { const pinnedFallbackJestSpec = getPinnedFallbackJestSpec(); spawnSyncSpy .mockReturnValueOnce({ status: 0, stdout: "11.11.0\n", stderr: "" }) - .mockReturnValueOnce({ status: 0 }); + .mockReturnValueOnce({ status: 0, stderr: Buffer.from("") }); const fallbackWarningSpy = jest.fn(); const result = runManagedJest(["--version"], { @@ -444,7 +760,9 @@ describe("run-managed-jest", () => { runIsolatedFallbackJestFn: () => null, }); - expect(result).toEqual({ status: 0, error: null }); + expect(result).toEqual( + expect.objectContaining({ status: 0, error: null, stderr: "" }) + ); expect(fallbackWarningSpy).toHaveBeenCalledTimes(1); expect(spawnSyncSpy).toHaveBeenNthCalledWith( 1, @@ -456,7 +774,10 @@ describe("run-managed-jest", () => { 2, toShellCommand("npm"), ["exec", "--yes", `--package=${pinnedFallbackJestSpec}`, "--", "jest", "--version"], - expect.objectContaining({ cwd: REPO_ROOT, stdio: "inherit" }) + expect.objectContaining({ + cwd: REPO_ROOT, + stdio: ["inherit", "inherit", "pipe"], + }) ); }); @@ -482,16 +803,21 @@ describe("run-managed-jest", () => { const pinnedFallbackJestSpec = getPinnedFallbackJestSpec(); spawnSyncSpy .mockReturnValueOnce({ status: 0, stdout: "6.14.18\n", stderr: "" }) - .mockReturnValueOnce({ status: 0 }); + .mockReturnValueOnce({ status: 0, stderr: Buffer.from("") }); const result = runManagedJest(["--version"]); - expect(result).toEqual({ status: 0, error: null }); + expect(result).toEqual( + expect.objectContaining({ status: 0, error: null, stderr: "" }) + ); expect(spawnSyncSpy).toHaveBeenNthCalledWith( 2, toShellCommand("npx"), ["--yes", `--package=${pinnedFallbackJestSpec}`, "jest", "--version"], - expect.objectContaining({ cwd: REPO_ROOT, stdio: "inherit" }) + expect.objectContaining({ + cwd: REPO_ROOT, + stdio: ["inherit", "inherit", "pipe"], + }) ); }); @@ -500,16 +826,21 @@ describe("run-managed-jest", () => { const pinnedFallbackJestSpec = getPinnedFallbackJestSpec(); spawnSyncSpy .mockReturnValueOnce({ status: 1, stdout: "", stderr: "npm unavailable" }) - .mockReturnValueOnce({ status: 0 }); + .mockReturnValueOnce({ status: 0, stderr: Buffer.from("") }); const result = runManagedJest(["--version"]); - expect(result).toEqual({ status: 0, error: null }); + expect(result).toEqual( + expect.objectContaining({ status: 0, error: null, stderr: "" }) + ); expect(spawnSyncSpy).toHaveBeenNthCalledWith( 2, toShellCommand("npx"), ["--yes", `--package=${pinnedFallbackJestSpec}`, "jest", "--version"], - expect.objectContaining({ cwd: REPO_ROOT, stdio: "inherit" }) + expect.objectContaining({ + cwd: REPO_ROOT, + stdio: ["inherit", "inherit", "pipe"], + }) ); }); @@ -519,22 +850,1537 @@ describe("run-managed-jest", () => { spawnSyncSpy .mockReturnValueOnce({ status: 0, stdout: "11.11.0\n", stderr: "" }) .mockReturnValueOnce({ status: null, error: { code: "EACCES", message: "npm denied" } }) - .mockReturnValueOnce({ status: 0 }); + .mockReturnValueOnce({ status: 0, stderr: Buffer.from("") }); const result = runManagedJest(["--version"]); - expect(result).toEqual({ status: 0, error: null }); + expect(result).toEqual( + expect.objectContaining({ status: 0, error: null, stderr: "" }) + ); expect(spawnSyncSpy).toHaveBeenNthCalledWith( 2, toShellCommand("npm"), ["exec", "--yes", `--package=${pinnedFallbackJestSpec}`, "--", "jest", "--version"], - expect.objectContaining({ cwd: REPO_ROOT, stdio: "inherit" }) + expect.objectContaining({ + cwd: REPO_ROOT, + stdio: ["inherit", "inherit", "pipe"], + }) ); expect(spawnSyncSpy).toHaveBeenNthCalledWith( 3, toShellCommand("npx"), ["--yes", `--package=${pinnedFallbackJestSpec}`, "jest", "--version"], - expect.objectContaining({ cwd: REPO_ROOT, stdio: "inherit" }) + expect.objectContaining({ + cwd: REPO_ROOT, + stdio: ["inherit", "inherit", "pipe"], + }) + ); + }); +}); + +describe("run-managed-jest self-heal and decoder integration", () => { + let originalSkipIntegrity; + beforeEach(() => { + // Same rationale as the parent describe block: these tests exercise + // tier-level recovery (npm ci, isolated cache reset, banner timing) + // and predate the integrity gate. Skip the gate to isolate the + // tier-level contract; the gate has its own dedicated suite below. + originalSkipIntegrity = process.env.DXMSG_HOOK_SKIP_INTEGRITY; + process.env.DXMSG_HOOK_SKIP_INTEGRITY = "1"; + }); + afterEach(() => { + if (originalSkipIntegrity === undefined) { + delete process.env.DXMSG_HOOK_SKIP_INTEGRITY; + } else { + process.env.DXMSG_HOOK_SKIP_INTEGRITY = originalSkipIntegrity; + } + }); + + test("runCommandCapturingStderr returns { status, error, stderr } with stderr decoded", () => { + // Use a tiny node invocation to validate the wiring end-to-end. This + // is cross-platform because we invoke process.execPath directly (not + // npm/npx), so no shell shim is involved. + const writeStderrSpy = jest.spyOn(process.stderr, "write").mockImplementation(() => true); + try { + const result = runCommandCapturingStderr( + process.execPath, + ["-e", "process.stderr.write('hello'); process.exit(7);"] + ); + + expect(typeof result.stderr).toBe("string"); + expect(result.stderr).toContain("hello"); + expect(result.status).toBe(7); + expect(result.error).toBeNull(); + } finally { + writeStderrSpy.mockRestore(); + } + }); + + test("isolated-path MISSING_TEST_RUNNER stderr triggers exactly one cache reset and one retry", () => { + // Use a captured runner path that lives under the isolated cache + // root so the new path-classifier wire-up routes to the + // cache-reset branch (the historical behavior). A non-classifiable + // path now goes to the "unknown" branch and prints the banner + // without auto-repair, covered by a separate test below. + const isolatedRunnerPath = path.join( + ISOLATED_JEST_CACHE_ROOT, + "jest_30.3.0", + "node_modules", + "jest-circus", + "build", + "runner.js" + ); + const failingResult = { + status: 1, + error: null, + stderr: `Module ${isolatedRunnerPath} in the testRunner option was not found.`, + }; + const recoveredResult = { + status: 0, + error: null, + stderr: "", + }; + const runIsolatedFallbackJestFn = jest + .fn() + .mockReturnValueOnce(failingResult) + .mockReturnValueOnce(recoveredResult); + const attemptIsolatedCacheResetFn = jest.fn(() => true); + const attemptNpmCiRecoveryFn = jest.fn(); + const runLocalJestFn = jest.fn(); + const printActionableRepairBannerFn = jest.fn(); + const printLocalJestFallbackWarningFn = jest.fn(); + const existsSyncSpy = jest.spyOn(fs, "existsSync").mockReturnValue(true); + + try { + const result = runManagedJest(["--version"], { + hasHealthyLocalJestInstallFn: () => false, + runLocalJestFn, + runIsolatedFallbackJestFn, + attemptIsolatedCacheResetFn, + attemptNpmCiRecoveryFn, + printActionableRepairBannerFn, + printLocalJestFallbackWarningFn, + getPinnedFallbackJestSpecFn: () => "jest@30.3.0", + }); + + expect(result).toBe(recoveredResult); + expect(runIsolatedFallbackJestFn).toHaveBeenCalledTimes(2); + expect(attemptIsolatedCacheResetFn).toHaveBeenCalledTimes(1); + expect(attemptIsolatedCacheResetFn).toHaveBeenCalledWith("jest@30.3.0"); + // npm-ci recovery is scoped to the local tier; isolated reset + // never invokes it. + expect(attemptNpmCiRecoveryFn).not.toHaveBeenCalled(); + // No banner on successful self-heal. + expect(printActionableRepairBannerFn).not.toHaveBeenCalled(); + } finally { + existsSyncSpy.mockRestore(); + } + }); + + test("post-Jest MISSING_TEST_RUNNER with repo-classified path routes to npm ci recovery", () => { + // The captured runner path lives under the repo's node_modules + // tree, signaling a partial-extract failure that isolated cache + // reset cannot fix. The classifier returns "repo" and the + // dispatcher must invoke attemptNpmCiRecoveryFn (gated by + // isAutoRepairAllowed) and re-probe via the subprocess probe. + const repoRunnerPath = path.join( + REPO_ROOT, + "node_modules", + "jest-circus", + "build", + "runner.js" + ); + const failingResult = { + status: 1, + error: null, + stderr: `Module ${repoRunnerPath} in the testRunner option was not found.`, + }; + const recoveredResult = { status: 0, error: null, stderr: "" }; + const runIsolatedFallbackJestFn = jest + .fn() + .mockReturnValueOnce(failingResult) + .mockReturnValueOnce(recoveredResult); + const attemptIsolatedCacheResetFn = jest.fn(); + const attemptNpmCiRecoveryFn = jest.fn(() => ({ status: 0, error: null })); + const probeIntegrityInSubprocessFn = jest.fn(() => ({ ok: true, missing: [] })); + const isAutoRepairAllowedFn = jest.fn(() => ({ allowed: true, reason: null })); + const printActionableRepairBannerFn = jest.fn(); + const printLocalJestFallbackWarningFn = jest.fn(); + const existsSyncSpy = jest.spyOn(fs, "existsSync").mockReturnValue(true); + + try { + const result = runManagedJest(["--version"], { + hasHealthyLocalJestInstallFn: () => false, + runIsolatedFallbackJestFn, + attemptIsolatedCacheResetFn, + attemptNpmCiRecoveryFn, + probeIntegrityInSubprocessFn, + isAutoRepairAllowedFn, + printActionableRepairBannerFn, + printLocalJestFallbackWarningFn, + getPinnedFallbackJestSpecFn: () => "jest@30.3.0", + }); + + expect(result).toBe(recoveredResult); + // npm ci ran exactly once, cache reset did NOT. + expect(attemptNpmCiRecoveryFn).toHaveBeenCalledTimes(1); + expect(attemptIsolatedCacheResetFn).not.toHaveBeenCalled(); + // Subprocess re-probe ran before the retry. + expect(probeIntegrityInSubprocessFn).toHaveBeenCalledTimes(1); + // Retry attempt followed. + expect(runIsolatedFallbackJestFn).toHaveBeenCalledTimes(2); + // No banner on successful self-heal. + expect(printActionableRepairBannerFn).not.toHaveBeenCalled(); + } finally { + existsSyncSpy.mockRestore(); + } + }); + + test("post-Jest MISSING_TEST_RUNNER with unknown-classified path skips auto-repair and prints banner only", () => { + // Captured path is outside BOTH the repo node_modules and the + // isolated cache root. The classifier returns "unknown"; the + // dispatcher must refuse to auto-repair and print the banner. + const failingResult = { + status: 1, + error: null, + stderr: "Module /nowhere/special/runner.js in the testRunner option was not found.", + }; + const runIsolatedFallbackJestFn = jest.fn(() => failingResult); + const attemptIsolatedCacheResetFn = jest.fn(); + const attemptNpmCiRecoveryFn = jest.fn(); + const probeIntegrityInSubprocessFn = jest.fn(); + const isAutoRepairAllowedFn = jest.fn(() => ({ allowed: true, reason: null })); + const printActionableRepairBannerFn = jest.fn(); + const printLocalJestFallbackWarningFn = jest.fn(); + const existsSyncSpy = jest.spyOn(fs, "existsSync").mockReturnValue(true); + const warnFn = jest.fn(); + + try { + const result = runManagedJest(["--version"], { + hasHealthyLocalJestInstallFn: () => false, + runIsolatedFallbackJestFn, + attemptIsolatedCacheResetFn, + attemptNpmCiRecoveryFn, + probeIntegrityInSubprocessFn, + isAutoRepairAllowedFn, + printActionableRepairBannerFn, + printLocalJestFallbackWarningFn, + getPinnedFallbackJestSpecFn: () => "jest@30.3.0", + warnFn, + }); + + expect(result).toBe(failingResult); + // Neither auto-repair path was attempted. + expect(attemptIsolatedCacheResetFn).not.toHaveBeenCalled(); + expect(attemptNpmCiRecoveryFn).not.toHaveBeenCalled(); + expect(probeIntegrityInSubprocessFn).not.toHaveBeenCalled(); + // Banner was printed exactly once. + expect(printActionableRepairBannerFn).toHaveBeenCalledTimes(1); + // Only the initial isolated call ran; no retry. + expect(runIsolatedFallbackJestFn).toHaveBeenCalledTimes(1); + } finally { + existsSyncSpy.mockRestore(); + } + }); + + test("post-Jest MISSING_TEST_RUNNER with repo path is refused when auto-repair is disabled", () => { + // Operator override: even with a repo-classified path, the + // dispatcher honors isAutoRepairAllowed and prints the banner. + const repoRunnerPath = path.join( + REPO_ROOT, + "node_modules", + "jest-circus", + "build", + "runner.js" + ); + const failingResult = { + status: 1, + error: null, + stderr: `Module ${repoRunnerPath} in the testRunner option was not found.`, + }; + const runIsolatedFallbackJestFn = jest.fn(() => failingResult); + const attemptNpmCiRecoveryFn = jest.fn(); + const probeIntegrityInSubprocessFn = jest.fn(); + const isAutoRepairAllowedFn = jest.fn(() => ({ + allowed: false, + reason: "DXMSG_HOOK_NO_AUTOREPAIR=1 set", + })); + const printActionableRepairBannerFn = jest.fn(); + const printLocalJestFallbackWarningFn = jest.fn(); + const existsSyncSpy = jest.spyOn(fs, "existsSync").mockReturnValue(true); + + try { + const result = runManagedJest(["--version"], { + hasHealthyLocalJestInstallFn: () => false, + runIsolatedFallbackJestFn, + attemptNpmCiRecoveryFn, + probeIntegrityInSubprocessFn, + isAutoRepairAllowedFn, + printActionableRepairBannerFn, + printLocalJestFallbackWarningFn, + getPinnedFallbackJestSpecFn: () => "jest@30.3.0", + }); + + expect(result).toBe(failingResult); + expect(attemptNpmCiRecoveryFn).not.toHaveBeenCalled(); + expect(probeIntegrityInSubprocessFn).not.toHaveBeenCalled(); + expect(printActionableRepairBannerFn).toHaveBeenCalledTimes(1); + expect(runIsolatedFallbackJestFn).toHaveBeenCalledTimes(1); + } finally { + existsSyncSpy.mockRestore(); + } + }); + + test("CORRUPT_ISOLATED_CACHE (npmCi flag absent) bypasses classification and always cache-resets", () => { + // CORRUPT_ISOLATED_CACHE's selfHeal only carries isolatedCacheReset + // (no npmCi), so the dispatcher must skip the classifier entirely + // and take the cache-reset branch even though stderr does not + // expose a captured runner path. This locks the dispatch + // shortcut so future regex changes don't accidentally change + // routing semantics for this pattern. + const failingResult = { + status: 1, + error: null, + stderr: "Error: Cannot find module 'jest-circus/runner'", + }; + const recoveredResult = { status: 0, error: null, stderr: "" }; + const runIsolatedFallbackJestFn = jest + .fn() + .mockReturnValueOnce(failingResult) + .mockReturnValueOnce(recoveredResult); + const attemptIsolatedCacheResetFn = jest.fn(() => true); + const attemptNpmCiRecoveryFn = jest.fn(); + const printActionableRepairBannerFn = jest.fn(); + const printLocalJestFallbackWarningFn = jest.fn(); + const existsSyncSpy = jest.spyOn(fs, "existsSync").mockReturnValue(true); + + try { + const result = runManagedJest(["--version"], { + hasHealthyLocalJestInstallFn: () => false, + runIsolatedFallbackJestFn, + attemptIsolatedCacheResetFn, + attemptNpmCiRecoveryFn, + printActionableRepairBannerFn, + printLocalJestFallbackWarningFn, + getPinnedFallbackJestSpecFn: () => "jest@30.3.0", + }); + + expect(result).toBe(recoveredResult); + expect(attemptIsolatedCacheResetFn).toHaveBeenCalledTimes(1); + expect(attemptNpmCiRecoveryFn).not.toHaveBeenCalled(); + } finally { + existsSyncSpy.mockRestore(); + } + }); + + test("local-path MISSING_LOCAL_JEST stderr triggers exactly one npm ci recovery", () => { + const failingResult = { + status: 1, + error: null, + stderr: "Error: Cannot find module 'jest/bin/jest.js'", + }; + const recoveredResult = { + status: 0, + error: null, + stderr: "", + }; + const runLocalJestFn = jest + .fn() + .mockReturnValueOnce(failingResult) + .mockReturnValueOnce(recoveredResult); + const attemptNpmCiRecoveryFn = jest.fn(() => ({ status: 0, error: null })); + const attemptIsolatedCacheResetFn = jest.fn(); + const printActionableRepairBannerFn = jest.fn(); + + const result = runManagedJest(["--version"], { + hasHealthyLocalJestInstallFn: () => true, + runLocalJestFn, + attemptNpmCiRecoveryFn, + attemptIsolatedCacheResetFn, + printActionableRepairBannerFn, + }); + + expect(result).toBe(recoveredResult); + expect(runLocalJestFn).toHaveBeenCalledTimes(2); + expect(attemptNpmCiRecoveryFn).toHaveBeenCalledTimes(1); + // Local-tier failures must never trigger isolated cache reset. + expect(attemptIsolatedCacheResetFn).not.toHaveBeenCalled(); + expect(printActionableRepairBannerFn).not.toHaveBeenCalled(); + }); + + test("no decoder match passes status through unchanged with no recovery", () => { + const failingResult = { + status: 1, + error: null, + stderr: "Some other Jest assertion failure: expected true to be false", + }; + const runLocalJestFn = jest.fn(() => failingResult); + const attemptNpmCiRecoveryFn = jest.fn(); + const attemptIsolatedCacheResetFn = jest.fn(); + const printActionableRepairBannerFn = jest.fn(); + + const result = runManagedJest(["--version"], { + hasHealthyLocalJestInstallFn: () => true, + runLocalJestFn, + attemptNpmCiRecoveryFn, + attemptIsolatedCacheResetFn, + printActionableRepairBannerFn, + }); + + expect(result).toBe(failingResult); + expect(runLocalJestFn).toHaveBeenCalledTimes(1); + expect(attemptNpmCiRecoveryFn).not.toHaveBeenCalled(); + expect(attemptIsolatedCacheResetFn).not.toHaveBeenCalled(); + expect(printActionableRepairBannerFn).not.toHaveBeenCalled(); + }); + + test("local-tier MISSING_TEST_RUNNER stderr does NOT invoke attemptIsolatedCacheReset and falls through to isolated tier", () => { + // The LOCAL tier must scope cache reset to the isolated path only. It + // also has no in-place repair for a MISSING_TEST_RUNNER stderr (npm + // ci wouldn't fix a missing runner module), so the wrapper falls + // through to the isolated-fallback tier. No banner is printed at the + // local tier because a later tier may self-heal or surface its own + // diagnostic. + const failingResult = { + status: 1, + error: null, + stderr: "Module /tmp/foo/runner.js in the testRunner option was not found.", + }; + const isolatedResult = { status: 0, error: null }; + const runLocalJestFn = jest.fn(() => failingResult); + const runIsolatedFallbackJestFn = jest.fn(() => isolatedResult); + const attemptIsolatedCacheResetFn = jest.fn(); + const attemptNpmCiRecoveryFn = jest.fn(); + const printActionableRepairBannerFn = jest.fn(); + const printLocalJestFallbackWarningFn = jest.fn(); + const existsSyncSpyLocal = jest.spyOn(fs, "existsSync").mockReturnValue(true); + + try { + const result = runManagedJest(["--version"], { + hasHealthyLocalJestInstallFn: () => true, + runLocalJestFn, + runIsolatedFallbackJestFn, + attemptIsolatedCacheResetFn, + attemptNpmCiRecoveryFn, + printActionableRepairBannerFn, + printLocalJestFallbackWarningFn, + }); + + expect(result).toBe(isolatedResult); + expect(runLocalJestFn).toHaveBeenCalledTimes(1); + expect(runIsolatedFallbackJestFn).toHaveBeenCalledTimes(1); + // Local tier must never invoke isolated cache reset. + expect(attemptIsolatedCacheResetFn).not.toHaveBeenCalled(); + // npm ci is not appropriate for MISSING_TEST_RUNNER — selfHeal flag is isolatedCacheReset, not npmCi. + expect(attemptNpmCiRecoveryFn).not.toHaveBeenCalled(); + // No banner at the local tier: fall-through defers to later tiers. + expect(printActionableRepairBannerFn).not.toHaveBeenCalled(); + } finally { + existsSyncSpyLocal.mockRestore(); + } + }); + + test("runLocalJest returns null when stderr matches MISSING_TEST_RUNNER so caller falls through to isolated tier", () => { + // Production behavior: when `runLocalJest` itself observes a + // MISSING_TEST_RUNNER stderr from the spawned Jest process, it treats + // the local install as unhealthy and returns null. This avoids + // forcing every caller to special-case the failure mode. + const resolvedPath = path.join(REPO_ROOT, "node_modules", "jest-circus", "build", "runner.js"); + const failingResult = { + status: 1, + error: null, + stderr: "Module /tmp/foo/runner.js in the testRunner option was not found.", + }; + const runCommandFn = jest.fn(() => failingResult); + const warnFn = jest.fn(); + + const result = runLocalJest(["--version"], { + moduleResolver: () => resolvedPath, + tryLoadModuleFn: () => true, + existsSyncFn: () => true, + runCommandFn, + warnFn, + }); + + expect(result).toBeNull(); + expect(runCommandFn).toHaveBeenCalledTimes(1); + expect( + warnFn.mock.calls.some((call) => String(call[0]).includes("MISSING_TEST_RUNNER")) + ).toBe(true); + }); + + test("runLocalJest passes MISSING_LOCAL_JEST stderr through to caller (handled by runManagedJest)", () => { + // Local-tier MISSING_LOCAL_JEST is recoverable in-place via `npm ci`, + // so `runLocalJest` does NOT collapse it to null. The caller decides + // whether to attempt recovery. + const resolvedPath = path.join(REPO_ROOT, "node_modules", "jest-circus", "build", "runner.js"); + const failingResult = { + status: 1, + error: null, + stderr: "Error: Cannot find module 'jest/bin/jest.js'", + }; + const runCommandFn = jest.fn(() => failingResult); + + const result = runLocalJest(["--version"], { + moduleResolver: () => resolvedPath, + tryLoadModuleFn: () => true, + existsSyncFn: () => true, + runCommandFn, + warnFn: () => {}, + }); + + expect(result).toBe(failingResult); + }); + + test("banner is printed exactly once per final failure", () => { + const failingResult = { + status: 1, + error: null, + stderr: "Error: Cannot find module 'jest-circus/runner'", + }; + const runIsolatedFallbackJestFn = jest.fn(() => failingResult); + const attemptIsolatedCacheResetFn = jest.fn(() => true); + const printActionableRepairBannerFn = jest.fn(); + const printLocalJestFallbackWarningFn = jest.fn(); + const existsSyncSpy = jest.spyOn(fs, "existsSync").mockReturnValue(true); + + try { + // Both the initial isolated attempt and the retry fail with the + // same CORRUPT_ISOLATED_CACHE stderr. + const result = runManagedJest(["--version"], { + hasHealthyLocalJestInstallFn: () => false, + runIsolatedFallbackJestFn, + attemptIsolatedCacheResetFn, + printActionableRepairBannerFn, + printLocalJestFallbackWarningFn, + getPinnedFallbackJestSpecFn: () => "jest@30.3.0", + }); + + expect(result).toBe(failingResult); + // Initial call + retry = 2 isolated invocations. + expect(runIsolatedFallbackJestFn).toHaveBeenCalledTimes(2); + expect(attemptIsolatedCacheResetFn).toHaveBeenCalledTimes(1); + // Banner printed exactly once after the retry fails. + expect(printActionableRepairBannerFn).toHaveBeenCalledTimes(1); + } finally { + existsSyncSpy.mockRestore(); + } + }); + + test("no banner is printed when self-heal retry succeeds", () => { + // Use an isolated-cache-rooted runner path so the post-Jest + // classifier routes to the cache-reset branch (the legacy + // self-heal path). The dispatcher's "unknown" branch has its own + // dedicated test above. + const isolatedRunnerPath = path.join( + ISOLATED_JEST_CACHE_ROOT, + "jest_30.3.0", + "node_modules", + "jest-circus", + "build", + "runner.js" ); + const failingResult = { + status: 1, + error: null, + stderr: `Module ${isolatedRunnerPath} in the testRunner option was not found.`, + }; + const successResult = { + status: 0, + error: null, + stderr: "", + }; + const runIsolatedFallbackJestFn = jest + .fn() + .mockReturnValueOnce(failingResult) + .mockReturnValueOnce(successResult); + const attemptIsolatedCacheResetFn = jest.fn(() => true); + const printActionableRepairBannerFn = jest.fn(); + const printLocalJestFallbackWarningFn = jest.fn(); + const existsSyncSpy = jest.spyOn(fs, "existsSync").mockReturnValue(true); + + try { + const result = runManagedJest(["--version"], { + hasHealthyLocalJestInstallFn: () => false, + runIsolatedFallbackJestFn, + attemptIsolatedCacheResetFn, + printActionableRepairBannerFn, + printLocalJestFallbackWarningFn, + getPinnedFallbackJestSpecFn: () => "jest@30.3.0", + }); + + expect(result).toBe(successResult); + expect(printActionableRepairBannerFn).not.toHaveBeenCalled(); + } finally { + existsSyncSpy.mockRestore(); + } + }); + + test("attemptIsolatedCacheReset deletes the isolated install directory", () => { + const rmSyncFn = jest.fn(); + const warnFn = jest.fn(); + const ok = attemptIsolatedCacheReset("jest@30.3.0", { rmSyncFn, warnFn }); + + expect(ok).toBe(true); + expect(rmSyncFn).toHaveBeenCalledTimes(1); + const [installDir, options] = rmSyncFn.mock.calls[0]; + expect(installDir).toContain(path.join("dxmessaging-managed-jest", "jest_30.3.0")); + expect(options).toEqual({ recursive: true, force: true }); + expect(warnFn).not.toHaveBeenCalled(); + }); + + test("attemptIsolatedCacheReset refuses to delete when jestSpec resolves outside ISOLATED_JEST_CACHE_ROOT (parent traversal)", () => { + // sanitizeCacheKey("..") returns "..", which would naively resolve to + // the parent of ISOLATED_JEST_CACHE_ROOT. Defense-in-depth: refuse to + // rm anything that isn't a strict descendant of the cache root. + const rmSyncFn = jest.fn(); + const warnFn = jest.fn(); + + const ok = attemptIsolatedCacheReset("..", { rmSyncFn, warnFn }); + + expect(ok).toBe(false); + expect(rmSyncFn).not.toHaveBeenCalled(); + expect( + warnFn.mock.calls.some((call) => String(call[0]).includes("not a descendant")) + ).toBe(true); + }); + + test("attemptIsolatedCacheReset refuses to delete when jestSpec resolves to ISOLATED_JEST_CACHE_ROOT itself (empty key)", () => { + // Empty input sanitizes to "_" which IS a descendant, so use the + // current-directory traversal ".". sanitizeCacheKey(".") returns ".", + // which resolves back to the cache root itself — also forbidden. + const rmSyncFn = jest.fn(); + const warnFn = jest.fn(); + + const ok = attemptIsolatedCacheReset(".", { rmSyncFn, warnFn }); + + expect(ok).toBe(false); + expect(rmSyncFn).not.toHaveBeenCalled(); + expect( + warnFn.mock.calls.some((call) => String(call[0]).includes("not a descendant")) + ).toBe(true); + }); + + test("attemptIsolatedCacheReset deletes successfully when jestSpec resolves under ISOLATED_JEST_CACHE_ROOT", () => { + const rmSyncFn = jest.fn(); + const warnFn = jest.fn(); + + const ok = attemptIsolatedCacheReset("jest@30.3.0", { rmSyncFn, warnFn }); + + expect(ok).toBe(true); + expect(rmSyncFn).toHaveBeenCalledTimes(1); + const [installDir] = rmSyncFn.mock.calls[0]; + expect(installDir.startsWith(path.resolve(ISOLATED_JEST_CACHE_ROOT))).toBe(true); + expect(installDir).not.toBe(path.resolve(ISOLATED_JEST_CACHE_ROOT)); + }); + + test("attemptIsolatedCacheReset does NOT trip the traversal guard for prefix-only matches like '..foo'", () => { + // The guard rejects "", "..", and any path starting with ".." + + // path.sep, but a sanitized key that merely STARTS with ".." + // (e.g. "..foo") resolves to a legitimate descendant of the + // cache root and must be allowed. This locks the + // segment-boundary semantics of the guard. + const rmSyncFn = jest.fn(); + const warnFn = jest.fn(); + + // sanitizeCacheKey("..foo") -> "..foo" (allowed chars: ._-a-zA-Z0-9). + const ok = attemptIsolatedCacheReset("..foo", { rmSyncFn, warnFn }); + + expect(ok).toBe(true); + expect(rmSyncFn).toHaveBeenCalledTimes(1); + const [installDir] = rmSyncFn.mock.calls[0]; + expect(installDir.startsWith(path.resolve(ISOLATED_JEST_CACHE_ROOT))).toBe(true); + // The directory name must literally be "..foo" sanitized form. + expect(installDir).toContain("..foo"); + expect(warnFn).not.toHaveBeenCalled(); + }); + + test("attemptIsolatedCacheReset returns false when rm fails", () => { + const rmSyncFn = jest.fn(() => { + throw new Error("EBUSY: resource busy"); + }); + const warnFn = jest.fn(); + const ok = attemptIsolatedCacheReset("jest@30.3.0", { rmSyncFn, warnFn }); + + expect(ok).toBe(false); + expect(warnFn).toHaveBeenCalledTimes(1); + expect(warnFn.mock.calls[0][0]).toContain("EBUSY"); + }); + + test("attemptNpmCiRecovery invokes npm ci and returns the runCommand result", () => { + const runCommandFn = jest.fn(() => ({ status: 0, error: null })); + const warnFn = jest.fn(); + const result = attemptNpmCiRecovery({ runCommandFn, warnFn }); + + expect(result).toEqual({ status: 0, error: null }); + expect(runCommandFn).toHaveBeenCalledTimes(1); + const [command, args, options] = runCommandFn.mock.calls[0]; + expect(command).toBe("npm"); + expect(args).toEqual(["ci", "--no-audit", "--no-fund"]); + expect(options).toEqual(expect.objectContaining({ cwd: REPO_ROOT })); + }); + + test("attemptNpmCiRecovery returns the failure result and warns when npm ci fails", () => { + const failureResult = { status: 1, error: null }; + const runCommandFn = jest.fn(() => failureResult); + const warnFn = jest.fn(); + const result = attemptNpmCiRecovery({ runCommandFn, warnFn }); + + expect(result).toBe(failureResult); + // Assert content, not call count: the wrapper announces the attempt + // and then the failure. Exact call count is brittle to future logging + // additions. + const warnMessages = warnFn.mock.calls.map((call) => String(call[0])); + expect(warnMessages.some((message) => message.includes("Attempting `npm ci` recovery"))).toBe(true); + expect(warnMessages.some((message) => message.includes("did not succeed"))).toBe(true); + }); + + test("printActionableRepairBanner writes the banner once, no-op on null decoded", () => { + const writeFn = jest.fn(); + + printActionableRepairBanner(null, { writeFn, envCi: "" }); + expect(writeFn).not.toHaveBeenCalled(); + + const decoded = { + kind: "MISSING_TEST_RUNNER", + regex: /x/, + summary: "Test summary.", + rootCauses: ["cause one"], + repairCommands: ["do thing"], + skillRef: ".llm/skills/scripting/jest-hook-robustness.md", + selfHeal: { retryOnce: true }, + capturedMatch: null, + }; + printActionableRepairBanner(decoded, { writeFn, envCi: "1" }); + expect(writeFn).toHaveBeenCalledTimes(1); + expect(writeFn.mock.calls[0][0]).toContain("jest-hook diagnostic: MISSING_TEST_RUNNER"); + expect(writeFn.mock.calls[0][0]).toContain("do thing"); + }); +}); + +describe("run-managed-jest integrity gate (Step 5)", () => { + // These tests exercise the integrity gate without skipping it, so they + // control the gate via injected fakes only. They live in their own + // describe block so the parent's DXMSG_HOOK_SKIP_INTEGRITY setup does + // not interfere. + const { + __clearIntegrityGateCacheForTests: clearGateCache, + } = require("../lib/integrity-gate-with-recovery"); + + // The gate caches its "ok" verdict per-repoRoot. Multiple tests in this + // suite share the same repoRoot (the production REPO_ROOT), so we clear + // the cache between tests; otherwise a previous test's success verdict + // would short-circuit subsequent tests and their injected probeIntegrityFn + // fakes would never be invoked. + beforeEach(() => { + clearGateCache(); + }); + + function makeIntegrityResult(ok, missing = []) { + return { ok, missing }; + } + + test("integrity gate runs BEFORE any tier", () => { + const probeIntegrityFn = jest.fn(() => makeIntegrityResult(false, [ + { tool: "jest-circus", relPath: "node_modules/jest-circus/build/runner.js", reason: "missing" }, + ])); + const probeIntegrityInSubprocessFn = jest.fn(() => makeIntegrityResult(true)); + const attemptNpmCiRecoveryFn = jest.fn(() => ({ status: 0, error: null })); + const isAutoRepairAllowedFn = jest.fn(() => ({ allowed: true, reason: null })); + const runLocalJestFn = jest.fn(() => ({ status: 0, error: null, stderr: "" })); + const runIsolatedFallbackJestFn = jest.fn(); + const runNpmExecJestFn = jest.fn(); + const runNpxJestFn = jest.fn(); + const printActionableRepairBannerFn = jest.fn(); + // Re-implement runIntegrityGateWithRecoveryFn to provably gate tier + // dispatch via call ordering. The default implementation already + // does this; the test asserts the wiring. + const result = runManagedJest(["--version"], { + envFn: () => ({}), + probeIntegrityFn, + probeIntegrityInSubprocessFn, + attemptNpmCiRecoveryFn, + isAutoRepairAllowedFn, + runLocalJestFn, + runIsolatedFallbackJestFn, + runNpmExecJestFn, + runNpxJestFn, + hasHealthyLocalJestInstallFn: () => true, + printActionableRepairBannerFn, + // Provide the real gate so the call ordering test is meaningful. + }); + + // Initial probe must run. + expect(probeIntegrityFn).toHaveBeenCalledTimes(1); + // Auto-repair decision must be consulted because integrity failed. + expect(isAutoRepairAllowedFn).toHaveBeenCalledTimes(1); + // npm ci is invoked. + expect(attemptNpmCiRecoveryFn).toHaveBeenCalledTimes(1); + // Subprocess re-probe runs. + expect(probeIntegrityInSubprocessFn).toHaveBeenCalledTimes(1); + // After re-probe succeeds, tier dispatch proceeds (runLocalJestFn + // was called). + expect(runLocalJestFn).toHaveBeenCalledTimes(1); + // Result preserves whatever runLocalJestFn returned (here null -> + // cascades to runIsolatedFallbackJestFn, etc., but our fakes return + // undefined). The important assertion is that the call ORDER had + // the gate first. + // Establish call order via mock.invocationCallOrder. + expect(probeIntegrityFn.mock.invocationCallOrder[0]).toBeLessThan( + isAutoRepairAllowedFn.mock.invocationCallOrder[0] + ); + expect(isAutoRepairAllowedFn.mock.invocationCallOrder[0]).toBeLessThan( + attemptNpmCiRecoveryFn.mock.invocationCallOrder[0] + ); + expect(attemptNpmCiRecoveryFn.mock.invocationCallOrder[0]).toBeLessThan( + probeIntegrityInSubprocessFn.mock.invocationCallOrder[0] + ); + expect(probeIntegrityInSubprocessFn.mock.invocationCallOrder[0]).toBeLessThan( + runLocalJestFn.mock.invocationCallOrder[0] + ); + // No banner because recovery succeeded. + expect(printActionableRepairBannerFn).not.toHaveBeenCalled(); + // Result preserves whatever runLocalJestFn returned. The important + // assertion is the call ORDER above; the wrapper returns the + // tier's result on success. + expect(result).toEqual(expect.objectContaining({ status: 0 })); + }); + + test("integrity ok: gate does not run npm ci or print banner; tier dispatch proceeds", () => { + const probeIntegrityFn = jest.fn(() => makeIntegrityResult(true)); + const probeIntegrityInSubprocessFn = jest.fn(); + const attemptNpmCiRecoveryFn = jest.fn(); + const isAutoRepairAllowedFn = jest.fn(); + const runLocalJestFn = jest.fn(() => ({ status: 0, error: null })); + const printActionableRepairBannerFn = jest.fn(); + + const result = runManagedJest(["--version"], { + envFn: () => ({}), + probeIntegrityFn, + probeIntegrityInSubprocessFn, + attemptNpmCiRecoveryFn, + isAutoRepairAllowedFn, + runLocalJestFn, + hasHealthyLocalJestInstallFn: () => true, + printActionableRepairBannerFn, + }); + + expect(probeIntegrityFn).toHaveBeenCalledTimes(1); + expect(isAutoRepairAllowedFn).not.toHaveBeenCalled(); + expect(attemptNpmCiRecoveryFn).not.toHaveBeenCalled(); + expect(probeIntegrityInSubprocessFn).not.toHaveBeenCalled(); + expect(printActionableRepairBannerFn).not.toHaveBeenCalled(); + expect(runLocalJestFn).toHaveBeenCalledTimes(1); + expect(result).toEqual({ status: 0, error: null }); + }); + + test("single-pass after failed auto-repair: banner printed once, no Jest invocation", () => { + const probeIntegrityFn = jest.fn(() => makeIntegrityResult(false, [ + { tool: "jest", relPath: "node_modules/jest/bin/jest.js", reason: "missing" }, + ])); + const probeIntegrityInSubprocessFn = jest.fn(() => makeIntegrityResult(false, [ + { tool: "jest", relPath: "node_modules/jest/bin/jest.js", reason: "missing" }, + ])); + const attemptNpmCiRecoveryFn = jest.fn(() => ({ status: 0, error: null })); + const isAutoRepairAllowedFn = jest.fn(() => ({ allowed: true, reason: null })); + const runLocalJestFn = jest.fn(); + const printActionableRepairBannerFn = jest.fn(); + + const result = runManagedJest(["--version"], { + envFn: () => ({}), + probeIntegrityFn, + probeIntegrityInSubprocessFn, + attemptNpmCiRecoveryFn, + isAutoRepairAllowedFn, + runLocalJestFn, + hasHealthyLocalJestInstallFn: () => true, + printActionableRepairBannerFn, + }); + + // The gate ran probe -> repair -> reprobe exactly once each. + expect(probeIntegrityFn).toHaveBeenCalledTimes(1); + expect(attemptNpmCiRecoveryFn).toHaveBeenCalledTimes(1); + expect(probeIntegrityInSubprocessFn).toHaveBeenCalledTimes(1); + // Banner printed exactly once. + expect(printActionableRepairBannerFn).toHaveBeenCalledTimes(1); + // Tier dispatch was NOT invoked. + expect(runLocalJestFn).not.toHaveBeenCalled(); + // Status = 1, error null per the gate's failure contract. + expect(result).toEqual({ status: 1, error: null }); + }); + + test("DXMSG_HOOK_NO_AUTOREPAIR=1 skips npm ci but proceeds with degraded gate", () => { + const probeIntegrityFn = jest.fn(() => makeIntegrityResult(false, [ + { tool: "jest", relPath: "node_modules/jest/bin/jest.js", reason: "missing" }, + ])); + const probeIntegrityInSubprocessFn = jest.fn(); + const attemptNpmCiRecoveryFn = jest.fn(); + const runLocalJestFn = jest.fn(() => ({ status: 5, error: null })); + const printActionableRepairBannerFn = jest.fn(); + + const result = runManagedJest(["--version"], { + envFn: () => ({ DXMSG_HOOK_NO_AUTOREPAIR: "1" }), + probeIntegrityFn, + probeIntegrityInSubprocessFn, + attemptNpmCiRecoveryFn, + runLocalJestFn, + hasHealthyLocalJestInstallFn: () => true, + printActionableRepairBannerFn, + }); + + // Probe ran, npm ci skipped, subprocess re-probe skipped. + expect(probeIntegrityFn).toHaveBeenCalledTimes(1); + expect(attemptNpmCiRecoveryFn).not.toHaveBeenCalled(); + expect(probeIntegrityInSubprocessFn).not.toHaveBeenCalled(); + // Banner printed once for the integrity failure. + expect(printActionableRepairBannerFn).toHaveBeenCalledTimes(1); + // Tier dispatch proceeded (degraded gate) and returned status=5. + expect(runLocalJestFn).toHaveBeenCalledTimes(1); + expect(result).toEqual({ status: 5, error: null }); + }); + + test("DXMSG_HOOK_SKIP_INTEGRITY=1 skips probe entirely", () => { + const probeIntegrityFn = jest.fn(); + const probeIntegrityInSubprocessFn = jest.fn(); + const attemptNpmCiRecoveryFn = jest.fn(); + const runLocalJestFn = jest.fn(() => ({ status: 0, error: null })); + + const result = runManagedJest(["--version"], { + envFn: () => ({ DXMSG_HOOK_SKIP_INTEGRITY: "1" }), + probeIntegrityFn, + probeIntegrityInSubprocessFn, + attemptNpmCiRecoveryFn, + runLocalJestFn, + hasHealthyLocalJestInstallFn: () => true, + }); + + // Gate is bypassed entirely. + expect(probeIntegrityFn).not.toHaveBeenCalled(); + expect(probeIntegrityInSubprocessFn).not.toHaveBeenCalled(); + expect(attemptNpmCiRecoveryFn).not.toHaveBeenCalled(); + expect(runLocalJestFn).toHaveBeenCalledTimes(1); + expect(result).toEqual({ status: 0, error: null }); + }); + + test("auto-repair refused when getNpmMajorVersion returns null", () => { + const probeIntegrityFn = jest.fn(() => makeIntegrityResult(false, [ + { tool: "jest", relPath: "node_modules/jest/bin/jest.js", reason: "missing" }, + ])); + const attemptNpmCiRecoveryFn = jest.fn(); + const printActionableRepairBannerFn = jest.fn(); + // Use the production isAutoRepairAllowed by NOT injecting one; pass a + // fake getNpmMajorVersionFn that returns null. + const result = runManagedJest(["--version"], { + envFn: () => ({}), + probeIntegrityFn, + attemptNpmCiRecoveryFn, + getNpmMajorVersionFn: () => null, + printActionableRepairBannerFn, + hasHealthyLocalJestInstallFn: () => true, + runLocalJestFn: jest.fn(), + }); + + expect(attemptNpmCiRecoveryFn).not.toHaveBeenCalled(); + expect(printActionableRepairBannerFn).toHaveBeenCalledTimes(1); + expect(result).toEqual({ status: 1, error: null }); + }); + + test("DXMSG_HOOK_AGGRESSIVE_RECOVERY=1 invokes rmSync before npm ci", () => { + const rmSyncFn = jest.fn(); + const runCommandFn = jest.fn(() => ({ status: 0, error: null })); + const warnFn = jest.fn(); + const result = attemptNpmCiRecovery({ + rmSyncFn, + runCommandFn, + envFn: () => ({ DXMSG_HOOK_AGGRESSIVE_RECOVERY: "1" }), + warnFn, + }); + + expect(rmSyncFn).toHaveBeenCalledTimes(1); + const [rmTarget, rmOpts] = rmSyncFn.mock.calls[0]; + expect(rmTarget).toContain("node_modules"); + expect(rmOpts).toEqual({ recursive: true, force: true }); + // npm ci followed rm. + expect(runCommandFn.mock.invocationCallOrder[0]).toBeGreaterThan( + rmSyncFn.mock.invocationCallOrder[0] + ); + expect(result).toEqual({ status: 0, error: null }); + }); + + test("attemptNpmCiRecovery does NOT rm without DXMSG_HOOK_AGGRESSIVE_RECOVERY=1", () => { + const rmSyncFn = jest.fn(); + const runCommandFn = jest.fn(() => ({ status: 0, error: null })); + attemptNpmCiRecovery({ + rmSyncFn, + runCommandFn, + envFn: () => ({}), + warnFn: jest.fn(), + }); + expect(rmSyncFn).not.toHaveBeenCalled(); + expect(runCommandFn).toHaveBeenCalledTimes(1); + }); + + test("attemptNpmCiRecovery passes explicit cwd: REPO_ROOT", () => { + const runCommandFn = jest.fn(() => ({ status: 0, error: null })); + attemptNpmCiRecovery({ + runCommandFn, + envFn: () => ({}), + warnFn: jest.fn(), + }); + expect(runCommandFn).toHaveBeenCalledTimes(1); + const [, , opts] = runCommandFn.mock.calls[0]; + expect(opts).toEqual(expect.objectContaining({ cwd: REPO_ROOT })); + }); +}); + +describe("isAutoRepairAllowed (production policy)", () => { + const { isAutoRepairAllowed } = require("../lib/integrity-gate-with-recovery"); + + test("refused when getNpmMajorVersionFn returns null", () => { + const result = isAutoRepairAllowed({ + env: {}, + repoRoot: "/repo", + getNpmMajorVersionFn: () => null, + existsSyncFn: () => false, + spawnPlatformCommandSyncFn: () => ({ status: 0 }), + }); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("npm executable unavailable"); + }); + + test("refused mid-rebase (.git/rebase-merge present)", () => { + const result = isAutoRepairAllowed({ + env: {}, + repoRoot: "/repo", + getNpmMajorVersionFn: () => 10, + existsSyncFn: (p) => p.endsWith("rebase-merge"), + spawnPlatformCommandSyncFn: () => ({ status: 0 }), + }); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("rebase-merge"); + }); + + test("refused mid-rebase-apply", () => { + const result = isAutoRepairAllowed({ + env: {}, + repoRoot: "/repo", + getNpmMajorVersionFn: () => 10, + existsSyncFn: (p) => p.endsWith("rebase-apply"), + spawnPlatformCommandSyncFn: () => ({ status: 0 }), + }); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("rebase-apply"); + }); + + test("refused when package-lock dirty (git diff --quiet exits non-zero)", () => { + // Also asserts that the production policy forwards cwd: repoRoot to + // spawnPlatformCommandSyncFn. Without that, `git diff --quiet + // package-lock.json` would run from whatever the parent process + // happened to chdir to, which on Windows during pre-commit hooks + // can be different from the repo root. + const spawnPlatformCommandSyncFn = jest.fn(() => ({ status: 1 })); + const result = isAutoRepairAllowed({ + env: {}, + repoRoot: "/repo", + getNpmMajorVersionFn: () => 10, + existsSyncFn: () => false, + spawnPlatformCommandSyncFn, + }); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("package-lock.json"); + expect(spawnPlatformCommandSyncFn).toHaveBeenCalledTimes(1); + const [cmd, args, opts] = spawnPlatformCommandSyncFn.mock.calls[0]; + expect(cmd).toBe("git"); + expect(args).toEqual(["diff", "--quiet", "--", "package-lock.json"]); + expect(opts).toEqual(expect.objectContaining({ cwd: "/repo" })); + }); + + test("refused when DXMSG_HOOK_NO_AUTOREPAIR is set", () => { + const result = isAutoRepairAllowed({ + env: { DXMSG_HOOK_NO_AUTOREPAIR: "1" }, + repoRoot: "/repo", + getNpmMajorVersionFn: () => 10, + existsSyncFn: () => false, + spawnPlatformCommandSyncFn: () => ({ status: 0 }), + }); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("DXMSG_HOOK_NO_AUTOREPAIR"); + }); + + test("allowed in clean state", () => { + const result = isAutoRepairAllowed({ + env: {}, + repoRoot: "/repo", + getNpmMajorVersionFn: () => 10, + existsSyncFn: () => false, + spawnPlatformCommandSyncFn: () => ({ status: 0 }), + }); + expect(result.allowed).toBe(true); + expect(result.reason).toBeNull(); + }); + + test("refused when spawnPlatformCommandSync returns null (defense in depth)", () => { + // A null spawn result (process spawn outright failed) must be + // treated the same as a non-zero exit: refuse, so npm ci cannot + // run in an environment where we cannot even read the lockfile + // status. + const result = isAutoRepairAllowed({ + env: {}, + repoRoot: "/repo", + getNpmMajorVersionFn: () => 10, + existsSyncFn: () => false, + spawnPlatformCommandSyncFn: () => null, + }); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("package-lock.json"); + }); +}); + +describe("runIntegrityGateWithRecovery (native binary + formatter wiring)", () => { + const { + runIntegrityGateWithRecovery, + __clearIntegrityGateCacheForTests, + } = require("../lib/integrity-gate-with-recovery"); + const jestErrorDecoderModule = require("../lib/jest-error-decoder"); + + // The gate caches its "ok" verdict per-repoRoot in a module-level Map to + // amortize the resolver-probe subprocess spawn across the managed wrappers + // in a single hook. Every test in this suite uses repoRoot "/repo", so we + // must clear the cache between tests; otherwise a prior test's success + // verdict short-circuits the probe and the injected fakes never run. + beforeEach(() => { + __clearIntegrityGateCacheForTests(); + }); + + test("Windows-only: gate runs findZeroByteNativeBinaries and includes results in missing[]", () => { + // The probe itself returns ok=true, but a zero-byte *.node binary + // surfaces a second-class failure that should be reported through + // the same auto-repair flow. Inject platformFn so we don't depend + // on the host actually being Windows. + const findZeroByteNativeBinariesFn = jest.fn(() => [ + "node_modules/fake-native-binding/build/Release/binding.node", + ]); + let captured; + const formatIntegrityFailureFn = jest.fn((result) => { + captured = result; + return "Integrity probe failed: synthetic-fixture"; + }); + const warnFn = jest.fn(); + const printActionableRepairBannerFn = jest.fn(); + const isAutoRepairAllowedFn = jest.fn(() => ({ allowed: false, reason: "test" })); + + const result = runIntegrityGateWithRecovery({ + repoRoot: "/repo", + probeIntegrityFn: () => ({ ok: true, missing: [] }), + probeIntegrityInSubprocessFn: jest.fn(), + probeResolverHealthFn: () => ({ ok: true, failures: [] }), + attemptNpmCiRecoveryFn: jest.fn(), + isAutoRepairAllowedFn, + printActionableRepairBannerFn, + decoder: jestErrorDecoderModule, + findZeroByteNativeBinariesFn, + formatIntegrityFailureFn, + platformFn: () => "win32", + warnFn, + }); + + expect(findZeroByteNativeBinariesFn).toHaveBeenCalledTimes(1); + expect(findZeroByteNativeBinariesFn).toHaveBeenCalledWith( + expect.objectContaining({ repoRoot: "/repo", platform: "win32" }) + ); + // The captured failure passed to the formatter must include the + // synthetic native-binding entry concatenated to missing[]. + expect(captured.missing.some((m) => m.tool === "" && m.reason === "zero-byte")).toBe(true); + // augmented result is not ok because zero-byte natives were found. + expect(result.ok).toBe(false); + }); + + test("non-Windows: findZeroByteNativeBinaries returns [] and the augmented result mirrors initial.ok", () => { + const findZeroByteNativeBinariesFn = jest.fn(() => []); + const formatIntegrityFailureFn = jest.fn(() => "noop"); + const result = runIntegrityGateWithRecovery({ + repoRoot: "/repo", + probeIntegrityFn: () => ({ ok: true, missing: [] }), + probeIntegrityInSubprocessFn: jest.fn(), + probeResolverHealthFn: () => ({ ok: true, failures: [] }), + attemptNpmCiRecoveryFn: jest.fn(), + isAutoRepairAllowedFn: jest.fn(), + printActionableRepairBannerFn: jest.fn(), + decoder: jestErrorDecoderModule, + findZeroByteNativeBinariesFn, + formatIntegrityFailureFn, + platformFn: () => "linux", + warnFn: jest.fn(), + }); + expect(findZeroByteNativeBinariesFn).toHaveBeenCalledWith( + expect.objectContaining({ platform: "linux" }) + ); + expect(result.ok).toBe(true); + // The formatter is never invoked on a clean pass. + expect(formatIntegrityFailureFn).not.toHaveBeenCalled(); + }); + + test("warning text comes from formatIntegrityFailure (not an ad-hoc string)", () => { + const formatIntegrityFailureFn = jest.fn( + () => "Integrity probe failed: missing CANARY (missing) for jest-circus" + ); + const warnFn = jest.fn(); + runIntegrityGateWithRecovery({ + repoRoot: "/repo", + probeIntegrityFn: () => ({ + ok: false, + missing: [ + { tool: "jest-circus", relPath: "node_modules/jest-circus/build/runner.js", reason: "missing" }, + ], + }), + probeIntegrityInSubprocessFn: jest.fn(), + probeResolverHealthFn: () => ({ ok: true, failures: [] }), + attemptNpmCiRecoveryFn: jest.fn(() => ({ status: 1 })), + isAutoRepairAllowedFn: jest.fn(() => ({ allowed: true })), + printActionableRepairBannerFn: jest.fn(), + decoder: jestErrorDecoderModule, + findZeroByteNativeBinariesFn: () => [], + formatIntegrityFailureFn, + platformFn: () => "linux", + warnFn, + }); + // The warn line includes the formatter output verbatim. + const sawCanary = warnFn.mock.calls.some((call) => + String(call[0]).includes("CANARY") + ); + expect(sawCanary).toBe(true); + }); + + test("resolver probe failure: gate combines file probe + resolver probe and triggers npm ci", () => { + // File probe says ok; resolver probe finds the Windows + // `unrs-resolver` failure. Gate must treat the combined as !ok and + // attempt auto-repair. + const attemptNpmCiRecoveryFn = jest.fn(() => ({ status: 0 })); + const probeIntegrityInSubprocessFn = jest.fn(() => ({ + ok: true, + missing: [], + })); + const isAutoRepairAllowedFn = jest.fn(() => ({ allowed: true })); + const printActionableRepairBannerFn = jest.fn(); + const formatIntegrityFailureFn = jest.fn(() => "Integrity probe failed: synthetic"); + let observedAugmented; + const wrappedFormatter = jest.fn((res) => { + observedAugmented = res; + return formatIntegrityFailureFn(res); + }); + // Resolver probe: first call (before npm ci) reports failure; second + // call (after npm ci re-probe) reports ok. + const probeResolverHealthFn = jest + .fn() + .mockReturnValueOnce({ + ok: false, + failures: [ + { + specifier: "jest-circus/runner", + error: "Failed to load native binding: @unrs/resolver-binding-win32-x64-msvc", + }, + ], + }) + .mockReturnValueOnce({ ok: true, failures: [] }); + + const result = runIntegrityGateWithRecovery({ + repoRoot: "/repo", + probeIntegrityFn: () => ({ ok: true, missing: [] }), + probeIntegrityInSubprocessFn, + probeResolverHealthFn, + attemptNpmCiRecoveryFn, + isAutoRepairAllowedFn, + printActionableRepairBannerFn, + decoder: jestErrorDecoderModule, + findZeroByteNativeBinariesFn: () => [], + formatIntegrityFailureFn: wrappedFormatter, + platformFn: () => "win32", + warnFn: jest.fn(), + }); + + // npm ci was attempted because resolver failed initially. + expect(attemptNpmCiRecoveryFn).toHaveBeenCalledTimes(1); + // The augmented missing[] passed to the formatter must include the + // resolver failure entry tagged with the sentinel. + expect(observedAugmented.missing.some( + (m) => m.tool === "" + && m.relPath === "jest-circus/runner" + && m.reason.startsWith("resolver-throw:") + )).toBe(true); + // After successful re-probe + re-resolve, gate succeeds with + // didRecover=true. + expect(result).toEqual({ ok: true, didRecover: true, reason: null }); + // probeResolverHealthFn was called twice: once before npm ci, + // once after. + expect(probeResolverHealthFn).toHaveBeenCalledTimes(2); + }); + + test("resolver probe failure persists after npm ci: gate fails and prints banner", () => { + // Both pre-repair and post-repair resolver probes fail. The gate + // must surface the failure, print the banner, and return ok:false. + const probeResolverHealthFn = jest.fn(() => ({ + ok: false, + failures: [ + { + specifier: "jest-circus/runner", + error: "still broken after npm ci", + }, + ], + })); + const printActionableRepairBannerFn = jest.fn(); + const result = runIntegrityGateWithRecovery({ + repoRoot: "/repo", + probeIntegrityFn: () => ({ ok: true, missing: [] }), + probeIntegrityInSubprocessFn: jest.fn(() => ({ ok: true, missing: [] })), + probeResolverHealthFn, + attemptNpmCiRecoveryFn: jest.fn(() => ({ status: 0 })), + isAutoRepairAllowedFn: jest.fn(() => ({ allowed: true })), + printActionableRepairBannerFn, + decoder: jestErrorDecoderModule, + findZeroByteNativeBinariesFn: () => [], + formatIntegrityFailureFn: () => "noop", + platformFn: () => "win32", + warnFn: jest.fn(), + }); + expect(result.ok).toBe(false); + expect(printActionableRepairBannerFn).toHaveBeenCalledTimes(1); + }); + + test("warning messages emit POSIX-style paths regardless of host platform", () => { + // Drive the gate down the "refused" path so the banner is printed + // with a synthetic POSIX-form path. Assertion: nothing in the + // banner text contains backslashes. + const formatIntegrityFailureFn = jest.fn(() => + "Integrity probe failed: missing D:\\\\Code\\\\dxmessaging\\\\node_modules\\\\jest-circus\\\\build\\\\runner.js (missing) for jest-circus" + ); + let formattedOutput = null; + const warnFn = jest.fn((msg) => { + // Capture the first call (formatted summary); subsequent calls + // are auto-repair refusal context. + if (formattedOutput === null && typeof msg === "string" && msg.includes("Integrity probe failed")) { + formattedOutput = msg; + } + }); + runIntegrityGateWithRecovery({ + repoRoot: "/repo", + probeIntegrityFn: () => ({ + ok: false, + missing: [ + { + tool: "jest-circus", + relPath: "node_modules\\jest-circus\\build\\runner.js", + reason: "missing", + }, + ], + }), + probeIntegrityInSubprocessFn: jest.fn(), + probeResolverHealthFn: () => ({ ok: true, failures: [] }), + attemptNpmCiRecoveryFn: jest.fn(), + isAutoRepairAllowedFn: jest.fn(() => ({ allowed: false, reason: "refused" })), + printActionableRepairBannerFn: jest.fn(), + decoder: jestErrorDecoderModule, + findZeroByteNativeBinariesFn: () => [], + // Drive the formatter to assert its output is forwarded + // verbatim; the production formatIntegrityFailure POSIX- + // normalizes its relPath input independently (see the + // node-modules-integrity tests). + formatIntegrityFailureFn, + platformFn: () => "linux", + warnFn, + }); + expect(formatIntegrityFailureFn).toHaveBeenCalled(); + // The warnFn captured the synthetic line; in this test the + // formatter intentionally returns a Windows-flavored string so we + // know the test is exercising the right code path. Production + // formatIntegrityFailure POSIX-normalizes; that contract is + // tested in node-modules-integrity.test.js. + expect(formattedOutput).toBeTruthy(); + }); + + test("formatIntegrityFailure (production) emits POSIX paths for Windows-flavored relPath inputs", () => { + // Wire the gate with the REAL formatIntegrityFailure and inject a + // Windows-style relPath; the warn line should NOT contain a + // backslash. + const { + formatIntegrityFailure: realFormat, + } = require("../lib/node-modules-integrity"); + let warnLine = null; + const warnFn = jest.fn((msg) => { + if (warnLine === null && typeof msg === "string" && msg.includes("Integrity probe failed")) { + warnLine = msg; + } + }); + runIntegrityGateWithRecovery({ + repoRoot: "/repo", + probeIntegrityFn: () => ({ + ok: false, + missing: [ + { + tool: "jest-circus", + relPath: "node_modules\\jest-circus\\build\\runner.js", + reason: "missing", + }, + ], + }), + probeIntegrityInSubprocessFn: jest.fn(), + probeResolverHealthFn: () => ({ ok: true, failures: [] }), + attemptNpmCiRecoveryFn: jest.fn(), + isAutoRepairAllowedFn: jest.fn(() => ({ allowed: false, reason: "test" })), + printActionableRepairBannerFn: jest.fn(), + decoder: jestErrorDecoderModule, + findZeroByteNativeBinariesFn: () => [], + formatIntegrityFailureFn: realFormat, + platformFn: () => "linux", + warnFn, + }); + expect(warnLine).toContain("node_modules/jest-circus/build/runner.js"); + expect(warnLine).not.toContain("\\"); + }); + + test("resolver probe failure: gate does NOT auto-repair when DXMSG_HOOK_NO_AUTOREPAIR=1", () => { + const attemptNpmCiRecoveryFn = jest.fn(); + const probeResolverHealthFn = jest.fn(() => ({ + ok: false, + failures: [ + { + specifier: "jest-circus/runner", + error: "binding broken", + }, + ], + })); + const printActionableRepairBannerFn = jest.fn(); + const isAutoRepairAllowedFn = jest.fn(() => ({ + allowed: false, + reason: "DXMSG_HOOK_NO_AUTOREPAIR=1 set", + })); + const result = runIntegrityGateWithRecovery({ + repoRoot: "/repo", + probeIntegrityFn: () => ({ ok: true, missing: [] }), + probeIntegrityInSubprocessFn: jest.fn(), + probeResolverHealthFn, + attemptNpmCiRecoveryFn, + isAutoRepairAllowedFn, + printActionableRepairBannerFn, + decoder: jestErrorDecoderModule, + findZeroByteNativeBinariesFn: () => [], + formatIntegrityFailureFn: () => "noop", + platformFn: () => "linux", + warnFn: jest.fn(), + env: { DXMSG_HOOK_NO_AUTOREPAIR: "1" }, + }); + // npm ci skipped per the opt-out. + expect(attemptNpmCiRecoveryFn).not.toHaveBeenCalled(); + // Banner printed with the hint augmentation. + expect(printActionableRepairBannerFn).toHaveBeenCalledTimes(1); + const bannerArg = printActionableRepairBannerFn.mock.calls[0][0]; + // Hint about the opt-out should appear in rootCauses or + // repairCommands. + const allText = JSON.stringify(bannerArg); + expect(allText).toContain("DXMSG_HOOK_NO_AUTOREPAIR"); + expect(allText).toContain("unset DXMSG_HOOK_NO_AUTOREPAIR"); + expect(allText).toContain("Remove-Item Env:"); + // Gate result reflects the refusal. + expect(result.ok).toBe(false); + expect(result.reason).toContain("DXMSG_HOOK_NO_AUTOREPAIR"); + }); + + describe("in-process cache (S4)", () => { + // The gate memoizes its "ok" verdict per-repoRoot to amortize the + // resolver-probe subprocess spawn across the managed wrappers (jest, + // prettier, cspell, validate-node-tooling) in a single hook + // invocation. These tests pin both the fast-path and the bypass. + test("second call with same repoRoot short-circuits and skips the probes", () => { + __clearIntegrityGateCacheForTests(); + const probeIntegrityFn = jest.fn(() => ({ ok: true, missing: [] })); + const probeResolverHealthFn = jest.fn(() => ({ ok: true, failures: [] })); + const findZeroByteNativeBinariesFn = jest.fn(() => []); + + const common = { + repoRoot: "/cache-repo", + probeIntegrityFn, + probeIntegrityInSubprocessFn: jest.fn(), + probeResolverHealthFn, + attemptNpmCiRecoveryFn: jest.fn(), + isAutoRepairAllowedFn: jest.fn(() => ({ allowed: true })), + printActionableRepairBannerFn: jest.fn(), + decoder: jestErrorDecoderModule, + findZeroByteNativeBinariesFn, + formatIntegrityFailureFn: () => "noop", + platformFn: () => "linux", + warnFn: jest.fn(), + }; + + const first = runIntegrityGateWithRecovery(common); + const second = runIntegrityGateWithRecovery(common); + + expect(first).toEqual( + expect.objectContaining({ ok: true, didRecover: false }) + ); + expect(second).toEqual( + expect.objectContaining({ ok: true, didRecover: false, cached: true }) + ); + // Each probe should have run exactly once across the two calls. + expect(probeIntegrityFn).toHaveBeenCalledTimes(1); + expect(probeResolverHealthFn).toHaveBeenCalledTimes(1); + expect(findZeroByteNativeBinariesFn).toHaveBeenCalledTimes(1); + }); + + test("failure verdicts are NOT cached (re-probe happens on subsequent calls)", () => { + __clearIntegrityGateCacheForTests(); + const probeIntegrityFn = jest.fn(() => ({ + ok: false, + missing: [ + { + tool: "jest-circus", + relPath: "node_modules/jest-circus/build/runner.js", + reason: "missing", + }, + ], + })); + const common = { + repoRoot: "/cache-fail-repo", + probeIntegrityFn, + probeIntegrityInSubprocessFn: jest.fn(), + probeResolverHealthFn: () => ({ ok: true, failures: [] }), + attemptNpmCiRecoveryFn: jest.fn(() => ({ status: 1 })), + isAutoRepairAllowedFn: () => ({ allowed: true }), + printActionableRepairBannerFn: jest.fn(), + decoder: jestErrorDecoderModule, + findZeroByteNativeBinariesFn: () => [], + formatIntegrityFailureFn: () => "noop", + platformFn: () => "linux", + warnFn: jest.fn(), + }; + + runIntegrityGateWithRecovery(common); + runIntegrityGateWithRecovery(common); + + // Both calls invoked the file probe; the cache is success-only. + expect(probeIntegrityFn).toHaveBeenCalledTimes(2); + }); + + test("bypassCache=true forces a fresh probe even when cached", () => { + __clearIntegrityGateCacheForTests(); + const probeIntegrityFn = jest.fn(() => ({ ok: true, missing: [] })); + const common = { + repoRoot: "/cache-bypass-repo", + probeIntegrityFn, + probeIntegrityInSubprocessFn: jest.fn(), + probeResolverHealthFn: () => ({ ok: true, failures: [] }), + attemptNpmCiRecoveryFn: jest.fn(), + isAutoRepairAllowedFn: jest.fn(), + printActionableRepairBannerFn: jest.fn(), + decoder: jestErrorDecoderModule, + findZeroByteNativeBinariesFn: () => [], + formatIntegrityFailureFn: () => "noop", + platformFn: () => "linux", + warnFn: jest.fn(), + }; + + runIntegrityGateWithRecovery(common); + runIntegrityGateWithRecovery({ ...common, bypassCache: true }); + + expect(probeIntegrityFn).toHaveBeenCalledTimes(2); + }); }); }); \ No newline at end of file diff --git a/scripts/__tests__/run-managed-prettier.test.js b/scripts/__tests__/run-managed-prettier.test.js index 6e015483..a03299fb 100644 --- a/scripts/__tests__/run-managed-prettier.test.js +++ b/scripts/__tests__/run-managed-prettier.test.js @@ -5,7 +5,12 @@ "use strict"; const childProcess = require("child_process"); -const { toShellCommand } = require("../lib/shell-command"); +const path = require("path"); +const { + MISSING_BUNDLED_NPX_CLI_MESSAGE, + resolveBundledNpxCliPath, + runBundledNpxCommand, +} = require("../lib/managed-prettier"); const { REPO_ROOT, runCommand, @@ -14,6 +19,18 @@ const { } = require("../run-managed-prettier"); describe("run-managed-prettier", () => { + // The integrity gate caches its "ok" verdict per-repoRoot to amortize + // the resolver-probe subprocess spawn across the managed wrappers in a + // single hook. Tests below that exercise the gate share the same + // REPO_ROOT; without a per-test reset, a previous test's success + // verdict would short-circuit later tests' gate-failure assertions. + const { + __clearIntegrityGateCacheForTests, + } = require("../lib/integrity-gate-with-recovery"); + beforeEach(() => { + __clearIntegrityGateCacheForTests(); + }); + afterEach(() => { jest.restoreAllMocks(); }); @@ -23,6 +40,11 @@ describe("run-managed-prettier", () => { const runNpxPrettierFn = jest.fn(() => ({ status: 1, error: null })); const result = runManagedPrettier(["--check", "README.md"], { + // Bypass the integrity gate so this tier-fallback test is + // independent of on-disk node_modules state. See + // scripts/__tests__/run-managed-cspell.test.js for the same + // pattern. + envFn: () => ({ DXMSG_HOOK_SKIP_INTEGRITY: "1" }), existsSyncFn: () => true, runLocalPrettierFn, runNpxPrettierFn, @@ -38,6 +60,8 @@ describe("run-managed-prettier", () => { const runNpxPrettierFn = jest.fn(() => ({ status: 0, error: null })); const result = runManagedPrettier(["--write", "README.md"], { + // Bypass the integrity gate (see rationale above). + envFn: () => ({ DXMSG_HOOK_SKIP_INTEGRITY: "1" }), existsSyncFn: () => false, runLocalPrettierFn, runNpxPrettierFn, @@ -48,22 +72,15 @@ describe("run-managed-prettier", () => { expect(runNpxPrettierFn).toHaveBeenCalledWith(["--write", "README.md"]); }); - test("runNpxPrettier invokes npx with pinned package spec", () => { - const spawnSyncSpy = jest - .spyOn(childProcess, "spawnSync") - .mockReturnValue({ status: 0, error: null }); - - const result = runNpxPrettier(["--check", "README.md"], "prettier@3.8.3"); + test("runNpxPrettier invokes bundled npx with pinned package spec", () => { + const runBundledNpxCommandFn = jest.fn(() => ({ status: 0, error: null })); - const expectedOptions = { cwd: REPO_ROOT, stdio: "inherit" }; - if (process.platform === "win32") { - expectedOptions.shell = true; - expectedOptions.windowsHide = true; - } + const result = runNpxPrettier(["--check", "README.md"], "prettier@3.8.3", { + runBundledNpxCommandFn, + }); expect(result).toEqual({ status: 0, error: null }); - expect(spawnSyncSpy).toHaveBeenCalledWith( - toShellCommand("npx"), + expect(runBundledNpxCommandFn).toHaveBeenCalledWith( [ "--yes", "--package=prettier@3.8.3", @@ -71,10 +88,28 @@ describe("run-managed-prettier", () => { "--check", "README.md", ], - expect.objectContaining(expectedOptions) + expect.objectContaining({ + cwd: REPO_ROOT, + stdio: "inherit", + }) ); }); + test("runNpxPrettier returns launch error object when bundled npx resolver throws", () => { + const missingCliError = new Error("missing npx-cli.js"); + + const result = runNpxPrettier(["--check", "README.md"], "prettier@3.8.3", { + runBundledNpxCommandFn: () => { + throw missingCliError; + }, + }); + + expect(result).toEqual({ + status: null, + error: missingCliError, + }); + }); + test("runCommand delegates non-shell-shim commands to child_process.spawnSync", () => { const spawnSyncSpy = jest .spyOn(childProcess, "spawnSync") @@ -90,4 +125,153 @@ describe("run-managed-prettier", () => { ); spawnSyncSpy.mockRestore(); }); + + test("resolveBundledNpxCliPath returns bundled npx-cli.js when present", () => { + const execPath = path.join(path.sep, "opt", "node", "bin", "node"); + const expected = path.join(path.dirname(execPath), "node_modules", "npm", "bin", "npx-cli.js"); + + const resolved = resolveBundledNpxCliPath({ + execPath, + existsSyncFn: (candidatePath) => candidatePath === expected, + }); + + expect(resolved).toBe(expected); + }); + + test("resolveBundledNpxCliPath returns null when bundled npx-cli.js is missing", () => { + const resolved = resolveBundledNpxCliPath({ + execPath: path.join(path.sep, "opt", "node", "bin", "node"), + existsSyncFn: () => false, + }); + + expect(resolved).toBeNull(); + }); + + test("resolveBundledNpxCliPath supports Linux distro npm layout", () => { + const execPath = path.join(path.sep, "usr", "bin", "node"); + const expected = path.join(path.sep, "usr", "lib", "node_modules", "npm", "bin", "npx-cli.js"); + + const resolved = resolveBundledNpxCliPath({ + execPath, + existsSyncFn: (candidatePath) => candidatePath === expected, + }); + + expect(resolved).toBe(expected); + }); + + test("runBundledNpxCommand invokes Node with the bundled npx CLI", () => { + const execPath = String.raw`C:\node\node.exe`; + const npxCliPath = String.raw`C:\node\node_modules\npm\bin\npx-cli.js`; + const runCommandFn = jest.fn(() => ({ status: 0 })); + + const result = runBundledNpxCommand(["--yes", "prettier", "--check", "README.md"], { + execPath, + resolveBundledNpxCliPathFn: () => npxCliPath, + runCommandFn, + cwd: path.join(path.sep, "repo"), + }); + + expect(result).toEqual({ status: 0 }); + expect(runCommandFn).toHaveBeenCalledWith( + execPath, + [npxCliPath, "--yes", "prettier", "--check", "README.md"], + expect.objectContaining({ + cwd: path.join(path.sep, "repo"), + encoding: "utf8", + }) + ); + }); + + test("runBundledNpxCommand fails closed when bundled npx CLI cannot be resolved", () => { + expect(() => + runBundledNpxCommand(["--yes", "prettier", "--check", "README.md"], { + resolveBundledNpxCliPathFn: () => null, + runCommandFn: jest.fn(), + }) + ).toThrow(MISSING_BUNDLED_NPX_CLI_MESSAGE); + }); + + test("integrity gate runs BEFORE prettier tier dispatch", () => { + // Step 7: prettier wrapper mirrors the jest wrapper's gate. If the + // gate fails and auto-repair is allowed, npm ci runs and we then + // re-probe before the local prettier path is taken. + const probeIntegrityFn = jest.fn(() => ({ + ok: false, + missing: [{ tool: "prettier", relPath: "node_modules/prettier/index.cjs", reason: "missing" }], + })); + const probeIntegrityInSubprocessFn = jest.fn(() => ({ ok: true, missing: [] })); + const attemptNpmCiRecoveryFn = jest.fn(() => ({ status: 0, error: null })); + const isAutoRepairAllowedFn = jest.fn(() => ({ allowed: true, reason: null })); + const runLocalPrettierFn = jest.fn(() => ({ status: 0, error: null })); + const runNpxPrettierFn = jest.fn(); + + const result = runManagedPrettier(["--check", "README.md"], { + envFn: () => ({}), + probeIntegrityFn, + probeIntegrityInSubprocessFn, + attemptNpmCiRecoveryFn, + isAutoRepairAllowedFn, + existsSyncFn: () => true, + runLocalPrettierFn, + runNpxPrettierFn, + }); + + expect(probeIntegrityFn).toHaveBeenCalledTimes(1); + expect(isAutoRepairAllowedFn).toHaveBeenCalledTimes(1); + expect(attemptNpmCiRecoveryFn).toHaveBeenCalledTimes(1); + expect(probeIntegrityInSubprocessFn).toHaveBeenCalledTimes(1); + // Tier dispatch happens AFTER gate succeeds. + expect(probeIntegrityInSubprocessFn.mock.invocationCallOrder[0]).toBeLessThan( + runLocalPrettierFn.mock.invocationCallOrder[0] + ); + expect(result).toEqual({ status: 0, error: null }); + }); + + test("integrity gate failure with no auto-repair returns status=1 without invoking prettier", () => { + const probeIntegrityFn = jest.fn(() => ({ + ok: false, + missing: [{ tool: "prettier", relPath: "x", reason: "missing" }], + })); + const probeIntegrityInSubprocessFn = jest.fn(() => ({ + ok: false, + missing: [{ tool: "prettier", relPath: "x", reason: "missing" }], + })); + const attemptNpmCiRecoveryFn = jest.fn(() => ({ status: 0, error: null })); + const isAutoRepairAllowedFn = jest.fn(() => ({ allowed: true, reason: null })); + const runLocalPrettierFn = jest.fn(); + const printActionableRepairBannerFn = jest.fn(); + + const result = runManagedPrettier(["--check", "README.md"], { + envFn: () => ({}), + probeIntegrityFn, + probeIntegrityInSubprocessFn, + attemptNpmCiRecoveryFn, + isAutoRepairAllowedFn, + existsSyncFn: () => true, + runLocalPrettierFn, + runNpxPrettierFn: jest.fn(), + printActionableRepairBannerFn, + }); + + expect(runLocalPrettierFn).not.toHaveBeenCalled(); + expect(printActionableRepairBannerFn).toHaveBeenCalledTimes(1); + expect(result).toEqual({ status: 1, error: null }); + }); + + test("DXMSG_HOOK_SKIP_INTEGRITY=1 bypasses the prettier integrity gate", () => { + const probeIntegrityFn = jest.fn(); + const runLocalPrettierFn = jest.fn(() => ({ status: 0, error: null })); + + const result = runManagedPrettier(["--check", "README.md"], { + envFn: () => ({ DXMSG_HOOK_SKIP_INTEGRITY: "1" }), + probeIntegrityFn, + existsSyncFn: () => true, + runLocalPrettierFn, + runNpxPrettierFn: jest.fn(), + }); + + expect(probeIntegrityFn).not.toHaveBeenCalled(); + expect(runLocalPrettierFn).toHaveBeenCalledTimes(1); + expect(result).toEqual({ status: 0, error: null }); + }); }); diff --git a/scripts/__tests__/run-staged-md-pipeline.test.js b/scripts/__tests__/run-staged-md-pipeline.test.js index 54b1aa68..736f9e75 100644 --- a/scripts/__tests__/run-staged-md-pipeline.test.js +++ b/scripts/__tests__/run-staged-md-pipeline.test.js @@ -250,6 +250,20 @@ describe("run-staged-md-pipeline", () => { expect(toImportFileUrl(windowsPath)).toBe("file:///D:/repo/markdownlint-cli2.mjs"); }); + test("toImportFileUrl normalizes Windows UNC absolute paths to file URLs", () => { + const uncPath = String.raw`\\fileserver\engineering\markdownlint-cli2.mjs`; + expect(toImportFileUrl(uncPath)).toBe( + "file://fileserver/engineering/markdownlint-cli2.mjs" + ); + }); + + test("toImportFileUrl preserves forward-slash UNC absolute paths", () => { + const uncPath = "//fileserver/engineering/markdownlint-cli2.mjs"; + expect(toImportFileUrl(uncPath)).toBe( + "file://fileserver/engineering/markdownlint-cli2.mjs" + ); + }); + test("markdownlint argv uses POSIX repo-relative paths", () => { const repoRoot = path.resolve(__dirname, "../.."); const absPaths = [ diff --git a/scripts/__tests__/sync-banner-version-node.test.js b/scripts/__tests__/sync-banner-version-node.test.js new file mode 100644 index 00000000..5746239a --- /dev/null +++ b/scripts/__tests__/sync-banner-version-node.test.js @@ -0,0 +1,135 @@ +/** + * @fileoverview Tests for the Node banner sync fallback. + */ + +"use strict"; + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + syncBanner, + roundTestCount, + readPackageVersion, +} = require("../sync-banner-version.js"); + +function buildBanner(version = "1.0.0", testLabel = "0+ Tests") { + return ` + + + ${testLabel} + + + + + v${version} + +`; +} + +describe("sync-banner-version.js", () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "sync-banner-node-")); + fs.mkdirSync(path.join(tempDir, "docs", "images"), { recursive: true }); + fs.mkdirSync(path.join(tempDir, "Tests"), { recursive: true }); + fs.mkdirSync(path.join(tempDir, "scripts"), { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("syncBanner updates version, rounds test count, and stages the SVG", () => { + const packageJsonPath = path.join(tempDir, "package.json"); + const svgPath = path.join(tempDir, "docs", "images", "DxMessaging-banner.svg"); + const testFilePath = path.join(tempDir, "Tests", "ExampleTests.cs"); + const stageFileFn = jest.fn(); + + fs.writeFileSync( + packageJsonPath, + JSON.stringify({ version: "9.9.9" }, null, 2), + "utf8", + ); + + const testLines = []; + for (let i = 0; i < 105; i++) { + testLines.push("[Test]"); + testLines.push(`public void Case${i}() {}`); + } + fs.writeFileSync(testFilePath, testLines.join("\n"), "utf8"); + fs.writeFileSync(svgPath, buildBanner("1.0.0", "0+ Tests"), "utf8"); + + const result = syncBanner({ + repoRoot: tempDir, + packageJsonPath, + svgPath, + stageFileFn, + }); + + expect(result.updated).toBe(true); + expect(result.version).toBe("9.9.9"); + expect(result.testCount).toBe(105); + expect(result.testCountLabel).toBe("100+ Tests"); + expect(stageFileFn).toHaveBeenCalledWith(tempDir, svgPath); + + const updatedSvg = fs.readFileSync(svgPath, "utf8"); + expect(updatedSvg).toContain(">v9.9.9"); + expect(updatedSvg).toContain(">100+ Tests"); + }); + + test("syncBanner is a no-op when version and test label already match", () => { + const packageJsonPath = path.join(tempDir, "package.json"); + const svgPath = path.join(tempDir, "docs", "images", "DxMessaging-banner.svg"); + const testFilePath = path.join(tempDir, "Tests", "SampleTests.cs"); + const stageFileFn = jest.fn(); + + fs.writeFileSync( + packageJsonPath, + JSON.stringify({ version: "2.3.4" }, null, 2), + "utf8", + ); + fs.writeFileSync(testFilePath, "[Test]\npublic void OnlyCase() {}\n", "utf8"); + fs.writeFileSync(svgPath, buildBanner("2.3.4", "1+ Tests"), "utf8"); + + const before = fs.readFileSync(svgPath, "utf8"); + const result = syncBanner({ + repoRoot: tempDir, + packageJsonPath, + svgPath, + stageFileFn, + }); + const after = fs.readFileSync(svgPath, "utf8"); + + expect(result.updated).toBe(false); + expect(stageFileFn).not.toHaveBeenCalled(); + expect(after).toBe(before); + }); + + test("readPackageVersion rejects invalid semver prefixes", () => { + const packageJsonPath = path.join(tempDir, "package.json"); + fs.writeFileSync( + packageJsonPath, + JSON.stringify({ version: "v1" }, null, 2), + "utf8", + ); + + expect(() => readPackageVersion(packageJsonPath)).toThrow( + /Invalid version format/, + ); + }); + + test("roundTestCount rounds down to nearest hundred", () => { + expect(roundTestCount(0)).toBe(0); + expect(roundTestCount(1)).toBe(1); + expect(roundTestCount(99)).toBe(99); + expect(roundTestCount(100)).toBe(100); + expect(roundTestCount(199)).toBe(100); + expect(roundTestCount(2305)).toBe(2300); + expect(roundTestCount(10000)).toBe(10000); + expect(roundTestCount(10299)).toBe(10200); + expect(roundTestCount(99999)).toBe(99900); + }); +}); diff --git a/scripts/__tests__/sync-banner-version-node.test.js.meta b/scripts/__tests__/sync-banner-version-node.test.js.meta new file mode 100644 index 00000000..ad4bd778 --- /dev/null +++ b/scripts/__tests__/sync-banner-version-node.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: fd4221920af158f4d9e77f88c3a88e72 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/sync-banner-version.test.js b/scripts/__tests__/sync-banner-version.test.js index f5a183cd..7936794e 100644 --- a/scripts/__tests__/sync-banner-version.test.js +++ b/scripts/__tests__/sync-banner-version.test.js @@ -25,7 +25,7 @@ const path = require("path"); // - The logical pattern is identical; only the escape syntax differs // // PowerShell pattern: -// '\s*]*>\s*]*/?>\s*]*>v\d+\.\d+\.\d+[^<]*\s*' +// '\s*]*>\s*]*/>\s*]*>v\d+\.\d+\.\d+[^<]*\s*' // // JavaScript pattern (below) - note the \/ escapes for forward slashes: const VERSION_PATTERN = @@ -34,6 +34,12 @@ const VERSION_PATTERN = // SYNC: Keep semver pattern in sync with sync-banner-version.ps1 version validation const SEMVER_PATTERN = /^\d+\.\d+\.\d+/; +// SYNC: Keep test-count label pattern in sync with sync-banner-version.ps1 $testCountPattern +const TEST_COUNT_PATTERN = + /(]*\bx="20")(?=[^>]*\by="13")(?=[^>]*\bfill="#00d9ff")[^>]*>)(\d+\+ Tests)(<\/text>)/; + +const TEST_FILE_NAME_PATTERN = /(?:Test|Tests)\.cs$|\.(?:test|spec)\.js$/; + /** * Extracts version from a package.json content string. * @param {string} content - The package.json file content @@ -62,9 +68,9 @@ function extractVersion(content) { */ function generateVersionBadge(version) { return ` - - - v${version} + + + v${version} `; } @@ -98,6 +104,155 @@ function updateSvgVersion(svgContent, newVersion) { return svgContent.replace(VERSION_PATTERN, generateVersionBadge(newVersion)); } +function stripSourceComments(content) { + return content + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:])\/\/.*$/gm, "$1"); +} + +function maskJavaScriptNonCode(content) { + let result = ""; + let state = "code"; + let quote = ""; + let escaped = false; + + const mask = (char) => (char === "\n" || char === "\r" ? char : " "); + + for (let i = 0; i < content.length;) { + const char = content[i]; + const next = content[i + 1] ?? ""; + + if (state === "code") { + if (char === "/" && next === "/") { + result += mask(char) + mask(next); + i += 2; + state = "line-comment"; + continue; + } + if (char === "/" && next === "*") { + result += mask(char) + mask(next); + i += 2; + state = "block-comment"; + continue; + } + if (char === "'" || char === '"' || char === "`") { + result += mask(char); + quote = char; + escaped = false; + i++; + state = "string"; + continue; + } + + result += char; + i++; + continue; + } + + if (state === "line-comment") { + result += mask(char); + i++; + if (char === "\n" || char === "\r") { + state = "code"; + } + continue; + } + + if (state === "block-comment") { + result += mask(char); + if (char === "*" && next === "/") { + result += mask(next); + i += 2; + state = "code"; + continue; + } + i++; + continue; + } + + result += mask(char); + i++; + if (escaped) { + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (char === quote) { + state = "code"; + } + } + + return result; +} + +function countTestMarkers(filePath, content) { + if (filePath.endsWith(".cs")) { + const source = stripSourceComments(content); + return ( + source.match( + /\[(?:UnityTest|Test|TestCase|TestCaseSource|Theory|Fact)\b/g, + ) ?? [] + ).length; + } + if (/\.(?:test|spec)\.js$/.test(filePath)) { + const source = maskJavaScriptNonCode(content); + return (source.match(/(? + sum + countTestMarkers(filePath, fs.readFileSync(filePath, "utf-8")), + 0, + ); +} + describe("sync-banner-version", () => { describe("extractVersion", () => { test("should extract a valid semver version from package.json", () => { @@ -162,7 +317,7 @@ describe("sync-banner-version", () => { const badge = generateVersionBadge("2.1.4"); expect(badge).toContain("v2.1.4"); expect(badge).toContain(" - - - v2.1.4 + + + v2.1.4 `; @@ -209,8 +364,8 @@ describe("sync-banner-version", () => { test("should return null for malformed version badge", () => { const malformedSvg = ` - - + + no version here `; expect(extractCurrentVersion(malformedSvg)).toBeNull(); @@ -232,9 +387,9 @@ describe("sync-banner-version", () => { - - - v1.0.0 + + + v1.0.0 `; @@ -271,12 +426,82 @@ describe("sync-banner-version", () => { }); }); + describe("test count calculation", () => { + test("should count NUnit and Unity test attributes in C# test files", () => { + const content = ` +public class ExampleTests +{ + [Test] + public void One() {} + + [UnityTest] + public IEnumerator Two() { yield break; } + + [TestCase(1)] + [TestCaseSource(nameof(Cases))] + public void Parameterized(int value) {} +}`; + expect(countTestMarkers("Tests/ExampleTests.cs", content)).toBe(4); + }); + + test("should count Jest test and it calls in JavaScript test files", () => { + const content = ` +describe("suite", () => { + test("one", () => {}); + it("two", () => {}); + helper.test("not a test declaration"); +});`; + expect(countTestMarkers("scripts/__tests__/example.test.js", content)).toBe(2); + }); + + test("should ignore Jest markers inside JavaScript fixture strings", () => { + const content = ` +const source = "test('fixture', () => {}); it('also fixture', () => {});"; +const quoted = 'literal test( and it( text'; +const template = \`template test("not real", () => {})\`; +describe("suite", () => { + test("real", () => {}); + it("also real", () => {}); +});`; + expect(countTestMarkers("scripts/__tests__/example.test.js", content)).toBe(2); + }); + + test("should ignore test markers inside comments", () => { + const content = ` +// [Test] +/* test("commented", () => {}); */ +[Test] +public void RealTest() {}`; + expect(countTestMarkers("Tests/ExampleTests.cs", content)).toBe(1); + }); + + test("should round test counts down to stable hundred-count labels", () => { + expect(roundTestCount(2305)).toBe(2300); + expect(roundTestCount(99)).toBe(99); + }); + + test("should update the feature-row test count label", () => { + const svg = ` + + 300+ Tests + +`; + const updated = updateSvgTestCount(svg, 2305); + expect(updated).toContain("2300+ Tests"); + expect(updated).toContain('fill="#00d9ff">2300+ Tests'); + }); + + test("should return null when the feature-row test count label is missing", () => { + expect(updateSvgTestCount("", 2305)).toBeNull(); + }); + }); + describe("VERSION_PATTERN regex", () => { test("should match standard version badge format", () => { const badge = ` - - - v1.2.3 + + + v1.2.3 `; expect(VERSION_PATTERN.test(badge)).toBe(true); }); @@ -291,7 +516,7 @@ describe("sync-banner-version", () => { }); test("should not match without version comment", () => { - const noComment = ` + const noComment = ` v1.2.3 `; @@ -387,6 +612,22 @@ describe("sync-banner-version", () => { expect(svgVersion).toBe(packageVersion); } }); + + test("should have matching rounded test count between repository tests and SVG", () => { + const svgExists = fs.existsSync(svgPath); + expect(svgExists).toBe(true); + + if (svgExists) { + const svgContent = fs.readFileSync(svgPath, "utf-8"); + const labelMatch = svgContent.match(TEST_COUNT_PATTERN); + const repositoryTestCount = calculateRepositoryTestCount(repoRoot); + const expectedLabel = `${roundTestCount(repositoryTestCount)}+ Tests`; + + expect(repositoryTestCount).toBeGreaterThan(0); + expect(labelMatch).not.toBeNull(); + expect(labelMatch[2]).toBe(expectedLabel); + } + }); }); describe("edge cases", () => { @@ -395,7 +636,7 @@ describe("sync-banner-version", () => { - + v1.0.0 @@ -410,7 +651,7 @@ describe("sync-banner-version", () => { - + v2.5.0 @@ -472,9 +713,9 @@ describe("sync-banner-version", () => { '', '', ' ', - ' ', - ' ', - ' v1.2.3', + ' ', + ' ', + ' v1.2.3', ' ', '', ].join('\n'); @@ -487,9 +728,9 @@ describe("sync-banner-version", () => { '', '', ' ', - ' ', - ' ', - ' v2.0.0', + ' ', + ' ', + ' v2.0.0', ' ', '', ].join('\r\n'); diff --git a/scripts/__tests__/unity-workflow-shape.test.js b/scripts/__tests__/unity-workflow-shape.test.js index e34cc729..9c0fa764 100644 --- a/scripts/__tests__/unity-workflow-shape.test.js +++ b/scripts/__tests__/unity-workflow-shape.test.js @@ -4,9 +4,8 @@ * * These workflows have non-obvious invariants that, if violated, cause silent * regressions: - * - game-ci backed Unity workflows are temporarily moved out of - * .github/workflows while the GitHub-hosted game-ci jobs are disabled. - * Local runners remain available. + * - Licensed Unity jobs must only run for same-repo pull requests, + * protected branch pushes, schedules, and manual dispatch. * - All Unity workflows must include manifest, packages-lock, and * ProjectVersion in the exact Library cache key, with no broad restore * keys — otherwise stale Library/ dirs from a prior Unity version corrupt @@ -28,7 +27,8 @@ const yaml = require("js-yaml"); const REPO_ROOT = path.resolve(__dirname, "..", ".."); const WORKFLOWS_DIR = path.join(REPO_ROOT, ".github", "workflows"); const DISABLED_WORKFLOWS_DIR = path.join(REPO_ROOT, ".github", "workflows-disabled"); -const DISABLED_UNITY_WORKFLOWS = ["unity-tests.yml", "unity-il2cpp.yml", "unity-benchmarks.yml"]; +const UNITY_WORKFLOWS = ["unity-tests.yml", "unity-il2cpp.yml", "unity-benchmarks.yml"]; +const UNITY_VERSIONS = ["2021.3.45f1", "2022.3.45f1", "6000.0.32f1"]; function readWorkflow(name) { const abs = path.join(WORKFLOWS_DIR, name); @@ -72,108 +72,279 @@ function expectExactUnityLibraryCache(text) { expect(text).not.toContain("restore-keys:"); } -describe("Unity game-ci workflows disabled in GitHub", () => { - test.each(DISABLED_UNITY_WORKFLOWS)("%s is not an active GitHub workflow", (name) => { - expect(fs.existsSync(path.join(WORKFLOWS_DIR, name))).toBe(false); +function getOnBlock(parsed) { + return parsed.on || parsed[true]; +} + +function expectUnityRunnerContract(job, expectation) { + // Unity Pro is a single-seat license, so every Unity-credential-using + // job shares the `unity-pro-license` concurrency group with + // `cancel-in-progress: false`. This serializes Unity work across all + // four workflows so two licensed jobs cannot run simultaneously on + // ELI-MACHINE and DAD-MACHINE and fight for the license. + expect(job.concurrency).toEqual({ + group: "unity-pro-license", + "cancel-in-progress": false + }); + + // All four Unity-credential-using jobs request the same static label set + // so either Windows machine can pick up any job. Within-workflow matrix + // serialization is provided by `strategy.max-parallel: 1` (asserted + // separately); cross-workflow serialization comes from the shared + // concurrency group above. + expect(job["runs-on"]).toEqual(["self-hosted", "Windows", "RAM-64GB"]); + + if (expectation.hasMatrix) { + // The validator (findMatrixConcurrencyEvictionViolations) requires + // matrix + shared concurrency group to declare max-parallel: 1; here + // we assert the contract directly so a regression is caught even if + // the validator rule shifts. + expect(job.strategy).toBeDefined(); + expect(job.strategy["max-parallel"]).toBe(1); + } else { + // Non-matrix jobs (release.unity-checks) need no max-parallel. + if (job.strategy !== undefined) { + expect(job.strategy["max-parallel"]).toBeUndefined(); + } + } +} + +function expectDiagnosticsStep(job) { + expect(Array.isArray(job.steps)).toBe(true); + const diagnosticsStep = job.steps.find( + (step) => step && step.name === "Print runner diagnostics" + ); + expect(diagnosticsStep).toBeDefined(); + expect(diagnosticsStep.shell).toBe("bash"); +} + +function expectSameRepoAndProtectedBranchGuard(job) { + expect(job.if).toContain("github.event_name != 'pull_request'"); + expect(job.if).toContain("github.event.pull_request.head.repo.full_name == github.repository"); + expect(job.if).toContain("github.event_name != 'push'"); + expect(job.if).toContain("github.ref_protected"); +} + +describe("Unity workflows are active GitHub workflows", () => { + test.each(UNITY_WORKFLOWS)("%s exists under .github/workflows", (name) => { + expect(fs.existsSync(path.join(WORKFLOWS_DIR, name))).toBe(true); expect(fs.existsSync(path.join(DISABLED_WORKFLOWS_DIR, name))).toBe(true); }); }); -describe(".github/workflows-disabled/unity-tests.yml", () => { - let text; - let parsed; +// Data-driven contract for every Unity-credential-using job across all four +// workflows. Anything that genuinely repeats (runner contract, concurrency +// contract, license secrets, Library cache contract, upload-artifact version, +// diagnostics step, same-repo + protected-branch guards) lives here. +const UNITY_LICENSED_JOBS = [ + { + workflow: "unity-tests.yml", + jobId: "unity-tests", + requiresProtectedBranchGuard: true, + requiresLibraryCache: true, + requiresLicenseSecrets: true, + hasMatrix: true + }, + { + workflow: "unity-il2cpp.yml", + jobId: "il2cpp-tests", + requiresProtectedBranchGuard: true, + requiresLibraryCache: true, + requiresLicenseSecrets: true, + hasMatrix: true + }, + { + workflow: "unity-benchmarks.yml", + jobId: "benchmarks", + requiresProtectedBranchGuard: false, + requiresLibraryCache: true, + requiresLicenseSecrets: true, + hasMatrix: true + }, + { + workflow: "release.yml", + jobId: "unity-checks", + requiresProtectedBranchGuard: false, + requiresLibraryCache: false, + requiresLicenseSecrets: true, + hasMatrix: false + } +]; + +describe("Unity-credential-using jobs share the same runner + concurrency contract", () => { + test.each(UNITY_LICENSED_JOBS)( + "$workflow job '$jobId' uses static [self-hosted, Windows, RAM-64GB] runs-on and the unity-pro-license group", + ({ workflow, jobId, hasMatrix }) => { + const parsed = loadWorkflowYaml(workflow); + expectUnityRunnerContract(parsed.jobs[jobId], { hasMatrix }); + } + ); - beforeAll(() => { - text = readDisabledWorkflow("unity-tests.yml"); - parsed = loadDisabledWorkflowYaml("unity-tests.yml"); - }); + test.each(UNITY_LICENSED_JOBS)( + "$workflow contains exactly one literal 'group: unity-pro-license' occurrence", + ({ workflow }) => { + // Defense-in-depth: even if the structural assertion above ever + // regressed silently (e.g., due to YAML parser quirks), the raw text + // must still reference the canonical group name exactly so log + // readers can grep for the licensing serialization mechanism. + // + // We further assert exactly ONE occurrence of `group: unity-pro-license` + // per workflow file: introducing a second group declaration (for + // example, an accidentally-duplicated job or a workflow-level + // concurrency block reusing the license group name) would silently + // alter the serialization semantics and is treated as a regression. + // This complements the structural assertion above; both checks catch + // different regressions. + const text = readWorkflow(workflow); + expect(text).toMatch(/group:\s*unity-pro-license/); + // Anchor the count to the YAML declaration form (`group: + // unity-pro-license` at the start of a line, allowing leading + // indentation). Diagnostic `echo` lines that print the human-readable + // string `Concurrency group: unity-pro-license` use multiple + // spaces and live mid-line, so they do not collide with this anchor. + const declarationOccurrences = text.match(/^\s*group: unity-pro-license\b/gm) || []; + expect(declarationOccurrences).toHaveLength(1); + } + ); - test("stays workflow_dispatch only as a disabled template", () => { - const onBlock = parsed.on || parsed[true]; - expect(Object.keys(onBlock).sort()).toEqual(["workflow_dispatch"]); - expect(parsed.jobs["matrix-config"].if).toBe("${{ false }}"); + test("no active Unity workflow declares concurrency.group: wallstop-organization-builds", () => { + // The legacy sentinel must never reappear. Cross-check by raw text so + // even commented or otherwise-skipped references are caught. + for (const { workflow } of UNITY_LICENSED_JOBS) { + const text = readWorkflow(workflow); + // Covers all three YAML forms the validator catches: + // - block: `concurrency:\n group: wallstop-organization-builds` + // - flow-map: `concurrency: { group: wallstop-organization-builds, ... }` + // - scalar: `concurrency: wallstop-organization-builds` + expect(text).not.toMatch( + /(?:concurrency|group):\s*["']?wallstop-organization-builds/ + ); + } }); - test("uses game-ci/unity-test-runner@v4", () => { - expect(text).toContain("game-ci/unity-test-runner@v4"); - }); + test.each(UNITY_LICENSED_JOBS)( + "$workflow job '$jobId' has a 'Print runner diagnostics' bash step", + ({ workflow, jobId }) => { + const parsed = loadWorkflowYaml(workflow); + expectDiagnosticsStep(parsed.jobs[jobId]); + } + ); - test("references secrets.UNITY_LICENSE", () => { - expect(text).toMatch(/secrets\.UNITY_LICENSE/); - }); + test.each(UNITY_LICENSED_JOBS.filter((job) => job.requiresLicenseSecrets))( + "$workflow references Unity license + serial secrets", + ({ workflow }) => { + const text = readWorkflow(workflow); + expect(text).toMatch(/secrets\.UNITY_LICENSE/); + expect(text).toMatch(/secrets\.UNITY_SERIAL/); + } + ); - test("references secrets.UNITY_SERIAL for paid serial activation", () => { - expect(text).toMatch(/secrets\.UNITY_SERIAL/); - }); + test.each(UNITY_LICENSED_JOBS.filter((job) => job.requiresLibraryCache))( + "$workflow declares an exact Library cache key with no broad restore-keys", + ({ workflow }) => { + expectExactUnityLibraryCache(readWorkflow(workflow)); + } + ); - test("Library cache key references manifest.json, packages-lock.json, and ProjectVersion.txt", () => { - expectExactUnityLibraryCache(text); - }); + test.each(UNITY_LICENSED_JOBS)( + "$workflow uses actions/upload-artifact@v7 (matches repo baseline)", + ({ workflow }) => { + // unity-checks in release.yml does not currently upload its own + // artifacts (the validate job does), so this assertion only applies + // workflow-wide rather than per-job. + const text = readWorkflow(workflow); + if (workflow === "release.yml") { + // release.yml uploads the packed npm artifact from the validate job. + expect(text).toContain("actions/upload-artifact@v7"); + } else { + expect(text).toContain("actions/upload-artifact@v7"); + } + } + ); - test("uses actions/upload-artifact@v7 (matches repo baseline)", () => { - expect(text).toContain("actions/upload-artifact@v7"); - }); + test.each(UNITY_LICENSED_JOBS.filter((job) => job.requiresProtectedBranchGuard))( + "$workflow job '$jobId' guards same-repo + protected-branch execution", + ({ workflow, jobId }) => { + const parsed = loadWorkflowYaml(workflow); + const text = readWorkflow(workflow); + expectSameRepoAndProtectedBranchGuard(parsed.jobs[jobId]); + expect(text).not.toContain("pull_request_target"); + } + ); }); -describe(".github/workflows-disabled/unity-il2cpp.yml", () => { +describe(".github/workflows/unity-tests.yml", () => { let text; let parsed; beforeAll(() => { - text = readDisabledWorkflow("unity-il2cpp.yml"); - parsed = loadDisabledWorkflowYaml("unity-il2cpp.yml"); + text = readWorkflow("unity-tests.yml"); + parsed = loadWorkflowYaml("unity-tests.yml"); }); - test("stays workflow_dispatch only as a disabled template", () => { - const onBlock = parsed.on || parsed[true]; - expect(Object.keys(onBlock).sort()).toEqual(["workflow_dispatch"]); - expect(parsed.jobs["matrix-config"].if).toBe("${{ false }}"); + test("runs for same-repo PRs, protected branch pushes, schedules, and dispatch", () => { + const onBlock = getOnBlock(parsed); + expect(Object.keys(onBlock).sort()).toEqual([ + "pull_request", + "push", + "schedule", + "workflow_dispatch" + ]); + expect(text).not.toContain("pull_request_target"); }); - test("uses game-ci/unity-builder@v4", () => { - expect(text).toContain("game-ci/unity-builder@v4"); - }); - - test("references secrets.UNITY_LICENSE", () => { - expect(text).toMatch(/secrets\.UNITY_LICENSE/); + test("uses the full Unity version x test mode matrix", () => { + for (const unityVersion of UNITY_VERSIONS) { + expect(text).toContain(unityVersion); + } + expect(text).toContain('modes=\'["editmode","playmode"]\''); }); +}); - test("references secrets.UNITY_SERIAL for paid serial activation", () => { - expect(text).toMatch(/secrets\.UNITY_SERIAL/); - }); +describe(".github/workflows/unity-il2cpp.yml", () => { + let text; + let parsed; - test("Library cache key references manifest.json, packages-lock.json, and ProjectVersion.txt", () => { - expectExactUnityLibraryCache(text); + beforeAll(() => { + text = readWorkflow("unity-il2cpp.yml"); + parsed = loadWorkflowYaml("unity-il2cpp.yml"); }); - test("uses actions/upload-artifact@v7", () => { - expect(text).toContain("actions/upload-artifact@v7"); + test("runs separately for same-repo PRs, protected branch pushes, schedules, and dispatch", () => { + const onBlock = getOnBlock(parsed); + expect(Object.keys(onBlock).sort()).toEqual([ + "pull_request", + "push", + "schedule", + "workflow_dispatch" + ]); + expect(text).not.toContain("pull_request_target"); }); - test("references secrets.UNITY_SERIAL for paid serial activation", () => { - expect(text).toMatch(/secrets\.UNITY_SERIAL/); + test("runs the standalone IL2CPP path through the repo runner", () => { + expect(text).toContain("-Platform standalone"); + expect(text).toContain("-Runner docker"); }); }); -describe(".github/workflows-disabled/unity-benchmarks.yml", () => { +describe(".github/workflows/unity-benchmarks.yml", () => { let text; let parsed; beforeAll(() => { - text = readDisabledWorkflow("unity-benchmarks.yml"); - parsed = loadDisabledWorkflowYaml("unity-benchmarks.yml"); + text = readWorkflow("unity-benchmarks.yml"); + parsed = loadWorkflowYaml("unity-benchmarks.yml"); }); - test("`on:` block has ONLY workflow_dispatch as a disabled template", () => { + test("`on:` block has ONLY schedule and workflow_dispatch", () => { // YAML 1.1 turns the bare `on` key into `true`; check both keys to // tolerate either representation across yaml/parser versions. - const onBlock = parsed.on || parsed[true]; + const onBlock = getOnBlock(parsed); expect(onBlock).toBeDefined(); expect(typeof onBlock).toBe("object"); const triggerKeys = Object.keys(onBlock).sort(); - expect(triggerKeys).toEqual(["workflow_dispatch"]); - expect(parsed.jobs["matrix-config"].if).toBe("${{ false }}"); + expect(triggerKeys).toEqual(["schedule", "workflow_dispatch"]); // Belt-and-suspenders text grep: a stray `pull_request:` or `push:` // anywhere in the on: block (even commented-out or otherwise missed @@ -186,12 +357,88 @@ describe(".github/workflows-disabled/unity-benchmarks.yml", () => { expect(onSection).not.toMatch(/^\s{2}push:/m); }); - test("Library cache key references manifest.json, packages-lock.json, and ProjectVersion.txt", () => { - expectExactUnityLibraryCache(text); + test("includes perf assemblies through the repo runner", () => { + expect(text).toContain("-IncludePerf"); + expect(text).not.toContain("pull_request_target"); }); +}); + +describe(".github/workflows/release.yml", () => { + let text; + let parsed; - test("uses actions/upload-artifact@v7", () => { + beforeAll(() => { + text = readWorkflow("release.yml"); + parsed = loadWorkflowYaml("release.yml"); + }); + + test("is tag-triggered only and validates exact semver tags", () => { + const onBlock = getOnBlock(parsed); + expect(Object.keys(onBlock)).toEqual(["push"]); + expect(onBlock.push).toEqual({ tags: ["v[0-9]*.[0-9]*.[0-9]*"] }); + expect(text).toContain("^v[0-9]+\\.[0-9]+\\.[0-9]+$"); + expect(text).toContain("Tag ${tag} does not match package.json version"); + expect(text).not.toContain("workflow_dispatch"); + }); + + test("validates, runs Unity checks, packs, attests, releases, and publishes with provenance", () => { + expect(Object.keys(parsed.jobs).sort()).toEqual([ + "publish", + "unity-checks", + "validate", + "verify-tag" + ]); + expect(parsed.jobs.publish["runs-on"]).toBe("ubuntu-latest"); + expect(text).toContain("npm run validate:npm-meta"); + expect(text).toContain("npm run test:unity-contracts"); + expect(text).toContain("npm pack --json"); + expect(text).toContain("actions/attest-build-provenance@v3"); expect(text).toContain("actions/upload-artifact@v7"); + expect(text).toContain("gh release create"); + expect(text).toContain("npx --yes --package=npm@^11.5.1 npm publish"); + expect(text).toContain("--provenance"); + expect(text).not.toContain("NPM_TOKEN"); + }); + + test("attestation job grants provenance permissions and validates from a full checkout", () => { + const attestationJobEntry = Object.entries(parsed.jobs).find(([, job]) => + job.steps.some((step) => step.uses === "actions/attest-build-provenance@v3") + ); + expect(attestationJobEntry).toBeDefined(); + + const [, attestationJob] = attestationJobEntry; + expect(attestationJob.permissions).toEqual({ + attestations: "write", + contents: "read", + "id-token": "write" + }); + + const checkoutIndex = attestationJob.steps.findIndex( + (step) => step.uses === "actions/checkout@v6" + ); + const validateAllIndex = attestationJob.steps.findIndex( + (step) => step.run && step.run.includes("npm run validate:all") + ); + + expect(checkoutIndex).toBeGreaterThanOrEqual(0); + expect(validateAllIndex).toBeGreaterThan(checkoutIndex); + + const checkoutStep = attestationJob.steps[checkoutIndex]; + expect(checkoutStep).toEqual( + expect.objectContaining({ + with: expect.objectContaining({ + "fetch-depth": 0 + }) + }) + ); + }); + + test("publish job has Trusted Publishing permissions", () => { + expect(parsed.jobs.publish.permissions).toEqual({ + attestations: "write", + contents: "write", + "id-token": "write" + }); }); }); diff --git a/scripts/__tests__/update-llms-txt.test.js b/scripts/__tests__/update-llms-txt.test.js index 25282a67..a89e0279 100644 --- a/scripts/__tests__/update-llms-txt.test.js +++ b/scripts/__tests__/update-llms-txt.test.js @@ -111,12 +111,12 @@ describe("update-llms-txt.js", () => { test("should include repository URL", () => { const content = generateLlmsTxt(); - expect(content).toContain("https://github.com/wallstop/DxMessaging"); + expect(content).toContain("https://github.com/Ambiguous-Interactive/DxMessaging"); }); test("should include documentation URL", () => { const content = generateLlmsTxt(); - expect(content).toContain("https://wallstop.github.io/DxMessaging/"); + expect(content).toContain("https://ambiguous-interactive.github.io/DxMessaging/"); }); test("should include skill count", () => { diff --git a/scripts/__tests__/validate-node-tooling.test.js b/scripts/__tests__/validate-node-tooling.test.js index 298332b2..2f8b5ab9 100644 --- a/scripts/__tests__/validate-node-tooling.test.js +++ b/scripts/__tests__/validate-node-tooling.test.js @@ -1,6 +1,16 @@ "use strict"; -const { formatInstallGuidance, validateTooling } = require("../validate-node-tooling"); +const path = require("path"); + +const { + formatInstallGuidance, + validateManagedNpxCliAvailability, + validateManagedNpxPolicy, + validateTooling +} = require("../validate-node-tooling"); +const { toPosixPath } = require("../lib/path-classifier"); + +const REPO_ROOT = path.resolve(__dirname, "../.."); describe("validate-node-tooling", () => { test("passes when required files exist and load checks succeed", async () => { @@ -8,6 +18,9 @@ describe("validate-node-tooling", () => { existsSyncFn: () => true, requireFn: () => ({}), importFn: async () => ({}), + enforceManagedNpxCliAvailability: false, + enforceIntegrityProbe: false, + scriptSources: [], toolSpecs: [ { name: "prettier", @@ -34,6 +47,9 @@ describe("validate-node-tooling", () => { importFn: async () => { throw new Error("Cannot find package 'fast-glob' imported from globby/index.js"); }, + enforceManagedNpxCliAvailability: false, + enforceIntegrityProbe: false, + scriptSources: [], toolSpecs: [ { name: "markdownlint-cli2", @@ -50,8 +66,248 @@ describe("validate-node-tooling", () => { ]); }); + test("reports unresolved modules for resolve-based tooling checks", async () => { + const violations = await validateTooling({ + existsSyncFn: () => true, + resolveModuleFn: () => null, + enforceManagedNpxCliAvailability: false, + enforceIntegrityProbe: false, + scriptSources: [], + toolSpecs: [ + { + name: "jest-circus", + requiredFiles: [], + load: "resolve", + entry: "jest-circus/runner" + } + ] + }); + + expect(violations).toEqual(["jest-circus: failed to resolve jest-circus/runner"]); + }); + + test("reports missing files when resolve-based entries point to stale paths", async () => { + const resolvedRunnerPath = "/repo/node_modules/jest-circus/build/runner.js"; + const violations = await validateTooling({ + existsSyncFn: () => false, + resolveModuleFn: () => resolvedRunnerPath, + enforceManagedNpxCliAvailability: false, + enforceIntegrityProbe: false, + scriptSources: [], + toolSpecs: [ + { + name: "jest-circus", + requiredFiles: [], + load: "resolve", + entry: "jest-circus/runner" + } + ] + }); + + expect(violations).toEqual([ + `jest-circus: resolved jest-circus/runner to missing file: ${resolvedRunnerPath}` + ]); + }); + + test("reports load failure when resolve-based entry exists but cannot be required", async () => { + const resolvedRunnerPath = "/repo/node_modules/jest-circus/build/runner.js"; + const violations = await validateTooling({ + existsSyncFn: () => true, + resolveModuleFn: () => resolvedRunnerPath, + requireFn: (modulePath) => { + if (modulePath === resolvedRunnerPath) { + throw new Error("Unexpected end of file"); + } + return {}; + }, + enforceManagedNpxCliAvailability: false, + enforceIntegrityProbe: false, + scriptSources: [], + toolSpecs: [ + { + name: "jest-circus", + requiredFiles: [], + load: "resolve", + entry: "jest-circus/runner" + } + ] + }); + + expect(violations).toEqual([ + "jest-circus: resolved jest-circus/runner could not be loaded: Unexpected end of file" + ]); + }); + + test("validateManagedNpxPolicy reports direct npx process spawns", () => { + const violations = validateManagedNpxPolicy({ + scriptSources: [ + { + filePath: path.join(REPO_ROOT, "scripts", "bad-npx.js"), + content: 'childProcess.spawnSync("npx", ["--yes", "prettier", "--check", "README.md"]);' + } + ] + }); + + expect(violations).toEqual([ + expect.stringContaining("managed-npx-policy: scripts/bad-npx.js uses direct npx process spawning") + ]); + }); + + test("validateManagedNpxPolicy reports npx command variables passed to process invokers", () => { + const violations = validateManagedNpxPolicy({ + scriptSources: [ + { + filePath: path.join(REPO_ROOT, "scripts", "bad-npx-command-var.js"), + content: [ + 'const childProcess = require("child_process");', + 'const npxCommand = "npx";', + "childProcess.spawnSync(npxCommand, [\"--yes\", \"prettier\", \"--check\", \"README.md\"]);" + ].join("\n") + } + ] + }); + + expect(violations).toEqual([ + expect.stringContaining( + "managed-npx-policy: scripts/bad-npx-command-var.js uses direct npx process spawning" + ) + ]); + }); + + test("validateManagedNpxPolicy reports child_process alias invokers with npx command variables", () => { + const violations = validateManagedNpxPolicy({ + scriptSources: [ + { + filePath: path.join(REPO_ROOT, "scripts", "bad-npx-invoker-alias.js"), + content: [ + 'const childProcess = require("child_process");', + "const { spawnSync: invoke } = childProcess;", + 'const cmd = "npx.cmd";', + "invoke(cmd, [\"--yes\", \"prettier\", \"--check\", \"README.md\"]);" + ].join("\n") + } + ] + }); + + expect(violations).toEqual([ + expect.stringContaining( + "managed-npx-policy: scripts/bad-npx-invoker-alias.js uses direct npx process spawning" + ) + ]); + }); + + test("validateManagedNpxPolicy allows managed bundled npx helper usage", () => { + const violations = validateManagedNpxPolicy({ + scriptSources: [ + { + filePath: path.join(REPO_ROOT, "scripts", "good-npx.js"), + content: + 'runBundledNpxCommand(["--yes", "--package=prettier@3.8.3", "prettier", "--check", "README.md"]);' + } + ] + }); + + expect(violations).toEqual([]); + }); + + test("validateManagedNpxCliAvailability reports missing bundled npx-cli", () => { + const violations = validateManagedNpxCliAvailability({ + execPath: "/opt/node/bin/node", + resolveBundledNpxCliPathFn: () => null, + existsSyncFn: () => false + }); + + expect(violations).toEqual([ + expect.stringContaining("unable to resolve npm bundled npx-cli.js") + ]); + }); + + test("validateManagedNpxCliAvailability passes when bundled npx-cli resolves", () => { + const violations = validateManagedNpxCliAvailability({ + execPath: "/opt/node/bin/node", + resolveBundledNpxCliPathFn: () => "/opt/node/bin/node_modules/npm/bin/npx-cli.js", + existsSyncFn: () => true + }); + + expect(violations).toEqual([]); + }); + + test("validateTooling includes managed npx-cli availability violations by default", async () => { + const violations = await validateTooling({ + existsSyncFn: () => true, + statSyncFn: () => ({ size: 100 }), + requireFn: () => ({}), + importFn: async () => ({}), + resolveBundledNpxCliPathFn: () => null, + scriptSources: [], + toolSpecs: [] + }); + + expect(violations).toEqual([ + expect.stringContaining("unable to resolve npm bundled npx-cli.js") + ]); + }); + + test("flags missing jest-circus/build/runner.js even when bin/jest.js exists (integrity layer)", async () => { + // Step 6: the integrity probe layer must surface the partial-extract + // failure mode (`testRunner option was not found`) - bin/jest.js present + // but build/runner.js missing - as a violation. + const violations = await validateTooling({ + // POSIX-normalize the absolute path before substring comparison so the + // fixture behaves identically on Windows (where path.join uses "\") + // and on POSIX. + existsSyncFn: (abs) => !toPosixPath(abs).endsWith("runner.js"), + statSyncFn: () => ({ size: 100 }), + requireFn: () => ({}), + importFn: async () => ({}), + resolveModuleFn: () => "/repo/node_modules/jest-circus/build/runner.js", + enforceManagedNpxCliAvailability: false, + scriptSources: [], + toolSpecs: [] + }); + expect( + violations.some((v) => + v.includes("jest-circus: missing node_modules/jest-circus/build/runner.js") + ) + ).toBe(true); + }); + + test("flags zero-byte critical files (size 0) via the integrity layer", async () => { + const violations = await validateTooling({ + existsSyncFn: () => true, + statSyncFn: (abs) => + // POSIX-normalize the absolute path before substring comparison so + // the fixture is platform-agnostic. + toPosixPath(abs).endsWith("prettier/index.cjs") ? { size: 0 } : { size: 100 }, + requireFn: () => ({}), + importFn: async () => ({}), + resolveModuleFn: () => "/repo/node_modules/jest-circus/build/runner.js", + enforceManagedNpxCliAvailability: false, + scriptSources: [], + toolSpecs: [] + }); + expect( + violations.some((v) => + v.includes("prettier: node_modules/prettier/index.cjs is empty (size 0)") + ) + ).toBe(true); + }); + + test("INTEGRITY_TARGETS is the source of truth shared with the gate", () => { + const { INTEGRITY_TARGETS: gateTargets } = require("../lib/node-modules-integrity"); + const { INTEGRITY_TARGETS: validatorTargets } = require("../validate-node-tooling"); + // The validator re-exports the same frozen array reference. + expect(validatorTargets).toBe(gateTargets); + }); + test("install guidance keeps hooks out of dependency bootstrapping", () => { expect(formatInstallGuidance()).toContain("npm install"); expect(formatInstallGuidance()).toContain("npm ci"); }); + + test("install guidance references preflight:pre-push and the jest-hook-robustness skill", () => { + const guidance = formatInstallGuidance(); + expect(guidance).toContain("npm run preflight:pre-push"); + expect(guidance).toContain(".llm/skills/scripting/jest-hook-robustness.md"); + }); }); diff --git a/scripts/__tests__/validate-npm-meta.test.js b/scripts/__tests__/validate-npm-meta.test.js index 83eed995..378b5767 100644 --- a/scripts/__tests__/validate-npm-meta.test.js +++ b/scripts/__tests__/validate-npm-meta.test.js @@ -592,7 +592,7 @@ describe("validate-npm-meta", () => { test("validateNpmMeta should pass against the real npm pack --dry-run output on the current branch", () => { // Integration check: shells out to the real npm pack flow via the script's own // getPackageFiles() and asserts the current branch is clean. This is the live - // guardrail that issue #204 (https://github.com/wallstop/DxMessaging/issues/204) + // guardrail that issue #204 (https://github.com/Ambiguous-Interactive/DxMessaging/issues/204) // cannot regress without the test failing. jest.spyOn(console, "log").mockImplementation(() => {}); @@ -653,7 +653,7 @@ describe("validate-npm-meta", () => { // ------------------------------------------------------------------------- // Issue #204 regression coverage // - // GitHub issue #204 (https://github.com/wallstop/DxMessaging/issues/204) + // GitHub issue #204 (https://github.com/Ambiguous-Interactive/DxMessaging/issues/204) // reported `GuidDB::CreateMetaFileMappings` warnings on every Unity asset-database // refresh after installing the npm package. Pre-2.1.8 tarballs shipped // SourceGenerator `bin/Debug/netstandard2.0/...` build outputs and `obj/...` files diff --git a/scripts/__tests__/validate-pre-commit-tooling.test.js b/scripts/__tests__/validate-pre-commit-tooling.test.js index 91ba3f93..8e2840ec 100644 --- a/scripts/__tests__/validate-pre-commit-tooling.test.js +++ b/scripts/__tests__/validate-pre-commit-tooling.test.js @@ -15,7 +15,11 @@ const { hasRequiredPackageJsonFormatCommand, hasRequiredNodeToolingCommand, hasRequiredHookMarkdownCommand, + hasRequiredLlmMarkdownCommand, hasRequiredScriptsCspellCommand, + hasRequiredWorkflowCspellCommand, + hasRequiredWorkflowValidationCommand, + hasRequiredBannerSyncCommand, hasRequiredChangelogValidationCommand, hasRequiredParserSuiteTestPaths, hasNpxInstallPolicy, @@ -30,8 +34,12 @@ const { REQUIRED_PRECHECK_PARSER_COMMAND, REQUIRED_NODE_TOOLING_COMMAND, REQUIRED_HOOK_MARKDOWN_COMMAND, + REQUIRED_LLM_MARKDOWN_COMMAND, REQUIRED_PACKAGE_JSON_FORMAT_COMMAND, REQUIRED_SCRIPTS_CSPELL_COMMAND, + REQUIRED_WORKFLOW_CSPELL_COMMAND, + REQUIRED_WORKFLOW_VALIDATION_COMMAND, + REQUIRED_BANNER_SYNC_COMMAND, REQUIRED_CHANGELOG_VALIDATION_COMMAND, REQUIRED_PARSER_SUITE_HOOK_ID, REQUIRED_PARSER_SUITE_TEST_PATHS, @@ -39,6 +47,27 @@ const { validateConfigFile } = require("../validate-pre-commit-tooling.js"); +function requiredPreflightScript({ remove = [] } = {}) { + const requiredCommands = [ + REQUIRED_NODE_TOOLING_COMMAND, + REQUIRED_HOOK_MARKDOWN_COMMAND, + REQUIRED_LLM_MARKDOWN_COMMAND, + REQUIRED_PACKAGE_JSON_FORMAT_COMMAND, + "npm run validate:pre-commit-tooling", + REQUIRED_SCRIPTS_CSPELL_COMMAND, + REQUIRED_WORKFLOW_CSPELL_COMMAND, + REQUIRED_WORKFLOW_VALIDATION_COMMAND, + REQUIRED_BANNER_SYNC_COMMAND, + REQUIRED_CHANGELOG_VALIDATION_COMMAND, + REQUIRED_PRECHECK_PARSER_COMMAND + ]; + + const removedCommands = new Set(remove); + return requiredCommands + .filter((command) => !removedCommands.has(command)) + .join(" && "); +} + describe("validate-pre-commit-tooling", () => { test("parseHookEntries reads folded and inline entry styles", () => { const content = [ @@ -204,6 +233,24 @@ describe("validate-pre-commit-tooling", () => { expect(hasRequiredHookMarkdownCommand(script)).toBe(false); }); + test("hasRequiredLlmMarkdownCommand detects .llm markdown precheck step", () => { + const script = [ + REQUIRED_NODE_TOOLING_COMMAND, + REQUIRED_HOOK_MARKDOWN_COMMAND, + REQUIRED_LLM_MARKDOWN_COMMAND, + REQUIRED_PACKAGE_JSON_FORMAT_COMMAND, + REQUIRED_PRECHECK_PARSER_COMMAND + ].join(" && "); + + expect(hasRequiredLlmMarkdownCommand(script)).toBe(true); + }); + + test("hasRequiredLlmMarkdownCommand rejects substring-only matches", () => { + const script = "npm run validate:pre-commit-tooling && echo npm run validate:llm-markdown"; + + expect(hasRequiredLlmMarkdownCommand(script)).toBe(false); + }); + test("hasRequiredScriptsCspellCommand detects script cspell command as chained step", () => { const script = [ REQUIRED_NODE_TOOLING_COMMAND, @@ -222,6 +269,61 @@ describe("validate-pre-commit-tooling", () => { expect(hasRequiredScriptsCspellCommand(script)).toBe(false); }); + test("hasRequiredWorkflowCspellCommand detects workflow cspell command as chained step", () => { + const script = [ + REQUIRED_NODE_TOOLING_COMMAND, + REQUIRED_HOOK_MARKDOWN_COMMAND, + REQUIRED_PACKAGE_JSON_FORMAT_COMMAND, + REQUIRED_SCRIPTS_CSPELL_COMMAND, + REQUIRED_WORKFLOW_CSPELL_COMMAND, + REQUIRED_PRECHECK_PARSER_COMMAND + ].join(" && "); + + expect(hasRequiredWorkflowCspellCommand(script)).toBe(true); + }); + + test("hasRequiredWorkflowCspellCommand rejects substring-only matches", () => { + const script = "npm run validate:pre-commit-tooling && echo npm run check:workflow-cspell"; + + expect(hasRequiredWorkflowCspellCommand(script)).toBe(false); + }); + + test("hasRequiredWorkflowValidationCommand detects workflow validation command", () => { + const script = [ + REQUIRED_NODE_TOOLING_COMMAND, + REQUIRED_HOOK_MARKDOWN_COMMAND, + REQUIRED_PACKAGE_JSON_FORMAT_COMMAND, + REQUIRED_WORKFLOW_VALIDATION_COMMAND, + REQUIRED_PRECHECK_PARSER_COMMAND + ].join(" && "); + + expect(hasRequiredWorkflowValidationCommand(script)).toBe(true); + }); + + test("hasRequiredWorkflowValidationCommand rejects substring-only matches", () => { + const script = "npm run validate:pre-commit-tooling && echo npm run validate:workflows"; + + expect(hasRequiredWorkflowValidationCommand(script)).toBe(false); + }); + + test("hasRequiredBannerSyncCommand detects banner sync command", () => { + const script = [ + REQUIRED_NODE_TOOLING_COMMAND, + REQUIRED_HOOK_MARKDOWN_COMMAND, + REQUIRED_PACKAGE_JSON_FORMAT_COMMAND, + REQUIRED_BANNER_SYNC_COMMAND, + REQUIRED_PRECHECK_PARSER_COMMAND + ].join(" && "); + + expect(hasRequiredBannerSyncCommand(script)).toBe(true); + }); + + test("hasRequiredBannerSyncCommand rejects substring-only matches", () => { + const script = "npm run validate:pre-commit-tooling && echo npm run check:banner-sync"; + + expect(hasRequiredBannerSyncCommand(script)).toBe(false); + }); + test("hasRequiredChangelogValidationCommand detects changelog validation step", () => { const script = [ REQUIRED_NODE_TOOLING_COMMAND, @@ -304,6 +406,19 @@ describe("validate-pre-commit-tooling", () => { expect(hasInlinedPrettierEntry("node scripts/run-managed-jest.js")).toBe(false); }); + test("hasInlinedPrettierEntry also accepts the managed-runner shape (integrity-gate phase)", () => { + // The integrity-gate phase moved the prettier hook entry from the + // inlined `bash -c` form to `node scripts/run-managed-prettier.js`. + // Both forms must be accepted; the managed-runner form performs the + // same local-vs-npx dispatch internally AND gates on node_modules + // integrity ahead of either branch. + expect( + hasInlinedPrettierEntry("node scripts/run-managed-prettier.js --write") + ).toBe(true); + // Wrong invocation - missing the `node` token - is not accepted. + expect(hasInlinedPrettierEntry("scripts/run-managed-prettier.js --write")).toBe(false); + }); + test("hasInlinedPrettierInvocation only enforces inlining for the prettier hook id", () => { const inlined = "bash -c 'if [ -f node_modules/prettier/bin/prettier.cjs ]; then " + @@ -379,7 +494,7 @@ describe("validate-pre-commit-tooling", () => { if (filePath === "/tmp/package.json") { return JSON.stringify({ scripts: { - "preflight:pre-commit": `${REQUIRED_NODE_TOOLING_COMMAND} && ${REQUIRED_HOOK_MARKDOWN_COMMAND} && ${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_CHANGELOG_VALIDATION_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}` + "preflight:pre-commit": requiredPreflightScript() } }); } @@ -604,7 +719,7 @@ describe("validate-pre-commit-tooling", () => { if (filePath === "/tmp/package.json") { return JSON.stringify({ scripts: { - "preflight:pre-commit": `${REQUIRED_NODE_TOOLING_COMMAND} && ${REQUIRED_HOOK_MARKDOWN_COMMAND} && ${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_CHANGELOG_VALIDATION_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}` + "preflight:pre-commit": requiredPreflightScript() } }); } @@ -646,10 +761,18 @@ describe("validate-pre-commit-tooling", () => { ); expect(preflightScript).toContain(REQUIRED_NODE_TOOLING_COMMAND); expect(preflightScript).toContain("npm run validate:hook-markdown"); + expect(preflightScript).toContain(REQUIRED_LLM_MARKDOWN_COMMAND); expect(preflightScript).toContain(REQUIRED_PACKAGE_JSON_FORMAT_COMMAND); expect(preflightScript).toContain("npm run check:prettier:hooks"); expect(preflightScript).toContain(REQUIRED_SCRIPTS_CSPELL_COMMAND); + expect(preflightScript).toContain(REQUIRED_WORKFLOW_CSPELL_COMMAND); + expect(preflightScript).toContain(REQUIRED_WORKFLOW_VALIDATION_COMMAND); + expect(preflightScript).toContain(REQUIRED_BANNER_SYNC_COMMAND); expect(preflightScript).toContain(REQUIRED_CHANGELOG_VALIDATION_COMMAND); + expect(packageJson.scripts["check:workflow-cspell"]).toContain( + "cspell@10.0.0" + ); + expect(packageJson.scripts["check:banner-sync"]).toBe("node scripts/validate-banner.js"); expect(packageJson.scripts["check:yaml"]).toContain("pre-commit run yamllint --all-files"); expect(preflightScript).toContain("npm run check:yaml"); expect(preflightScript).toContain("node scripts/generate-skills-index.js --check"); @@ -663,7 +786,7 @@ describe("validate-pre-commit-tooling", () => { if (filePath === "/tmp/package.json") { return JSON.stringify({ scripts: { - "preflight:pre-commit": `${REQUIRED_NODE_TOOLING_COMMAND} && ${REQUIRED_HOOK_MARKDOWN_COMMAND} && ${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && npm run validate:pre-commit-tooling && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_CHANGELOG_VALIDATION_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}` + "preflight:pre-commit": requiredPreflightScript() } }); } @@ -697,7 +820,9 @@ describe("validate-pre-commit-tooling", () => { if (filePath === "/tmp/package.json") { return JSON.stringify({ scripts: { - "preflight:pre-commit": `${REQUIRED_NODE_TOOLING_COMMAND} && ${REQUIRED_HOOK_MARKDOWN_COMMAND} && ${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && npm run validate:pre-commit-tooling && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_CHANGELOG_VALIDATION_COMMAND}` + "preflight:pre-commit": requiredPreflightScript({ + remove: [REQUIRED_PRECHECK_PARSER_COMMAND] + }) } }); } @@ -731,7 +856,9 @@ describe("validate-pre-commit-tooling", () => { if (filePath === "/tmp/package.json") { return JSON.stringify({ scripts: { - "preflight:pre-commit": `${REQUIRED_NODE_TOOLING_COMMAND} && ${REQUIRED_HOOK_MARKDOWN_COMMAND} && npm run validate:pre-commit-tooling && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_CHANGELOG_VALIDATION_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}` + "preflight:pre-commit": requiredPreflightScript({ + remove: [REQUIRED_PACKAGE_JSON_FORMAT_COMMAND] + }) } }); } @@ -765,7 +892,9 @@ describe("validate-pre-commit-tooling", () => { if (filePath === "/tmp/package.json") { return JSON.stringify({ scripts: { - "preflight:pre-commit": `${REQUIRED_NODE_TOOLING_COMMAND} && ${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && npm run validate:pre-commit-tooling && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_CHANGELOG_VALIDATION_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}` + "preflight:pre-commit": requiredPreflightScript({ + remove: [REQUIRED_HOOK_MARKDOWN_COMMAND] + }) } }); } @@ -794,12 +923,50 @@ describe("validate-pre-commit-tooling", () => { expect(violations[0].message).toContain(REQUIRED_HOOK_MARKDOWN_COMMAND); }); + test("validatePreflightScriptPolicy reports missing .llm markdown precheck command", () => { + const readFileSyncMock = jest.fn((filePath) => { + if (filePath === "/tmp/package.json") { + return JSON.stringify({ + scripts: { + "preflight:pre-commit": requiredPreflightScript({ + remove: [REQUIRED_LLM_MARKDOWN_COMMAND] + }) + } + }); + } + + if (filePath === "/tmp/pre-commit.yaml") { + return [ + "repos:", + " - repo: local", + " hooks:", + ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, + " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js scripts/__tests__/fix-csharp-underscore-methods.test.js scripts/__tests__/validate-changelog.test.js scripts/__tests__/pre-commit-hook-stage-policy.test.js" + ].join("\n"); + } + + return ""; + }); + + const violations = validatePreflightScriptPolicy( + readFileSyncMock, + "/tmp/package.json", + "/tmp/pre-commit.yaml" + ); + + expect(violations).toHaveLength(1); + expect(violations[0].hookId).toBe("preflight-script"); + expect(violations[0].message).toContain(REQUIRED_LLM_MARKDOWN_COMMAND); + }); + test("validatePreflightScriptPolicy reports missing scripts cspell precheck command", () => { const readFileSyncMock = jest.fn((filePath) => { if (filePath === "/tmp/package.json") { return JSON.stringify({ scripts: { - "preflight:pre-commit": `${REQUIRED_NODE_TOOLING_COMMAND} && ${REQUIRED_HOOK_MARKDOWN_COMMAND} && ${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && npm run validate:pre-commit-tooling && ${REQUIRED_CHANGELOG_VALIDATION_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}` + "preflight:pre-commit": requiredPreflightScript({ + remove: [REQUIRED_SCRIPTS_CSPELL_COMMAND] + }) } }); } @@ -828,12 +995,122 @@ describe("validate-pre-commit-tooling", () => { expect(violations[0].message).toContain(REQUIRED_SCRIPTS_CSPELL_COMMAND); }); + test("validatePreflightScriptPolicy reports missing workflow cspell precheck command", () => { + const readFileSyncMock = jest.fn((filePath) => { + if (filePath === "/tmp/package.json") { + return JSON.stringify({ + scripts: { + "preflight:pre-commit": requiredPreflightScript({ + remove: [REQUIRED_WORKFLOW_CSPELL_COMMAND] + }) + } + }); + } + + if (filePath === "/tmp/pre-commit.yaml") { + return [ + "repos:", + " - repo: local", + " hooks:", + ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, + " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js scripts/__tests__/fix-csharp-underscore-methods.test.js scripts/__tests__/validate-changelog.test.js scripts/__tests__/pre-commit-hook-stage-policy.test.js" + ].join("\n"); + } + + return ""; + }); + + const violations = validatePreflightScriptPolicy( + readFileSyncMock, + "/tmp/package.json", + "/tmp/pre-commit.yaml" + ); + + expect(violations).toHaveLength(1); + expect(violations[0].hookId).toBe("preflight-script"); + expect(violations[0].message).toContain(REQUIRED_WORKFLOW_CSPELL_COMMAND); + }); + + test("validatePreflightScriptPolicy reports missing workflow validation command", () => { + const readFileSyncMock = jest.fn((filePath) => { + if (filePath === "/tmp/package.json") { + return JSON.stringify({ + scripts: { + "preflight:pre-commit": requiredPreflightScript({ + remove: [REQUIRED_WORKFLOW_VALIDATION_COMMAND] + }) + } + }); + } + + if (filePath === "/tmp/pre-commit.yaml") { + return [ + "repos:", + " - repo: local", + " hooks:", + ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, + " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js scripts/__tests__/fix-csharp-underscore-methods.test.js scripts/__tests__/validate-changelog.test.js scripts/__tests__/pre-commit-hook-stage-policy.test.js" + ].join("\n"); + } + + return ""; + }); + + const violations = validatePreflightScriptPolicy( + readFileSyncMock, + "/tmp/package.json", + "/tmp/pre-commit.yaml" + ); + + expect(violations).toHaveLength(1); + expect(violations[0].hookId).toBe("preflight-script"); + expect(violations[0].message).toContain(REQUIRED_WORKFLOW_VALIDATION_COMMAND); + }); + + test("validatePreflightScriptPolicy reports missing banner sync command", () => { + const readFileSyncMock = jest.fn((filePath) => { + if (filePath === "/tmp/package.json") { + return JSON.stringify({ + scripts: { + "preflight:pre-commit": requiredPreflightScript({ + remove: [REQUIRED_BANNER_SYNC_COMMAND] + }) + } + }); + } + + if (filePath === "/tmp/pre-commit.yaml") { + return [ + "repos:", + " - repo: local", + " hooks:", + ` - id: ${REQUIRED_PARSER_SUITE_HOOK_ID}`, + " entry: node scripts/run-managed-jest.js --runTestsByPath scripts/__tests__/generate-skills-index.test.js scripts/__tests__/fix-csharp-underscore-methods.test.js scripts/__tests__/validate-changelog.test.js scripts/__tests__/pre-commit-hook-stage-policy.test.js" + ].join("\n"); + } + + return ""; + }); + + const violations = validatePreflightScriptPolicy( + readFileSyncMock, + "/tmp/package.json", + "/tmp/pre-commit.yaml" + ); + + expect(violations).toHaveLength(1); + expect(violations[0].hookId).toBe("preflight-script"); + expect(violations[0].message).toContain(REQUIRED_BANNER_SYNC_COMMAND); + }); + test("validatePreflightScriptPolicy reports missing changelog validation command", () => { const readFileSyncMock = jest.fn((filePath) => { if (filePath === "/tmp/package.json") { return JSON.stringify({ scripts: { - "preflight:pre-commit": `${REQUIRED_NODE_TOOLING_COMMAND} && ${REQUIRED_HOOK_MARKDOWN_COMMAND} && ${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && npm run validate:pre-commit-tooling && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}` + "preflight:pre-commit": requiredPreflightScript({ + remove: [REQUIRED_CHANGELOG_VALIDATION_COMMAND] + }) } }); } @@ -867,7 +1144,7 @@ describe("validate-pre-commit-tooling", () => { if (filePath === "/tmp/package.json") { return JSON.stringify({ scripts: { - "preflight:pre-commit": `${REQUIRED_NODE_TOOLING_COMMAND} && ${REQUIRED_HOOK_MARKDOWN_COMMAND} && ${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && npm run validate:pre-commit-tooling && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_CHANGELOG_VALIDATION_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}` + "preflight:pre-commit": requiredPreflightScript() } }); } @@ -901,7 +1178,7 @@ describe("validate-pre-commit-tooling", () => { if (filePath === "/tmp/package.json") { return JSON.stringify({ scripts: { - "preflight:pre-commit": `${REQUIRED_NODE_TOOLING_COMMAND} && ${REQUIRED_HOOK_MARKDOWN_COMMAND} && ${REQUIRED_PACKAGE_JSON_FORMAT_COMMAND} && npm run validate:pre-commit-tooling && ${REQUIRED_SCRIPTS_CSPELL_COMMAND} && ${REQUIRED_CHANGELOG_VALIDATION_COMMAND} && ${REQUIRED_PRECHECK_PARSER_COMMAND}` + "preflight:pre-commit": requiredPreflightScript() } }); } diff --git a/scripts/__tests__/validate-repo-identity.test.js b/scripts/__tests__/validate-repo-identity.test.js new file mode 100644 index 00000000..bbf9993f --- /dev/null +++ b/scripts/__tests__/validate-repo-identity.test.js @@ -0,0 +1,164 @@ +/** + * @fileoverview Tests for validate-repo-identity.js. + */ + +"use strict"; + +const { + ALLOWED_PACKAGE_ID, + EXPECTED_REPOSITORY, + findStaleIdentityReferencesInContent, + getRepositoryCandidateFiles, + parseGitFileList, + validateRepoIdentity +} = require("../validate-repo-identity.js"); + +const STALE_REPOSITORY = ["wallstop", "DxMessaging"].join("/"); +const STALE_REPOSITORY_URL = `https://github.com/${STALE_REPOSITORY}`; +const STALE_DOCS_URL = ["https://wallstop.github.io", "DxMessaging"].join("/"); +const STALE_PACKAGE_REPOSITORY = ["wallstop-studios", "com.wallstop-studios.dxmessaging"].join("/"); + +describe("validate-repo-identity", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test("detects stale wallstop GitHub URLs", () => { + const errors = findStaleIdentityReferencesInContent( + [ + `repository: ${STALE_REPOSITORY_URL}`, + `changelog: ${STALE_REPOSITORY_URL}/blob/master/CHANGELOG.md` + ].join("\n"), + "README.md" + ); + + expect(errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + file: "README.md", + line: 1, + value: STALE_REPOSITORY_URL + }), + expect.objectContaining({ + file: "README.md", + line: 2, + value: `${STALE_REPOSITORY_URL}/blob/master/CHANGELOG.md` + }) + ]) + ); + }); + + test("detects stale GitHub Pages URLs", () => { + const errors = findStaleIdentityReferencesInContent( + `docs: ${STALE_DOCS_URL}/getting-started/install/`, + "docs/index.md" + ); + + expect(errors).toEqual([ + expect.objectContaining({ + file: "docs/index.md", + line: 1, + value: `${STALE_DOCS_URL}/getting-started/install/` + }) + ]); + }); + + test("detects stale repository slugs and old release-drafter guards", () => { + const errors = findStaleIdentityReferencesInContent( + [ + `repo: ${STALE_REPOSITORY}`, + `mirror: ${STALE_PACKAGE_REPOSITORY}`, + `if: github.repository == '${STALE_REPOSITORY}'` + ].join("\n"), + ".github/workflows/release-drafter.yml" + ); + + expect(errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + line: 1, + value: STALE_REPOSITORY + }), + expect.objectContaining({ + line: 2, + value: STALE_PACKAGE_REPOSITORY + }), + expect.objectContaining({ + line: 3, + value: `github.repository == '${STALE_REPOSITORY}'` + }) + ]) + ); + }); + + test("detects stale Dependabot owner routing", () => { + const errors = findStaleIdentityReferencesInContent( + ["assignees:", " - wallstop", "reviewers:", " - wallstop"].join("\n"), + ".github/dependabot.yml" + ); + + expect(errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "stale-dependabot-routing", + line: 2, + value: "- wallstop" + }), + expect.objectContaining({ + type: "stale-dependabot-routing", + line: 4, + value: "- wallstop" + }) + ]) + ); + }); + + test("allows current repository identity and Unity package id", () => { + const errors = findStaleIdentityReferencesInContent( + [ + `repo: ${EXPECTED_REPOSITORY}`, + `package: ${ALLOWED_PACKAGE_ID}`, + `openupm add ${ALLOWED_PACKAGE_ID}`, + `https://openupm.com/packages/${ALLOWED_PACKAGE_ID}/`, + `if: github.repository == '${EXPECTED_REPOSITORY}'` + ].join("\n"), + "package.json" + ); + + expect(errors).toHaveLength(0); + }); + + test("validateRepoIdentity returns invalid with stale references", () => { + jest.spyOn(console, "error").mockImplementation(() => {}); + + const result = validateRepoIdentity({ + files: ["README.md"], + readFileSync: () => STALE_REPOSITORY_URL, + check: false + }); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + }); + + test("parseGitFileList normalizes git output", () => { + expect(parseGitFileList("a\r\nb\n\n")).toEqual(["a", "b"]); + }); + + test("candidate files include tracked, staged, and untracked files", () => { + const execFileSync = jest + .fn() + .mockReturnValueOnce("tracked.md\nshared.md\n") + .mockReturnValueOnce("staged.yml\nshared.md\n") + .mockReturnValueOnce("untracked.js\n"); + + const files = getRepositoryCandidateFiles(execFileSync); + + expect(files).toEqual(["shared.md", "staged.yml", "tracked.md", "untracked.js"]); + expect(execFileSync).toHaveBeenCalledWith( + "git", + ["ls-files", "--others", "--exclude-standard"], + expect.any(Object) + ); + }); +}); diff --git a/scripts/__tests__/validate-repo-identity.test.js.meta b/scripts/__tests__/validate-repo-identity.test.js.meta new file mode 100644 index 00000000..6bc9d6d2 --- /dev/null +++ b/scripts/__tests__/validate-repo-identity.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5b119e34d2265454abb2e3fc578355d3 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/validate-workflows-concurrency-and-labels.test.js b/scripts/__tests__/validate-workflows-concurrency-and-labels.test.js new file mode 100644 index 00000000..2105a08e --- /dev/null +++ b/scripts/__tests__/validate-workflows-concurrency-and-labels.test.js @@ -0,0 +1,1208 @@ +/** + * @fileoverview Tests for validator checks in scripts/validate-workflows.js + * AND workflow-shape contracts for the stuck-job-recovery workflows. + * + * Validator-check suites (added after the unity matrix-eviction incident): + * + * - findForbiddenSharedConcurrencyViolations (sentinel guard) + * - findMatrixConcurrencyEvictionViolations (matrix-without-expansion guard) + * - findSelfHostedLabelAllowlistViolations (self-hosted label allowlist) + * - findDynamicRunsOnMissingNeedsViolations (dynamic runs-on / needs guard) + * - extractEmittedLabelSetsFromBash + extractJobConcurrencyGroup / + * extractWorkflowConcurrencyGroup / extractJobMatrixMaxParallel / + * extractJobNeeds / parseInlineLabelArray (helper-extractor contracts) + * + * Each validator suite covers positive (clean), negative (each violation + * form), and the order-insensitive equivalence required by the allowlist + * comparison. + * + * Workflow-shape contract suites (added with the GitHub Actions dispatcher + * stuck-run recovery workflows -- Community Discussion #186811): + * + * - unstick-run.yml workflow contract (manual one-click recovery + * workflow: trigger inputs, permissions, concurrency, and the inline + * bash safety guards) + * - stuck-job-watchdog.yml tightened thresholds (cron `*\/5` and + * MIN_QUEUE_AGE_SECONDS=300 invariants so we cannot accidentally + * regress to the looser pre-tightening cadence) + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const yaml = require("js-yaml"); + +const { + findForbiddenSharedConcurrencyViolations, + findMatrixConcurrencyEvictionViolations, + findSelfHostedLabelAllowlistViolations, + findDynamicRunsOnMissingNeedsViolations, + extractEmittedLabelSetsFromBash, + extractJobConcurrencyGroup, + extractWorkflowConcurrencyGroup, + extractJobMatrixMaxParallel, + extractJobNeeds, + parseInlineLabelArray, + extractJobs, +} = require("../validate-workflows.js"); + +const REPO_ROOT_FOR_FILES = path.resolve(__dirname, "..", ".."); +const WORKFLOWS_DIR_FOR_FILES = path.join(REPO_ROOT_FOR_FILES, ".github", "workflows"); +const UNSTICK_RUN_PATH = path.join(WORKFLOWS_DIR_FOR_FILES, "unstick-run.yml"); +const STUCK_WATCHDOG_PATH = path.join(WORKFLOWS_DIR_FOR_FILES, "stuck-job-watchdog.yml"); + +function loadWorkflowYamlFromPath(absPath) { + const text = fs.readFileSync(absPath, "utf8"); + // js-yaml interprets bare `on` as YAML 1.1 boolean true; load with the + // default schema and let the caller pull either `on` or `true`. + return yaml.load(text); +} + +function getOnBlock(doc) { + // Pull the trigger block whether it parses as `on` (string key) or + // `true` (YAML 1.1 boolean coercion). + if (doc && Object.prototype.hasOwnProperty.call(doc, "on")) { + return doc.on; + } + if (doc && Object.prototype.hasOwnProperty.call(doc, true)) { + return doc[true]; + } + return undefined; +} + +function asLines(text) { + // Strip a single leading blank line so test fixtures can start with `\n`. + const trimmed = text.replace(/^\n/, ""); + return trimmed.split("\n"); +} + +describe("findForbiddenSharedConcurrencyViolations", () => { + test("flags the wallstop-organization-builds sentinel group", () => { + const lines = asLines(` +jobs: + unity-tests: + runs-on: [self-hosted, Windows, RAM-64GB] + concurrency: + group: wallstop-organization-builds + cancel-in-progress: false + steps: + - run: echo hi +`); + + const violations = findForbiddenSharedConcurrencyViolations("test.yml", lines); + expect(violations).toHaveLength(1); + expect(violations[0].severity).toBe("error"); + expect(violations[0].message).toContain("wallstop-organization-builds"); + expect(violations[0].message).toContain("reserved sentinel"); + // Line citation points at the group: line within the fixture. + expect(violations[0].line).toBe(5); + }); + + test("clean workflow with no concurrency block produces no violations", () => { + const lines = asLines(` +jobs: + unity-tests: + runs-on: [self-hosted, Windows, RAM-64GB] + steps: + - run: echo hi +`); + expect(findForbiddenSharedConcurrencyViolations("test.yml", lines)).toEqual([]); + }); + + test("clean workflow with a non-sentinel concurrency.group produces no violations", () => { + const lines = asLines(` +jobs: + unity-tests: + runs-on: [self-hosted, Windows, RAM-64GB] + concurrency: + group: \${{ github.workflow }}-\${{ github.ref }} + cancel-in-progress: true + steps: + - run: echo hi +`); + expect(findForbiddenSharedConcurrencyViolations("test.yml", lines)).toEqual([]); + }); + + test("flags sentinel in inline concurrency mapping form", () => { + const lines = asLines(` +jobs: + unity-tests: + runs-on: [self-hosted, Windows, RAM-64GB] + concurrency: { group: wallstop-organization-builds, cancel-in-progress: false } + steps: + - run: echo hi +`); + const violations = findForbiddenSharedConcurrencyViolations("test.yml", lines); + expect(violations).toHaveLength(1); + expect(violations[0].message).toContain("wallstop-organization-builds"); + }); +}); + +describe("findMatrixConcurrencyEvictionViolations", () => { + test("flags matrix job with a shared concurrency.group and no max-parallel declaration", () => { + const lines = asLines(` +jobs: + unity: + runs-on: [self-hosted, Windows, RAM-64GB] + concurrency: + group: shared-unity-lock + cancel-in-progress: false + strategy: + matrix: + unity-version: + - "2021.3.45f1" + - "2022.3.45f1" + steps: + - run: echo hi +`); + + const violations = findMatrixConcurrencyEvictionViolations("test.yml", lines); + expect(violations).toHaveLength(1); + expect(violations[0].severity).toBe("error"); + expect(violations[0].message).toContain("shared-unity-lock"); + expect(violations[0].message).toContain("\${{ matrix.* }}"); + expect(violations[0].message).toContain("max-parallel: 1"); + expect(violations[0].message).toContain("no strategy.max-parallel declaration"); + }); + + test("flags matrix job with a shared concurrency.group and max-parallel > 1", () => { + const lines = asLines(` +jobs: + unity: + runs-on: [self-hosted, Windows, RAM-64GB] + concurrency: + group: shared-unity-lock + cancel-in-progress: false + strategy: + max-parallel: 2 + matrix: + unity-version: + - "2021.3.45f1" + - "2022.3.45f1" + steps: + - run: echo hi +`); + + const violations = findMatrixConcurrencyEvictionViolations("test.yml", lines); + expect(violations).toHaveLength(1); + expect(violations[0].severity).toBe("error"); + expect(violations[0].message).toContain("shared-unity-lock"); + expect(violations[0].message).toContain("strategy.max-parallel: 2"); + }); + + test("allows matrix job whose concurrency.group expands ${{ matrix.unity-version }}", () => { + const lines = asLines(` +jobs: + unity: + runs-on: [self-hosted, Windows, RAM-64GB] + concurrency: + group: unity-\${{ matrix.unity-version }}-\${{ matrix.test-mode }} + cancel-in-progress: false + strategy: + matrix: + unity-version: + - "2021.3.45f1" + test-mode: + - editmode + steps: + - run: echo hi +`); + expect(findMatrixConcurrencyEvictionViolations("test.yml", lines)).toEqual([]); + }); + + test("allows matrix job with shared concurrency.group when strategy.max-parallel: 1 is declared", () => { + // This is the canonical Unity-Pro-license configuration: all four + // Unity-credential-using jobs share `unity-pro-license` and rely on + // matrix-internal serialization (`max-parallel: 1`) so matrix entries + // never compete for the same group slot. + const lines = asLines(` +jobs: + unity: + runs-on: [self-hosted, Windows, RAM-64GB] + concurrency: + group: unity-pro-license + cancel-in-progress: false + strategy: + fail-fast: false + max-parallel: 1 + matrix: + unity-version: + - "2021.3.45f1" + - "2022.3.45f1" + test-mode: + - editmode + - playmode + steps: + - run: echo hi +`); + expect(findMatrixConcurrencyEvictionViolations("test.yml", lines)).toEqual([]); + }); + + test("allows matrix job with no concurrency.group at all", () => { + const lines = asLines(` +jobs: + unity: + runs-on: [self-hosted, Windows, RAM-64GB] + strategy: + matrix: + unity-version: + - "2021.3.45f1" + steps: + - run: echo hi +`); + expect(findMatrixConcurrencyEvictionViolations("test.yml", lines)).toEqual([]); + }); + + test("allows non-matrix job with a static concurrency.group", () => { + const lines = asLines(` +jobs: + release: + runs-on: ubuntu-latest + concurrency: + group: release-lock + cancel-in-progress: false + steps: + - run: echo hi +`); + expect(findMatrixConcurrencyEvictionViolations("test.yml", lines)).toEqual([]); + }); +}); + +describe("extractJobMatrixMaxParallel", () => { + function jobOf(text) { + const lines = asLines(text); + const jobs = extractJobs(lines); + return { lines, job: jobs[0] }; + } + + test("returns the integer value for `max-parallel: 1`", () => { + const { lines, job } = jobOf(` +jobs: + unity: + runs-on: [self-hosted, Windows, RAM-64GB] + strategy: + max-parallel: 1 + matrix: + unity-version: + - "2021.3.45f1" + steps: + - run: echo hi +`); + expect(extractJobMatrixMaxParallel(lines, job)).toBe(1); + }); + + test("returns the integer value for `max-parallel: 4`", () => { + const { lines, job } = jobOf(` +jobs: + unity: + runs-on: ubuntu-latest + strategy: + max-parallel: 4 + matrix: + node: + - 20 + - 22 + steps: + - run: echo hi +`); + expect(extractJobMatrixMaxParallel(lines, job)).toBe(4); + }); + + test("returns the integer for a double-quoted scalar `max-parallel: \"1\"`", () => { + const { lines, job } = jobOf(` +jobs: + unity: + runs-on: ubuntu-latest + strategy: + max-parallel: "1" + matrix: + node: + - 20 + steps: + - run: echo hi +`); + expect(extractJobMatrixMaxParallel(lines, job)).toBe(1); + }); + + test("returns the integer for a single-quoted scalar `max-parallel: '1'`", () => { + const { lines, job } = jobOf(` +jobs: + unity: + runs-on: ubuntu-latest + strategy: + max-parallel: '1' + matrix: + node: + - 20 + steps: + - run: echo hi +`); + expect(extractJobMatrixMaxParallel(lines, job)).toBe(1); + }); + + test("returns null when `max-parallel:` is absent", () => { + const { lines, job } = jobOf(` +jobs: + unity: + runs-on: ubuntu-latest + strategy: + matrix: + node: + - 20 + steps: + - run: echo hi +`); + expect(extractJobMatrixMaxParallel(lines, job)).toBeNull(); + }); + + test("returns null when the value is non-integer (expression form)", () => { + // We refuse to statically resolve ${{ ... }} expressions; a dynamic + // value cannot be guaranteed to be 1. + const { lines, job } = jobOf(` +jobs: + unity: + runs-on: ubuntu-latest + strategy: + max-parallel: \${{ vars.MAX_PARALLEL }} + matrix: + node: + - 20 + steps: + - run: echo hi +`); + expect(extractJobMatrixMaxParallel(lines, job)).toBeNull(); + }); + + test("returns null when there is no strategy block", () => { + const { lines, job } = jobOf(` +jobs: + unity: + runs-on: ubuntu-latest + steps: + - run: echo hi +`); + expect(extractJobMatrixMaxParallel(lines, job)).toBeNull(); + }); +}); + +describe("findSelfHostedLabelAllowlistViolations", () => { + test("allows the standard inline-array Windows-64GB label set", () => { + const lines = asLines(` +jobs: + unity: + runs-on: [self-hosted, Windows, RAM-64GB] + steps: + - run: echo hi +`); + expect(findSelfHostedLabelAllowlistViolations("test.yml", lines)).toEqual([]); + }); + + test("allows the fast Windows-64GB label set", () => { + const lines = asLines(` +jobs: + unity: + runs-on: [self-hosted, Windows, RAM-64GB, fast] + steps: + - run: echo hi +`); + expect(findSelfHostedLabelAllowlistViolations("test.yml", lines)).toEqual([]); + }); + + test("treats label order as insignificant (order-insensitive equivalence)", () => { + const lines = asLines(` +jobs: + a: + runs-on: [Windows, RAM-64GB, self-hosted] + steps: + - run: echo a + b: + runs-on: [fast, Windows, RAM-64GB, self-hosted] + steps: + - run: echo b +`); + expect(findSelfHostedLabelAllowlistViolations("test.yml", lines)).toEqual([]); + }); + + test("flags typo'd casing such as RAM-64Gb", () => { + const lines = asLines(` +jobs: + unity: + runs-on: [self-hosted, Windows, RAM-64Gb] + steps: + - run: echo hi +`); + const violations = findSelfHostedLabelAllowlistViolations("test.yml", lines); + expect(violations).toHaveLength(1); + expect(violations[0].severity).toBe("error"); + expect(violations[0].message).toContain("RAM-64Gb"); + expect(violations[0].message).toContain("not in the documented allowlist"); + }); + + test("ignores hosted runners (no self-hosted label)", () => { + const lines = asLines(` +jobs: + ubuntu: + runs-on: ubuntu-latest + steps: + - run: echo hi + ubuntu-array: + runs-on: [ubuntu-latest, large] + steps: + - run: echo hi +`); + expect(findSelfHostedLabelAllowlistViolations("test.yml", lines)).toEqual([]); + }); + + test("flags multi-line block list self-hosted label sets that drift from allowlist", () => { + const lines = asLines(` +jobs: + unity: + runs-on: + - self-hosted + - Windows + - RAM-128GB + steps: + - run: echo hi +`); + const violations = findSelfHostedLabelAllowlistViolations("test.yml", lines); + expect(violations).toHaveLength(1); + expect(violations[0].message).toContain("RAM-128GB"); + }); + + test("accepts dynamic ${{ fromJSON(...) }} runs-on whose emitter produces allowlisted sets", () => { + const lines = asLines(` +jobs: + matrix-config: + runs-on: ubuntu-latest + outputs: + runner-labels: \${{ steps.runners.outputs.labels }} + steps: + - id: runners + shell: bash + run: | + if [[ "\${{ github.event_name }}" == "pull_request" ]]; then + echo 'labels=["self-hosted","Windows","RAM-64GB","fast"]' >> "$GITHUB_OUTPUT" + else + echo 'labels=["self-hosted","Windows","RAM-64GB"]' >> "$GITHUB_OUTPUT" + fi + unity: + needs: matrix-config + runs-on: \${{ fromJSON(needs.matrix-config.outputs.runner-labels) }} + steps: + - run: echo hi +`); + expect(findSelfHostedLabelAllowlistViolations("test.yml", lines)).toEqual([]); + }); + + test("flags dynamic runs-on whose emitter produces a forbidden label set", () => { + const lines = asLines(` +jobs: + matrix-config: + runs-on: ubuntu-latest + outputs: + runner-labels: \${{ steps.runners.outputs.labels }} + steps: + - id: runners + shell: bash + run: | + if [[ "\${{ github.event_name }}" == "pull_request" ]]; then + echo 'labels=["self-hosted","Windows","RAM-64Gb"]' >> "$GITHUB_OUTPUT" + else + echo 'labels=["self-hosted","Windows","RAM-64GB"]' >> "$GITHUB_OUTPUT" + fi + unity: + needs: matrix-config + runs-on: \${{ fromJSON(needs.matrix-config.outputs.runner-labels) }} + steps: + - run: echo hi +`); + const violations = findSelfHostedLabelAllowlistViolations("test.yml", lines); + expect(violations.length).toBeGreaterThanOrEqual(1); + expect(violations.some((v) => v.message.includes("RAM-64Gb"))).toBe(true); + }); + + test("flags dynamic runs-on whose emitter produces no labels= lines", () => { + const lines = asLines(` +jobs: + matrix-config: + runs-on: ubuntu-latest + outputs: + runner-labels: \${{ steps.runners.outputs.labels }} + steps: + - id: runners + shell: bash + run: | + echo "no labels declared here" + unity: + needs: matrix-config + runs-on: \${{ fromJSON(needs.matrix-config.outputs.runner-labels) }} + steps: + - run: echo hi +`); + const violations = findSelfHostedLabelAllowlistViolations("test.yml", lines); + expect(violations).toHaveLength(1); + expect(violations[0].message).toContain("emits no 'labels=[...]'"); + }); +}); + +describe("extractEmittedLabelSetsFromBash", () => { + test("parses single-quoted labels= JSON arrays from echo statements", () => { + const runText = [ + "if [[ \"x\" == \"pull_request\" ]]; then", + " echo 'labels=[\"self-hosted\",\"Windows\",\"RAM-64GB\",\"fast\"]' >> \"$GITHUB_OUTPUT\"", + "else", + " echo 'labels=[\"self-hosted\",\"Windows\",\"RAM-64GB\"]' >> \"$GITHUB_OUTPUT\"", + "fi" + ].join("\n"); + expect(extractEmittedLabelSetsFromBash(runText)).toEqual([ + ["self-hosted", "Windows", "RAM-64GB", "fast"], + ["self-hosted", "Windows", "RAM-64GB"] + ]); + }); + + test("parses a bare labels=... assignment without surrounding bash quotes", () => { + const runText = "labels=[\"self-hosted\",\"Windows\",\"RAM-64GB\"]"; + expect(extractEmittedLabelSetsFromBash(runText)).toEqual([ + ["self-hosted", "Windows", "RAM-64GB"] + ]); + }); + + test("returns empty array when no labels= line is present", () => { + expect(extractEmittedLabelSetsFromBash("echo hi")).toEqual([]); + }); + + test("returns null entry for malformed JSON in a labels= assignment", () => { + const runText = "labels=[not-json]"; + expect(extractEmittedLabelSetsFromBash(runText)).toEqual([null]); + }); + + test("tolerates a label literal that contains a `]` character inside a JSON string", () => { + // The previous regex `[^\]]*` would stop at the first `]`. With balanced- + // bracket scanning that respects JSON string spans, the inner `]` is + // captured as part of the label literal. + const runText = `labels=["self-hosted","weird]name","RAM-64GB"]`; + expect(extractEmittedLabelSetsFromBash(runText)).toEqual([ + ["self-hosted", "weird]name", "RAM-64GB"] + ]); + }); +}); + +describe("scalar shorthand concurrency form (extractJobConcurrencyGroup)", () => { + function singleJobLines(concurrencyLine) { + return [ + "jobs:", + " unity-tests:", + " runs-on: [self-hosted, Windows, RAM-64GB]", + ` ${concurrencyLine}`, + " steps:", + " - run: echo hi" + ]; + } + + test("recognizes bare scalar concurrency: wallstop-organization-builds", () => { + const lines = singleJobLines("concurrency: wallstop-organization-builds"); + const jobs = extractJobs(lines); + const result = extractJobConcurrencyGroup(lines, jobs[0]); + expect(result).not.toBeNull(); + expect(result.group).toBe("wallstop-organization-builds"); + expect(result.cancelInProgress).toBeUndefined(); + }); + + test("recognizes double-quoted scalar concurrency: \"wallstop-organization-builds\"", () => { + const lines = singleJobLines('concurrency: "wallstop-organization-builds"'); + const jobs = extractJobs(lines); + const result = extractJobConcurrencyGroup(lines, jobs[0]); + expect(result).not.toBeNull(); + expect(result.group).toBe("wallstop-organization-builds"); + }); + + test("recognizes single-quoted scalar concurrency: 'wallstop-organization-builds'", () => { + const lines = singleJobLines("concurrency: 'wallstop-organization-builds'"); + const jobs = extractJobs(lines); + const result = extractJobConcurrencyGroup(lines, jobs[0]); + expect(result).not.toBeNull(); + expect(result.group).toBe("wallstop-organization-builds"); + }); + + test("returns null for `concurrency: ~` (YAML null) and bare empty value", () => { + const linesNull = singleJobLines("concurrency: ~"); + const linesEmpty = [ + "jobs:", + " unity-tests:", + " runs-on: [self-hosted, Windows, RAM-64GB]", + " concurrency:", + " steps:", + " - run: echo hi" + ]; + const jobsNull = extractJobs(linesNull); + const jobsEmpty = extractJobs(linesEmpty); + expect(extractJobConcurrencyGroup(linesNull, jobsNull[0])).toBeNull(); + expect(extractJobConcurrencyGroup(linesEmpty, jobsEmpty[0])).toBeNull(); + }); +}); + +describe("findForbiddenSharedConcurrencyViolations: shorthand and workflow-level", () => { + test("flags scalar shorthand at job level (bare)", () => { + const lines = asLines(` +jobs: + unity-tests: + runs-on: [self-hosted, Windows, RAM-64GB] + concurrency: wallstop-organization-builds + steps: + - run: echo hi +`); + const violations = findForbiddenSharedConcurrencyViolations("test.yml", lines); + expect(violations).toHaveLength(1); + expect(violations[0].message).toContain("wallstop-organization-builds"); + expect(violations[0].message).toContain("reserved sentinel"); + }); + + test("flags scalar shorthand at job level (double-quoted)", () => { + const lines = asLines(` +jobs: + unity-tests: + runs-on: [self-hosted, Windows, RAM-64GB] + concurrency: "wallstop-organization-builds" + steps: + - run: echo hi +`); + const violations = findForbiddenSharedConcurrencyViolations("test.yml", lines); + expect(violations).toHaveLength(1); + expect(violations[0].message).toContain("wallstop-organization-builds"); + }); + + test("flags scalar shorthand at job level (single-quoted)", () => { + const lines = asLines(` +jobs: + unity-tests: + runs-on: [self-hosted, Windows, RAM-64GB] + concurrency: 'wallstop-organization-builds' + steps: + - run: echo hi +`); + const violations = findForbiddenSharedConcurrencyViolations("test.yml", lines); + expect(violations).toHaveLength(1); + expect(violations[0].message).toContain("wallstop-organization-builds"); + }); + + test("flags workflow-level inline mapping with sentinel name", () => { + const lines = asLines(` +name: Unity Tests +concurrency: + group: wallstop-organization-builds + cancel-in-progress: false +jobs: + unity-tests: + runs-on: [self-hosted, Windows, RAM-64GB] + steps: + - run: echo hi +`); + const violations = findForbiddenSharedConcurrencyViolations("test.yml", lines); + expect(violations).toHaveLength(1); + expect(violations[0].message).toContain("Workflow-level"); + expect(violations[0].message).toContain("wallstop-organization-builds"); + }); + + test("flags workflow-level scalar shorthand with sentinel name", () => { + const lines = asLines(` +name: Unity Tests +concurrency: wallstop-organization-builds +jobs: + unity-tests: + runs-on: [self-hosted, Windows, RAM-64GB] + steps: + - run: echo hi +`); + const violations = findForbiddenSharedConcurrencyViolations("test.yml", lines); + expect(violations).toHaveLength(1); + expect(violations[0].message).toContain("Workflow-level"); + expect(violations[0].message).toContain("wallstop-organization-builds"); + }); + + test("allows workflow-level non-sentinel concurrency", () => { + const lines = asLines(` +name: Unity Tests +concurrency: + group: \${{ github.workflow }}-\${{ github.ref }} + cancel-in-progress: true +jobs: + unity-tests: + runs-on: [self-hosted, Windows, RAM-64GB] + steps: + - run: echo hi +`); + expect(findForbiddenSharedConcurrencyViolations("test.yml", lines)).toEqual([]); + }); +}); + +describe("extractWorkflowConcurrencyGroup", () => { + test("returns group + line for inline mapping workflow-level concurrency", () => { + const lines = asLines(` +name: Unity Tests +concurrency: { group: foo, cancel-in-progress: false } +jobs: + a: + runs-on: ubuntu-latest + steps: + - run: echo +`); + const result = extractWorkflowConcurrencyGroup(lines); + expect(result).not.toBeNull(); + expect(result.group).toBe("foo"); + }); + + test("returns null when no workflow-level concurrency exists", () => { + const lines = asLines(` +name: Unity Tests +jobs: + a: + runs-on: ubuntu-latest + concurrency: foo + steps: + - run: echo +`); + expect(extractWorkflowConcurrencyGroup(lines)).toBeNull(); + }); +}); + +describe("findMatrixConcurrencyEvictionViolations: scalar shorthand on a matrix job", () => { + test("flags scalar shorthand on a matrix job missing matrix expansion", () => { + const lines = asLines(` +jobs: + unity: + runs-on: [self-hosted, Windows, RAM-64GB] + concurrency: shared-unity-lock + strategy: + matrix: + unity-version: + - "2021.3.45f1" + - "2022.3.45f1" + steps: + - run: echo hi +`); + const violations = findMatrixConcurrencyEvictionViolations("test.yml", lines); + expect(violations).toHaveLength(1); + expect(violations[0].message).toContain("shared-unity-lock"); + }); + + test("allows scalar shorthand on a matrix job whose group expands ${{ matrix.* }}", () => { + const lines = asLines(` +jobs: + unity: + runs-on: [self-hosted, Windows, RAM-64GB] + concurrency: unity-\${{ matrix.unity-version }} + strategy: + matrix: + unity-version: + - "2021.3.45f1" + steps: + - run: echo hi +`); + expect(findMatrixConcurrencyEvictionViolations("test.yml", lines)).toEqual([]); + }); +}); + +describe("findSelfHostedLabelAllowlistViolations: extra coverage", () => { + test("flags `runs-on: 'self-hosted'` scalar quoted form for missing modifiers", () => { + const lines = asLines(` +jobs: + unity: + runs-on: 'self-hosted' + steps: + - run: echo hi +`); + const violations = findSelfHostedLabelAllowlistViolations("test.yml", lines); + expect(violations).toHaveLength(1); + expect(violations[0].message).toContain("not in the documented allowlist"); + }); + + test("flags `runs-on: \"self-hosted\"` scalar double-quoted form for missing modifiers", () => { + const lines = asLines(` +jobs: + unity: + runs-on: "self-hosted" + steps: + - run: echo hi +`); + const violations = findSelfHostedLabelAllowlistViolations("test.yml", lines); + expect(violations).toHaveLength(1); + expect(violations[0].message).toContain("not in the documented allowlist"); + }); + + test("flags `runs-on: self-hosted` scalar bare form for missing modifiers", () => { + const lines = asLines(` +jobs: + unity: + runs-on: self-hosted + steps: + - run: echo hi +`); + const violations = findSelfHostedLabelAllowlistViolations("test.yml", lines); + expect(violations).toHaveLength(1); + expect(violations[0].message).toContain("not in the documented allowlist"); + }); + + test("flags trailing-comma inline label array with a clear error", () => { + const lines = asLines(` +jobs: + unity: + runs-on: [self-hosted, Windows, RAM-64GB,] + steps: + - run: echo hi +`); + const violations = findSelfHostedLabelAllowlistViolations("test.yml", lines); + expect(violations).toHaveLength(1); + expect(violations[0].message).toContain("Trailing or duplicate comma"); + }); + + test("matrix-include-only fixture: matrix.include emits allowlisted self-hosted entries", () => { + // Even when the matrix uses include-only syntax (no top-level matrix + // dimensions), the self-hosted label allowlist still applies to the + // job's static runs-on declaration. + const lines = asLines(` +jobs: + unity: + runs-on: [self-hosted, Windows, RAM-64GB, fast] + strategy: + matrix: + include: + - unity-version: "2021.3.45f1" + test-mode: editmode + - unity-version: "2022.3.45f1" + test-mode: playmode + steps: + - run: echo hi +`); + expect(findSelfHostedLabelAllowlistViolations("test.yml", lines)).toEqual([]); + }); + + test("multiple \${{ matrix.* }} tokens in one expansion are accepted for matrix-eviction check", () => { + const lines = asLines(` +jobs: + unity: + runs-on: [self-hosted, Windows, RAM-64GB] + concurrency: + group: unity-\${{ matrix.unity-version }}-\${{ matrix.test-mode }}-\${{ matrix.platform }} + cancel-in-progress: false + strategy: + matrix: + unity-version: + - "2021.3.45f1" + test-mode: + - editmode + platform: + - windows + steps: + - run: echo hi +`); + expect(findMatrixConcurrencyEvictionViolations("test.yml", lines)).toEqual([]); + }); +}); + +describe("parseInlineLabelArray: trailing comma rejection", () => { + test("throws for `[a, b, c,]` trailing comma", () => { + expect(() => parseInlineLabelArray("[a, b, c,]")).toThrow(/Trailing or duplicate comma/); + }); + + test("throws for `[a,,b]` duplicate comma", () => { + expect(() => parseInlineLabelArray("[a,,b]")).toThrow(/Trailing or duplicate comma/); + }); + + test("accepts well-formed `[a, b, c]`", () => { + expect(parseInlineLabelArray("[a, b, c]")).toEqual(["a", "b", "c"]); + }); +}); + +describe("extractJobNeeds", () => { + function jobsFrom(text) { + return extractJobs(asLines(text)); + } + + test("returns [] when no needs declared", () => { + const lines = asLines(` +jobs: + a: + runs-on: ubuntu-latest + steps: + - run: echo +`); + expect(extractJobNeeds(lines, jobsFrom(` +jobs: + a: + runs-on: ubuntu-latest + steps: + - run: echo +`)[0])).toEqual([]); + }); + + test("parses scalar form needs: matrix-config", () => { + const text = ` +jobs: + unity: + needs: matrix-config + runs-on: ubuntu-latest + steps: + - run: echo +`; + const lines = asLines(text); + expect(extractJobNeeds(lines, jobsFrom(text)[0])).toEqual(["matrix-config"]); + }); + + test("parses inline-array form needs: [a, b]", () => { + const text = ` +jobs: + unity: + needs: [matrix-config, validate] + runs-on: ubuntu-latest + steps: + - run: echo +`; + const lines = asLines(text); + expect(extractJobNeeds(lines, jobsFrom(text)[0])).toEqual(["matrix-config", "validate"]); + }); + + test("parses multi-line block list form", () => { + const text = ` +jobs: + unity: + needs: + - matrix-config + - validate + runs-on: ubuntu-latest + steps: + - run: echo +`; + const lines = asLines(text); + expect(extractJobNeeds(lines, jobsFrom(text)[0])).toEqual(["matrix-config", "validate"]); + }); +}); + +describe("findDynamicRunsOnMissingNeedsViolations", () => { + test("flags dynamic runs-on whose target job is not in needs", () => { + const lines = asLines(` +jobs: + matrix-config: + runs-on: ubuntu-latest + outputs: + runner-labels: \${{ steps.runners.outputs.labels }} + steps: + - id: runners + run: | + echo 'labels=["self-hosted","Windows","RAM-64GB"]' >> "$GITHUB_OUTPUT" + unity: + runs-on: \${{ fromJSON(needs.matrix-config.outputs.runner-labels) }} + steps: + - run: echo hi +`); + const violations = findDynamicRunsOnMissingNeedsViolations("test.yml", lines); + expect(violations).toHaveLength(1); + expect(violations[0].message).toContain("matrix-config"); + expect(violations[0].message).toContain("not in the job's needs:"); + }); + + test("accepts dynamic runs-on whose target job is in needs (scalar)", () => { + const lines = asLines(` +jobs: + matrix-config: + runs-on: ubuntu-latest + outputs: + runner-labels: \${{ steps.runners.outputs.labels }} + steps: + - id: runners + run: | + echo 'labels=["self-hosted","Windows","RAM-64GB"]' >> "$GITHUB_OUTPUT" + unity: + needs: matrix-config + runs-on: \${{ fromJSON(needs.matrix-config.outputs.runner-labels) }} + steps: + - run: echo hi +`); + expect(findDynamicRunsOnMissingNeedsViolations("test.yml", lines)).toEqual([]); + }); + + test("accepts dynamic runs-on whose target job is in needs (inline array)", () => { + const lines = asLines(` +jobs: + matrix-config: + runs-on: ubuntu-latest + outputs: + runner-labels: \${{ steps.runners.outputs.labels }} + steps: + - id: runners + run: echo + validate: + runs-on: ubuntu-latest + steps: + - run: echo + unity: + needs: [matrix-config, validate] + runs-on: \${{ fromJSON(needs.matrix-config.outputs.runner-labels) }} + steps: + - run: echo hi +`); + expect(findDynamicRunsOnMissingNeedsViolations("test.yml", lines)).toEqual([]); + }); + + test("ignores jobs that do not use the dynamic fromJSON pattern", () => { + const lines = asLines(` +jobs: + unity: + runs-on: ubuntu-latest + steps: + - run: echo hi +`); + expect(findDynamicRunsOnMissingNeedsViolations("test.yml", lines)).toEqual([]); + }); +}); + +describe("historical sentinel string remains searchable in error messages", () => { + // The reviewer asked that future log readers be able to grep CI failure + // text for the historical 'wallstop-organization-builds' incident name. + // This test asserts at least one validator code path mentions it. + test("at least one violation message in the sentinel-guard pipeline contains the incident name", () => { + const lines = asLines(` +jobs: + unity-tests: + runs-on: [self-hosted, Windows, RAM-64GB] + concurrency: + group: wallstop-organization-builds + cancel-in-progress: false + steps: + - run: echo hi +`); + const violations = findForbiddenSharedConcurrencyViolations("test.yml", lines); + expect(violations.some((v) => v.message.includes("wallstop-organization-builds"))).toBe(true); + }); +}); + +describe("unstick-run.yml workflow contract", () => { + let doc; + let raw; + + beforeAll(() => { + raw = fs.readFileSync(UNSTICK_RUN_PATH, "utf8"); + doc = loadWorkflowYamlFromPath(UNSTICK_RUN_PATH); + }); + + test("file exists at .github/workflows/unstick-run.yml", () => { + expect(fs.existsSync(UNSTICK_RUN_PATH)).toBe(true); + }); + + test("parses cleanly with js-yaml", () => { + expect(doc).toBeTruthy(); + expect(typeof doc).toBe("object"); + }); + + test("declares workflow_dispatch trigger with a required string run_id input", () => { + const onBlock = getOnBlock(doc); + expect(onBlock).toBeTruthy(); + expect(onBlock).toHaveProperty("workflow_dispatch"); + + const dispatch = onBlock.workflow_dispatch; + expect(dispatch).toBeTruthy(); + expect(dispatch.inputs).toBeTruthy(); + expect(dispatch.inputs.run_id).toBeTruthy(); + expect(dispatch.inputs.run_id.required).toBe(true); + expect(dispatch.inputs.run_id.type).toBe("string"); + }); + + test("run_id input has a non-empty description (so the GitHub UI dispatch dialog guides operators)", () => { + // Positive smoke: the GitHub Actions "Run workflow" dropdown renders + // input descriptions as helper text under each field. An empty or + // missing description leaves the operator guessing what value to + // paste, which is exactly the failure mode this workflow exists to + // prevent (a wrong run id, dispatched at the wrong moment, cancels + // the wrong run). + const onBlock = getOnBlock(doc); + const runIdInput = onBlock.workflow_dispatch.inputs.run_id; + expect(typeof runIdInput.description).toBe("string"); + expect(runIdInput.description.trim().length).toBeGreaterThan(0); + }); + + test("declares optional force_redispatch and bypass_exclusion boolean inputs", () => { + const onBlock = getOnBlock(doc); + const inputs = onBlock.workflow_dispatch.inputs; + expect(inputs.force_redispatch).toBeTruthy(); + expect(inputs.force_redispatch.type).toBe("boolean"); + expect(inputs.force_redispatch.default).toBe(false); + expect(inputs.bypass_exclusion).toBeTruthy(); + expect(inputs.bypass_exclusion.type).toBe("boolean"); + expect(inputs.bypass_exclusion.default).toBe(false); + }); + + test("runs on ubuntu-latest", () => { + expect(doc.jobs).toBeTruthy(); + const jobIds = Object.keys(doc.jobs); + expect(jobIds).toHaveLength(1); + const job = doc.jobs[jobIds[0]]; + expect(job["runs-on"]).toBe("ubuntu-latest"); + }); + + test("declares exact permissions: { actions: write, contents: read }", () => { + expect(doc.permissions).toEqual({ + actions: "write", + contents: "read", + }); + }); + + test("does NOT declare contents: write (unstick workflow must never touch the state branch)", () => { + // Defense in depth: even if the permissions block were ever rewritten, + // a bare `contents: write` would be a regression. + expect(doc.permissions).not.toHaveProperty("contents", "write"); + expect(raw).not.toMatch(/contents:\s*write/); + }); + + test("declares a concurrency group keyed on inputs.run_id with cancel-in-progress: false", () => { + expect(doc.concurrency).toBeTruthy(); + expect(doc.concurrency.group).toMatch(/unstick-run-\$\{\{\s*inputs\.run_id\s*\}\}/); + expect(doc.concurrency["cancel-in-progress"]).toBe(false); + }); + + test("inline bash references MIN_AGE_SECONDS=30 (fresh-run guard)", () => { + expect(raw).toMatch(/MIN_AGE_SECONDS:\s*"30"/); + }); + + test("inline bash invokes 'gh run cancel' (the recovery action)", () => { + expect(raw).toMatch(/gh run cancel\s+"\$\{run_id\}"/); + }); + + test("inline bash validates run_id with a positive-integer regex", () => { + expect(raw).toMatch(/\[\[\s+"\$\{run_id\}"\s+=~\s+\^\[0-9\]\+\$\s+\]\]/); + }); + + test("inline bash respects the workflow exclusion list with a bypass_exclusion opt-out", () => { + expect(raw).toMatch(/DEFAULT_EXCLUDED_WORKFLOWS:\s*"release\.yml"/); + expect(raw).toMatch(/bypass_exclusion/); + }); + + test("optional REST re-dispatch is gated behind force_redispatch=true", () => { + expect(raw).toMatch(/\$\{force_redispatch\}.*==.*"true"/s); + expect(raw).toMatch(/actions\/workflows\/\$\{run_workflow_id\}\/dispatches/); + }); +}); + +describe("stuck-job-watchdog.yml tightened thresholds", () => { + let raw; + + beforeAll(() => { + raw = fs.readFileSync(STUCK_WATCHDOG_PATH, "utf8"); + }); + + test("file exists", () => { + expect(fs.existsSync(STUCK_WATCHDOG_PATH)).toBe(true); + }); + + test("schedule cron is exactly '*/5 * * * *' (tightened from */10)", () => { + // Use a line-level regex so accidental whitespace or alternate cron + // entries are still caught. + expect(raw).toMatch(/-\s*cron:\s*"\*\/5 \* \* \* \*"/); + // Negative assertion to catch a regression to the looser schedule. + expect(raw).not.toMatch(/-\s*cron:\s*"\*\/10 \* \* \* \*"/); + }); + + test("MIN_QUEUE_AGE_SECONDS env value is 300 (tightened from 600)", () => { + expect(raw).toMatch(/MIN_QUEUE_AGE_SECONDS:\s*"300"/); + expect(raw).not.toMatch(/MIN_QUEUE_AGE_SECONDS:\s*"600"/); + }); +}); diff --git a/scripts/__tests__/validate-workflows-concurrency-and-labels.test.js.meta b/scripts/__tests__/validate-workflows-concurrency-and-labels.test.js.meta new file mode 100644 index 00000000..299a407a --- /dev/null +++ b/scripts/__tests__/validate-workflows-concurrency-and-labels.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a66395c264f74025658df218e0855173 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/__tests__/validate-workflows.test.js b/scripts/__tests__/validate-workflows.test.js index 611a85ad..80cf0546 100644 --- a/scripts/__tests__/validate-workflows.test.js +++ b/scripts/__tests__/validate-workflows.test.js @@ -21,7 +21,11 @@ const { findLockfileInstallViolations, detectBashSyntaxPattern, findWindowsBashPortabilityViolations, + findForbiddenRunsOnGroupViolations, findChangelogCoverageCheckoutViolations, + resolveWorkflowLineLengthPolicy, + resolveWorkflowLineLengthMax, + findWorkflowLineLengthViolations, validateWorkflow } = require("../validate-workflows.js"); @@ -639,6 +643,121 @@ describe("windows matrix bash shell portability policy", () => { }); }); +describe("findForbiddenRunsOnGroupViolations", () => { + test.each([ + { + name: "flags multi-line runs-on with a group: key", + lines: [ + "jobs:", + " unity:", + " runs-on:", + " group: some-runner-group", + " labels:", + " - self-hosted", + " - Windows", + " steps:", + " - run: echo hello" + ], + expectedViolations: 1 + }, + { + name: "flags inline mapping runs-on with a group: key", + lines: [ + "jobs:", + " unity:", + " runs-on: { group: some-runner-group, labels: [self-hosted, Windows] }", + " steps:", + " - run: echo hello" + ], + expectedViolations: 1 + }, + { + name: "allows labels-only inline array runs-on", + lines: [ + "jobs:", + " unity:", + " runs-on: [self-hosted, Windows, RAM-64GB]", + " steps:", + " - run: echo hello" + ], + expectedViolations: 0 + }, + { + name: "allows labels-only block list runs-on", + lines: [ + "jobs:", + " unity:", + " runs-on:", + " - self-hosted", + " - Windows", + " - RAM-64GB", + " steps:", + " - run: echo hello" + ], + expectedViolations: 0 + }, + { + name: "allows plain scalar runs-on", + lines: [ + "jobs:", + " ubuntu:", + " runs-on: ubuntu-latest", + " steps:", + " - run: echo hello" + ], + expectedViolations: 0 + }, + { + name: "does not confuse concurrency.group with runs-on.group (multi-line)", + lines: [ + "jobs:", + " unity:", + " concurrency:", + " group: wallstop-organization-builds", + " cancel-in-progress: false", + " runs-on:", + " labels:", + " - self-hosted", + " - Windows", + " - RAM-64GB", + " steps:", + " - run: echo hello" + ], + expectedViolations: 0 + }, + { + name: "still flags runs-on.group when a sibling concurrency.group is present", + lines: [ + "jobs:", + " unity:", + " concurrency:", + " group: wallstop-organization-builds", + " cancel-in-progress: false", + " runs-on:", + " group: some-runner-group", + " labels:", + " - self-hosted", + " - Windows", + " - RAM-64GB", + " steps:", + " - run: echo hello" + ], + expectedViolations: 1 + } + ])("$name", ({ lines, expectedViolations }) => { + const violations = findForbiddenRunsOnGroupViolations("test.yml", lines); + + expect(violations).toHaveLength(expectedViolations); + if (expectedViolations > 0) { + for (const violation of violations) { + expect(violation.severity).toBe("error"); + expect(violation.message).toContain("runs-on.group is forbidden"); + expect(violation.message).toContain("per-runner serialization"); + } + } + }); +}); + describe("Real workflow patterns", () => { describe("should correctly handle actual workflow content", () => { test("correct per-extension loop pattern", () => { @@ -700,6 +819,135 @@ describe("validateWorkflow newline handling", () => { }); }); +describe("workflow line-length guard", () => { + test("flags workflow lines above configured maximum", () => { + const lines = ["name: Test", `${"x".repeat(201)}`]; + + const violations = findWorkflowLineLengthViolations("test.yml", lines, 200); + + expect(violations).toHaveLength(1); + expect(violations[0]).toEqual( + expect.objectContaining({ + file: "test.yml", + line: 2, + severity: "error" + }) + ); + expect(violations[0].message).toContain("Workflow line exceeds 200 characters (201)"); + }); + + test("allows lines at or below configured maximum", () => { + const lines = ["name: Test", `${"x".repeat(200)}`]; + + const violations = findWorkflowLineLengthViolations("test.yml", lines, 200); + + expect(violations).toHaveLength(0); + }); + + test("allows overlong non-breakable words when enabled", () => { + const lines = ["name: Test", `key: ${"x".repeat(205)}`]; + + const violations = findWorkflowLineLengthViolations("test.yml", lines, 200, { + allowNonBreakableWords: true + }); + + expect(violations).toHaveLength(0); + }); + + test("still fails overlong non-breakable words when disabled", () => { + const lines = ["name: Test", `key: ${"x".repeat(205)}`]; + + const violations = findWorkflowLineLengthViolations("test.yml", lines, 200, { + allowNonBreakableWords: false + }); + + expect(violations).toHaveLength(1); + }); +}); + +describe("resolveWorkflowLineLengthPolicy", () => { + test("loads max and non-breakable options from .yamllint.yaml", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "validate-workflows-policy-yamllint-")); + try { + fs.writeFileSync( + path.join(tempDir, ".yamllint.yaml"), + [ + "rules:", + " line-length:", + " max: 180", + " allow-non-breakable-words: false", + " allow-non-breakable-inline-mappings: false" + ].join("\n"), + "utf8" + ); + + const policy = resolveWorkflowLineLengthPolicy(tempDir); + + expect(policy).toEqual({ + max: 180, + allowNonBreakableWords: false, + allowNonBreakableInlineMappings: false + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("treats inline-mappings option as implying non-breakable words", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "validate-workflows-policy-inline-map-")); + try { + fs.writeFileSync( + path.join(tempDir, ".yamllint.yaml"), + [ + "rules:", + " line-length:", + " max: 200", + " allow-non-breakable-words: false", + " allow-non-breakable-inline-mappings: true" + ].join("\n"), + "utf8" + ); + + const policy = resolveWorkflowLineLengthPolicy(tempDir); + expect(policy.allowNonBreakableInlineMappings).toBe(true); + expect(policy.allowNonBreakableWords).toBe(true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); + +describe("resolveWorkflowLineLengthMax", () => { + test("loads max from .yamllint.yaml when present", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "validate-workflows-yamllint-")); + try { + fs.writeFileSync( + path.join(tempDir, ".yamllint.yaml"), + [ + "rules:", + " line-length:", + " max: 187", + " allow-non-breakable-words: true" + ].join("\n"), + "utf8" + ); + + expect(resolveWorkflowLineLengthMax(tempDir)).toBe(187); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("falls back to default when .yamllint.yaml is absent", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "validate-workflows-yamllint-missing-")); + try { + expect(resolveWorkflowLineLengthMax(tempDir)).toBe(200); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); + describe("findChangelogCoverageCheckoutViolations", () => { test.each([ ["direct changelog validator", "node scripts/validate-changelog.js --check-coverage"], @@ -920,6 +1168,48 @@ describe("validateWorkflow policy integration", () => { } }); + test("reports workflow line-length violations using repo yamllint ceiling", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "validate-workflows-line-length-")); + try { + const workflowDir = path.join(tempDir, ".github", "workflows"); + fs.mkdirSync(workflowDir, { recursive: true }); + fs.writeFileSync( + path.join(tempDir, ".yamllint.yaml"), + ["rules:", " line-length:", " max: 40"].join("\n"), + "utf8" + ); + + const workflowPath = path.join(workflowDir, "line-length.yml"); + fs.writeFileSync( + workflowPath, + [ + "name: Line Length", + "jobs:", + " lint:", + " runs-on: ubuntu-latest", + " steps:", + " - run: echo this workflow line is intentionally longer than forty chars" + ].join("\n"), + "utf8" + ); + + const violations = validateWorkflow(workflowPath, { + repoRoot: tempDir, + isIgnoredPathFn: () => false + }); + + expect( + violations.some( + (violation) => + violation.severity === "error" + && violation.message.includes("Workflow line exceeds 40 characters") + ) + ).toBe(true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + test("surfaces ignore policy evaluation failures as validation errors", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "validate-workflows-policy-error-")); try { diff --git a/scripts/__tests__/verify-managed-jest-fallback.test.js b/scripts/__tests__/verify-managed-jest-fallback.test.js index f2f25bae..451ef975 100644 --- a/scripts/__tests__/verify-managed-jest-fallback.test.js +++ b/scripts/__tests__/verify-managed-jest-fallback.test.js @@ -33,6 +33,22 @@ describe("verify-managed-jest-fallback", () => { ).toBe(true); }); + test("isManagedRunnerPathSafe accepts Windows-style managed runner paths", () => { + const pathModule = { resolve: (inputPath) => inputPath }; + expect( + isManagedRunnerPathSafe( + "C:\\repo\\node_modules\\jest-circus\\build\\runner.cjs", + { pathModule } + ) + ).toBe(true); + expect( + isManagedRunnerPathSafe( + "C:\\repo\\node_modules\\jest-circus\\build\\runner.mjs", + { pathModule } + ) + ).toBe(true); + }); + test("assertManagedRunnerPathSafe rejects unexpected deletion targets", () => { expect(() => assertManagedRunnerPathSafe("/tmp/runner.js")).toThrow( "Refusing to delete unexpected runner path" diff --git a/scripts/doctor.js b/scripts/doctor.js new file mode 100644 index 00000000..4869a189 --- /dev/null +++ b/scripts/doctor.js @@ -0,0 +1,1175 @@ +#!/usr/bin/env node +/** + * doctor.js + * + * Agentic preflight gate: a pure-Node, read-only diagnostic that audits the + * tooling pre-push hooks depend on. The doctor is the primary signal that + * lets an automated agent answer "is this branch safe to push?" before the + * hooks ever fire, by inspecting: + * + * 1. node_modules freshness for the TOOL_SPECS that validate-node-tooling + * enforces (including jest-circus/runner -- the exact dependency whose + * partial install caused the MISSING_TEST_RUNNER stderr that motivated + * this phase of work). + * 2. The isolated managed-Jest cache under os.tmpdir(): does it resolve, + * is any entry stale, and what is the manual-reset command? + * 3. The shared EOL policy constants (CRLF/LF extension counts). + * 4. The pre-commit YAML: hook counts, pre-push subset, cross-reference + * with the preflight:pre-push npm script. + * 5. The hook performance budget via scoreConfig() from precommit-perf-score. + * 6. Cross-platform sanity (platform, pwsh + bash availability, + * package.json line-ending). + * 7. Working-tree state: untracked-and-unignored paths (mirrors the + * validate-untracked-policy preflight step) plus a soft warning for + * modified-but-unstaged files. Untracked paths fail; unstaged + * modifications warn. + * + * The module is read-only by design: every probe is a query, never a fix. + * Each section is a separately exported function that takes a + * dependency-injection options bag so tests can drive it with deterministic + * fakes; the real runtime uses fs / child_process / os defaults. + * + * Exit code semantics: + * 0 = every section returned "ok" or "warn"; the push is safe to attempt + * 1 = at least one section returned "fail"; the push will almost certainly + * fail at hook time, so the agent should repair before invoking + * `npm run preflight:pre-push`. + */ + +"use strict"; + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const childProcess = require("child_process"); +const { createRequire } = require("module"); + +const { TOOL_SPECS } = require("./validate-node-tooling"); +const eolPolicy = require("./lib/eol-policy"); +const precommitYaml = require("./lib/precommit-yaml"); +const precommitPerfScore = require("./lib/precommit-perf-score"); +const shellCommand = require("./lib/shell-command"); +const { isTruthyEnv } = require("./lib/jest-error-decoder"); +const { ISOLATED_JEST_CACHE_ROOT } = require("./run-managed-jest"); +const { + INTEGRITY_TARGETS, + probeIntegrity, +} = require("./lib/node-modules-integrity"); + +const REPO_ROOT = path.resolve(__dirname, ".."); +const REPO_REQUIRE = createRequire(path.join(REPO_ROOT, "package.json")); +const PACKAGE_JSON_PATH = path.join(REPO_ROOT, "package.json"); +const PRECOMMIT_CONFIG_PATH = path.join(REPO_ROOT, ".pre-commit-config.yaml"); + +const STALE_CACHE_DAYS = 30; +const STALE_CACHE_MS = STALE_CACHE_DAYS * 24 * 60 * 60 * 1000; + +const ANSI_RESET = "\x1b[0m"; +const ANSI_GREEN = "\x1b[32m"; +const ANSI_YELLOW = "\x1b[33m"; +const ANSI_RED = "\x1b[31m"; +const ANSI_BOLD = "\x1b[1m"; + +const KNOWN_STATUSES = Object.freeze(["ok", "warn", "fail"]); + +function statusRank(status) { + if (status === "fail") return 2; + if (status === "warn") return 1; + return 0; +} + +/** + * Aggregate the worst section status. Sections that report an unrecognized + * status string are fail-safe: the aggregator logs a warning and treats them + * as "fail" so a typo in a new section cannot silently downgrade a real + * failure to OK. Callers can override the warn sink in tests. + * + * @param {Array<{status: string, name?: string}>} sections + * @param {{warn?: Function}} [options] + * @returns {string} + */ +function aggregateStatus(sections, options = {}) { + const warn = options.warn || ((message) => process.stderr.write(`${message}\n`)); + let worst = "ok"; + for (const section of sections) { + let effectiveStatus = section.status; + if (!KNOWN_STATUSES.includes(effectiveStatus)) { + const name = section && section.name ? section.name : "(unnamed)"; + warn(`doctor: section '${name}' returned unrecognized status '${effectiveStatus}'; treating as 'fail'.`); + effectiveStatus = "fail"; + } + if (statusRank(effectiveStatus) > statusRank(worst)) { + worst = effectiveStatus; + } + } + return worst; +} + +function statusToExitCode(status) { + return status === "fail" ? 1 : 0; +} + +/** + * 1. Node modules freshness -- for each entry in validate-node-tooling's + * TOOL_SPECS, report a resolve + exists + load triple. + */ +function checkNodeModulesFreshness(options = {}) { + const { + requireResolveFn = (specifier) => REPO_REQUIRE.resolve(specifier), + existsSyncFn = fs.existsSync, + statSyncFn = fs.statSync, + requireFn = (absPath) => REPO_REQUIRE(absPath), + toolSpecs = TOOL_SPECS, + integrityTargets = INTEGRITY_TARGETS, + probeIntegrityFn = probeIntegrity, + repoRoot = REPO_ROOT, + } = options; + + // Step 10: file-existence (and zero-byte) audits delegate to + // probeIntegrity from scripts/lib/node-modules-integrity.js. The doctor + // remains read-only; we only present the structured probe results in + // the existing per-tool layout. + const integrityResult = probeIntegrityFn({ + repoRoot, + existsSyncFn, + statSyncFn, + targets: integrityTargets, + }); + const integrityMissingByTool = new Map(); + if (integrityResult && Array.isArray(integrityResult.missing)) { + for (const entry of integrityResult.missing) { + if (!integrityMissingByTool.has(entry.tool)) { + integrityMissingByTool.set(entry.tool, []); + } + integrityMissingByTool.get(entry.tool).push(entry); + } + } + + const lines = []; + let failed = 0; + + for (const tool of toolSpecs) { + const issues = []; + const checks = []; + + // Surface integrity probe entries scoped to this tool first. + const integrityEntries = integrityMissingByTool.get(tool.name) || []; + for (const entry of integrityEntries) { + if (entry.reason === "missing") { + issues.push(`missing required file ${entry.relPath}`); + } else if (entry.reason === "empty") { + issues.push(`${entry.relPath} is empty (size 0)`); + } else { + issues.push(`${entry.relPath} integrity probe: ${entry.reason}`); + } + } + + for (const requiredFile of tool.requiredFiles || []) { + const absPath = path.join(repoRoot, ...requiredFile.split("/")); + // Skip the existsSync check when integrity has already flagged + // this same path; avoids duplicate lines in the report. + const alreadyFlagged = integrityEntries.some((e) => e.relPath === requiredFile); + if (alreadyFlagged) { + continue; + } + if (!existsSyncFn(absPath)) { + issues.push(`missing required file ${requiredFile}`); + } else { + checks.push(`exists: ${requiredFile}`); + } + } + + if (tool.entry) { + const loadMode = tool.load || "exists"; + + if (loadMode === "resolve") { + // "resolve" entries are module specifiers (e.g. "jest-circus/runner"), + // resolved against the repo's package.json. This mirrors + // validate-node-tooling.js TOOL_SPECS semantics. + try { + const resolved = requireResolveFn(tool.entry); + if (!resolved) { + issues.push(`failed to resolve ${tool.entry}`); + } else if (!existsSyncFn(resolved)) { + issues.push(`resolved ${tool.entry} to missing path ${resolved}`); + } else { + checks.push(`resolved: ${tool.entry} -> ${path.relative(repoRoot, resolved) || resolved}`); + try { + requireFn(resolved); + checks.push(`loaded: ${tool.entry}`); + } catch (error) { + const detail = error && error.message ? error.message : String(error); + issues.push(`could not load ${tool.entry}: ${detail}`); + } + } + } catch (error) { + const detail = error && error.message ? error.message : String(error); + issues.push(`could not resolve ${tool.entry}: ${detail}`); + } + } else if (loadMode === "require") { + // "require" entries are repo-relative paths (e.g. + // "node_modules/prettier/index.cjs") that must be made + // absolute before require()-ing. + const absEntry = path.join(repoRoot, ...tool.entry.split("/")); + if (!existsSyncFn(absEntry)) { + issues.push(`require entry missing on disk: ${tool.entry}`); + } else { + try { + requireFn(absEntry); + checks.push(`required: ${tool.entry}`); + } catch (error) { + const detail = error && error.message ? error.message : String(error); + issues.push(`could not load ${tool.entry}: ${detail}`); + } + } + } else if (loadMode === "import") { + // ESM entries cannot be loaded synchronously here. The doctor + // is a synchronous report by design (see file header); we + // accept resolve+exists as the freshness signal for ESM + // entries and rely on validate-node-tooling.js to do the + // async import at preflight time. + const absEntry = path.join(repoRoot, ...tool.entry.split("/")); + if (!existsSyncFn(absEntry)) { + issues.push(`import entry missing on disk: ${tool.entry}`); + } else { + checks.push(`import entry exists (async import deferred to preflight): ${tool.entry}`); + } + } + } + + if (issues.length === 0) { + lines.push(` ok ${tool.name}`); + for (const check of checks) { + lines.push(` ${check}`); + } + } else { + failed += 1; + lines.push(` FAIL ${tool.name}`); + for (const issue of issues) { + lines.push(` - ${issue}`); + } + } + } + + if (failed > 0) { + lines.push(""); + lines.push(" Remediation: run `npm ci` to restore node_modules, then re-run `npm run doctor`."); + } + + return { + name: "node_modules freshness", + status: failed === 0 ? "ok" : "fail", + lines, + }; +} + +/** + * 2. Isolated managed-Jest cache audit. + * - Lists each install directory under ISOLATED_JEST_CACHE_ROOT. + * - For each, verifies jest-circus/runner resolution from inside that dir. + * - Flags entries older than STALE_CACHE_DAYS as stale. + * - Always prints the manual-reset command, even on a clean cache. + */ +function checkIsolatedJestCache(options = {}) { + const { + readdirSyncFn = fs.readdirSync, + statSyncFn = fs.statSync, + existsSyncFn = fs.existsSync, + createRequireFn = createRequire, + cacheRoot = ISOLATED_JEST_CACHE_ROOT, + nowMs = Date.now(), + staleMs = STALE_CACHE_MS, + } = options; + + const lines = []; + const resetCommand = + "node -e \"require('fs').rmSync(require('path').join(require('os').tmpdir(), 'dxmessaging-managed-jest'), { recursive: true, force: true })\""; + + if (!existsSyncFn(cacheRoot)) { + lines.push(` ok No isolated managed-Jest cache yet (${cacheRoot} does not exist).`); + lines.push(` Manual reset (safe even when absent): ${resetCommand}`); + return { + name: "isolated managed-Jest cache", + status: "ok", + lines, + }; + } + + let entries; + try { + entries = readdirSyncFn(cacheRoot, { withFileTypes: true }); + } catch (error) { + const detail = error && error.message ? error.message : String(error); + lines.push(` FAIL Could not read cache root ${cacheRoot}: ${detail}`); + lines.push(` Manual reset: ${resetCommand}`); + return { + name: "isolated managed-Jest cache", + status: "fail", + lines, + }; + } + + const installDirs = entries.filter((entry) => entry.isDirectory()); + + if (installDirs.length === 0) { + lines.push(` ok Cache root exists but contains no install dirs (${cacheRoot}).`); + lines.push(` Manual reset: ${resetCommand}`); + return { + name: "isolated managed-Jest cache", + status: "ok", + lines, + }; + } + + let staleCount = 0; + let runnerFailures = 0; + + for (const entry of installDirs) { + const installDir = path.join(cacheRoot, entry.name); + const packageJsonPath = path.join(installDir, "package.json"); + + let mtimeMs = null; + let ageDays = null; + let stale = false; + try { + const stats = statSyncFn(installDir); + mtimeMs = stats.mtimeMs; + ageDays = Math.floor((nowMs - mtimeMs) / (24 * 60 * 60 * 1000)); + stale = nowMs - mtimeMs > staleMs; + } catch (error) { + const detail = error && error.message ? error.message : String(error); + lines.push(` FAIL ${entry.name}: stat failed (${detail})`); + runnerFailures += 1; + continue; + } + + let runnerStatus = "ok"; + let runnerDetail = ""; + if (existsSyncFn(packageJsonPath)) { + try { + const isolatedRequire = createRequireFn(packageJsonPath); + const resolved = isolatedRequire.resolve("jest-circus/runner"); + if (!resolved || !existsSyncFn(resolved)) { + runnerStatus = "fail"; + runnerDetail = `jest-circus/runner resolved to missing path ${resolved || "(null)"}`; + runnerFailures += 1; + } else { + runnerDetail = `jest-circus/runner -> ${resolved}`; + } + } catch (error) { + runnerStatus = "fail"; + const message = error && error.message ? error.message : String(error); + runnerDetail = `jest-circus/runner resolve threw: ${message}`; + runnerFailures += 1; + } + } else { + runnerStatus = "fail"; + runnerDetail = `package.json missing at ${packageJsonPath}`; + runnerFailures += 1; + } + + const ageLabel = ageDays === null ? "age=?" : `age=${ageDays}d`; + const staleLabel = stale ? " STALE" : ""; + const tag = runnerStatus === "fail" ? "FAIL" : stale ? "warn" : "ok "; + lines.push(` ${tag} ${entry.name} (${ageLabel}${staleLabel})`); + lines.push(` ${runnerDetail}`); + + if (stale) { + staleCount += 1; + } + } + + lines.push(""); + lines.push(` Manual reset (clears every install dir under the cache root):`); + lines.push(` ${resetCommand}`); + + if (runnerFailures > 0) { + return { + name: "isolated managed-Jest cache", + status: "fail", + lines, + }; + } + + if (staleCount > 0) { + return { + name: "isolated managed-Jest cache", + status: "warn", + lines, + }; + } + + return { + name: "isolated managed-Jest cache", + status: "ok", + lines, + }; +} + +/** + * 3. EOL policy stats -- pure stats output, no mutation. Imports the shared + * constants from scripts/lib/eol-policy.js so a future drift between this + * section and the live policy surfaces immediately. + */ +function checkEolPolicy(options = {}) { + const { + // readFileSyncFn is accepted for parity with the other section + // signatures and so the test can prove the section does not read any + // file (the policy is a static constants module). + // eslint-disable-next-line no-unused-vars + readFileSyncFn = fs.readFileSync, + policy = eolPolicy, + } = options; + + const lines = []; + const crlfExts = [...policy.crlfExts].sort(); + const lfExts = [...policy.lfExts].sort(); + const total = crlfExts.length + lfExts.length; + + lines.push(` ok EOL policy loaded from scripts/lib/eol-policy.js`); + lines.push(` CRLF extensions (${crlfExts.length}): ${crlfExts.join(", ")}`); + lines.push(` LF extensions (${lfExts.length}): ${lfExts.join(", ")}`); + lines.push(` total tracked extensions: ${total}`); + + const overlapping = crlfExts.filter((ext) => policy.lfExts.has(ext)); + if (overlapping.length > 0) { + lines.push(` FAIL Extensions appear in BOTH crlfExts and lfExts: ${overlapping.join(", ")}`); + return { + name: "EOL policy", + status: "fail", + lines, + }; + } + + return { + name: "EOL policy", + status: "ok", + lines, + }; +} + +/** + * 4. Pre-commit config audit -- counts every hook, the pre-push subset, and + * cross-references with preflight:pre-push. Also tries to print + * `pre-commit --version` when the binary is on PATH. + */ +function checkPreCommitConfig(options = {}) { + const { + readFileSyncFn = fs.readFileSync, + parsePrecommitYaml = precommitYaml, + runCommandFn = defaultRunCommand, + configPath = PRECOMMIT_CONFIG_PATH, + packageJsonPath = PACKAGE_JSON_PATH, + // Pre-resolved pre-commit --version output. When provided, the section + // does not spawn a child process; this lets runDoctor consolidate the + // version probes into a single shared spawn (see runDoctor + M1). + preCommitVersionResult = null, + } = options; + + const lines = []; + let failed = false; + + let configContent; + try { + configContent = readFileSyncFn(configPath, "utf8"); + } catch (error) { + const detail = error && error.message ? error.message : String(error); + return { + name: "pre-commit config", + status: "fail", + lines: [` FAIL Could not read ${configPath}: ${detail}`], + }; + } + + const configLines = configContent.replace(/\r\n/g, "\n").split("\n"); + const blocks = parsePrecommitYaml.findAllHookBlocks(configLines); + + const prePushHookIds = []; + for (const block of blocks) { + const stages = parsePrecommitYaml.extractStagesFromHookBlock(block); + const effective = stages.length === 0 ? ["pre-commit"] : stages; + if (effective.includes("pre-push")) { + prePushHookIds.push(block.id); + } + } + + lines.push(` ok Parsed ${blocks.length} hook block(s) from ${path.relative(REPO_ROOT, configPath) || configPath}`); + lines.push(` pre-push hooks (${prePushHookIds.length}): ${prePushHookIds.join(", ") || "(none)"}`); + + const versionResult = preCommitVersionResult !== null + ? preCommitVersionResult + : runCommandFn("pre-commit", ["--version"]); + if (versionResult && versionResult.status === 0 && typeof versionResult.stdout === "string") { + lines.push(` pre-commit --version: ${versionResult.stdout.trim()}`); + } else if (versionResult && versionResult.error && versionResult.error.code === "ENOENT") { + // Without pre-commit on PATH, `npm run preflight:pre-push` cannot run + // its final `pre-commit run --hook-stage pre-push --all-files` step. + // The doctor's purpose is to predict whether a push will succeed; a + // missing pre-commit binary makes that impossible, so this is FAIL. + lines.push(" FAIL pre-commit --version: not on PATH (ENOENT). preflight:pre-push cannot run without it."); + lines.push(" Install pre-commit via pip (e.g. `pip install pre-commit`) then re-run `npm run doctor`."); + failed = true; + } else { + const detail = versionResult && versionResult.error && versionResult.error.message + ? versionResult.error.message + : `status=${versionResult ? versionResult.status : "null"}`; + lines.push(` FAIL pre-commit --version: not available (${detail}). preflight:pre-push cannot run without it.`); + failed = true; + } + + let packageJsonContent; + try { + packageJsonContent = readFileSyncFn(packageJsonPath, "utf8"); + } catch (error) { + const detail = error && error.message ? error.message : String(error); + lines.push(` FAIL Could not read ${packageJsonPath}: ${detail}`); + failed = true; + packageJsonContent = ""; + } + + let parsedPackageJson = null; + if (packageJsonContent) { + try { + parsedPackageJson = JSON.parse(packageJsonContent); + } catch (error) { + const detail = error && error.message ? error.message : String(error); + lines.push(` FAIL package.json is not valid JSON: ${detail}`); + failed = true; + } + } + + const scripts = (parsedPackageJson && parsedPackageJson.scripts) || {}; + const preflightPrePush = scripts["preflight:pre-push"]; + if (typeof preflightPrePush === "string" && preflightPrePush.length > 0) { + lines.push(` preflight:pre-push script present`); + if (!/pre-commit\s+run\s+--hook-stage\s+pre-push\s+--all-files/.test(preflightPrePush)) { + lines.push(` FAIL preflight:pre-push does not invoke 'pre-commit run --hook-stage pre-push --all-files'`); + failed = true; + } else { + lines.push(` coverage: invokes 'pre-commit run --hook-stage pre-push --all-files' (covers all ${prePushHookIds.length} pre-push hooks)`); + } + } else { + lines.push(` FAIL package.json scripts.preflight:pre-push is missing or empty`); + failed = true; + } + + return { + name: "pre-commit config", + status: failed ? "fail" : "ok", + lines, + }; +} + +/** + * 5. Hook-perf budget -- delegates entirely to scoreConfig() from + * precommit-perf-score.js. No reimplementation; we just summarize. + */ +function checkHookPerfBudget(options = {}) { + const { + readFileSyncFn = fs.readFileSync, + scoreConfigFn = precommitPerfScore.scoreConfig, + budget = precommitPerfScore.PERF_BUDGET, + perHookCeiling = precommitPerfScore.PER_HOOK_CEILING, + configPath = PRECOMMIT_CONFIG_PATH, + } = options; + + const lines = []; + + let content; + try { + content = readFileSyncFn(configPath, "utf8"); + } catch (error) { + const detail = error && error.message ? error.message : String(error); + return { + name: "hook-perf budget", + status: "fail", + lines: [` FAIL Could not read ${configPath}: ${detail}`], + }; + } + + const result = scoreConfigFn(content); + const total = result.totalScore || 0; + + let status = "ok"; + const indicator = total > budget ? "FAIL" : "ok "; + lines.push(` ${indicator} pre-commit perf score: ${total} (budget=${budget}, per-hook ceiling=${perHookCeiling})`); + + if (total > budget) { + status = "fail"; + lines.push(` score exceeds whole-pipeline budget`); + } + + if (result.perHookViolations && result.perHookViolations.length > 0) { + status = "fail"; + for (const violation of result.perHookViolations) { + lines.push(` FAIL per-hook ceiling violation: ${violation.id} scored ${violation.score} > ${violation.ceiling}`); + } + } + + if (result.rejections && result.rejections.length > 0) { + status = "fail"; + for (const rejection of result.rejections) { + lines.push(` FAIL rejected perf-allow directive on ${rejection.id} (line ${rejection.startLine}): ${rejection.error}`); + } + } + + if (result.allowList && result.allowList.length > 0) { + lines.push(` waivers in effect: ${result.allowList.length}`); + } + + return { + name: "hook-perf budget", + status, + lines, + }; +} + +/** + * 6. Cross-platform sanity -- platform string, pwsh + bash availability, + * package.json line-ending check. The shells are diagnostic only: pwsh + * being absent on Linux is a warning, not a failure. Bash being absent + * on Windows is also a warning (Git Bash usually ships it). Only the + * package.json line-ending check is a hard failure. + */ +function checkCrossPlatformSanity(options = {}) { + const { + platformFn = () => process.platform, + runCommandFn = defaultRunCommand, + readFileSyncFn = fs.readFileSync, + packageJsonPath = PACKAGE_JSON_PATH, + shellEnv = process.env.SHELL || "", + // Pre-resolved version probe results. When provided, the section + // does not spawn child processes for these probes; runDoctor shares + // the spawn output across header and section via this surface (M1). + pwshVersionResult = null, + bashVersionResult = null, + } = options; + + const lines = []; + let failed = false; + let warned = false; + + const platform = platformFn(); + lines.push(` ok process.platform: ${platform}`); + lines.push(` SHELL env: ${shellEnv || "(unset)"}`); + + const pwshResult = pwshVersionResult !== null + ? pwshVersionResult + : runCommandFn("pwsh", ["--version"]); + if (pwshResult && pwshResult.status === 0 && typeof pwshResult.stdout === "string") { + lines.push(` pwsh: ${pwshResult.stdout.trim().split("\n")[0]}`); + } else if (pwshResult && pwshResult.error && pwshResult.error.code === "ENOENT") { + lines.push(` pwsh: not installed (ENOENT) - acceptable on Linux/macOS`); + if (platform === "win32") { + warned = true; + } + } else { + const detail = pwshResult && pwshResult.error && pwshResult.error.message + ? pwshResult.error.message + : `status=${pwshResult ? pwshResult.status : "null"}`; + lines.push(` pwsh: not available (${detail})`); + if (platform === "win32") { + warned = true; + } + } + + const bashResult = bashVersionResult !== null + ? bashVersionResult + : runCommandFn("bash", ["--version"]); + if (bashResult && bashResult.status === 0 && typeof bashResult.stdout === "string") { + lines.push(` bash: ${bashResult.stdout.trim().split("\n")[0]}`); + } else if (bashResult && bashResult.error && bashResult.error.code === "ENOENT") { + lines.push(` bash: not installed (ENOENT)`); + // On Linux/macOS bash absence is unusual but not a hard fail for the + // doctor; pre-commit can still run hooks via system shells. + warned = true; + } else { + const detail = bashResult && bashResult.error && bashResult.error.message + ? bashResult.error.message + : `status=${bashResult ? bashResult.status : "null"}`; + lines.push(` bash: not available (${detail})`); + warned = true; + } + + let packageContent; + try { + packageContent = readFileSyncFn(packageJsonPath, "utf8"); + } catch (error) { + const detail = error && error.message ? error.message : String(error); + lines.push(` FAIL Could not read ${packageJsonPath} to check EOL: ${detail}`); + return { + name: "cross-platform sanity", + status: "fail", + lines, + }; + } + + if (packageContent.includes("\r\n")) { + lines.push(` FAIL package.json contains CRLF line endings; policy requires LF (.json is in lfExts).`); + failed = true; + } else { + lines.push(` ok package.json line endings: LF`); + } + + return { + name: "cross-platform sanity", + status: failed ? "fail" : warned ? "warn" : "ok", + lines, + }; +} + +/** + * 7. Working-tree state -- mirrors the validate-untracked-policy preflight + * step so the doctor can predict whether `npm run preflight:pre-push` will + * even *start*. Runs `git status --porcelain` (NUL-terminated via -z for + * safety with weird filenames) and inspects each entry: + * + * - Untracked paths ("??"): FAIL. validate-untracked-policy rejects + * these. The list of offending paths is included verbatim so an agent + * can either commit them or add them to .gitignore. + * - Modified-but-not-staged (" M", " D", " T", " R", " C"): WARN. + * preflight:pre-push does not fail on these, but flagging them helps + * the operator notice unintended drift. + * - Staged changes ("M ", "A ", "D ", etc.): OK. The push will carry + * these along. + * - Renames/copies ("R ", "C "): OK on the staged side; the destination + * path follows the arrow in -z output via a NUL pair. + * - Anything else: OK (status flags we do not inspect are still + * reported but never trigger fail/warn). + * + * The `git add -N` intent-to-add state shows as " A" (staged-ish) in + * `git status --porcelain`, which falls into the "modified-but-not-staged" + * bucket -- it will warn, but it will NOT fail. This matches + * validate-untracked-policy semantics: `git ls-files --others + * --exclude-standard` does not list intent-to-add files. + */ +function checkWorkingTreeState(options = {}) { + const { + runCommandFn = defaultRunCommand, + cwd = REPO_ROOT, + } = options; + + const lines = []; + + const result = runCommandFn( + "git", + ["-c", "core.quotepath=false", "status", "--porcelain"], + { cwd } + ); + + if (result && result.error) { + if (result.error.code === "ENOENT") { + lines.push(" FAIL git not found on PATH; cannot inspect working tree."); + return { + name: "working-tree state", + status: "fail", + lines, + }; + } + const detail = result.error.message ? result.error.message : String(result.error); + lines.push(` FAIL git status failed: ${detail}`); + return { + name: "working-tree state", + status: "fail", + lines, + }; + } + + if (!result || typeof result.status !== "number" || result.status !== 0) { + const stderr = result && result.stderr ? String(result.stderr).trim() : ""; + const statusVal = result && typeof result.status === "number" ? result.status : "null"; + lines.push(` FAIL git status exited with status ${statusVal}: ${stderr || "no stderr"}`); + return { + name: "working-tree state", + status: "fail", + lines, + }; + } + + const stdoutText = typeof result.stdout === "string" + ? result.stdout + : (result.stdout ? result.stdout.toString("utf8") : ""); + + if (stdoutText.length === 0) { + lines.push(" ok Working tree is clean (no untracked, modified, or staged paths)."); + return { + name: "working-tree state", + status: "ok", + lines, + }; + } + + // Each porcelain line: "XY path". Untracked is "?? path"; the first two + // characters are the index (X) and worktree (Y) statuses respectively. + // We split on \n (not \0) because we did not pass -z; weird filenames + // would be quoted, but we already set core.quotepath=false to keep them + // raw within their octet ranges. This matches validate-untracked-policy's + // semantics for the relevant case (untracked paths from porcelain "??"). + const untracked = []; + const modifiedUnstaged = []; + const staged = []; + + const porcelainLines = stdoutText.replace(/\r\n/g, "\n").split("\n"); + for (const rawLine of porcelainLines) { + if (rawLine.length === 0) { + continue; + } + // Porcelain v1: positions 0 and 1 are the X (index) and Y (worktree) + // codes; position 2 is a single space; positions 3+ are the path. + const x = rawLine.charAt(0); + const y = rawLine.charAt(1); + const pathPart = rawLine.slice(3); + + if (x === "?" && y === "?") { + untracked.push(pathPart); + continue; + } + + // X is a space means "no index entry" but the worktree differs from + // HEAD, i.e. unstaged modification/deletion/etc. Y non-space and + // non-"?" means worktree differs from index after the staged change. + const hasStagedChange = x !== " " && x !== "?"; + const hasUnstagedChange = y !== " " && y !== "?"; + + if (hasStagedChange) { + staged.push(`${x}${y} ${pathPart}`); + } + if (hasUnstagedChange) { + modifiedUnstaged.push(`${x}${y} ${pathPart}`); + } + } + + if (untracked.length > 0) { + lines.push(` FAIL ${untracked.length} untracked-and-unignored path(s) (validate-untracked-policy will reject these):`); + for (const entry of untracked) { + lines.push(` ?? ${entry}`); + } + lines.push(""); + lines.push(" Remediation: commit each path, add it to .gitignore (and .npmignore if it should not ship)"); + lines.push(" with a one-line rationale, OR run `git add -N ` to mark intent-to-add"); + lines.push(" (intent-to-add files are NOT listed by `git ls-files --others --exclude-standard`)."); + + // Also include staged + modified-unstaged for situational awareness, + // but only as informational lines below the FAIL diagnostic. + if (staged.length > 0) { + lines.push(""); + lines.push(` info ${staged.length} staged path(s) (will be included in the next commit):`); + for (const entry of staged) { + lines.push(` ${entry}`); + } + } + if (modifiedUnstaged.length > 0) { + lines.push(""); + lines.push(` info ${modifiedUnstaged.length} modified-but-unstaged path(s):`); + for (const entry of modifiedUnstaged) { + lines.push(` ${entry}`); + } + } + + return { + name: "working-tree state", + status: "fail", + lines, + }; + } + + if (modifiedUnstaged.length > 0) { + lines.push(` warn ${modifiedUnstaged.length} modified-but-unstaged path(s) (preflight does NOT fail on these, but the push will not carry them):`); + for (const entry of modifiedUnstaged) { + lines.push(` ${entry}`); + } + if (staged.length > 0) { + lines.push(""); + lines.push(` info ${staged.length} staged path(s) (will be included in the next commit):`); + for (const entry of staged) { + lines.push(` ${entry}`); + } + } + return { + name: "working-tree state", + status: "warn", + lines, + }; + } + + // Only staged or otherwise-clean content -> OK. + if (staged.length > 0) { + lines.push(` ok ${staged.length} staged path(s), no untracked or unstaged-modification entries:`); + for (const entry of staged) { + lines.push(` ${entry}`); + } + } else { + lines.push(" ok Working tree is clean (porcelain returned non-empty but nothing actionable)."); + } + + return { + name: "working-tree state", + status: "ok", + lines, + }; +} + +/** + * Default runCommandFn used by section probes. Wraps spawnSync via the + * cross-platform helper so npm/npx shimming on Windows is consistent with + * the rest of the scripts/. Returns a normalized object with status/error/ + * stdout/stderr keys regardless of whether the underlying spawn succeeded. + */ +function defaultRunCommand(command, args, options = {}) { + const spawnOptions = { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + ...options, + }; + + let result; + try { + result = shellCommand.spawnPlatformCommandSync( + command, + args, + spawnOptions, + childProcess.spawnSync + ); + } catch (error) { + return { status: null, error, stdout: "", stderr: "" }; + } + + if (!result) { + return { status: null, error: new Error("spawnSync returned null"), stdout: "", stderr: "" }; + } + + return { + status: typeof result.status === "number" ? result.status : null, + error: result.error || null, + stdout: typeof result.stdout === "string" ? result.stdout : (result.stdout ? result.stdout.toString("utf8") : ""), + stderr: typeof result.stderr === "string" ? result.stderr : (result.stderr ? result.stderr.toString("utf8") : ""), + }; +} + +/** + * Spawn every external version probe the doctor needs exactly once and + * return a memoized lookup. Sections + the header banner consume the + * pre-resolved entries through their `*VersionResult` options so we do not + * fork the same subprocess multiple times per doctor run (M1). + * + * @param {{runCommandFn?: Function}} [options] + * @returns {{npm: object, preCommit: object, pwsh: object, bash: object}} + */ +function probeToolVersions(options = {}) { + const runCommandFn = options.runCommandFn || defaultRunCommand; + return { + npm: runCommandFn("npm", ["--version"]), + preCommit: runCommandFn("pre-commit", ["--version"]), + pwsh: runCommandFn("pwsh", ["--version"]), + bash: runCommandFn("bash", ["--version"]), + }; +} + +/** + * Run every doctor section in sequence, returning the aggregated structure. + * No I/O on the result -- printing is the caller's job (see main()). + * + * @param {object} [opts] + * @param {object} [opts.overrides] Per-section dependency-injection overrides + * keyed by the section function name. Tests use this to drive the whole + * doctor in one call with deterministic fakes. + * @param {object} [opts.versionProbes] Pre-resolved version-probe results + * (see probeToolVersions). When omitted, every section spawns its own + * probes; tests can supply deterministic fakes here. + */ +function runDoctor({ overrides = {}, versionProbes = null } = {}) { + const preCommitOverrides = { ...(overrides.checkPreCommitConfig || {}) }; + const crossPlatformOverrides = { ...(overrides.checkCrossPlatformSanity || {}) }; + + if (versionProbes) { + if (preCommitOverrides.preCommitVersionResult === undefined) { + preCommitOverrides.preCommitVersionResult = versionProbes.preCommit; + } + if (crossPlatformOverrides.pwshVersionResult === undefined) { + crossPlatformOverrides.pwshVersionResult = versionProbes.pwsh; + } + if (crossPlatformOverrides.bashVersionResult === undefined) { + crossPlatformOverrides.bashVersionResult = versionProbes.bash; + } + } + + const sections = [ + checkNodeModulesFreshness(overrides.checkNodeModulesFreshness || {}), + checkIsolatedJestCache(overrides.checkIsolatedJestCache || {}), + checkEolPolicy(overrides.checkEolPolicy || {}), + checkPreCommitConfig(preCommitOverrides), + checkHookPerfBudget(overrides.checkHookPerfBudget || {}), + checkCrossPlatformSanity(crossPlatformOverrides), + checkWorkingTreeState(overrides.checkWorkingTreeState || {}), + ]; + + const overall = aggregateStatus(sections); + return { + status: statusToExitCode(overall), + overall, + sections, + }; +} + +function decorateStatusLabel(status, useColor) { + const label = + status === "fail" ? "[FAIL]" : status === "warn" ? "[WARN]" : "[ OK ]"; + if (!useColor) { + return label; + } + if (status === "fail") return `${ANSI_RED}${label}${ANSI_RESET}`; + if (status === "warn") return `${ANSI_YELLOW}${label}${ANSI_RESET}`; + return `${ANSI_GREEN}${label}${ANSI_RESET}`; +} + +/** + * Decide whether to emit ANSI color escapes. Precedence (highest first): + * + * 1. NO_COLOR truthy -> never color. This honors the conventional + * NO_COLOR opt-out (https://no-color.org/) even on a TTY. + * 2. FORCE_COLOR truthy -> always color, even when stdout is not a TTY. + * Useful when piping through `tee` while preserving the visual + * diagnostic. + * 3. CI truthy -> never color (legacy behavior; many CI viewers strip + * escapes anyway). + * 4. Otherwise: color iff stdout is a TTY. + */ +function shouldUseColor({ + envCi = process.env.CI, + envNoColor = process.env.NO_COLOR, + envForceColor = process.env.FORCE_COLOR, + isTTY = Boolean(process.stdout && process.stdout.isTTY), +} = {}) { + if (isTruthyEnv(envNoColor)) { + return false; + } + if (isTruthyEnv(envForceColor)) { + return true; + } + if (isTruthyEnv(envCi)) { + return false; + } + return isTTY; +} + +function formatHeaderBanner({ + repoRoot = REPO_ROOT, + nodeVersion = process.version, + platform = process.platform, + arch = process.arch, + shellEnv = process.env.SHELL || "", + npmVersion = null, + // Pre-resolved npm --version probe result (see probeToolVersions). When + // provided, the banner does not spawn its own child process. + npmVersionResult = null, + runCommandFn = defaultRunCommand, +} = {}) { + let resolvedNpmVersion = npmVersion; + if (resolvedNpmVersion === null) { + const result = npmVersionResult !== null + ? npmVersionResult + : runCommandFn("npm", ["--version"]); + if (result && result.status === 0 && typeof result.stdout === "string") { + resolvedNpmVersion = result.stdout.trim(); + } else { + resolvedNpmVersion = "(unknown)"; + } + } + + const horizontal = "=".repeat(72); + return [ + horizontal, + "DxMessaging doctor: read-only preflight diagnostic", + horizontal, + `Repo: ${repoRoot}`, + `Node: ${nodeVersion}`, + `npm: ${resolvedNpmVersion}`, + `OS: ${platform}/${arch}`, + `Shell: ${shellEnv || "(unset)"}`, + horizontal, + ].join("\n"); +} + +function formatSection(section, useColor) { + const horizontal = "-".repeat(72); + const label = decorateStatusLabel(section.status, useColor); + const titleLine = `${label} ${section.name}`; + const decoratedTitle = useColor ? `${ANSI_BOLD}${titleLine}${ANSI_RESET}` : titleLine; + const out = [horizontal, decoratedTitle]; + for (const line of section.lines) { + out.push(line); + } + return out.join("\n"); +} + +function formatFooter(sections, overall, useColor) { + const horizontal = "=".repeat(72); + const lines = [horizontal, "Summary:"]; + for (const section of sections) { + const label = decorateStatusLabel(section.status, useColor); + lines.push(` ${label} ${section.name}`); + } + const overallLabel = decorateStatusLabel(overall, useColor); + lines.push(horizontal); + lines.push(`Overall: ${overallLabel}`); + lines.push(horizontal); + return lines.join("\n"); +} + +function main({ + writeFn = (text) => process.stdout.write(text), + envCi = process.env.CI, + envNoColor = process.env.NO_COLOR, + envForceColor = process.env.FORCE_COLOR, + isTTY = Boolean(process.stdout && process.stdout.isTTY), + runDoctorFn = runDoctor, + runCommandFn = defaultRunCommand, + probeToolVersionsFn = probeToolVersions, +} = {}) { + const useColor = shouldUseColor({ envCi, envNoColor, envForceColor, isTTY }); + + // M1: spawn npm/pre-commit/pwsh/bash --version exactly once per doctor + // run and thread the results into the header banner and the relevant + // sections, instead of forking these subprocesses repeatedly. + const versionProbes = probeToolVersionsFn({ runCommandFn }); + + writeFn(`${formatHeaderBanner({ runCommandFn, npmVersionResult: versionProbes.npm })}\n`); + + const report = runDoctorFn({ versionProbes }); + for (const section of report.sections) { + writeFn(`${formatSection(section, useColor)}\n`); + } + writeFn(`${formatFooter(report.sections, report.overall, useColor)}\n`); + + return report.status; +} + +module.exports = { + STALE_CACHE_DAYS, + STALE_CACHE_MS, + KNOWN_STATUSES, + aggregateStatus, + statusRank, + statusToExitCode, + checkNodeModulesFreshness, + checkIsolatedJestCache, + checkEolPolicy, + checkPreCommitConfig, + checkHookPerfBudget, + checkCrossPlatformSanity, + checkWorkingTreeState, + defaultRunCommand, + probeToolVersions, + runDoctor, + shouldUseColor, + decorateStatusLabel, + formatHeaderBanner, + formatSection, + formatFooter, + main, +}; + +if (require.main === module) { + const exitCode = main(); + process.exit(exitCode); +} diff --git a/scripts/doctor.js.meta b/scripts/doctor.js.meta new file mode 100644 index 00000000..2718879e --- /dev/null +++ b/scripts/doctor.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1338f8b014c5d1ddfcfd27f8d4e73350 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/generate-ambiguous-release-runbook.js b/scripts/generate-ambiguous-release-runbook.js new file mode 100644 index 00000000..649adcf4 --- /dev/null +++ b/scripts/generate-ambiguous-release-runbook.js @@ -0,0 +1,150 @@ +#!/usr/bin/env node + +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const ROOT_DIR = path.resolve(__dirname, ".."); +const OUTPUT_RELATIVE_PATH = ".operator-runbooks/ambiguous-release-setup.md"; +const TRACKED_RUNBOOK_SOURCES = [ + { + title: "Ambiguous release migration operator guide", + relativePath: "docs/ops/ambiguous-release-migration.md" + }, + { + title: "Release operations", + relativePath: "docs/ops/release-operations.md" + }, + { + title: "GitHub transfer", + relativePath: "docs/ops/github-transfer.md" + }, + { + title: "CI and GitHub settings", + relativePath: "docs/ops/ci-and-github-settings.md" + }, + { + title: "npm release publishing", + relativePath: "docs/ops/npm-release-publishing.md" + }, + { + title: "OpenUPM metadata", + relativePath: "docs/ops/openupm-metadata.md" + }, + { + title: "Unity Asset Store UPM", + relativePath: "docs/ops/unity-asset-store-upm.md" + }, + { + title: "Post-transfer verification", + relativePath: "docs/ops/post-transfer-verification.md" + } +]; + +function generateRunbookContent() { + return `# Ambiguous Release Setup Runbook + +This ignored local runbook is generated by \`scripts/generate-ambiguous-release-runbook.js\`. + +Use it only for non-sensitive public execution notes during the Ambiguous release migration. Do not paste secrets, account screenshots, publisher account identifiers, recovery codes, tokens, private account metadata, private contact details, publisher portal notes, provider configuration state, private review or approval details, draft-release links, or sensitive local status into tracked files or this local runbook. + +Tracked source documents to follow: + +${TRACKED_RUNBOOK_SOURCES.map((source) => `- \`${source.relativePath}\``).join("\n")} + +## Public Verification Notes + +- GitHub repository URL: +- GitHub Pages URL: +- Latest public release tag: +- npm package URL: +- OpenUPM package URL: +- Public OpenUPM PR URL: +- Public Unity Asset Store listing URL, if published: + +## Public Follow-Up Links + +- Public issue or PR: +- Public release notes: +- Public documentation update: + +## Non-Sensitive Next Actions + +- [ ] +`; +} + +function generateRunbook(options = {}) { + const rootDir = options.rootDir || ROOT_DIR; + const outputPath = options.outputPath || path.join(rootDir, OUTPUT_RELATIVE_PATH); + const force = options.force === true; + const content = generateRunbookContent(); + + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + try { + fs.writeFileSync(outputPath, content, { + encoding: "utf8", + flag: force ? "w" : "wx" + }); + } catch (error) { + if (!force && error && error.code === "EEXIST") { + const runbookExistsError = new Error( + `${path.relative(rootDir, outputPath).split(path.sep).join("/")} already exists. Re-run with --force to overwrite.` + ); + runbookExistsError.code = "RUNBOOK_EXISTS"; + throw runbookExistsError; + } + + throw error; + } + + return outputPath; +} + +function main(argv = process.argv.slice(2)) { + if (argv.includes("--help") || argv.includes("-h")) { + console.log("Usage: node scripts/generate-ambiguous-release-runbook.js [--force]"); + console.log(`Writes ${OUTPUT_RELATIVE_PATH} from tracked template content.`); + console.log("By default, refuses to overwrite an existing runbook."); + return 0; + } + + let force = false; + for (const arg of argv) { + if (arg === "--force") { + force = true; + continue; + } + + console.error(`Unknown argument: ${arg}`); + return 1; + } + + let outputPath; + try { + outputPath = generateRunbook({ force }); + } catch (error) { + if (error && error.code === "RUNBOOK_EXISTS") { + console.error(error.message); + return 1; + } + + throw error; + } + + console.log(`Generated ${path.relative(ROOT_DIR, outputPath).split(path.sep).join("/")}`); + return 0; +} + +if (require.main === module) { + process.exitCode = main(); +} + +module.exports = { + OUTPUT_RELATIVE_PATH, + TRACKED_RUNBOOK_SOURCES, + generateRunbook, + generateRunbookContent, + main +}; diff --git a/scripts/generate-ambiguous-release-runbook.js.meta b/scripts/generate-ambiguous-release-runbook.js.meta new file mode 100644 index 00000000..5339724f --- /dev/null +++ b/scripts/generate-ambiguous-release-runbook.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b07aa0b108ffd7147937043eaa71cd7e +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/generate-skills-index.js b/scripts/generate-skills-index.js index 19ac03d4..b0b7e82c 100644 --- a/scripts/generate-skills-index.js +++ b/scripts/generate-skills-index.js @@ -28,14 +28,16 @@ const { stripMatchingBoundaryQuotes, normalizeToLf, } = require("./lib/quote-parser"); -const { spawnPlatformCommandSync } = require("./lib/shell-command"); const { getPinnedPrettierSpec } = require("./lib/prettier-version"); +const { loadLocalPrettier, runBundledNpxCommand } = require("./lib/managed-prettier"); const SKILLS_DIR = path.join(__dirname, "..", ".llm", "skills"); const INDEX_PATH = path.join(SKILLS_DIR, "index.md"); const EXCLUDED_FILES = ["index.md", "specification.md"]; const EXCLUDED_DIRS = ["templates"]; const REPO_ROOT = path.join(__dirname, ".."); +const PRETTIER_LOCAL_BIN = path.join(REPO_ROOT, "node_modules", "prettier", "bin", "prettier.cjs"); +const PRETTIER_LOCAL_MODULE = path.join(REPO_ROOT, "node_modules", "prettier", "index.cjs"); /** * Brand name capitalization mapping. @@ -123,33 +125,68 @@ function getLatestSkillDate(skills) { return new Date().toISOString().split("T")[0]; } -function formatWithPrettier(content, spawnSyncImpl = spawnPlatformCommandSync) { - const prettierSpec = getPinnedPrettierSpec(); - const prettierArgs = [ - "--yes", - `--package=${prettierSpec}`, - "prettier", - "--stdin-filepath", - INDEX_PATH, - ]; - - // Keep the base command token here; platform-specific shim resolution belongs in spawnPlatformCommandSync. - const result = spawnSyncImpl("npx", prettierArgs, { - cwd: REPO_ROOT, - input: content, - encoding: "utf8", +async function formatWithPrettier(content, options = {}) { + const { + loadLocalPrettierFn = loadLocalPrettier, + runBundledNpxCommandFn = runBundledNpxCommand, + getPinnedPrettierSpecFn = getPinnedPrettierSpec, + prettierModulePath = PRETTIER_LOCAL_MODULE, + prettierBinPath = PRETTIER_LOCAL_BIN, + indexPath = INDEX_PATH, + repoRoot = REPO_ROOT, + } = options; + + const prettier = loadLocalPrettierFn({ + localPrettierModulePath: prettierModulePath, + localPrettierBinPath: prettierBinPath, }); + if (prettier) { + const optionsFromConfig = await prettier.resolveConfig(indexPath, { editorconfig: true }); + const fileInfo = await prettier.getFileInfo(indexPath, { + resolveConfig: false, + }); + + if (fileInfo.ignored) { + return content; + } + + return prettier.format(content, { + ...(optionsFromConfig || {}), + filepath: indexPath, + }); + } + + const prettierSpec = getPinnedPrettierSpecFn(); + const result = runBundledNpxCommandFn( + [ + "--yes", + `--package=${prettierSpec}`, + "prettier", + "--stdin-filepath", + indexPath, + ], + { + cwd: repoRoot, + input: content, + encoding: "utf8", + } + ); + if (result.error) { throw result.error; } if (result.status !== 0) { - const details = result.stderr ? result.stderr.trim() : "Unknown error"; - throw new Error(`Prettier failed: ${details}`); + const details = result.stderr + ? result.stderr.trim() + : result.stdout + ? result.stdout.trim() + : "Unknown error"; + throw new Error(`Prettier fallback via bundled npm npx-cli.js failed: ${details}`); } - return result.stdout; + return typeof result.stdout === "string" ? result.stdout : content; } /** @@ -478,7 +515,7 @@ function generateIndex(skills) { /** * Main entry point. */ -function main() { +async function main() { const args = process.argv.slice(2); const checkOnly = args.includes("--check"); @@ -494,7 +531,7 @@ function main() { let formattedContent; try { - formattedContent = formatWithPrettier(indexContent); + formattedContent = await formatWithPrettier(indexContent); } catch (error) { console.error(`Failed to format index with Prettier: ${error.message}`); return 1; @@ -539,5 +576,11 @@ if (typeof module !== 'undefined' && module.exports) { // Only run main when executed directly (not when required as a module) if (require.main === module) { - process.exit(main()); + main().then( + (code) => process.exit(code), + (error) => { + console.error(`generate-skills-index: fatal error: ${error.stack || error.message}`); + process.exit(1); + } + ); } diff --git a/scripts/lib/__tests__.meta b/scripts/lib/__tests__.meta new file mode 100644 index 00000000..05b3472b --- /dev/null +++ b/scripts/lib/__tests__.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d67e7eb34f8fabd4d94b1a6577882f47 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/lib/__tests__/source-stripping.test.js b/scripts/lib/__tests__/source-stripping.test.js new file mode 100644 index 00000000..0c9afbe8 --- /dev/null +++ b/scripts/lib/__tests__/source-stripping.test.js @@ -0,0 +1,263 @@ +/** + * @fileoverview Unit tests for scripts/lib/source-stripping.js. + * + * Pins behavior used by the `--testRunner` injection policy guards. If these + * tests fail, the static-analysis tests that depend on this helper will + * produce false positives or false negatives. + * + * Covers in particular the bypass-class fixed in the state-machine tokenizer + * rewrite: a block-comment regex that matched across string literals could + * be defeated by source like `const a = "/" + "*"; ...; const b = "*" + "/";` + * (with the tokens spelled as actual `/*` and `*\/` inside strings). The + * tokenizer recognizes such tokens as STRING CONTENT and preserves the code + * between the strings. + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const { stripJsCommentsAndStrings } = require("../source-stripping"); + +const REPO_ROOT = path.resolve(__dirname, "..", "..", ".."); +const RUN_MANAGED_JEST_PATH = path.join(REPO_ROOT, "scripts", "run-managed-jest.js"); + +describe("stripJsCommentsAndStrings", () => { + test("empty input returns empty output", () => { + expect(stripJsCommentsAndStrings("")).toBe(""); + }); + + test("non-string input returns empty string", () => { + expect(stripJsCommentsAndStrings(undefined)).toBe(""); + expect(stripJsCommentsAndStrings(null)).toBe(""); + expect(stripJsCommentsAndStrings(42)).toBe(""); + expect(stripJsCommentsAndStrings(Buffer.from("x"))).toBe(""); + }); + + test("all-whitespace input returns the same whitespace", () => { + expect(stripJsCommentsAndStrings(" \n\t\n")).toBe(" \n\t\n"); + }); + + test("block comments are removed", () => { + const source = "var a = 1; /* keep --testRunner out */ var b = 2;"; + const stripped = stripJsCommentsAndStrings(source); + expect(stripped).not.toContain("--testRunner"); + expect(stripped).toContain("var a = 1;"); + expect(stripped).toContain("var b = 2;"); + }); + + test("multi-line block comments are removed but preserve newlines", () => { + const source = [ + "var a = 1;", + "/*", + " * --testRunner banner explaining the regression", + " */", + "var b = 2;", + ].join("\n"); + const stripped = stripJsCommentsAndStrings(source); + expect(stripped).not.toContain("--testRunner"); + // Line count is preserved so error messages keep their line numbers. + expect(stripped.split("\n").length).toBe(source.split("\n").length); + }); + + test("line comments are removed", () => { + const source = "var a = 1; // forbid --testRunner here\nvar b = 2;"; + const stripped = stripJsCommentsAndStrings(source); + expect(stripped).not.toContain("--testRunner"); + expect(stripped).toContain("var a = 1;"); + expect(stripped).toContain("var b = 2;"); + }); + + test("single-quoted string literals are reduced to empty quotes", () => { + const source = "var a = 'hello --testRunner world';"; + const stripped = stripJsCommentsAndStrings(source); + expect(stripped).not.toContain("--testRunner"); + expect(stripped).toContain("''"); + }); + + test("double-quoted string literals are reduced to empty quotes", () => { + const source = "var a = \"hello --testRunner world\";"; + const stripped = stripJsCommentsAndStrings(source); + expect(stripped).not.toContain("--testRunner"); + expect(stripped).toContain("\"\""); + }); + + test("backtick template strings preserve backticks and erase literal portions", () => { + const source = "var a = `hello --testRunner world`;"; + const stripped = stripJsCommentsAndStrings(source); + expect(stripped).not.toContain("--testRunner"); + expect(stripped).toContain("``"); + }); + + test("escaped quotes inside strings are handled", () => { + const singleQuoted = "var a = 'it\\'s --testRunner';"; + const doubleQuoted = "var a = \"he said \\\"--testRunner\\\"\";"; + const backtickQuoted = "var a = `he said \\`--testRunner\\``;"; + + expect(stripJsCommentsAndStrings(singleQuoted)).not.toContain("--testRunner"); + expect(stripJsCommentsAndStrings(doubleQuoted)).not.toContain("--testRunner"); + expect(stripJsCommentsAndStrings(backtickQuoted)).not.toContain("--testRunner"); + }); + + test("URL-like substrings (http://) inside strings survive stripping pipeline", () => { + const source = "var a = \"https://example.com/path\";\nvar b = 2;"; + const stripped = stripJsCommentsAndStrings(source); + expect(stripped).toContain("var a ="); + expect(stripped).toContain("var b = 2;"); + // String payload is removed; no URL leakage either way. + expect(stripped).not.toContain("example.com"); + }); + + test("identifier ending in `https` followed by `+ \"://...\"` is not truncated", () => { + // The regex pipeline used a `[^:\\]` lookbehind hack to avoid eating + // `://`. The tokenizer does not need that hack because it tokenizes + // each construct in order; `//` only triggers `lineComment` when the + // characters appear in `code` state. + const source = "var a = https + \"://example.com\";"; + const stripped = stripJsCommentsAndStrings(source); + expect(stripped).toContain("https +"); + }); + + test("mixed real-world source from run-managed-jest.js is processed without throwing", () => { + const source = fs.readFileSync(RUN_MANAGED_JEST_PATH, "utf8"); + const stripped = stripJsCommentsAndStrings(source); + + expect(typeof stripped).toBe("string"); + expect(stripped.length).toBeGreaterThan(0); + + // The wrapper documents `--testRunner` in comments and forwards it + // via caller args — both are in comments or string literals. After + // stripping, the literal must NOT appear in remaining code. + expect(stripped).not.toContain("--testRunner"); + }); + + test("multiple constructs on a single line are all stripped", () => { + const source = "var a = \"--testRunner\"; /* --testRunner */ // --testRunner"; + const stripped = stripJsCommentsAndStrings(source); + expect(stripped).not.toContain("--testRunner"); + }); + + // ---- Bypass-class regression tests ---------------------------------- + + test("PoC: strings containing /* and */ do not cause code between them to be erased", () => { + // This is the C1 bypass that the regex-pipeline form was vulnerable to. + // The old `/\/\*[\s\S]*?\*\//g` regex would match from the `/*` inside + // the first string to the `*/` inside the last string, eating the + // entire function body. + const source = [ + "const _opener = \"/*\";", + "function inject(args) {", + " args.push(\"--testRunner\", \"/tmp/x.js\");", + "}", + "const _closer = \"*/\";", + ].join("\n"); + const stripped = stripJsCommentsAndStrings(source); + + // Real code between the strings MUST be preserved. + expect(stripped).toContain("function inject(args)"); + expect(stripped).toContain("args.push("); + + // String contents are erased; the literal `--testRunner` (which was + // inside a string) does not survive. + expect(stripped).not.toContain("--testRunner"); + expect(stripped).not.toContain("/tmp/x.js"); + }); + + test("strings containing // do not cause subsequent code to be treated as line comment", () => { + const source = [ + "const url = \"http://example.com\";", + "var a = 1;", + ].join("\n"); + const stripped = stripJsCommentsAndStrings(source); + expect(stripped).toContain("var a = 1;"); + }); + + test("block comments containing \"foo\" do not cause subsequent strings to be mis-parsed", () => { + const source = [ + "/* a block comment with \"foo\" inside */", + "var a = \"--testRunner\";", + "var b = 2;", + ].join("\n"); + const stripped = stripJsCommentsAndStrings(source); + expect(stripped).not.toContain("--testRunner"); + expect(stripped).not.toContain("foo"); + expect(stripped).toContain("var a ="); + expect(stripped).toContain("var b = 2;"); + }); + + test("template literal ${} expressions are processed as code; inner strings are blanked", () => { + // `foo ${"bar"} baz` — the literal portions and `"bar"` payload are + // erased, but the `${` and `}` delimiters survive so static analysis + // can recognize the expression boundary. + const source = "var a = `foo ${\"bar\"} baz ${\"--testRunner\"}`;"; + const stripped = stripJsCommentsAndStrings(source); + expect(stripped).not.toContain("--testRunner"); + expect(stripped).not.toContain("bar"); + expect(stripped).not.toContain("foo"); + // Expression delimiters preserved. + expect(stripped).toContain("${"); + expect(stripped).toContain("}"); + }); + + test("template literal with nested braces in ${} expression tracks brace depth", () => { + // `${ { a: "bar" } }` — the inner object braces should NOT prematurely + // pop the templateExpr state. + const source = "var a = `pre ${ { a: \"--testRunner\" } } post`;"; + const stripped = stripJsCommentsAndStrings(source); + expect(stripped).not.toContain("--testRunner"); + expect(stripped).toContain("${"); + // The object braces survive (they are code, not string). + expect(stripped).toMatch(/\{\s*a\s*:\s*""\s*\}/); + }); + + test("nested template literal inside ${} expression is handled", () => { + // `outer ${ `inner ${"x"}` } end` — every layer must be tokenized. + const source = "var a = `outer ${ `inner ${\"--testRunner\"}` } end`;"; + const stripped = stripJsCommentsAndStrings(source); + expect(stripped).not.toContain("--testRunner"); + expect(stripped).not.toContain("outer"); + expect(stripped).not.toContain("inner"); + expect(stripped).not.toContain("end"); + }); + + test("comment inside ${} expression is stripped", () => { + const source = "var a = `pre ${ /* --testRunner */ x } post`;"; + const stripped = stripJsCommentsAndStrings(source); + expect(stripped).not.toContain("--testRunner"); + expect(stripped).toContain("x"); + }); + + test("unterminated block comment does not throw and consumes rest of input", () => { + const source = "var a = 1; /* unterminated\nvar b = 2;"; + // Behavior: enter blockComment at `/*` and never exit. All subsequent + // characters are erased (line breaks are preserved). The function + // must return cleanly. + const stripped = stripJsCommentsAndStrings(source); + expect(typeof stripped).toBe("string"); + expect(stripped).toContain("var a = 1;"); + expect(stripped).not.toContain("--testRunner"); + }); + + test("unterminated string does not throw and consumes rest of input", () => { + const source = "var a = \"unterminated\nvar b = 2;"; + const stripped = stripJsCommentsAndStrings(source); + expect(typeof stripped).toBe("string"); + expect(stripped).toContain("var a ="); + }); + + test("line count is preserved across stripping", () => { + const source = [ + "var a = 1;", + "/* multi", + " line", + " block */", + "var b = \"multi\\n", + " line\";", + "// trailing", + "var c = 3;", + ].join("\n"); + const stripped = stripJsCommentsAndStrings(source); + expect(stripped.split("\n").length).toBe(source.split("\n").length); + }); +}); diff --git a/scripts/lib/__tests__/source-stripping.test.js.meta b/scripts/lib/__tests__/source-stripping.test.js.meta new file mode 100644 index 00000000..f8bedfc2 --- /dev/null +++ b/scripts/lib/__tests__/source-stripping.test.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3d056a835ae9734f005103ada95a436c +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/lib/integrity-gate-with-recovery.js b/scripts/lib/integrity-gate-with-recovery.js new file mode 100644 index 00000000..66410054 --- /dev/null +++ b/scripts/lib/integrity-gate-with-recovery.js @@ -0,0 +1,440 @@ +"use strict"; + +/** + * integrity-gate-with-recovery.js + * + * Shared "probe node_modules integrity, attempt npm-ci recovery, re-probe" + * gate. Consumed by every managed-tool wrapper (run-managed-jest, + * run-managed-prettier, run-managed-cspell) so the auto-repair flow is + * identical and not duplicated per wrapper. + * + * Behavior: + * 1. Probe in-process via probeIntegrityFn. If ok -> return success. + * 2. Consult isAutoRepairAllowedFn. If refused -> print banner via the + * decoder's PARTIAL_NODE_MODULES_INSTALL entry and return failure + * without invoking npm ci. + * 3. Warn naming the missing files. Call attemptNpmCiRecoveryFn. + * If npm ci status non-zero -> print banner, return failure. + * 4. Re-probe via probeIntegrityInSubprocessFn (defeats parent's stat + * cache). If subprocess probe ok -> return success with didRecover. + * Otherwise -> print banner, return failure. + * + * Opt-outs (the wrapper's caller threads these through): + * - DXMSG_HOOK_NO_AUTOREPAIR=1 -> isAutoRepairAllowedFn returns false. + * - DXMSG_HOOK_SKIP_INTEGRITY=1 -> the wrapper bypasses the gate entirely; + * this function is never called in that mode. + * - DXMSG_HOOK_AGGRESSIVE_RECOVERY=1 -> attemptNpmCiRecoveryFn rm-rf's + * node_modules before npm ci. + */ + +const { isTruthyEnv } = require("./jest-error-decoder"); +const { + findZeroByteNativeBinaries, + formatIntegrityFailure, + probeResolverHealth, +} = require("./node-modules-integrity"); + +/** + * Decide whether auto-repair (npm ci) is safe to run. + * + * Refusal cases are checked in this exact order (any one is enough); the + * JSDoc bullets MUST mirror the code below so reviewers can diff the policy + * top-to-bottom against the implementation: + * 1. DXMSG_HOOK_NO_AUTOREPAIR=1 -> caller asked us not to touch + * node_modules. Operator override; cheapest check first. + * 2. getNpmMajorVersionFn() returns null -> npm is unavailable or broken; + * we have no way to repair. + * 3. .git/rebase-merge or .git/rebase-apply exists -> we are mid-rebase; + * touching node_modules could clobber a partially-resolved state. + * 4. `git diff --quiet -- package-lock.json` exits non-zero -> the + * lockfile has unstaged changes; `npm ci` would refuse anyway, but + * surfacing the refusal here gives a clearer error. A null/error + * result from the spawn is also treated as refusal (defense in depth). + * + * Deliberate non-decision: we do NOT special-case CI environments (CI, + * GITHUB_ACTIONS, etc.). `npm ci` is the correct repair in CI too because the + * lockfile is committed and reproducible. Operators who want CI to fail + * rather than auto-repair set `DXMSG_HOOK_NO_AUTOREPAIR=1` on the runner. + * + * @param {object} options + * @param {object} options.env Process env (process.env or fake). + * @param {string} options.repoRoot Absolute path to repo root. + * @param {Function} options.getNpmMajorVersionFn Returns npm major version or null. + * @param {Function} [options.existsSyncFn] Override fs.existsSync (defaults to fs). + * @param {Function} [options.spawnPlatformCommandSyncFn] Override + * spawnPlatformCommandSync (defaults to scripts/lib/shell-command). + * @returns {{allowed: boolean, reason: string|null}} + */ +function isAutoRepairAllowed(options) { + const { + env, + repoRoot, + getNpmMajorVersionFn, + existsSyncFn = require("fs").existsSync, + spawnPlatformCommandSyncFn = require("./shell-command").spawnPlatformCommandSync, + path: pathModule = require("path"), + } = options; + + if (env && isTruthyEnv(env.DXMSG_HOOK_NO_AUTOREPAIR)) { + return { allowed: false, reason: "DXMSG_HOOK_NO_AUTOREPAIR=1 set" }; + } + + const npmMajor = getNpmMajorVersionFn(); + if (npmMajor === null || typeof npmMajor !== "number") { + return { allowed: false, reason: "npm executable unavailable (getNpmMajorVersion returned null)" }; + } + + const gitDir = pathModule.join(repoRoot, ".git"); + if (existsSyncFn(pathModule.join(gitDir, "rebase-merge"))) { + return { allowed: false, reason: "mid-rebase: .git/rebase-merge exists" }; + } + if (existsSyncFn(pathModule.join(gitDir, "rebase-apply"))) { + return { allowed: false, reason: "mid-rebase: .git/rebase-apply exists" }; + } + + // Cheap "is package-lock.json dirty?" check. `git diff --quiet -- ` + // exits 0 when there are no unstaged differences, 1 when there are, and + // 128 when not a git repo. We treat any non-zero exit as "refuse" so the + // operator's working copy is not silently overwritten by `npm ci`. + const lockResult = spawnPlatformCommandSyncFn( + "git", + ["diff", "--quiet", "--", "package-lock.json"], + { + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + } + ); + + if (!lockResult || lockResult.status !== 0) { + return { + allowed: false, + reason: "package-lock.json has unstaged changes (git diff --quiet returned non-zero)", + }; + } + + return { allowed: true, reason: null }; +} + +/** + * Module-level cache of the gate's "all probes happy" verdict, keyed by + * normalized repoRoot. The resolver probe spawns a subprocess; the file + * probe stats a fixed set of files. In a pre-push session a single hook + * invocation calls into this gate from each of the managed wrappers + * (run-managed-jest, run-managed-prettier, run-managed-cspell) AND in some + * cases from validate-node-tooling — that adds up to four subprocess spawns + * for the same answer. + * + * We cache ONLY the success verdict (`{ ok: true, didRecover: false }`) per + * repoRoot. Failure verdicts are NOT cached: a failure carries side effects + * (banner printed, npm ci attempted) that the next caller must observe + * fresh; caching them would also defeat the post-`npm ci` re-probe. + * + * The cache is intentionally scoped to the lifetime of the parent Node + * process (each hook invocation spawns its own Node, so the cache is + * naturally per-hook). The exported `__clearIntegrityGateCacheForTests` + * helper resets the cache between tests so injected fakes are exercised + * end-to-end on each run. + */ +const INTEGRITY_GATE_CACHE = new Map(); + +/** + * Normalize a repoRoot for cache keying. Same approach as path-classifier's + * normalizeForPathComparison but lighter (no fs touch) — the cache key only + * needs to be stable for a single Node process where the cwd does not + * change, so a plain path.resolve + lowercase-on-Windows is sufficient. + * + * @param {string} repoRoot Absolute repository root. + * @returns {string} Cache-stable form. + */ +function cacheKeyForRepoRoot(repoRoot) { + const path = require("path"); + const resolved = path.resolve(repoRoot); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +/** + * Reset the in-process integrity-gate cache. Test-only helper; production + * code should never need to call this. + */ +function __clearIntegrityGateCacheForTests() { + INTEGRITY_GATE_CACHE.clear(); +} + +/** + * Run the integrity probe -> auto-repair -> re-probe flow. + * + * Caching: a successful verdict ({@link INTEGRITY_GATE_CACHE}) is memoized + * per-repoRoot for the lifetime of the parent Node process so the resolver + * subprocess spawn (the most expensive step) does not repeat across the + * managed-wrapper chain in a single hook. Failures are not cached. See the + * {@link INTEGRITY_GATE_CACHE} comment for the rationale. + * + * @param {object} options + * @param {string} options.repoRoot Absolute path to repository root. + * @param {Function} options.probeIntegrityFn Function returning { ok, missing }. + * @param {Function} options.probeIntegrityInSubprocessFn Function returning + * { ok, missing } after re-probe in a child node. + * @param {Function} options.attemptNpmCiRecoveryFn Function performing npm ci. + * @param {Function} options.isAutoRepairAllowedFn Function returning + * { allowed, reason }. + * @param {Function} options.printActionableRepairBannerFn Decoder-driven + * banner printer. + * @param {object} options.decoder The jest-error-decoder module + * ({ PATTERNS, decodeJestStderr }) used to fetch the + * PARTIAL_NODE_MODULES_INSTALL entry. + * @param {Function} [options.warnFn] Logging sink (defaults to console.warn). + * @param {Function} [options.findZeroByteNativeBinariesFn] Windows-only + * scanner for zero-byte *.node binaries (defaults to the production + * implementation in node-modules-integrity). + * @param {Function} [options.formatIntegrityFailureFn] Formatter for the + * single-line failure summary (defaults to the production formatter). + * @param {Function} [options.probeResolverHealthFn] Resolver-health probe + * (defaults to the production implementation in node-modules-integrity). + * Augments the file-only probe with a runtime require.resolve check that + * catches the Windows `unrs-resolver` native-binding failure mode. + * @param {Function} [options.platformFn] Returns the current platform string + * (defaults to () => process.platform). Injectable for tests because + * jest's standard mocking story cannot easily clobber process.platform. + * @param {object} [options.env] Process env (defaults to process.env). Used + * to detect DXMSG_HOOK_NO_AUTOREPAIR for the banner hint. + * @param {boolean} [options.bypassCache] When true, skip the + * {@link INTEGRITY_GATE_CACHE} lookup and force a fresh probe. Defaults to + * false. Tests inject this to make each call observe injected fakes. + * @returns {{ok: boolean, didRecover: boolean, reason: string|null, cached?: boolean}} + */ +function runIntegrityGateWithRecovery(options) { + const { + repoRoot, + probeIntegrityFn, + probeIntegrityInSubprocessFn, + attemptNpmCiRecoveryFn, + isAutoRepairAllowedFn, + printActionableRepairBannerFn, + decoder, + warnFn = console.warn, + findZeroByteNativeBinariesFn = findZeroByteNativeBinaries, + formatIntegrityFailureFn = formatIntegrityFailure, + probeResolverHealthFn = probeResolverHealth, + platformFn = () => process.platform, + env = process.env, + bypassCache = false, + } = options; + + if (typeof repoRoot !== "string" || repoRoot.length === 0) { + throw new TypeError("runIntegrityGateWithRecovery requires options.repoRoot"); + } + if (typeof probeIntegrityFn !== "function") { + throw new TypeError("runIntegrityGateWithRecovery requires options.probeIntegrityFn"); + } + + // Cache fast-path: if a previous invocation in THIS Node process already + // confirmed the gate is clean for this repoRoot, skip the probe entirely. + // See the INTEGRITY_GATE_CACHE doc-block for the rationale (managed + // wrappers chain through the gate up to 4x per hook). Only the success + // verdict is cached; failures are re-probed. + const cacheKey = cacheKeyForRepoRoot(repoRoot); + if (!bypassCache && INTEGRITY_GATE_CACHE.has(cacheKey)) { + return { ok: true, didRecover: false, reason: null, cached: true }; + } + + // 1. Probe in-process. + const initial = probeIntegrityFn({ repoRoot }); + + // 1a. On Windows, supplement the standard probe with a scan for + // zero-byte *.node native binaries -- the canonical AV-mid-write + // failure mode where the JS probe passes but `require()` of a native + // module would still crash. On non-Windows, the helper returns [] + // immediately without walking. We merge the offenders into the + // missing[] list so the same downstream banner formatting applies. + let zeroByteNative = []; + try { + zeroByteNative = findZeroByteNativeBinariesFn({ + repoRoot, + platform: platformFn(), + }); + } catch { + // Defensive: the scanner already swallows readdir/stat errors, so + // this catch is only for a wholly broken injection. Treat as no + // additional offenders. + zeroByteNative = []; + } + + const augmentedMissing = Array.isArray(initial && initial.missing) + ? initial.missing.slice() + : []; + for (const relPath of zeroByteNative) { + augmentedMissing.push({ + tool: "", + relPath, + reason: "zero-byte", + }); + } + + // 1b. Resolver-health probe. The Windows `unrs-resolver` failure mode + // leaves jest-circus/build/runner.js present on disk (file probe OK) + // but throws from `require.resolve('jest-circus/runner')` because the + // platform-specific native binding (e.g. + // @unrs/resolver-binding-win32-x64-msvc) is missing/broken. The file + // probe cannot see this; the resolver probe can. We treat resolver + // failures as integrity failures and let the same auto-repair path + // (npm ci) attempt recovery. + let resolverFailures = []; + try { + const resolverResult = probeResolverHealthFn({ repoRoot }); + if (resolverResult && Array.isArray(resolverResult.failures)) { + resolverFailures = resolverResult.failures; + } + } catch { + // Defensive: a wholly broken injected probe should not crash the + // gate. Treat as "no resolver failures detected"; the file probe + // remains authoritative in that case. + resolverFailures = []; + } + for (const failure of resolverFailures) { + augmentedMissing.push({ + tool: "", + relPath: failure.specifier, + reason: "resolver-throw: " + failure.error, + }); + } + + const augmentedResult = { + ok: + !!(initial && initial.ok) + && zeroByteNative.length === 0 + && resolverFailures.length === 0, + missing: augmentedMissing, + }; + + if (augmentedResult.ok) { + INTEGRITY_GATE_CACHE.set(cacheKey, true); + return { ok: true, didRecover: false, reason: null }; + } + + warnFn(`WARNING: ${formatIntegrityFailureFn(augmentedResult)}`); + + // 2. Auto-repair allowed? + const repairDecision = isAutoRepairAllowedFn(); + if (!repairDecision || !repairDecision.allowed) { + warnFn( + `WARNING: auto-repair refused (${repairDecision && repairDecision.reason ? repairDecision.reason : "no reason"}); skipping npm ci.` + ); + printIntegrityGateBanner(printActionableRepairBannerFn, decoder, { env }); + return { ok: false, didRecover: false, reason: repairDecision && repairDecision.reason }; + } + + // 3. Run npm ci. + const recoveryResult = attemptNpmCiRecoveryFn(); + if (!recoveryResult || recoveryResult.status !== 0) { + warnFn("WARNING: npm ci recovery did not succeed; integrity gate failing."); + printIntegrityGateBanner(printActionableRepairBannerFn, decoder, { env }); + return { ok: false, didRecover: false, reason: "npm ci recovery failed" }; + } + + // 4. Re-probe after npm ci. Both probes own their own subprocess- + // freshness contract: `probeIntegrityInSubprocess` spawns a fresh + // Node to defeat the parent's fs.stat cache, and `probeResolverHealth` + // spawns its own fresh Node to defeat any cached native-binding load + // failure from the parent. Calling them again here is therefore safe + // and gives us a clean view of the post-`npm ci` filesystem and the + // just-reinstalled native binding. The subprocess-freshness invariant + // is owned by those functions; this caller only needs to invoke them. + const reprobe = probeIntegrityInSubprocessFn({ repoRoot }); + let postRepairResolverFailures = []; + try { + const reprobeResolver = probeResolverHealthFn({ repoRoot }); + if (reprobeResolver && Array.isArray(reprobeResolver.failures)) { + postRepairResolverFailures = reprobeResolver.failures; + } + } catch { + postRepairResolverFailures = []; + } + const reprobeOk = !!(reprobe && reprobe.ok) && postRepairResolverFailures.length === 0; + if (reprobeOk) { + INTEGRITY_GATE_CACHE.set(cacheKey, true); + return { ok: true, didRecover: true, reason: null }; + } + + warnFn( + "WARNING: node_modules integrity probe still failed after npm ci recovery; manual repair required." + ); + printIntegrityGateBanner(printActionableRepairBannerFn, decoder, { env }); + return { + ok: false, + didRecover: false, + reason: "subprocess re-probe still failed after npm ci", + }; +} + +/** + * Find the PARTIAL_NODE_MODULES_INSTALL decoder entry and pass a synthetic + * "decoded" object to the banner printer. This is the closed-loop way to + * reuse the same repair-banner formatter from the jest stderr path without + * baking a special branch into the decoder. + * + * When the operator has set DXMSG_HOOK_NO_AUTOREPAIR=1, append a hint + * explaining what the env var did (suppressed auto-repair) and how to + * unset it on POSIX + PowerShell. The hint is conveyed by augmenting the + * synthetic `rootCauses` and `repairCommands` so it lands in the same + * banner the operator already sees — no new code path is introduced for + * the operator to discover. + * + * @param {Function} printActionableRepairBannerFn + * @param {object} decoder Imported jest-error-decoder module. + * @param {object} [opts] + * @param {object} [opts.env] Process env (defaults to process.env). Used to + * detect DXMSG_HOOK_NO_AUTOREPAIR for the hint augmentation. + */ +function printIntegrityGateBanner(printActionableRepairBannerFn, decoder, opts = {}) { + if (typeof printActionableRepairBannerFn !== "function" || !decoder) { + return; + } + const patterns = Array.isArray(decoder.PATTERNS) ? decoder.PATTERNS : []; + const entry = patterns.find((p) => p && p.kind === "PARTIAL_NODE_MODULES_INSTALL"); + if (!entry) { + return; + } + + const env = (opts && opts.env) || process.env; + const optedOut = isTruthyEnv(env && env.DXMSG_HOOK_NO_AUTOREPAIR); + + // Snapshot the decoder entry's immutable arrays so we can append the + // hint without mutating the frozen PATTERNS table. + const rootCauses = Array.isArray(entry.rootCauses) ? entry.rootCauses.slice() : []; + const repairCommands = Array.isArray(entry.repairCommands) ? entry.repairCommands.slice() : []; + + if (optedOut) { + rootCauses.push("auto-repair disabled by DXMSG_HOOK_NO_AUTOREPAIR=1 (operator override)"); + // The banner numbers repair commands as sequential steps (1., 2., 3., + // ...). The two unset commands below are POSIX-or-PowerShell + // ALTERNATIVES (pick the one matching the operator's shell), not + // sequential steps. We prefix each with "Either:" so the rendered + // banner reads correctly even with the leading numeral, and the + // operator immediately sees they are not meant to be run in series. + repairCommands.push( + "Either: unset DXMSG_HOOK_NO_AUTOREPAIR # POSIX: re-enable auto-repair" + ); + repairCommands.push( + "Either: Remove-Item Env:\\DXMSG_HOOK_NO_AUTOREPAIR # PowerShell: re-enable auto-repair" + ); + } + + const decoded = { + kind: entry.kind, + summary: entry.summary, + rootCauses, + repairCommands, + skillRef: entry.skillRef, + selfHeal: entry.selfHeal, + capturedMatch: null, + }; + printActionableRepairBannerFn(decoded); +} + +module.exports = { + isAutoRepairAllowed, + runIntegrityGateWithRecovery, + printIntegrityGateBanner, + __clearIntegrityGateCacheForTests, +}; diff --git a/scripts/lib/integrity-gate-with-recovery.js.meta b/scripts/lib/integrity-gate-with-recovery.js.meta new file mode 100644 index 00000000..072480c4 --- /dev/null +++ b/scripts/lib/integrity-gate-with-recovery.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: fb9bcaadd17475dd32e31e752acde36d +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/lib/jest-error-decoder.js b/scripts/lib/jest-error-decoder.js new file mode 100644 index 00000000..812363da --- /dev/null +++ b/scripts/lib/jest-error-decoder.js @@ -0,0 +1,260 @@ +"use strict"; + +/** + * jest-error-decoder.js + * + * Pure module that turns opaque Jest stderr into actionable diagnostics for + * pre-push hooks. No I/O, no top-level side effects, no caching. + * + * Public surface: + * - PATTERNS: frozen array of decoder entries. + * - decodeJestStderr(stderr): returns the first matching entry merged with + * the captured regex match, or null. + * - formatRepairBanner(decoded, { color, env, isTTY }): returns a plain-ASCII + * banner suitable for Windows CMD, Git Bash, and POSIX terminals. + * + * Pattern ordering is significant: PATTERNS is iterated in declaration order + * and the first regex match wins. Earlier entries are MORE SPECIFIC than later + * entries, so they must remain ordered most-specific-first. In particular, + * MISSING_TEST_RUNNER (which only matches the precise "testRunner option was + * not found" sentence) comes before the broader CORRUPT_ISOLATED_CACHE and + * MISSING_LOCAL_JEST "Cannot find module" patterns. If stderr ever satisfies + * both, the more-specific MISSING_TEST_RUNNER decode is preferred. + */ + +const SKILL_REF = ".llm/skills/scripting/jest-hook-robustness.md"; +const PREFLIGHT_COMMAND = "npm run preflight:pre-push"; + +/** + * Cross-platform rm -rf snippet for an isolated managed-jest cache entry. + * + * NOTE: this clears the ENTIRE dxmessaging-managed-jest tree, not just the + * pinned-spec subdirectory that `attemptIsolatedCacheReset()` targets. The + * user-facing repair hint is broader on purpose: it works regardless of which + * pinned spec is in play, and it is safe to run when no isolated cache is + * present. The runtime self-heal path uses the narrower per-spec reset. + */ +const ISOLATED_CACHE_RESET_COMMAND = + "node -e \"require('fs').rmSync(require('path').join(require('os').tmpdir(), 'dxmessaging-managed-jest'), { recursive: true, force: true })\""; + +const PATTERNS = Object.freeze([ + Object.freeze({ + kind: "MISSING_TEST_RUNNER", + regex: /Module\s+(.+?)\s+in the testRunner option was not found\./i, + summary: "Jest's runner validator rejected the testRunner module path.", + rootCauses: Object.freeze([ + "partial node_modules install (jest-circus not fully extracted)", + "isolated managed-jest cache corruption", + "legacy --testRunner injection re-introduced from a regressed wrapper or hook entry", + ]), + repairCommands: Object.freeze([ + "npm ci", + "node scripts/validate-node-tooling.js", + PREFLIGHT_COMMAND, + ]), + skillRef: SKILL_REF, + // MISSING_TEST_RUNNER can be caused by either a partial repo + // node_modules install (the Windows failure mode that motivated this + // skill) or by a corrupt isolated managed-Jest cache. Both isolated + // cache reset and `npm ci` are appropriate recovery channels; the + // runtime decides which to attempt based on the resolved runner + // path's containing tree. + selfHeal: Object.freeze({ isolatedCacheReset: true, npmCi: true, retryOnce: true }), + }), + Object.freeze({ + kind: "CORRUPT_ISOLATED_CACHE", + // Anchor on Cannot find module to avoid colliding with MISSING_LOCAL_JEST. + // The trailing (?!-) keeps us from grabbing "jest-circus-..." extensions. + regex: /Cannot find module ['"]jest-circus(?:\/runner)?['"]/i, + summary: "Isolated managed-Jest cache is missing jest-circus.", + rootCauses: Object.freeze([ + "previous fallback install was interrupted", + "cache directory was partially deleted", + ]), + repairCommands: Object.freeze([ + ISOLATED_CACHE_RESET_COMMAND, + "node scripts/run-managed-jest.js --version", + PREFLIGHT_COMMAND, + ]), + skillRef: SKILL_REF, + selfHeal: Object.freeze({ isolatedCacheReset: true, retryOnce: true }), + }), + Object.freeze({ + kind: "MISSING_LOCAL_JEST", + // Anchored on either a line start OR an "Error:" prefix so a short + // module identifier ending in ": Cannot find module 'jest'" (e.g. the + // tail of an unrelated diagnostic) cannot accidentally trigger this + // pattern. The (?![\w-]) suffix prevents matching jest-circus, jest-cli, + // etc. Multiline flag is required for the `^` anchor to find the start + // of any line, not just the start of the whole buffer. + regex: /^(?:\s*Error:\s+)?Cannot find module ['"](?:jest|jest\/bin\/jest(?:\.js)?)['"](?![\w-])/m, + summary: "Local jest binary missing from node_modules.", + rootCauses: Object.freeze([ + "partial install", + "postinstall validator skipped", + "node_modules wiped", + ]), + repairCommands: Object.freeze([ + "npm ci", + "node scripts/validate-node-tooling.js", + PREFLIGHT_COMMAND, + ]), + skillRef: SKILL_REF, + selfHeal: Object.freeze({ npmCi: true, retryOnce: true }), + }), + // PARTIAL_NODE_MODULES_INSTALL is a sentinel that the integrity gate + // surfaces synthetically when probeIntegrity() reports missing/empty + // critical files but the auto-repair flow has already exhausted its + // budget. The regex deliberately matches only the synthetic sentinel + // string the gate emits, so the decoder never auto-binds to ambient + // Jest stderr. Pattern is listed LAST so the more-specific entries + // above always win when both could match. + Object.freeze({ + kind: "PARTIAL_NODE_MODULES_INSTALL", + regex: /^__INTEGRITY_GATE_FAILURE__$/, + summary: + "Repository node_modules is partially extracted; auto-repair could not recover.", + rootCauses: Object.freeze([ + "partial node_modules extract on Windows (long paths, antivirus, interrupted install)", + "npm install reported 'up to date' without re-extracting", + ]), + repairCommands: Object.freeze([ + "npm ci", + "node scripts/validate-node-tooling.js", + PREFLIGHT_COMMAND, + ]), + skillRef: SKILL_REF, + selfHeal: Object.freeze({ npmCi: false, retryOnce: false }), + }), +]); + +/** + * Treat null, empty string, "0", "false", "no", and "off" as falsy. Anything + * else (including "1", "true", arbitrary strings) is truthy. + * + * @param {*} value Raw environment-variable value. + * @returns {boolean} + */ +function isTruthyEnv(value) { + if (value == null) { + return false; + } + const stringValue = String(value).trim().toLowerCase(); + if (stringValue === "" || stringValue === "0" || stringValue === "false" || stringValue === "no" || stringValue === "off") { + return false; + } + return true; +} + +/** + * Decode the first matching pattern out of a Jest stderr stream. + * + * The function iterates `PATTERNS` in declaration order and returns the first + * match; patterns are ordered most-specific-first by convention (see the + * module header for details). + * + * @param {string|Buffer|null|undefined} stderr Raw stderr text from a Jest + * invocation. Buffers are converted to UTF-8 strings internally. + * @returns {object|null} A pattern object (kind, summary, rootCauses, + * repairCommands, skillRef, selfHeal) plus capturedMatch (regex result), or + * null when no pattern matched or input is empty. + */ +function decodeJestStderr(stderr) { + let text; + if (Buffer.isBuffer(stderr)) { + text = stderr.toString("utf8"); + } else if (typeof stderr === "string") { + text = stderr; + } else { + return null; + } + + if (text.length === 0) { + return null; + } + + for (const pattern of PATTERNS) { + const match = pattern.regex.exec(text); + if (match) { + return { + kind: pattern.kind, + summary: pattern.summary, + rootCauses: pattern.rootCauses, + repairCommands: pattern.repairCommands, + skillRef: pattern.skillRef, + selfHeal: pattern.selfHeal, + capturedMatch: match, + }; + } + } + + return null; +} + +/** + * Produce a plain-ASCII repair banner. Uses only `=`, `-`, `|` so Windows + * CMD cannot mojibake Unicode box-drawing characters. + * + * @param {object|null} decoded Output of decodeJestStderr(). + * @param {object} [options] + * @param {boolean} [options.color] When true, ANSI-color the header line, but + * only if the gating env/TTY checks pass. + * @param {object} [options.env] Process env object to inspect for CI flags. + * Defaults to process.env. Injectable so tests can be deterministic. + * @param {boolean} [options.isTTY] Whether the destination stream is a TTY. + * Defaults to process.stderr.isTTY (the banner is written to stderr, so the + * TTY decision must mirror stderr, not stdout). + * @returns {string} The banner, or "" when decoded is null. + */ +function formatRepairBanner(decoded, options = {}) { + if (!decoded) { + return ""; + } + + const color = Boolean(options && options.color); + const env = (options && options.env) || process.env; + const isTTY = options && Object.prototype.hasOwnProperty.call(options, "isTTY") + ? Boolean(options.isTTY) + : Boolean(process.stderr && process.stderr.isTTY); + + const horizontal = "=".repeat(64); + const divider = "-".repeat(64); + + const rootCauseLines = decoded.rootCauses.map((cause) => ` - ${cause}`); + const repairLines = decoded.repairCommands.map( + (command, index) => ` ${index + 1}. ${command}` + ); + + const headerLine = `jest-hook diagnostic: ${decoded.kind}`; + const ciIsSet = isTruthyEnv(env && env.CI); + const shouldColorize = color && isTTY && !ciIsSet; + const decoratedHeader = shouldColorize + ? `\x1b[31m${headerLine}\x1b[0m` + : headerLine; + + const lines = [ + horizontal, + decoratedHeader, + divider, + `Summary: ${decoded.summary}`, + "", + "Most likely root cause:", + ...rootCauseLines, + "", + "Suggested repair (run in order):", + ...repairLines, + "", + `Skill reference: ${decoded.skillRef}`, + `Run after repair: ${PREFLIGHT_COMMAND}`, + horizontal, + ]; + + return `${lines.join("\n")}\n`; +} + +module.exports = { + PATTERNS, + decodeJestStderr, + formatRepairBanner, + isTruthyEnv, +}; diff --git a/scripts/lib/jest-error-decoder.js.meta b/scripts/lib/jest-error-decoder.js.meta new file mode 100644 index 00000000..361e11d6 --- /dev/null +++ b/scripts/lib/jest-error-decoder.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 40bc1c8a5dd06ba4297767bd183a6d58 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/lib/managed-prettier.js b/scripts/lib/managed-prettier.js new file mode 100644 index 00000000..f374f894 --- /dev/null +++ b/scripts/lib/managed-prettier.js @@ -0,0 +1,138 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const childProcess = require("child_process"); + +const TOOL_LOAD_ERROR_PATTERNS = [ + /Cannot find package/i, + /Cannot find module/i, + /ERR_MODULE_NOT_FOUND/i, + /MODULE_NOT_FOUND/i +]; + +const MISSING_BUNDLED_NPX_CLI_MESSAGE = + "Unable to locate npm's npx-cli.js next to the active Node executable. Run `npm install` in a shell with a complete Node/npm installation, or ensure npm is installed with this Node runtime."; + +function isRecoverableToolLoadError(error) { + const message = `${error?.code || ""}\n${error?.message || ""}\n${error?.stack || ""}`; + return TOOL_LOAD_ERROR_PATTERNS.some((pattern) => pattern.test(message)); +} + +function resolveBundledNpxCliPath({ + execPath = process.execPath, + existsSyncFn = fs.existsSync +} = {}) { + const execDir = path.dirname(execPath); + const candidates = [ + path.join(execDir, "node_modules", "npm", "bin", "npx-cli.js"), + path.join(execDir, "..", "lib", "node_modules", "npm", "bin", "npx-cli.js"), + path.join(execDir, "..", "node_modules", "npm", "bin", "npx-cli.js") + ]; + + for (const candidate of candidates) { + const normalizedCandidate = path.normalize(candidate); + if (existsSyncFn(normalizedCandidate)) { + return normalizedCandidate; + } + } + + return null; +} + +function createMissingBundledNpxCliError() { + return new Error(MISSING_BUNDLED_NPX_CLI_MESSAGE); +} + +function runBundledNpxCommand(args, options = {}) { + const { + execPath = process.execPath, + resolveBundledNpxCliPathFn = resolveBundledNpxCliPath, + runCommandFn = childProcess.spawnSync, + cwd, + encoding = "utf8", + ...commandOptions + } = options; + + const npxCliPath = resolveBundledNpxCliPathFn({ execPath }); + if (!npxCliPath) { + throw createMissingBundledNpxCliError(); + } + + const resolvedOptions = { ...commandOptions }; + if (cwd !== undefined) { + resolvedOptions.cwd = cwd; + } + if (encoding !== undefined) { + resolvedOptions.encoding = encoding; + } + + return runCommandFn(execPath, [npxCliPath, ...args], resolvedOptions); +} + +function isHealthyFile(absPath, existsSyncFn, statSyncFn) { + if (!existsSyncFn(absPath)) { + return false; + } + try { + const stats = statSyncFn(absPath); + return Boolean(stats) && typeof stats.size === "number" && stats.size > 0; + } catch { + return false; + } +} + +function loadLocalPrettier({ + localPrettierModulePath, + localPrettierBinPath, + existsSyncFn = fs.existsSync, + statSyncFn = fs.statSync, + requireFn = require, + isRecoverableToolLoadErrorFn = isRecoverableToolLoadError +} = {}) { + if (!localPrettierModulePath || !localPrettierBinPath) { + throw new Error( + "loadLocalPrettier requires both localPrettierModulePath and localPrettierBinPath." + ); + } + + // Phase 4: probe BOTH the module path and the bin path for presence + // AND non-zero size. The bin path was previously only checked as a + // tie-breaker for the "unexpected layout" error. We now treat a + // zero-byte module OR a zero-byte bin as missing - either condition + // means Prettier will fail to run on Windows partial-extract systems + // even though existsSync says "yes". + const moduleHealthy = isHealthyFile(localPrettierModulePath, existsSyncFn, statSyncFn); + const binHealthy = isHealthyFile(localPrettierBinPath, existsSyncFn, statSyncFn); + + if (moduleHealthy && binHealthy) { + try { + return requireFn(localPrettierModulePath); + } catch (error) { + if (isRecoverableToolLoadErrorFn(error)) { + return null; + } + throw error; + } + } + + // Existing failure case preserved: bin present without index.cjs is + // surfaced as an explicit error so the operator knows what to repair. + if (existsSyncFn(localPrettierBinPath) && !existsSyncFn(localPrettierModulePath)) { + throw new Error( + "Prettier devDependency layout is unexpected: bin present but index.cjs missing. Run `npm install` to repair." + ); + } + + return null; +} + +module.exports = { + TOOL_LOAD_ERROR_PATTERNS, + MISSING_BUNDLED_NPX_CLI_MESSAGE, + isRecoverableToolLoadError, + resolveBundledNpxCliPath, + createMissingBundledNpxCliError, + runBundledNpxCommand, + loadLocalPrettier +}; \ No newline at end of file diff --git a/scripts/lib/managed-prettier.js.meta b/scripts/lib/managed-prettier.js.meta new file mode 100644 index 00000000..50fff64e --- /dev/null +++ b/scripts/lib/managed-prettier.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 0ffe84406ffe19344a876f6b7a1e8ed0 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/lib/node-modules-integrity.js b/scripts/lib/node-modules-integrity.js new file mode 100644 index 00000000..15da1a3d --- /dev/null +++ b/scripts/lib/node-modules-integrity.js @@ -0,0 +1,646 @@ +"use strict"; + +/** + * node-modules-integrity.js + * + * Single source of truth for the "are the on-disk node_modules files + * actually present and non-zero?" health check. Every managed wrapper + * (run-managed-jest, run-managed-prettier, run-managed-cspell) and the + * doctor + validate-node-tooling validators import INTEGRITY_TARGETS from + * here. Duplication elsewhere is a policy violation. + * + * Three layers of probe: + * + * 1. `probeIntegrity({ repoRoot })` - in-process synchronous check. + * Returns `{ ok, missing: [{ tool, relPath, reason }] }`. Reason is + * `"missing"` (existsSync false) or `"empty"` (size 0). Pure I/O, no + * side effects; the only writes happen later via the integrity-gate + * caller (npm ci). + * + * 2. `probeIntegrityInSubprocess({ repoRoot, execPath, spawnSyncFn })` - + * Re-runs probeIntegrity in a fresh Node process. Defeats Node's + * module / fs cache, which is critical after npm ci has rewritten + * node_modules: the parent process may still have stale require/stat + * cache entries for the previously-broken file. Parent and child + * MUST be in lockstep on what counts as "healthy"; the child inlines + * probeIntegrity via require() against this same file. + * + * 3. `findZeroByteNativeBinaries({ repoRoot })` - Windows-only quick + * scan for *.node files with size 0 under node_modules. On + * non-Windows, returns [] without walking. Bounded depth keeps the + * probe under ~50ms even on large trees. + */ + +const fs = require("fs"); +const path = require("path"); +const childProcess = require("child_process"); + +/** + * The canonical list of managed tools whose absence breaks the pre-push + * hooks. Every entry's `files` array lists the critical files that MUST + * exist on disk with non-zero size for the tool to actually run. + * + * Verification rules (when extending this list): + * - Every relPath MUST exist in a healthy `npm install` of this + * repository. Add a list only after confirming `ls -la + * node_modules//` on a fresh `npm ci`. + * - Prefer the load-bearing entrypoint (e.g. bin script, runner module) + * over README or package.json. The gate's purpose is to predict + * whether the tool's invocation will succeed, not whether the package + * metadata is complete. + * + * The list is frozen at module load; consumers cannot mutate it (and the + * tests assert this). + */ +const INTEGRITY_TARGETS = Object.freeze([ + Object.freeze({ + tool: "prettier", + files: Object.freeze([ + Object.freeze({ relPath: "node_modules/prettier/index.cjs", minBytes: 1 }), + Object.freeze({ relPath: "node_modules/prettier/bin/prettier.cjs", minBytes: 1 }), + ]), + }), + Object.freeze({ + tool: "markdownlint-cli2", + files: Object.freeze([ + Object.freeze({ + relPath: "node_modules/markdownlint-cli2/markdownlint-cli2.mjs", + minBytes: 1, + }), + ]), + }), + Object.freeze({ + tool: "cspell", + files: Object.freeze([ + Object.freeze({ relPath: "node_modules/cspell/bin.mjs", minBytes: 1 }), + ]), + }), + Object.freeze({ + tool: "jest", + files: Object.freeze([ + Object.freeze({ relPath: "node_modules/jest/bin/jest.js", minBytes: 1 }), + ]), + }), + Object.freeze({ + tool: "jest-circus", + files: Object.freeze([ + Object.freeze({ relPath: "node_modules/jest-circus/build/runner.js", minBytes: 1 }), + Object.freeze({ relPath: "node_modules/jest-circus/build/index.js", minBytes: 1 }), + // Verified live on the repo: jestAdapterInit.js lives directly + // under build/, not under build/legacy-code-todo-rewrite/. The + // plan called for the legacy path but on-disk inspection at + // 2026-05-18 shows the flat layout; we follow on-disk truth. + Object.freeze({ + relPath: "node_modules/jest-circus/build/jestAdapterInit.js", + minBytes: 1, + }), + ]), + }), +]); + +function joinRepoPath(repoRoot, relPath) { + return path.join(repoRoot, ...relPath.split("/")); +} + +/** + * Probe every file in INTEGRITY_TARGETS for presence + non-zero size. + * + * @param {object} [options] + * @param {string} options.repoRoot Absolute path to the repository root. + * @param {Function} [options.statSyncFn] Override fs.statSync (for tests). + * The callback receives the absolute path; it must throw ENOENT to + * signal missing, or return `{ size: N }`. + * @param {Function} [options.existsSyncFn] Override fs.existsSync (for tests). + * @param {Array} [options.targets] Override INTEGRITY_TARGETS (for tests). + * @returns {{ok: boolean, missing: Array<{tool: string, relPath: string, reason: string}>}} + */ +function probeIntegrity(options = {}) { + const { + repoRoot, + statSyncFn = fs.statSync, + existsSyncFn = fs.existsSync, + targets = INTEGRITY_TARGETS, + } = options; + + if (typeof repoRoot !== "string" || repoRoot.length === 0) { + throw new TypeError("probeIntegrity requires options.repoRoot (string)"); + } + + const missing = []; + + for (const target of targets) { + for (const file of target.files) { + const abs = joinRepoPath(repoRoot, file.relPath); + + // We deliberately call existsSync before stat. On Windows, stat + // on a phantom drive letter can throw EPERM rather than ENOENT; + // the existsSync gate normalizes that to a clean "missing" + // verdict, and a defensive try/catch around stat catches any + // remaining edge case (e.g. EACCES on a partially-restored + // file). + if (!existsSyncFn(abs)) { + missing.push({ tool: target.tool, relPath: file.relPath, reason: "missing" }); + continue; + } + + let stats; + try { + stats = statSyncFn(abs); + } catch (error) { + const code = error && error.code ? error.code : "stat-error"; + missing.push({ + tool: target.tool, + relPath: file.relPath, + reason: code === "ENOENT" ? "missing" : "empty", + }); + continue; + } + + const minBytes = typeof file.minBytes === "number" && file.minBytes > 0 ? file.minBytes : 1; + if (!stats || typeof stats.size !== "number" || stats.size < minBytes) { + missing.push({ tool: target.tool, relPath: file.relPath, reason: "empty" }); + } + } + } + + return { ok: missing.length === 0, missing }; +} + +/** + * Spawn `node -e ''` to run probeIntegrity in a fresh + * Node process. The child requires this same module and prints its JSON + * output to stdout; the parent parses and returns the same shape. + * + * Why a subprocess? After npm ci finishes, the parent process still has + * the original `node_modules/.../runner.js` cached in its require + fs + * stat caches. A re-probe in the same process can falsely report "still + * broken". The subprocess starts with a clean module cache. + * + * @param {object} [options] + * @param {string} options.repoRoot Absolute path to the repository root. + * @param {string} [options.execPath] Node executable to spawn. Defaults + * to process.execPath. + * @param {Function} [options.spawnSyncFn] Override child_process.spawnSync + * (for tests). + * @returns {{ok: boolean, missing: Array<{tool: string, relPath: string, reason: string}>}} + * On spawn failure, returns `{ ok: false, missing: [{ tool, relPath, reason }] }` + * with a synthetic entry describing the spawn error. + */ +function probeIntegrityInSubprocess(options = {}) { + const { + repoRoot, + execPath = process.execPath, + spawnSyncFn = childProcess.spawnSync, + } = options; + + if (typeof repoRoot !== "string" || repoRoot.length === 0) { + throw new TypeError("probeIntegrityInSubprocess requires options.repoRoot (string)"); + } + + // The inline script is intentionally minimal: it requires this exact + // module, calls probeIntegrity({ repoRoot }), and prints the JSON + // result. We JSON.stringify both the module path and repoRoot so + // backslashes (on Windows) and any embedded quotes are escape-safe. + // + // It additionally calls findZeroByteNativeBinaries on Windows so the + // post-`npm ci` re-probe surfaces the AV-truncation failure mode the + // first-pass in-process gate already covers. The result is reported + // under a separate `zeroByteNativeBinaries` field; the parent merges + // it into missing[] using the same shape as the in-process gate. + const integrityModulePath = __filename; + const inlineScript = + '"use strict";\n' + + "const integrity = require(" + + JSON.stringify(integrityModulePath) + + "); " + + "const repoRoot = " + + JSON.stringify(repoRoot) + + "; " + + "const result = integrity.probeIntegrity({ repoRoot }); " + + 'const zeroByteNativeBinaries = process.platform === "win32" ' + + "? integrity.findZeroByteNativeBinaries({ repoRoot }) " + + ": []; " + + "process.stdout.write(JSON.stringify(Object.assign({}, result, { zeroByteNativeBinaries })));"; + + const spawnResult = spawnSyncFn(execPath, ["-e", inlineScript], { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (!spawnResult || spawnResult.error) { + const errorMessage = + spawnResult && spawnResult.error && spawnResult.error.message + ? spawnResult.error.message + : "spawn returned null"; + return { + ok: false, + missing: [ + { + tool: "(subprocess)", + relPath: "(node -e probeIntegrity)", + reason: `spawn failed: ${errorMessage}`, + }, + ], + }; + } + + if (spawnResult.status !== 0) { + const stderrText = typeof spawnResult.stderr === "string" ? spawnResult.stderr : ""; + return { + ok: false, + missing: [ + { + tool: "(subprocess)", + relPath: "(node -e probeIntegrity)", + reason: `exit=${spawnResult.status}; stderr=${stderrText.trim().slice(0, 200)}`, + }, + ], + }; + } + + const stdoutText = typeof spawnResult.stdout === "string" ? spawnResult.stdout : ""; + try { + const parsed = JSON.parse(stdoutText); + if (!parsed || typeof parsed.ok !== "boolean" || !Array.isArray(parsed.missing)) { + throw new Error("subprocess returned malformed shape"); + } + // Merge zero-byte native bindings into the missing[] list using the + // same shape the in-process integrity gate uses, so the downstream + // banner formatter and recovery flow see one uniform offender list. + // The `zeroByteNativeBinaries` field is optional for backward + // compatibility with subprocess scripts that don't emit it. + const zeroByteNative = Array.isArray(parsed.zeroByteNativeBinaries) + ? parsed.zeroByteNativeBinaries + : []; + if (zeroByteNative.length === 0) { + return { ok: parsed.ok, missing: parsed.missing }; + } + const augmentedMissing = parsed.missing.slice(); + for (const relPath of zeroByteNative) { + augmentedMissing.push({ + tool: "", + relPath, + reason: "zero-byte", + }); + } + return { ok: false, missing: augmentedMissing }; + } catch (parseError) { + const detail = parseError && parseError.message ? parseError.message : String(parseError); + return { + ok: false, + missing: [ + { + tool: "(subprocess)", + relPath: "(node -e probeIntegrity)", + reason: `JSON parse failed: ${detail}`, + }, + ], + }; + } +} + +/** + * Recursively walk node_modules looking for `*.node` files with size 0. + * + * Native binaries get truncated by certain AV products on Windows mid- + * install, leaving the file present but unusable. JS files larger than 0 + * are still loadable; *.node files at size 0 always fail. We can scan + * once at probe time and surface a single actionable error. + * + * Bounded depth (~5) keeps the scan fast even on very large trees. On + * non-Windows platforms, this function returns [] without walking; the + * Windows-specific failure mode does not apply. + * + * @param {object} [options] + * @param {string} options.repoRoot Absolute path to the repository root. + * @param {Function} [options.readdirSyncFn] Override fs.readdirSync (for tests). + * @param {boolean} [options.skip] If true, returns [] immediately without + * walking. Callers use this when they have already confirmed the + * platform is non-Windows or the cost is unacceptable. + * @param {string} [options.platform] Override process.platform (for tests). + * @returns {string[]} Repo-relative POSIX-style paths of zero-byte *.node files. + */ +function findZeroByteNativeBinaries(options = {}) { + const { + repoRoot, + readdirSyncFn = fs.readdirSync, + statSyncFn = fs.statSync, + skip = false, + platform = process.platform, + maxDepth = 5, + } = options; + + if (skip || platform !== "win32") { + return []; + } + if (typeof repoRoot !== "string" || repoRoot.length === 0) { + return []; + } + + const nodeModulesRoot = path.join(repoRoot, "node_modules"); + const offenders = []; + + function walk(dirAbsPath, depth) { + if (depth > maxDepth) { + return; + } + let entries; + try { + entries = readdirSyncFn(dirAbsPath, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const childAbs = path.join(dirAbsPath, entry.name); + if (entry.isDirectory()) { + walk(childAbs, depth + 1); + continue; + } + if (entry.isFile() && entry.name.endsWith(".node")) { + try { + const stats = statSyncFn(childAbs); + if (stats && typeof stats.size === "number" && stats.size === 0) { + const rel = path.relative(repoRoot, childAbs).split(path.sep).join("/"); + offenders.push(rel); + } + } catch { + // ignore: a file we can't stat is not actionable here. + } + } + } + } + + walk(nodeModulesRoot, 0); + return offenders; +} + +/** + * Render a single-line summary of an integrity probe failure. + * + * All `relPath` values are emitted with POSIX separators so log output is + * identical across Windows, macOS, and Linux. Cross-platform string + * assertions in tests can therefore match a single, platform-agnostic form. + * + * @param {{ok: boolean, missing: Array<{tool: string, relPath: string, reason: string}>}} result + * @returns {string} + */ +function formatIntegrityFailure(result) { + if (!result || !Array.isArray(result.missing) || result.missing.length === 0) { + return "Integrity probe failed: no detail available."; + } + const first = result.missing[0]; + const remaining = result.missing.length - 1; + const tail = remaining > 0 ? `; ${remaining} more` : ""; + const relPathPosix = typeof first.relPath === "string" + ? first.relPath.replace(/\\/g, "/") + : first.relPath; + return `Integrity probe failed: missing ${relPathPosix} (${first.reason}) for ${first.tool}${tail}`; +} + +/** + * Critical resolver entries that MUST resolve via require.resolve from the + * repository root. The file-based integrity probe checks that these files + * are present on disk; this list is what the resolver-health probe asks + * Node's actual resolver to find at runtime. + * + * Why a separate list from INTEGRITY_TARGETS: + * - INTEGRITY_TARGETS pins exact relative file paths under node_modules. + * - The resolver chain depends on additional metadata (package.json + * "exports", platform-specific native bindings) that the file probe + * cannot evaluate. The Windows failure mode that motivated this probe + * is: jest-circus/build/runner.js IS present on disk (file probe OK), + * but `require.resolve('jest-circus/runner')` THROWS because the + * `@unrs/resolver-binding-win32-x64-msvc` native binding is missing + * or broken. + * - jest-circus/runner is the canonical specifier because it is the + * deepest, most-load-bearing entry; if it resolves cleanly, every + * intermediate package resolves too. + */ +const DEFAULT_RESOLVER_SPECIFIERS = Object.freeze(["jest-circus/runner"]); + +/** + * Spawn a fresh Node subprocess that EXERCISES the unrs-resolver native + * binding the same way jest-resolve will at test-runner startup, then + * additionally runs Node's own `require.resolve` for each critical specifier. + * Returns `{ ok, failures: [{ specifier, error }] }`. + * + * The probe is layered intentionally so a healthy file tree but a broken + * native binding still fails the gate: + * + * - LAYER 1: `require("unrs-resolver")` from the repo. On Windows when + * `@unrs/resolver-binding-win32-x64-msvc` is missing or partially + * extracted, napi-postinstall's loader throws at module load. If + * unrs-resolver is unreachable from this repo's tree, the script falls + * back to `require("jest-resolve")`, which transitively requires + * unrs-resolver and triggers the same failure surface. + * - LAYER 2: `new ResolverFactory({}).sync(repoRoot, "jest-circus/runner")`. + * Verified against `node_modules/unrs-resolver/index.d.ts`: the exported + * class is `ResolverFactory` with a `sync(directory, request)` method. + * `jest-resolve/build/index.js` uses the same shape (see lines 114, 172). + * A half-loaded native binding sometimes survives `require()` but throws + * here when the JS side instantiates the factory or calls into native. + * - LAYER 3: existing Node-resolver `require.resolve(spec)` checks. Kept + * belt-and-suspenders because they catch a different failure mode + * (missing peer dep, broken `exports` map) that the unrs-resolver layers + * do not surface as cleanly. + * + * Why a subprocess: like {@link probeIntegrityInSubprocess}, a fresh Node + * process starts with a clean module + native-binding cache. Probing from + * the parent risks false positives (loaded cached binding) and false + * negatives (parent already crashed loading the same binding). + * + * The subprocess writes a single JSON document to stdout. The parent never + * runs the user's code via `eval` — it only spawns a strict-mode Node + * runtime with a hand-rolled inline script whose body is fully under our + * control. Inputs (`repoRoot`, `specifiers`) flow through `JSON.stringify` + * so backslashes and embedded quotes are escape-safe on Windows. + * + * @param {object} [options] + * @param {string} options.repoRoot Absolute path to the repository root. + * @param {string} [options.execPath] Node executable to spawn. Defaults to + * process.execPath. + * @param {Function} [options.spawnSyncFn] Override child_process.spawnSync + * (for tests). + * @param {string[]} [options.specifiers] Resolver specifiers to probe. + * Defaults to {@link DEFAULT_RESOLVER_SPECIFIERS}. + * @returns {{ok: boolean, failures: Array<{specifier: string, error: string}>}} + * On any subprocess malfunction (non-zero exit, malformed stdout, + * missing stdout), returns `{ ok: false, failures: [{ specifier: + * "", error: ... }] }` so callers can treat it uniformly with + * real resolver failures. + */ +function probeResolverHealth(options = {}) { + const { + repoRoot, + execPath = process.execPath, + spawnSyncFn = childProcess.spawnSync, + specifiers = DEFAULT_RESOLVER_SPECIFIERS, + } = options; + + if (typeof repoRoot !== "string" || repoRoot.length === 0) { + throw new TypeError("probeResolverHealth requires options.repoRoot (string)"); + } + + // Hand-rolled inline script. The parent fully controls every byte; all + // dynamic values are routed through JSON.stringify so a malicious + // repoRoot or specifier cannot inject code. + // + // Layered probe (see function JSDoc for the rationale): + // 1. require("unrs-resolver") to trigger napi-postinstall's native + // binding load. Falls back to require("jest-resolve") if + // unrs-resolver is not directly reachable from the repo tree. + // 2. Instantiate ResolverFactory and call .sync() to exercise the + // native binding's actual entry points (a half-loaded binding can + // survive require but throw here). + // 3. Node's createRequire(...).resolve(spec) as the legacy fallback. + // + // The literal "unrs-resolver" token in this script body is asserted by + // the policy test in node-modules-integrity.test.js so a refactor cannot + // silently regress this probe to Node-only resolution. + const inlineScript = + '"use strict";\n' + + "const Module = require('module');\n" + + "const path = require('path');\n" + + "const repoRoot = " + JSON.stringify(repoRoot) + ";\n" + + "const repoRequire = Module.createRequire(path.join(repoRoot, 'package.json'));\n" + + "const specifiers = " + JSON.stringify(specifiers) + ";\n" + + "const failures = [];\n" + + "function describeError(e) {\n" + + " if (!e) return 'unknown';\n" + + " const code = e.code ? e.code + ': ' : '';\n" + + " return code + (e.message || String(e));\n" + + "}\n" + + "// LAYER 1: force-load unrs-resolver. On Windows with a broken\n" + + "// @unrs/resolver-binding-* native binding, this throws at module\n" + + "// load time (napi-postinstall's loader surfaces the underlying\n" + + "// MODULE_NOT_FOUND or load error here).\n" + + "//\n" + + "// If unrs-resolver itself cannot be located (MODULE_NOT_FOUND for the\n" + + "// JS file, not for the native binding), we additionally try to load\n" + + "// jest-resolve, which transitively requires unrs-resolver. That path\n" + + "// catches the failure surface even in a repo whose direct dependency\n" + + "// tree does not list unrs-resolver. In either case, a failure to load\n" + + "// IS a probe failure and is always recorded.\n" + + "let unrsModule = null;\n" + + "let unrsLoadVia = 'unrs-resolver';\n" + + "let unrsLoadOk = false;\n" + + "try {\n" + + " unrsModule = repoRequire('unrs-resolver');\n" + + " unrsLoadOk = true;\n" + + "} catch (primaryErr) {\n" + + " failures.push({ specifier: 'unrs-resolver', error: describeError(primaryErr) });\n" + + " // Probe via jest-resolve as a secondary signal: if it ALSO\n" + + " // throws, we learn the failure is reproducible through the\n" + + " // jest-resolve load chain too; if it succeeds, the failure is\n" + + " // confined to the direct unrs-resolver entrypoint.\n" + + " try {\n" + + " repoRequire('jest-resolve');\n" + + " unrsLoadVia = 'jest-resolve (transitive unrs-resolver)';\n" + + " } catch (fallbackErr) {\n" + + " failures.push({ specifier: 'jest-resolve', error: describeError(fallbackErr) });\n" + + " }\n" + + "}\n" + + "// LAYER 2: actually instantiate ResolverFactory and exercise sync().\n" + + "if (unrsModule && typeof unrsModule.ResolverFactory === 'function') {\n" + + " let factory = null;\n" + + " try {\n" + + " factory = new unrsModule.ResolverFactory({});\n" + + " } catch (factoryErr) {\n" + + " failures.push({\n" + + " specifier: 'unrs-resolver(' + unrsLoadVia + ')',\n" + + " error: 'ResolverFactory ctor failed: ' + describeError(factoryErr)\n" + + " });\n" + + " }\n" + + " if (factory) {\n" + + " for (const spec of specifiers) {\n" + + " try { factory.sync(repoRoot, spec); }\n" + + " catch (resolveErr) {\n" + + " failures.push({ specifier: 'unrs-resolver:' + spec, error: describeError(resolveErr) });\n" + + " }\n" + + " }\n" + + " }\n" + + "}\n" + + "// LAYER 3: legacy Node-resolver probe (different failure modes).\n" + + "for (const spec of specifiers) {\n" + + " try { repoRequire.resolve(spec); }\n" + + " catch (e) { failures.push({ specifier: spec, error: describeError(e) }); }\n" + + "}\n" + + "process.stdout.write(JSON.stringify({ ok: failures.length === 0, failures }));\n"; + + let spawnResult; + try { + spawnResult = spawnSyncFn(execPath, ["-e", inlineScript], { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (spawnError) { + const detail = spawnError && spawnError.message ? spawnError.message : String(spawnError); + return { + ok: false, + failures: [{ specifier: "", error: "spawn threw: " + detail }], + }; + } + + if (!spawnResult) { + return { + ok: false, + failures: [{ specifier: "", error: "spawn returned null" }], + }; + } + if (spawnResult.error) { + const detail = spawnResult.error.message || String(spawnResult.error); + return { + ok: false, + failures: [{ specifier: "", error: "spawn errored: " + detail }], + }; + } + if (spawnResult.status !== 0) { + const stderrText = typeof spawnResult.stderr === "string" + ? spawnResult.stderr.trim().slice(0, 200) + : ""; + return { + ok: false, + failures: [{ + specifier: "", + error: "exit=" + spawnResult.status + (stderrText ? "; stderr=" + stderrText : ""), + }], + }; + } + + const stdoutText = typeof spawnResult.stdout === "string" ? spawnResult.stdout : ""; + if (stdoutText.length === 0) { + return { + ok: false, + failures: [{ specifier: "", error: "empty stdout from probe" }], + }; + } + + try { + const parsed = JSON.parse(stdoutText); + if (!parsed || typeof parsed.ok !== "boolean" || !Array.isArray(parsed.failures)) { + throw new Error("subprocess returned malformed shape"); + } + return { ok: parsed.ok, failures: parsed.failures }; + } catch (parseError) { + const detail = parseError && parseError.message ? parseError.message : String(parseError); + return { + ok: false, + failures: [{ + specifier: "", + error: "malformed probe output: " + detail, + }], + }; + } +} + +module.exports = { + INTEGRITY_TARGETS, + DEFAULT_RESOLVER_SPECIFIERS, + probeIntegrity, + probeIntegrityInSubprocess, + findZeroByteNativeBinaries, + formatIntegrityFailure, + probeResolverHealth, +}; diff --git a/scripts/lib/node-modules-integrity.js.meta b/scripts/lib/node-modules-integrity.js.meta new file mode 100644 index 00000000..11eaae5c --- /dev/null +++ b/scripts/lib/node-modules-integrity.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3ab41416548830e36ec1589c2f3bc6f9 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/lib/path-classifier.js b/scripts/lib/path-classifier.js new file mode 100644 index 00000000..d64947cb --- /dev/null +++ b/scripts/lib/path-classifier.js @@ -0,0 +1,174 @@ +"use strict"; + +/** + * path-classifier.js + * + * Pure helpers for resolving and classifying filesystem paths during managed + * Jest/Prettier/cspell self-heal flows. The integrity gate (and several + * tier-level decisions) need to answer two questions cheaply: + * + * 1. "Is this resolved path inside a particular directory?" Used to refuse + * cache-reset against the repo's node_modules, and to refuse repo-wide + * repair against an isolated cache subtree. + * 2. "Does a captured runner path belong to the repo, the isolated cache, + * or neither?" Used to choose between npm-ci and isolated-cache-reset + * recoveries. + * + * No side effects at module load; every function is pure modulo the + * fs.realpathSync probe inside `normalizeForPathComparison` (which is the + * existing production behavior preserved verbatim). + */ + +const fs = require("fs"); +const path = require("path"); + +const PATH_CLASS_REPO = "repo"; +const PATH_CLASS_ISOLATED = "isolated"; +const PATH_CLASS_UNKNOWN = "unknown"; + +/** + * Resolve a path to an absolute, OS-canonical, symlink-followed form suitable + * for prefix/inside-of comparison. On Windows, the comparison is + * case-insensitive (lowercased). On POSIX, the comparison is case-sensitive. + * + * If `fs.realpathSync` fails (e.g. the target does not exist), the resolved + * path is returned without realpath resolution; callers handle existence + * separately. This mirrors the original `run-managed-jest.js` implementation. + * + * @param {string} targetPath Path to normalize. + * @returns {string} Normalized absolute path. + */ +function normalizeForPathComparison(targetPath) { + let resolved = path.resolve(targetPath); + try { + resolved = fs.realpathSync.native ? fs.realpathSync.native(resolved) : fs.realpathSync(resolved); + } catch { + // Keep resolved path when target is unavailable; callers handle existence separately. + } + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +/** + * Return true when `filePath` is `directoryPath` itself or a descendant of it. + * Comparison is symlink-resolved and case-folded on Windows (see + * `normalizeForPathComparison`). + * + * @param {string} filePath Path under test. + * @param {string} directoryPath Candidate parent directory. + * @returns {boolean} + */ +function isPathInsideDirectory(filePath, directoryPath) { + const normalizedFilePath = normalizeForPathComparison(filePath); + const normalizedDirectoryPath = normalizeForPathComparison(directoryPath); + const relativePath = path.relative(normalizedDirectoryPath, normalizedFilePath); + return ( + relativePath === "" || + (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)) + ); +} + +/** + * Classify a captured runner/module path into one of three buckets: + * - "repo" - the path lives under the repository node_modules tree. + * - "isolated" - the path lives under the isolated managed-Jest cache root. + * - "unknown" - the path is null/undefined, empty, non-string, or lives + * outside both trees. + * + * The repo path takes precedence over isolated when both options exist (the + * repo's node_modules is rarely placed under the isolated cache root in + * practice, but defense-in-depth: if it ever is, the repo-tier recovery is + * the correct first attempt). + * + * @param {string|null|undefined} capturedPath Path observed in stderr (or + * resolved by the wrapper). + * @param {object} bounds Bucket boundaries. + * @param {string} bounds.repoNodeModules Absolute path to the repo's + * node_modules directory. + * @param {string} bounds.isolatedCacheRoot Absolute path to the isolated + * managed-Jest cache root. + * @returns {"repo"|"isolated"|"unknown"} + */ +function classifyCapturedPath(capturedPath, { repoNodeModules, isolatedCacheRoot } = {}) { + if (typeof capturedPath !== "string" || capturedPath.length === 0) { + return PATH_CLASS_UNKNOWN; + } + if (typeof repoNodeModules === "string" && repoNodeModules.length > 0) { + if (isPathInsideDirectory(capturedPath, repoNodeModules)) { + return PATH_CLASS_REPO; + } + } + if (typeof isolatedCacheRoot === "string" && isolatedCacheRoot.length > 0) { + if (isPathInsideDirectory(capturedPath, isolatedCacheRoot)) { + return PATH_CLASS_ISOLATED; + } + } + return PATH_CLASS_UNKNOWN; +} + +/** + * Convert any path-like string to POSIX (forward-slash) separators. + * + * Idempotent on POSIX input. Does NOT resolve or normalize; pure separator + * swap. Use for user-facing display strings and for cross-platform string + * assertions where the comparison value is known in POSIX form. + * + * Null / undefined map to the empty string (`""`) so callers can use this + * helper inside template literals without paying for runtime type narrowing + * AND without leaking the strings `"null"` / `"undefined"` into log output + * when the upstream value was unset. Non-null primitives (number, boolean) + * are coerced via `String(value)` and then separator-swapped; this keeps + * the helper resilient when a caller accidentally hands it a non-string. + * + * @param {*} value Path-like value (typically a string). + * @returns {string} POSIX-separator form; `""` for null / undefined; the + * stringified-and-swapped form for other non-string inputs. + */ +function toPosixPath(value) { + if (value === null || value === undefined) { + return ""; + } + if (typeof value !== "string") { + return String(value).replace(/\\/g, "/"); + } + return value.replace(/\\/g, "/"); +} + +/** + * Repo-relative POSIX form of `absPath`. + * + * Falls back to the POSIX absolute form (via {@link toPosixPath}) when the + * path lives outside `repoRoot` (i.e. `path.relative` returns a parent- + * traversal or an absolute path on Windows for cross-drive inputs). Non- + * string inputs are returned unchanged. + * + * Use this helper anywhere a user-facing log line names a path that is + * "usually" inside the repo: the relative form is shorter and platform- + * agnostic; the absolute fallback is still POSIX-normalized so log scrapers + * never see backslashes. + * + * @param {*} absPath Absolute path-like value (typically a string). + * @param {*} repoRoot Absolute repository root path. + * @returns {*} POSIX-relative path when inside repo, POSIX-absolute fallback + * otherwise; original value when either input is not a string. + */ +function toRepoPosixRelative(absPath, repoRoot) { + if (typeof absPath !== "string" || typeof repoRoot !== "string") { + return absPath; + } + const rel = path.relative(repoRoot, absPath); + if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) { + return toPosixPath(absPath); + } + return toPosixPath(rel); +} + +module.exports = { + PATH_CLASS_REPO, + PATH_CLASS_ISOLATED, + PATH_CLASS_UNKNOWN, + normalizeForPathComparison, + isPathInsideDirectory, + classifyCapturedPath, + toPosixPath, + toRepoPosixRelative, +}; diff --git a/scripts/lib/path-classifier.js.meta b/scripts/lib/path-classifier.js.meta new file mode 100644 index 00000000..0b56fcdd --- /dev/null +++ b/scripts/lib/path-classifier.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a8a8bf6804732619811771118b765286 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/lib/source-stripping.js b/scripts/lib/source-stripping.js new file mode 100644 index 00000000..cc51b264 --- /dev/null +++ b/scripts/lib/source-stripping.js @@ -0,0 +1,254 @@ +"use strict"; + +/** + * @fileoverview Pure source-stripping helper shared by static-analysis tests. + * + * Removes comments and replaces string-literal payloads with empty quotes so + * downstream regex/substring checks operate only on actual JavaScript code. + * This is used by the `--testRunner` injection policy guards and any other + * static lint that needs to avoid false positives from docstrings, banners, + * error messages, or string-table contents. + * + * Implementation: this is a single-pass state-machine tokenizer that walks + * the source one character at a time. The earlier regex-pipeline form had a + * bypass: a block-comment regex of `/\/\*[\s\S]*?\*\//g` matches greedily + * across string literals, so source like: + * + * const opener = "/*"; + * args.push("--testRunner", "/tmp/x.js"); + * const closer = "* /"; // (real source uses a literal `*` and `/`) + * + * would have the entire middle line eaten as a "block comment". The + * tokenizer below correctly recognizes the `/*` and `*\/` tokens as STRING + * CONTENT (not comment delimiters) and preserves the surrounding code. + * + * States tracked: + * - `code` (default) + * - `lineComment` (after `//`) + * - `blockComment` (after `/*` outside any string) + * - `stringSingle` (inside `'...'`) + * - `stringDouble` (inside `"..."`) + * - `templateLiteral` (inside backticks) + * - `templateExpr` (inside `${...}` within a template literal — this + * sub-context contains real code and may recursively + * contain strings, templates, and comments) + * + * Output invariants: + * - Line breaks are preserved everywhere (including inside comments and + * multi-line strings) so line numbers in error snippets stay accurate. + * - Comment payloads (both line and block) are erased entirely; only the + * line breaks they spanned remain. + * - String/template literal payloads are erased; only the surrounding + * quote/backtick markers and any line breaks remain. For template + * literals, the `${` and `}` delimiters of expressions are preserved + * and the expression content is processed as code. + * + * What this DOES NOT protect against: + * - Runtime indirection. If code stores a forbidden literal in a variable + * (e.g. `const flag = "--testRunner"; args.push(flag);`), the tokenizer + * correctly erases the string content but the runtime injection still + * happens. Source-scanning guards cannot detect this; structural guards + * in the policy tests (e.g. "no `*.push()` call site references a known + * runner-path identifier") are the defense. + * - Regex literals. We do not tokenize `/regex/` patterns. In practice this + * is acceptable for the policy tests because regex literals cannot + * contain the bare token `--testRunner` (the `-` would be ambiguous in a + * character class) AND a regex literal's body is not a comment delimiter. + * If a future policy needs regex-literal awareness, add a `regex` state + * that triggers when `/` follows an operator/keyword context. + * + * Input handling: + * - Non-string input (undefined, null, numbers, Buffers, etc.) returns the + * empty string. Callers that need to pass a Buffer must `.toString("utf8")` + * it first; we deliberately do not auto-decode to keep the helper pure + * and to avoid accidentally swallowing binary payloads. + * + * Pure: no `require`s with side effects, no top-level I/O, no globals. + */ + +function stripJsCommentsAndStrings(source) { + if (typeof source !== "string") { + return ""; + } + if (source.length === 0) { + return ""; + } + + const out = []; + // Stack permits templateExpr nesting (e.g. `a${`b${c}d`}e`). The top of + // the stack is the active state. + const stack = [{ kind: "code" }]; + const n = source.length; + let i = 0; + + while (i < n) { + const frame = stack[stack.length - 1]; + const state = frame.kind; + const ch = source[i]; + const next = i + 1 < n ? source[i + 1] : ""; + + if (state === "code" || state === "templateExpr") { + if (ch === "/" && next === "/") { + stack.push({ kind: "lineComment" }); + i += 2; + continue; + } + if (ch === "/" && next === "*") { + stack.push({ kind: "blockComment" }); + i += 2; + continue; + } + if (ch === "'") { + stack.push({ kind: "stringSingle" }); + out.push("'"); + i++; + continue; + } + if (ch === "\"") { + stack.push({ kind: "stringDouble" }); + out.push("\""); + i++; + continue; + } + if (ch === "`") { + stack.push({ kind: "templateLiteral" }); + out.push("`"); + i++; + continue; + } + + if (state === "templateExpr") { + if (ch === "{") { + frame.depth = (frame.depth || 0) + 1; + out.push(ch); + i++; + continue; + } + if (ch === "}") { + if ((frame.depth || 0) === 0) { + // Closing brace of the `${...}` expression itself; + // pop back to the surrounding template literal. + stack.pop(); + out.push("}"); + i++; + continue; + } + frame.depth -= 1; + out.push(ch); + i++; + continue; + } + } + + out.push(ch); + i++; + continue; + } + + if (state === "lineComment") { + if (ch === "\n") { + stack.pop(); + out.push("\n"); + i++; + continue; + } + // Defensive: a `\r` immediately before `\n` is part of CRLF; we + // preserve neither the `\r` nor the comment payload, only the + // `\n` (when we reach it on the next iteration). + i++; + continue; + } + + if (state === "blockComment") { + if (ch === "*" && next === "/") { + stack.pop(); + i += 2; + continue; + } + if (ch === "\n") { + out.push("\n"); + } + i++; + continue; + } + + if (state === "stringSingle") { + if (ch === "\\" && i + 1 < n) { + // Skip the escape sequence entirely (both chars). For + // multi-line escapes like `\` we still drop the + // backslash and the newline — the line-count invariant in + // the file as a whole is unaffected because such constructs + // also remove a logical line from the source. The dominant + // case (single-char escapes) is correct. + i += 2; + continue; + } + if (ch === "'") { + stack.pop(); + out.push("'"); + i++; + continue; + } + if (ch === "\n") { + // Unterminated single-quoted string spanning a newline — + // legal in source only via an escape; preserve the newline + // so line numbers remain stable. + out.push("\n"); + } + i++; + continue; + } + + if (state === "stringDouble") { + if (ch === "\\" && i + 1 < n) { + i += 2; + continue; + } + if (ch === "\"") { + stack.pop(); + out.push("\""); + i++; + continue; + } + if (ch === "\n") { + out.push("\n"); + } + i++; + continue; + } + + if (state === "templateLiteral") { + if (ch === "\\" && i + 1 < n) { + i += 2; + continue; + } + if (ch === "`") { + stack.pop(); + out.push("`"); + i++; + continue; + } + if (ch === "$" && next === "{") { + stack.push({ kind: "templateExpr", depth: 0 }); + out.push("${"); + i += 2; + continue; + } + if (ch === "\n") { + out.push("\n"); + } + i++; + continue; + } + + // Defensive: unknown state, advance one char to guarantee forward + // progress. This branch is unreachable given the states above. + i++; + } + + return out.join(""); +} + +module.exports = { + stripJsCommentsAndStrings, +}; diff --git a/scripts/lib/source-stripping.js.meta b/scripts/lib/source-stripping.js.meta new file mode 100644 index 00000000..f5ac1e84 --- /dev/null +++ b/scripts/lib/source-stripping.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f641cdf6eee59e4ce52d437d95ad2c7a +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/postinstall.js b/scripts/postinstall.js new file mode 100644 index 00000000..f7b94500 --- /dev/null +++ b/scripts/postinstall.js @@ -0,0 +1,40 @@ +#!/usr/bin/env node +/** + * postinstall.js + * + * Runs validate-node-tooling.js after npm install to surface drift + * (missing/broken Jest runner files, etc.) immediately. Always exits 0 + * so a non-fatal warning never blocks `npm install` on broken machines. + * Cross-platform: uses Node only, no shell-specific syntax. + */ + +"use strict"; + +const path = require("path"); +const childProcess = require("child_process"); + +const VALIDATOR = path.join(__dirname, "validate-node-tooling.js"); + +try { + const result = childProcess.spawnSync( + process.execPath, + [VALIDATOR], + { stdio: "inherit" } + ); + + if (result.error) { + console.warn( + `āš ļø postinstall: validate-node-tooling launch failed (${result.error.message}); continuing.` + ); + } else if (typeof result.status === "number" && result.status !== 0) { + console.warn( + "āš ļø postinstall: validate-node-tooling reported drift; install continues (non-fatal)." + ); + } +} catch (error) { + console.warn( + `āš ļø postinstall: unexpected error running validate-node-tooling (${error.message}); continuing.` + ); +} + +process.exit(0); diff --git a/scripts/postinstall.js.meta b/scripts/postinstall.js.meta new file mode 100644 index 00000000..87342771 --- /dev/null +++ b/scripts/postinstall.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 0aab6abd708a46799f605aecdf5443bb +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/run-managed-cspell.js b/scripts/run-managed-cspell.js new file mode 100644 index 00000000..7bb564af --- /dev/null +++ b/scripts/run-managed-cspell.js @@ -0,0 +1,211 @@ +#!/usr/bin/env node +/** + * run-managed-cspell.js + * + * Runs cspell in a robust, non-interactive way for hooks and local automation: + * 1) Integrity gate (Step 8): probe node_modules health BEFORE invoking + * cspell. If a partial extract is detected and auto-repair is allowed, + * run npm ci, then re-probe in a subprocess (defeats the parent's stat + * cache). + * 2) Prefer local devDependency (node_modules/cspell/bin.mjs). + * 3) If local cspell is missing, provision a pinned fallback via npm's + * bundled npx-cli. + * + * Cspell version pin: read from package.json devDependencies.cspell with + * a static fallback. The hook entry in `.pre-commit-config.yaml` points at + * this wrapper, which then forwards args via spawnPlatformCommandSync for + * Windows shell-shim safety. + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const childProcess = require("child_process"); +const { runBundledNpxCommand } = require("./lib/managed-prettier"); +const { spawnPlatformCommandSync } = require("./lib/shell-command"); +const jestErrorDecoderModule = require("./lib/jest-error-decoder"); +const { isTruthyEnv } = jestErrorDecoderModule; +const { + probeIntegrity, + probeIntegrityInSubprocess, + probeResolverHealth, +} = require("./lib/node-modules-integrity"); +const { + isAutoRepairAllowed: defaultIsAutoRepairAllowed, + runIntegrityGateWithRecovery, +} = require("./lib/integrity-gate-with-recovery"); +const { + getNpmMajorVersion, + attemptNpmCiRecovery, + printActionableRepairBanner, +} = require("./run-managed-jest"); + +const REPO_ROOT = path.join(__dirname, ".."); +const PACKAGE_JSON_PATH = path.join(REPO_ROOT, "package.json"); +const LOCAL_CSPELL_BIN = path.join(REPO_ROOT, "node_modules", "cspell", "bin.mjs"); +// Static fallback used only when package.json is unparseable. Kept in sync +// with the package.json devDependency by the cspell-version-parity test. +const FALLBACK_CSPELL_SPEC = "cspell@10.0.0"; + +function normalizeVersion(rawVersion) { + if (typeof rawVersion !== "string") { + return null; + } + const trimmed = rawVersion.trim().replace(/^[~^]/, ""); + if (!/^\d+\.\d+\.\d+(?:[-+].+)?$/.test(trimmed)) { + return null; + } + return trimmed; +} + +function getPinnedCspellSpec(readFileSyncFn = fs.readFileSync) { + try { + const pkg = JSON.parse(readFileSyncFn(PACKAGE_JSON_PATH, "utf8")); + const version = normalizeVersion( + pkg && pkg.devDependencies && pkg.devDependencies.cspell + ); + if (version) { + return `cspell@${version}`; + } + } catch { + // Fall through to static fallback when package.json is unavailable. + } + return FALLBACK_CSPELL_SPEC; +} + +function runCommand(command, args, options = {}) { + const spawnSync = command === "npm" || command === "npx" + ? spawnPlatformCommandSync + : childProcess.spawnSync; + const result = spawnSync(command, args, { + cwd: REPO_ROOT, + stdio: "inherit", + ...options, + }); + return { + status: result.status, + error: result.error || null, + }; +} + +function runLocalCspell(args) { + return runCommand(process.execPath, [LOCAL_CSPELL_BIN, ...args]); +} + +function runNpxCspell(args, cspellSpec = getPinnedCspellSpec(), options = {}) { + const { runBundledNpxCommandFn = runBundledNpxCommand } = options; + try { + const result = runBundledNpxCommandFn( + ["--yes", `--package=${cspellSpec}`, "cspell", ...args], + { + cwd: REPO_ROOT, + stdio: "inherit", + } + ); + return { + status: result.status, + error: result.error || null, + }; + } catch (error) { + return { + status: null, + error, + }; + } +} + +function runManagedCspell(args, options = {}) { + const { + existsSyncFn = fs.existsSync, + runLocalCspellFn = runLocalCspell, + runNpxCspellFn = runNpxCspell, + // Integrity gate dependencies (Step 8). + runIntegrityGateWithRecoveryFn = runIntegrityGateWithRecovery, + probeIntegrityFn = probeIntegrity, + probeIntegrityInSubprocessFn = probeIntegrityInSubprocess, + probeResolverHealthFn = probeResolverHealth, + attemptNpmCiRecoveryFn = attemptNpmCiRecovery, + getNpmMajorVersionFn = getNpmMajorVersion, + printActionableRepairBannerFn = printActionableRepairBanner, + isAutoRepairAllowedFn = null, + envFn = () => process.env, + warnFn = console.warn, + } = options; + + const env = (envFn && envFn()) || process.env; + if (!isTruthyEnv(env.DXMSG_HOOK_SKIP_INTEGRITY)) { + const resolvedIsAutoRepairAllowed = isAutoRepairAllowedFn !== null + ? isAutoRepairAllowedFn + : () => defaultIsAutoRepairAllowed({ + env, + repoRoot: REPO_ROOT, + getNpmMajorVersionFn, + }); + + const gateResult = runIntegrityGateWithRecoveryFn({ + repoRoot: REPO_ROOT, + probeIntegrityFn, + probeIntegrityInSubprocessFn, + probeResolverHealthFn, + attemptNpmCiRecoveryFn, + isAutoRepairAllowedFn: resolvedIsAutoRepairAllowed, + printActionableRepairBannerFn, + decoder: jestErrorDecoderModule, + warnFn, + env, + }); + + if (!gateResult || !gateResult.ok) { + if (isTruthyEnv(env.DXMSG_HOOK_NO_AUTOREPAIR)) { + warnFn( + "WARNING: integrity gate failed but DXMSG_HOOK_NO_AUTOREPAIR=1 -> proceeding to cspell invocation with degraded gate." + ); + } else { + return { status: 1, error: null }; + } + } + } + + if (existsSyncFn(LOCAL_CSPELL_BIN)) { + return runLocalCspellFn(args); + } + return runNpxCspellFn(args); +} + +function printManagedCspellLaunchError(error) { + const detail = error && error.message ? ` (${error.message})` : ""; + console.error(`Failed to launch managed cspell${detail}.`); + console.error("Ensure Node.js/npm are available in this shell, or run npm install."); + if (process.platform === "win32") { + console.error( + "Windows tip: if you use nvm/fnm, open PowerShell or Git Bash with Node initialized and verify npm --version." + ); + } +} + +function main() { + const result = runManagedCspell(process.argv.slice(2)); + if (result.error) { + printManagedCspellLaunchError(result.error); + process.exit(1); + } + process.exit(typeof result.status === "number" ? result.status : 1); +} + +module.exports = { + REPO_ROOT, + LOCAL_CSPELL_BIN, + FALLBACK_CSPELL_SPEC, + normalizeVersion, + getPinnedCspellSpec, + runCommand, + runLocalCspell, + runNpxCspell, + runManagedCspell, + printManagedCspellLaunchError, +}; + +if (require.main === module) { + main(); +} diff --git a/scripts/run-managed-cspell.js.meta b/scripts/run-managed-cspell.js.meta new file mode 100644 index 00000000..1ab22f40 --- /dev/null +++ b/scripts/run-managed-cspell.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 7485946f6ffe7da3f0a810e7e33a8168 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/run-managed-jest.js b/scripts/run-managed-jest.js index 81b4f8bf..7a163ebe 100644 --- a/scripts/run-managed-jest.js +++ b/scripts/run-managed-jest.js @@ -6,6 +6,11 @@ * 1) Prefer local devDependency (node_modules/jest/bin/jest.js). * 2) If local Jest is missing, provision a pinned fallback via npm exec. * 3) If npm is too old for npm exec (or unavailable), fall back to npx. + * + * Discovery note: stderr-based self-heal and the actionable repair banner are + * implemented in scripts/lib/jest-error-decoder.js. That module is pure (no + * I/O), so its `PATTERNS` table is the single source of truth for what + * failures we recognize and what repair we attempt. */ "use strict"; @@ -20,6 +25,32 @@ const { isShellShimCommand, spawnPlatformCommandSync, } = require("./lib/shell-command"); +const jestErrorDecoderModule = require("./lib/jest-error-decoder"); +const { + decodeJestStderr, + formatRepairBanner, + isTruthyEnv, +} = jestErrorDecoderModule; +const { + normalizeForPathComparison, + isPathInsideDirectory, + classifyCapturedPath, + PATH_CLASS_REPO, + PATH_CLASS_ISOLATED, + PATH_CLASS_UNKNOWN, + toPosixPath, + toRepoPosixRelative, +} = require("./lib/path-classifier"); +const { + INTEGRITY_TARGETS, + probeIntegrity, + probeIntegrityInSubprocess, + probeResolverHealth, +} = require("./lib/node-modules-integrity"); +const { + isAutoRepairAllowed: defaultIsAutoRepairAllowed, + runIntegrityGateWithRecovery, +} = require("./lib/integrity-gate-with-recovery"); const REPO_ROOT = path.join(__dirname, ".."); const REPO_NODE_MODULES = path.join(REPO_ROOT, "node_modules"); @@ -73,8 +104,315 @@ function runCommand(command, args, spawnOptions = {}) { }; } -function runLocalJest(args) { - return runCommand(process.execPath, [LOCAL_JEST_BIN, ...args]); +/** + * Like runCommand() but captures stderr so the wrapper can decode the failure + * mode after the child exits. stderr is BATCH-FORWARDED: spawnSync (which we + * use to keep the rest of this module synchronous) buffers the entire stderr + * stream until the child exits, and only then writes it through to the + * parent's stderr. Output appears in one burst at the end rather than as it + * is produced. + * + * All Jest invocations that need stderr decoding (local + isolated) use this + * capturing wrapper. Batch-forwarding stderr is acceptable because: + * (1) successful Jest runs produce minimal stderr, + * (2) failed runs need the captured stderr for decoder-driven self-healing + * (e.g. the local-tier MISSING_TEST_RUNNER fall-through into the + * isolated fallback), and + * (3) `spawnSync` cannot truly stream stderr in a synchronous API; the + * alternative (async child_process.spawn with a `data` listener + * mirroring chunks) would require either a busy-wait loop or a worker + * thread to keep the call synchronous, both of which add complexity + * for marginal benefit on these low-volume paths. + * + * IMPORTANT: `stdio` is intentionally NOT overridable via spawnOptions. + * Capture mode requires the stderr pipe; if a caller could pass + * `stdio: "inherit"` we would silently lose decode capability and report an + * empty `stderr` string, which is worse than the current behavior of "decode + * is sometimes useful". + * + * @param {string} command Executable name. + * @param {string[]} args Command arguments. + * @param {object} spawnOptions Additional spawnSync options. `stdio` keys are + * ignored to protect capture-mode invariants. + * @returns {{status: number|null, error: Error|null, stderr: string}} + */ +function runCommandCapturingStderr(command, args, spawnOptions = {}) { + const spawnSyncImpl = isShellShimCommand(command) + ? spawnPlatformCommandSync + : childProcess.spawnSync; + + // Note the order: spawnOptions spread FIRST, then our defaults override. + // This protects the stdio invariant against caller-supplied keys while + // still letting callers pass cwd, env, encoding, etc. + const merged = { + cwd: REPO_ROOT, + ...spawnOptions, + // stdio is NEVER overridable by callers: capture mode requires this + // exact pipe configuration for the stderr decoder to function. + stdio: ["inherit", "inherit", "pipe"], + }; + + const result = spawnSyncImpl(command, args, merged); + + let stderrBuffer = result.stderr; + let stderrText = ""; + if (stderrBuffer) { + if (Buffer.isBuffer(stderrBuffer)) { + // Forward captured bytes to the parent's stderr in a single batch. + // This happens AFTER the child has exited (see JSDoc above); stderr + // is not streamed as it is produced. Banner output (printed by + // callers) lands after this burst so ordering is preserved. + try { + process.stderr.write(stderrBuffer); + } catch { + // Swallow write errors (e.g. EPIPE when stderr was closed) + // so we always return the captured buffer to the caller. + } + stderrText = stderrBuffer.toString("utf8"); + } else if (typeof stderrBuffer === "string") { + try { + process.stderr.write(stderrBuffer); + } catch { + // Swallow write errors (see above). + } + stderrText = stderrBuffer; + } + } + + return { + status: result.status, + error: result.error || null, + stderr: stderrText, + }; +} + +/** + * Delete the isolated managed-Jest install directory for the given spec so + * the next invocation re-bootstraps from scratch. Returns true on success, + * false on error (errors are logged via warnFn). + * + * Scoped to the isolated fallback path only; never touches local node_modules. + * + * Safety: the resolved install directory is validated to be a STRICT + * descendant of ISOLATED_JEST_CACHE_ROOT before any deletion. This prevents + * `sanitizeCacheKey("..")` (which preserves "..", since `.` is in the + * allow-list) from being weaponized into deleting the cache root's parent + * (typically the OS temp dir). + * + * @param {string} jestSpec Pinned jest spec (e.g. "jest@30.3.0"). + * @param {object} options Dependency injection options. + * @returns {boolean} + */ +function attemptIsolatedCacheReset( + jestSpec, + { rmSyncFn = fs.rmSync, warnFn = console.warn } = {} +) { + const { installDir } = getIsolatedJestPaths(jestSpec); + + // Defense-in-depth: refuse to delete anything that does not normalize to + // a strict descendant of the isolated cache root. This blocks + // path-traversal inputs like "..", ".", or absolute paths. + const cacheRoot = path.resolve(ISOLATED_JEST_CACHE_ROOT); + const resolvedInstallDir = path.resolve(installDir); + const relativePath = path.relative(cacheRoot, resolvedInstallDir); + // Reject the cache root itself ("") and any genuine parent-traversal + // (either bare ".." or any path whose first segment is ".."). The + // ".." + path.sep check intentionally rejects only segment-boundary + // traversal: a sanitized key like "..foo" is NOT a traversal and + // must not trip the guard. + if ( + relativePath === "" || + relativePath === ".." || + relativePath.startsWith(".." + path.sep) || + path.isAbsolute(relativePath) + ) { + warnFn( + `WARNING: Refusing to reset isolated managed-Jest cache; resolved path is not a descendant of ${toPosixPath(cacheRoot)}: ${toPosixPath(resolvedInstallDir)}` + ); + return false; + } + + try { + rmSyncFn(resolvedInstallDir, { recursive: true, force: true }); + return true; + } catch (error) { + const detail = error && error.message ? error.message : String(error); + warnFn(`WARNING: Failed to reset isolated managed-Jest cache at ${toPosixPath(resolvedInstallDir)}: ${detail}`); + return false; + } +} + +/** + * Run `npm ci --no-audit --no-fund` against the repository root in an attempt + * to repair a partially-installed node_modules. Returns the raw runCommand + * result so the caller can decide whether to retry. + * + * @param {object} options Dependency injection options. + * @returns {{status: number|null, error: Error|null}} + */ +function attemptNpmCiRecovery({ + runCommandFn = runCommand, + rmSyncFn = fs.rmSync, + envFn = () => process.env, + warnFn = console.warn, +} = {}) { + const env = (envFn && envFn()) || process.env; + const aggressive = isTruthyEnv(env.DXMSG_HOOK_AGGRESSIVE_RECOVERY); + + if (aggressive) { + const nodeModulesPath = path.join(REPO_ROOT, "node_modules"); + warnFn( + `WARNING: DXMSG_HOOK_AGGRESSIVE_RECOVERY=1 -> removing ${toPosixPath(nodeModulesPath)} before npm ci.` + ); + try { + rmSyncFn(nodeModulesPath, { recursive: true, force: true }); + } catch (error) { + const detail = error && error.message ? error.message : String(error); + warnFn(`WARNING: Aggressive recovery rm-rf failed (${detail}); continuing with npm ci.`); + } + } + + warnFn("WARNING: Attempting `npm ci` recovery to repair local node_modules..."); + const result = runCommandFn("npm", ["ci", "--no-audit", "--no-fund"], { + cwd: REPO_ROOT, + }); + + if (!result || result.status !== 0) { + const detail = result && result.error && result.error.message + ? result.error.message + : `status=${result ? result.status : "null"}`; + warnFn(`WARNING: \`npm ci\` recovery did not succeed (${detail}).`); + } + + return result; +} + +/** + * Print the actionable repair banner to stderr. No-op when decoded is null. + * + * The banner is preceded by a single blank line so it stands out from the + * batch-forwarded Jest stderr that `runCommandCapturingStderr` writes + * immediately before this function is called. + * + * @param {object|null} decoded Output of decodeJestStderr(). + * @param {object} [options] Dependency injection options. + * @param {Function} [options.writeFn] Where to write the banner (defaults to + * process.stderr.write bound to stderr). + * @param {string|null|undefined} [options.envCi] CI env value to pass through + * to the formatter. Defaults to process.env.CI. Accepts any truthy/falsy + * string per isTruthyEnv semantics. + * @param {object} [options.env] Full env object to pass to the formatter, + * overriding envCi. Tests use this to inject deterministic env. + * @param {boolean} [options.isTTY] Whether the destination stream is a TTY. + * Defaults to process.stderr.isTTY (banner is written to stderr). + */ +function printActionableRepairBanner( + decoded, + { + writeFn = process.stderr.write.bind(process.stderr), + envCi = process.env.CI, + env = null, + isTTY = undefined, + } = {} +) { + if (!decoded) { + return; + } + + const formatterEnv = env || { CI: envCi }; + const formatterOptions = { color: true, env: formatterEnv }; + if (typeof isTTY === "boolean") { + formatterOptions.isTTY = isTTY; + } + + const banner = formatRepairBanner(decoded, formatterOptions); + if (!banner) { + return; + } + + // Prefix with a blank line so the banner visually separates from the + // batch-forwarded Jest stderr that arrived just before. + const output = `\n${banner}`; + try { + writeFn(output); + } catch { + // Swallow write errors (e.g. EPIPE) so failure to print the banner + // never masks the underlying Jest exit code. This is intentional. + } +} + +function tryLoadModule( + moduleSpecifier, + repoRequire = REPO_REQUIRE +) { + try { + repoRequire(moduleSpecifier); + return true; + } catch { + return false; + } +} + +function runLocalJest(args, options = {}) { + const { + moduleResolver = resolveLocalModule, + tryLoadModuleFn = tryLoadModule, + hasCliOptionFn = hasCliOption, + // Deliberate tradeoff: local Jest uses the stderr-capturing wrapper + // (not a stdio:"inherit" passthrough) because the MISSING_TEST_RUNNER + // decode + fall-through into the isolated tier requires the decoder + // to see the child's stderr text. The cost is that stderr is + // batch-forwarded after the child exits rather than streamed; see + // the JSDoc on runCommandCapturingStderr for the full rationale. + runCommandFn = runCommandCapturingStderr, + existsSyncFn = fs.existsSync, + decodeJestStderrFn = decodeJestStderr, + warnFn = console.warn, + } = options; + + // Validate local jest-circus is healthy as a precondition. We deliberately + // DO NOT inject `--testRunner` with an absolute path: Jest 27+ defaults to + // jest-circus and its own internal resolver finds the bundled runner more + // reliably than we can second-guess from outside (notably on Windows, where + // jest-config's runner validator has rejected absolute paths that + // require.resolve + fs.existsSync both report as valid). If the caller + // explicitly passes `--testRunner`, we forward it unchanged. + const callerProvidedTestRunner = hasCliOptionFn(args, "--testRunner"); + + if (!callerProvidedTestRunner) { + const resolvedRunnerPath = moduleResolver("jest-circus/runner"); + if ( + !resolvedRunnerPath || + !existsSyncFn(resolvedRunnerPath) || + !isPathInsideDirectory(resolvedRunnerPath, REPO_NODE_MODULES) || + !tryLoadModuleFn("jest-circus/runner") + ) { + warnFn("WARNING: Local jest-circus/runner failed load validation; falling back to managed Jest."); + return null; + } + } + + const invocationArgs = [LOCAL_JEST_BIN, ...args]; + const result = runCommandFn(process.execPath, invocationArgs); + + // If local Jest emitted MISSING_TEST_RUNNER stderr, the local install is + // effectively unhealthy: the isolated-fallback tier (which CAN reset its + // cache) is the correct place to recover. Return null so `runManagedJest` + // falls through to the isolated tier rather than printing a banner and + // exiting. We intentionally only fall through for MISSING_TEST_RUNNER; + // MISSING_LOCAL_JEST is handled in `runManagedJest` because it can be + // self-healed in-place via `npm ci`. + if (result && result.status !== 0) { + const decoded = decodeJestStderrFn(result.stderr); + if (decoded && decoded.kind === "MISSING_TEST_RUNNER") { + warnFn( + "WARNING: Local Jest reported MISSING_TEST_RUNNER; treating local tier as unhealthy and falling through to isolated fallback." + ); + return null; + } + } + + return result; } function getPinnedFallbackJestSpec( @@ -96,7 +434,7 @@ function getPinnedFallbackJestSpec( function runNpmExecJest(args) { const jestSpec = getPinnedFallbackJestSpec(); - return runCommand("npm", [ + return runCommandCapturingStderr("npm", [ "exec", "--yes", `--package=${jestSpec}`, @@ -108,7 +446,7 @@ function runNpmExecJest(args) { function runNpxJest(args) { const jestSpec = getPinnedFallbackJestSpec(); - return runCommand("npx", [ + return runCommandCapturingStderr("npx", [ "--yes", `--package=${jestSpec}`, "jest", @@ -120,6 +458,10 @@ function sanitizeCacheKey(value) { return String(value).replace(/[^a-zA-Z0-9._-]+/g, "_"); } +function getDefaultIsolatedJestRunnerPath(installDir) { + return path.join(installDir, "node_modules", "jest-circus", "build", "runner.js"); +} + function getIsolatedJestPaths(jestSpec) { const cacheKey = sanitizeCacheKey(jestSpec); const installDir = path.join(ISOLATED_JEST_CACHE_ROOT, cacheKey); @@ -127,10 +469,39 @@ function getIsolatedJestPaths(jestSpec) { installDir, packageJsonPath: path.join(installDir, "package.json"), jestBinPath: path.join(installDir, "node_modules", "jest", "bin", "jest.js"), - jestRunnerPath: path.join(installDir, "node_modules", "jest-circus", "build", "runner.js"), + jestRunnerPath: getDefaultIsolatedJestRunnerPath(installDir), }; } +function resolveIsolatedJestRunnerPath( + installDir, + { + existsSyncFn = fs.existsSync, + createRequireFn = createRequire, + } = {} +) { + const packageJsonPath = path.join(installDir, "package.json"); + const defaultRunnerPath = getDefaultIsolatedJestRunnerPath(installDir); + + try { + if (existsSyncFn(packageJsonPath)) { + const isolatedRequire = createRequireFn(packageJsonPath); + const resolvedRunnerPath = isolatedRequire.resolve("jest-circus/runner"); + if (existsSyncFn(resolvedRunnerPath)) { + return resolvedRunnerPath; + } + } + } catch { + // Fall back to the legacy internal path for compatibility with older layouts. + } + + if (existsSyncFn(defaultRunnerPath)) { + return defaultRunnerPath; + } + + return null; +} + function hasCliOption(args, optionName) { const normalizedOption = optionName.startsWith("--") ? optionName @@ -170,25 +541,77 @@ function prepareIsolatedFallbackJest( existsSyncFn = fs.existsSync, mkdirSyncFn = fs.mkdirSync, writeFileSyncFn = fs.writeFileSync, + rmSyncFn = fs.rmSync, runCommandFn = runCommand, + resolveIsolatedJestRunnerPathFn = resolveIsolatedJestRunnerPath, warnFn = console.warn, } = {} ) { - const { installDir, packageJsonPath, jestBinPath, jestRunnerPath } = getIsolatedJestPaths(jestSpec); + const { + installDir, + packageJsonPath, + jestBinPath, + jestRunnerPath: defaultJestRunnerPath, + } = getIsolatedJestPaths(jestSpec); const hasCachedJestBin = existsSyncFn(jestBinPath); - const hasCachedJestRunner = existsSyncFn(jestRunnerPath); + const cachedJestRunnerPath = resolveIsolatedJestRunnerPathFn(installDir, { + existsSyncFn, + }); + const hasCachedJestRunner = typeof cachedJestRunnerPath === "string"; if (hasCachedJestBin && hasCachedJestRunner) { return { jestBinPath, - jestRunnerPath, + jestRunnerPath: cachedJestRunnerPath, cacheHit: true, }; } if (hasCachedJestBin && !hasCachedJestRunner) { - warnFn(`āš ļø Isolated fallback cache is missing Jest runner; reinstalling fallback: ${jestRunnerPath}`); + warnFn(`āš ļø Isolated fallback cache is missing Jest runner; reinstalling fallback: ${toPosixPath(defaultJestRunnerPath)}`); + } + + // Idempotency fix (Step 3): when the install dir already exists but the + // cached runner is missing (or the bin is missing), `npm install` against + // a half-populated tree can short-circuit with "up to date" without ever + // re-extracting. Tear the dir down first and re-create from scratch. + // + // Safety: mirror the path-traversal validation from + // `attemptIsolatedCacheReset`. We refuse to rm anything that does not + // resolve to a strict descendant of ISOLATED_JEST_CACHE_ROOT, so a + // poisoned `jestSpec` cannot weaponize this code path into deleting an + // unrelated directory. + if (existsSyncFn(installDir) && !(hasCachedJestBin && hasCachedJestRunner)) { + const cacheRoot = path.resolve(ISOLATED_JEST_CACHE_ROOT); + const resolvedInstallDir = path.resolve(installDir); + const relativePath = path.relative(cacheRoot, resolvedInstallDir); + // Mirror the segment-boundary traversal guard from + // `attemptIsolatedCacheReset`: reject "" (the cache root itself), + // bare ".." and ".." followed by path.sep (genuine parent + // traversal), and absolute paths. A sanitized key like "..foo" + // resolves under the cache root, is NOT a traversal, and must not + // trip the guard. + const isStrictDescendant = + relativePath !== "" && + relativePath !== ".." && + !relativePath.startsWith(".." + path.sep) && + !path.isAbsolute(relativePath); + + if (!isStrictDescendant) { + warnFn( + `WARNING: Refusing to rm partial isolated fallback install dir; resolved path is not a descendant of ${toPosixPath(cacheRoot)}: ${toPosixPath(resolvedInstallDir)}` + ); + } else { + try { + rmSyncFn(resolvedInstallDir, { recursive: true, force: true }); + } catch (error) { + const detail = error && error.message ? error.message : String(error); + warnFn( + `WARNING: Failed to rm partial isolated fallback install dir at ${toPosixPath(resolvedInstallDir)}: ${detail}` + ); + } + } } mkdirSyncFn(installDir, { recursive: true }); @@ -226,7 +649,7 @@ function prepareIsolatedFallbackJest( } if (!existsSyncFn(jestBinPath)) { - warnFn(`āš ļø Isolated fallback Jest binary missing after install: ${jestBinPath}`); + warnFn(`āš ļø Isolated fallback Jest binary missing after install: ${toPosixPath(jestBinPath)}`); return { jestBinPath: null, jestRunnerPath: null, @@ -234,8 +657,12 @@ function prepareIsolatedFallbackJest( }; } - if (!existsSyncFn(jestRunnerPath)) { - warnFn(`āš ļø Isolated fallback Jest runner missing after install: ${jestRunnerPath}`); + const installedJestRunnerPath = resolveIsolatedJestRunnerPathFn(installDir, { + existsSyncFn, + }); + + if (!installedJestRunnerPath) { + warnFn(`āš ļø Isolated fallback Jest runner missing after install (legacy fallback path: ${toPosixPath(defaultJestRunnerPath)}).`); return { jestBinPath: null, jestRunnerPath: null, @@ -245,7 +672,7 @@ function prepareIsolatedFallbackJest( return { jestBinPath, - jestRunnerPath, + jestRunnerPath: installedJestRunnerPath, cacheHit: false, }; } @@ -254,25 +681,19 @@ function printIsolatedFallbackSelection( jestBinPath, cacheHit, { - testRunnerPath = null, - testRunnerInjected = false, callerProvidedTestRunner = false, nodePathOverride = null, } = {} ) { const cacheLabel = cacheHit ? "cache hit" : "fresh install"; - console.warn(`āš ļø Using isolated fallback Jest (${cacheLabel}): ${jestBinPath}`); - - if (testRunnerInjected && testRunnerPath) { - console.warn(`āš ļø Injected isolated Jest test runner: ${testRunnerPath}`); - } + console.warn(`āš ļø Using isolated fallback Jest (${cacheLabel}): ${toPosixPath(jestBinPath)}`); if (callerProvidedTestRunner) { console.warn("āš ļø Caller provided --testRunner; managed runner did not override it."); } if (nodePathOverride) { - console.warn(`āš ļø Injected NODE_PATH for isolated fallback: ${nodePathOverride}`); + console.warn(`āš ļø Injected NODE_PATH for isolated fallback: ${toPosixPath(nodePathOverride)}`); } } @@ -281,7 +702,7 @@ function runIsolatedFallbackJest( { getPinnedFallbackJestSpecFn = getPinnedFallbackJestSpec, prepareIsolatedFallbackJestFn = prepareIsolatedFallbackJest, - runCommandFn = runCommand, + runCommandFn = runCommandCapturingStderr, printIsolatedFallbackSelectionFn = printIsolatedFallbackSelection, existsSyncFn = fs.existsSync, hasCliOptionFn = hasCliOption, @@ -295,21 +716,23 @@ function runIsolatedFallbackJest( return null; } - const invocationArgs = [prepared.jestBinPath]; + // Validate the isolated jest-circus runner exists as a healthy-install + // precondition, but DO NOT inject `--testRunner` with an absolute path. + // Jest 27+ defaults to jest-circus and its own resolver, walking up from + // the isolated jest binary, reliably finds the sibling jest-circus we just + // installed. Passing an absolute path triggers a separate jest-config + // validator path that has been observed to reject otherwise-valid paths on + // Windows. If the caller explicitly passes `--testRunner`, forward it. const callerProvidedTestRunner = hasCliOptionFn(args, "--testRunner"); - let injectedRunnerPath = null; if (!callerProvidedTestRunner) { if (!prepared.jestRunnerPath || !existsSyncFn(prepared.jestRunnerPath)) { - warnFn(`āš ļø Isolated fallback Jest runner unavailable at expected path: ${prepared.jestRunnerPath}`); + warnFn(`āš ļø Isolated fallback Jest runner unavailable at expected path: ${toPosixPath(prepared.jestRunnerPath)}`); return null; } - - invocationArgs.push("--testRunner", prepared.jestRunnerPath); - injectedRunnerPath = prepared.jestRunnerPath; } - invocationArgs.push(...args); + const invocationArgs = [prepared.jestBinPath, ...args]; const isolatedNodeModulesPath = path.dirname( path.dirname(path.dirname(prepared.jestBinPath)) @@ -317,8 +740,6 @@ function runIsolatedFallbackJest( const isolatedNodePathEnv = buildNodePathEnv(isolatedNodeModulesPath); printIsolatedFallbackSelectionFn(prepared.jestBinPath, prepared.cacheHit, { - testRunnerPath: injectedRunnerPath, - testRunnerInjected: Boolean(injectedRunnerPath), callerProvidedTestRunner, nodePathOverride: isolatedNodePathEnv.NODE_PATH, }); @@ -340,23 +761,6 @@ function isCommandUnavailable(result) { return result.status === 127; } -function normalizeForPathComparison(targetPath) { - let resolved = path.resolve(targetPath); - try { - resolved = fs.realpathSync.native ? fs.realpathSync.native(resolved) : fs.realpathSync(resolved); - } catch { - // Keep resolved path when target is unavailable; callers handle existence separately. - } - return process.platform === "win32" ? resolved.toLowerCase() : resolved; -} - -function isPathInsideDirectory(filePath, directoryPath) { - const normalizedFilePath = normalizeForPathComparison(filePath); - const normalizedDirectoryPath = normalizeForPathComparison(directoryPath); - const relativePath = path.relative(normalizedDirectoryPath, normalizedFilePath); - return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); -} - function resolveLocalModule(moduleSpecifier) { try { return REPO_REQUIRE.resolve(moduleSpecifier); @@ -367,7 +771,8 @@ function resolveLocalModule(moduleSpecifier) { function hasHealthyLocalJestInstall( moduleResolver = resolveLocalModule, - existsSyncFn = fs.existsSync + existsSyncFn = fs.existsSync, + tryLoadModuleFn = tryLoadModule ) { if (!existsSyncFn(LOCAL_JEST_BIN)) { return false; @@ -382,7 +787,11 @@ function hasHealthyLocalJestInstall( return false; } - return isPathInsideDirectory(circusRunnerPath, REPO_NODE_MODULES); + if (!isPathInsideDirectory(circusRunnerPath, REPO_NODE_MODULES)) { + return false; + } + + return tryLoadModuleFn("jest-circus/runner"); } function printLocalJestFallbackWarning() { @@ -399,40 +808,325 @@ function runManagedJest(args, options = {}) { hasHealthyLocalJestInstallFn = hasHealthyLocalJestInstall, getNpmMajorVersionFn = getNpmMajorVersion, printLocalJestFallbackWarningFn = printLocalJestFallbackWarning, + runLocalJestFn = runLocalJest, runIsolatedFallbackJestFn = runIsolatedFallbackJest, runNpmExecJestFn = runNpmExecJest, runNpxJestFn = runNpxJest, + decodeJestStderrFn = decodeJestStderr, + printActionableRepairBannerFn = printActionableRepairBanner, + attemptIsolatedCacheResetFn = attemptIsolatedCacheReset, + attemptNpmCiRecoveryFn = attemptNpmCiRecovery, + getPinnedFallbackJestSpecFn = getPinnedFallbackJestSpec, + // Integrity gate dependencies (Step 5). Tests inject deterministic + // fakes; the production defaults wire through to the real probe and + // the real npm-ci recovery. + runIntegrityGateWithRecoveryFn = runIntegrityGateWithRecovery, + probeIntegrityFn = probeIntegrity, + probeIntegrityInSubprocessFn = probeIntegrityInSubprocess, + probeResolverHealthFn = probeResolverHealth, + isAutoRepairAllowedFn = null, + // Used by the isolated-tier post-Jest MISSING_TEST_RUNNER routing. + // Injected for testability; defaults to the production classifier. + classifyCapturedPathFn = classifyCapturedPath, + envFn = () => process.env, + warnFn = console.warn, } = options; + // ---- Integrity gate (runs BEFORE any tier) ---- + // + // The gate confirms the on-disk node_modules tree has the critical files + // every managed tool depends on. The Windows failure mode that motivated + // this rewrite (`testRunner option was not found`) was caused by a + // partial extract that the existing tier-level self-heal could not see. + // + // Opt-outs (use isTruthyEnv for shell-script-style truthiness): + // - DXMSG_HOOK_SKIP_INTEGRITY=1 -> bypass the gate entirely. + // - DXMSG_HOOK_NO_AUTOREPAIR=1 -> still probe, but if integrity fails, + // skip npm ci and proceed to Jest invocation with a degraded gate. + const env = (envFn && envFn()) || process.env; + if (!isTruthyEnv(env.DXMSG_HOOK_SKIP_INTEGRITY)) { + const resolvedIsAutoRepairAllowed = isAutoRepairAllowedFn !== null + ? isAutoRepairAllowedFn + : () => defaultIsAutoRepairAllowed({ + env, + repoRoot: REPO_ROOT, + getNpmMajorVersionFn, + }); + + const gateResult = runIntegrityGateWithRecoveryFn({ + repoRoot: REPO_ROOT, + probeIntegrityFn, + probeIntegrityInSubprocessFn, + probeResolverHealthFn, + attemptNpmCiRecoveryFn, + isAutoRepairAllowedFn: resolvedIsAutoRepairAllowed, + printActionableRepairBannerFn, + decoder: jestErrorDecoderModule, + warnFn, + env, + }); + + if (!gateResult || !gateResult.ok) { + if (isTruthyEnv(env.DXMSG_HOOK_NO_AUTOREPAIR)) { + // Degraded mode: proceed to tier dispatch even though + // integrity is bad. The operator has explicitly asked us not + // to repair; banner is already printed by the gate. + warnFn( + "WARNING: integrity gate failed but DXMSG_HOOK_NO_AUTOREPAIR=1 -> proceeding to Jest invocation with degraded gate." + ); + } else { + return { status: 1, error: null }; + } + } + } + + // ---- Local Jest path ---- + // + // Self-heal scope: this tier may invoke `npm ci` ONCE when stderr signals + // a missing local jest binary. We deliberately do NOT call + // attemptIsolatedCacheReset here; the isolated cache lives under + // os.tmpdir() and has no causal relationship to a corrupted local + // node_modules. Cache reset is scoped to the isolated-fallback tier only. if (hasHealthyLocalJestInstallFn()) { - return runLocalJest(args); + const localResult = runLocalJestFn(args); + + if (localResult !== null) { + if (localResult.status === 0) { + return localResult; + } + + const decoded = decodeJestStderrFn(localResult.stderr); + + if (decoded && decoded.kind === "MISSING_TEST_RUNNER") { + // Local Jest reports its testRunner is invalid. The local tier + // has no in-place repair for this (cache reset is scoped to the + // isolated tier, by design). Fall through to the isolated + // fallback tier instead of giving up here so that path's + // self-heal can attempt a recovery. + // + // Banner is intentionally NOT printed here; whichever later + // tier produces the final error will surface it (or self-heal + // will succeed and there is nothing to surface). + // + // Mirrors the production `runLocalJest` behavior of returning + // null when its own runCommandCapturingStderr observes the + // same stderr signature — this branch covers the case where a + // caller-injected `runLocalJestFn` mock returns the failing + // result directly. + // + // Note: MISSING_TEST_RUNNER's selfHeal now includes + // `npmCi: true` so the integrity gate can choose npm-ci + // recovery for repo-tree partial extracts. The tier-level + // dispatcher must NOT attempt npm ci here; by the time the + // local tier runs, the integrity gate has already verified + // node_modules integrity (or aborted upstream). + } else if ( + decoded && + decoded.selfHeal && + decoded.selfHeal.npmCi && + decoded.selfHeal.retryOnce + ) { + const recovery = attemptNpmCiRecoveryFn(); + if (recovery && recovery.status === 0) { + const retryResult = runLocalJestFn(args); + if (retryResult !== null) { + if (retryResult.status !== 0) { + const retryDecoded = decodeJestStderrFn(retryResult.stderr); + printActionableRepairBannerFn(retryDecoded); + } + return retryResult; + } + // Retry path failed-through (returned null); fall through + // to the managed fallback chain below. Banner is deferred + // to whichever later tier produces the final error. + } else { + printActionableRepairBannerFn(decoded); + return localResult; + } + } else if (decoded) { + printActionableRepairBannerFn(decoded); + return localResult; + } else { + return localResult; + } + } + // Local Jest could not be safely prepared (or returned MISSING_TEST_RUNNER); + // fall through to the managed fallback chain. } const hasLocalJestBinary = fs.existsSync(LOCAL_JEST_BIN); + // ---- Isolated fallback path ---- + // + // Self-heal scope: this tier may delete the isolated cache directory ONCE + // when stderr signals a corrupt cache or missing test runner. The reset + // never touches the repo's node_modules. if (hasLocalJestBinary) { printLocalJestFallbackWarningFn(); - const isolatedFallbackResult = runIsolatedFallbackJestFn(args); - if (isolatedFallbackResult) { - return isolatedFallbackResult; + const isolatedResult = runIsolatedFallbackJestFn(args); + if (isolatedResult) { + if (isolatedResult.status === 0) { + return isolatedResult; + } + + const decoded = decodeJestStderrFn(isolatedResult.stderr); + if ( + decoded && + decoded.selfHeal && + decoded.selfHeal.isolatedCacheReset && + decoded.selfHeal.retryOnce + ) { + // MISSING_TEST_RUNNER carries BOTH npmCi and + // isolatedCacheReset self-heal flags because the same + // stderr can come from either a partial repo + // node_modules extract or a corrupt isolated cache. + // Classify the captured runner path to choose the right + // recovery: + // - "repo" -> attemptNpmCiRecovery (gated by the + // production isAutoRepairAllowed check). + // On success, retry the isolated tier. + // - "isolated" -> existing behavior: cache reset + retry. + // - "unknown" -> refuse to auto-repair; print banner only. + // CORRUPT_ISOLATED_CACHE carries only isolatedCacheReset + // and falls through to the legacy isolated reset branch. + const supportsNpmCi = Boolean( + decoded.selfHeal && decoded.selfHeal.npmCi + ); + const capturedPath = + decoded.capturedMatch && typeof decoded.capturedMatch[1] === "string" + ? decoded.capturedMatch[1] + : null; + const classification = supportsNpmCi + ? classifyCapturedPathFn(capturedPath, { + repoNodeModules: REPO_NODE_MODULES, + isolatedCacheRoot: ISOLATED_JEST_CACHE_ROOT, + }) + : PATH_CLASS_ISOLATED; + + if (classification === PATH_CLASS_REPO) { + // Repo-tier partial extract surfacing as MISSING_TEST_RUNNER. + // Run the production auto-repair gate, then npm ci, then + // a subprocess re-probe before retrying the isolated tier. + const resolvedIsAutoRepairAllowed = isAutoRepairAllowedFn !== null + ? isAutoRepairAllowedFn + : () => defaultIsAutoRepairAllowed({ + env, + repoRoot: REPO_ROOT, + getNpmMajorVersionFn, + }); + const repairDecision = resolvedIsAutoRepairAllowed(); + if (!repairDecision || !repairDecision.allowed) { + warnFn( + `WARNING: post-Jest MISSING_TEST_RUNNER classified as repo, but auto-repair refused (${repairDecision && repairDecision.reason ? repairDecision.reason : "no reason"}); printing banner only.` + ); + printActionableRepairBannerFn(decoded); + return isolatedResult; + } + const recovery = attemptNpmCiRecoveryFn(); + if (recovery && recovery.status === 0) { + // Re-probe to make sure the partial extract is + // actually fixed; if so, retry the isolated tier + // (the local install was unhealthy enough to fall + // through here, so we retry isolated rather than + // ricocheting back to runLocalJestFn). + const reprobe = probeIntegrityInSubprocessFn({ repoRoot: REPO_ROOT }); + if (reprobe && reprobe.ok) { + const retryResult = runIsolatedFallbackJestFn(args); + if (retryResult) { + if (retryResult.status !== 0) { + const retryDecoded = decodeJestStderrFn(retryResult.stderr); + printActionableRepairBannerFn(retryDecoded); + } + return retryResult; + } + // Retry returned null; fall through to npm exec/npx. + } else { + printActionableRepairBannerFn(decoded); + return isolatedResult; + } + } else { + printActionableRepairBannerFn(decoded); + return isolatedResult; + } + } else if (classification === PATH_CLASS_UNKNOWN) { + // Path lives outside both the repo node_modules and the + // isolated cache. We have no safe recovery to attempt; + // surface the banner and return. + // + // We distinguish null/undefined ("(null)") from the empty + // string ("(empty)") in the operator-facing warning so a + // stderr regression that captures "" instead of dropping + // the field entirely is debuggable from log triage. + let pathLabel; + if (capturedPath === null || capturedPath === undefined) { + pathLabel = "(null)"; + } else if (capturedPath === "") { + pathLabel = "(empty)"; + } else { + pathLabel = toPosixPath(capturedPath); + } + warnFn( + `WARNING: post-Jest MISSING_TEST_RUNNER captured path ${pathLabel} is outside both the repo and isolated trees; refusing to auto-repair.` + ); + printActionableRepairBannerFn(decoded); + return isolatedResult; + } else { + // PATH_CLASS_ISOLATED (or CORRUPT_ISOLATED_CACHE which + // never opts into npmCi): existing behavior. + const jestSpec = getPinnedFallbackJestSpecFn(); + const resetOk = attemptIsolatedCacheResetFn(jestSpec); + if (resetOk) { + const retryResult = runIsolatedFallbackJestFn(args); + if (retryResult) { + if (retryResult.status !== 0) { + const retryDecoded = decodeJestStderrFn(retryResult.stderr); + printActionableRepairBannerFn(retryDecoded); + } + return retryResult; + } + // Retry returned null; fall through to npm exec/npx. + } else { + printActionableRepairBannerFn(decoded); + return isolatedResult; + } + } + } else if (decoded) { + printActionableRepairBannerFn(decoded); + return isolatedResult; + } else { + return isolatedResult; + } } console.warn("āš ļø Isolated fallback Jest was unavailable; trying npm exec/npx fallback."); } + // ---- npm exec / npx fallback paths ---- + // + // No self-healing here: these are last-resort, network-bound, and slow. + // We only decode stderr and print a banner on failure so the user knows + // what to repair manually. const npmMajor = getNpmMajorVersionFn(); + let finalResult; if (npmMajor === null || npmMajor < 7) { - return runNpxJestFn(args); + finalResult = runNpxJestFn(args); + } else { + const npmExecResult = runNpmExecJestFn(args); + if (isCommandUnavailable(npmExecResult)) { + finalResult = runNpxJestFn(args); + } else { + finalResult = npmExecResult; + } } - const npmExecResult = runNpmExecJestFn(args); - if (isCommandUnavailable(npmExecResult)) { - return runNpxJestFn(args); + if (finalResult && finalResult.status !== 0) { + const decoded = decodeJestStderrFn(finalResult.stderr); + printActionableRepairBannerFn(decoded); } - return npmExecResult; + return finalResult; } function printManagedJestLaunchError(error) { @@ -465,11 +1159,20 @@ module.exports = { ISOLATED_JEST_CACHE_ROOT, normalizeForPathComparison, isPathInsideDirectory, + classifyCapturedPath, + PATH_CLASS_REPO, + PATH_CLASS_ISOLATED, + PATH_CLASS_UNKNOWN, + toPosixPath, + toRepoPosixRelative, resolveLocalModule, + tryLoadModule, hasHealthyLocalJestInstall, printLocalJestFallbackWarning, sanitizeCacheKey, + getDefaultIsolatedJestRunnerPath, getIsolatedJestPaths, + resolveIsolatedJestRunnerPath, hasCliOption, buildNodePathEnv, writeIsolatedJestCacheManifest, @@ -481,6 +1184,7 @@ module.exports = { getNpmMajorVersion, getPinnedFallbackJestSpec, runCommand, + runCommandCapturingStderr, runLocalJest, runNpmExecJest, runNpxJest, @@ -489,6 +1193,18 @@ module.exports = { isCommandUnavailable, runManagedJest, printManagedJestLaunchError, + attemptIsolatedCacheReset, + attemptNpmCiRecovery, + printActionableRepairBanner, + decodeJestStderr, + formatRepairBanner, + isTruthyEnv, + INTEGRITY_TARGETS, + probeIntegrity, + probeIntegrityInSubprocess, + probeResolverHealth, + runIntegrityGateWithRecovery, + isAutoRepairAllowed: defaultIsAutoRepairAllowed, }; if (require.main === module) { diff --git a/scripts/run-managed-prettier.js b/scripts/run-managed-prettier.js index e13795f8..58359a20 100644 --- a/scripts/run-managed-prettier.js +++ b/scripts/run-managed-prettier.js @@ -12,8 +12,24 @@ const fs = require("fs"); const path = require("path"); const childProcess = require("child_process"); -const { isShellShimCommand, spawnPlatformCommandSync } = require("./lib/shell-command"); +const { runBundledNpxCommand } = require("./lib/managed-prettier"); const { getPinnedPrettierSpec } = require("./lib/prettier-version"); +const jestErrorDecoderModule = require("./lib/jest-error-decoder"); +const { isTruthyEnv } = jestErrorDecoderModule; +const { + probeIntegrity, + probeResolverHealth, + probeIntegrityInSubprocess, +} = require("./lib/node-modules-integrity"); +const { + isAutoRepairAllowed: defaultIsAutoRepairAllowed, + runIntegrityGateWithRecovery, +} = require("./lib/integrity-gate-with-recovery"); +const { + getNpmMajorVersion, + attemptNpmCiRecovery, + printActionableRepairBanner, +} = require("./run-managed-jest"); const REPO_ROOT = path.join(__dirname, ".."); const LOCAL_PRETTIER_BIN = path.join( @@ -25,11 +41,7 @@ const LOCAL_PRETTIER_BIN = path.join( ); function runCommand(command, args, options = {}) { - const spawnSyncImpl = isShellShimCommand(command) - ? spawnPlatformCommandSync - : childProcess.spawnSync; - - const result = spawnSyncImpl(command, args, { + const result = childProcess.spawnSync(command, args, { cwd: REPO_ROOT, stdio: "inherit", ...options, @@ -45,13 +57,32 @@ function runLocalPrettier(args) { return runCommand(process.execPath, [LOCAL_PRETTIER_BIN, ...args]); } -function runNpxPrettier(args, prettierSpec = getPinnedPrettierSpec()) { - return runCommand("npx", [ - "--yes", - `--package=${prettierSpec}`, - "prettier", - ...args, - ]); +function runNpxPrettier(args, prettierSpec = getPinnedPrettierSpec(), options = {}) { + const { + runBundledNpxCommandFn = runBundledNpxCommand, + } = options; + + try { + const result = runBundledNpxCommandFn([ + "--yes", + `--package=${prettierSpec}`, + "prettier", + ...args, + ], { + cwd: REPO_ROOT, + stdio: "inherit", + }); + + return { + status: result.status, + error: result.error || null, + }; + } catch (error) { + return { + status: null, + error, + }; + } } function runManagedPrettier(args, options = {}) { @@ -59,8 +90,56 @@ function runManagedPrettier(args, options = {}) { existsSyncFn = fs.existsSync, runLocalPrettierFn = runLocalPrettier, runNpxPrettierFn = runNpxPrettier, + // Integrity gate dependencies (Step 7). Same shape as + // run-managed-jest; the gate logic lives in + // scripts/lib/integrity-gate-with-recovery.js. + runIntegrityGateWithRecoveryFn = runIntegrityGateWithRecovery, + probeIntegrityFn = probeIntegrity, + probeIntegrityInSubprocessFn = probeIntegrityInSubprocess, + probeResolverHealthFn = probeResolverHealth, + attemptNpmCiRecoveryFn = attemptNpmCiRecovery, + getNpmMajorVersionFn = getNpmMajorVersion, + printActionableRepairBannerFn = printActionableRepairBanner, + isAutoRepairAllowedFn = null, + envFn = () => process.env, + warnFn = console.warn, } = options; + // ---- Integrity gate (runs BEFORE tier dispatch) ---- + const env = (envFn && envFn()) || process.env; + if (!isTruthyEnv(env.DXMSG_HOOK_SKIP_INTEGRITY)) { + const resolvedIsAutoRepairAllowed = isAutoRepairAllowedFn !== null + ? isAutoRepairAllowedFn + : () => defaultIsAutoRepairAllowed({ + env, + repoRoot: REPO_ROOT, + getNpmMajorVersionFn, + }); + + const gateResult = runIntegrityGateWithRecoveryFn({ + repoRoot: REPO_ROOT, + probeIntegrityFn, + probeIntegrityInSubprocessFn, + probeResolverHealthFn, + attemptNpmCiRecoveryFn, + isAutoRepairAllowedFn: resolvedIsAutoRepairAllowed, + printActionableRepairBannerFn, + decoder: jestErrorDecoderModule, + warnFn, + env, + }); + + if (!gateResult || !gateResult.ok) { + if (isTruthyEnv(env.DXMSG_HOOK_NO_AUTOREPAIR)) { + warnFn( + "WARNING: integrity gate failed but DXMSG_HOOK_NO_AUTOREPAIR=1 -> proceeding to Prettier invocation with degraded gate." + ); + } else { + return { status: 1, error: null }; + } + } + } + if (existsSyncFn(LOCAL_PRETTIER_BIN)) { return runLocalPrettierFn(args); } diff --git a/scripts/run-staged-md-pipeline.js b/scripts/run-staged-md-pipeline.js index fe86e570..31b494fe 100644 --- a/scripts/run-staged-md-pipeline.js +++ b/scripts/run-staged-md-pipeline.js @@ -39,6 +39,13 @@ const path = require("path"); const childProcess = require("child_process"); const { pathToFileURL } = require("url"); const { spawnPlatformCommandSync } = require("./lib/shell-command"); +const { + TOOL_LOAD_ERROR_PATTERNS, + isRecoverableToolLoadError, + resolveBundledNpxCliPath, + runBundledNpxCommand, + loadLocalPrettier +} = require("./lib/managed-prettier"); const ROOT_DIR = path.resolve(__dirname, ".."); @@ -67,13 +74,8 @@ const MARKDOWNLINT_CLI2_MODULE = path.join( "markdownlint-cli2", "markdownlint-cli2.mjs" ); -const TOOL_LOAD_ERROR_PATTERNS = [ - /Cannot find package/i, - /Cannot find module/i, - /ERR_MODULE_NOT_FOUND/i, - /MODULE_NOT_FOUND/i -]; const WINDOWS_ABSOLUTE_PATH_RE = /^[A-Za-z]:[\\/]/; +const WINDOWS_UNC_ABSOLUTE_PATH_RE = /^(?:\\\\|\/\/)[^\\/]+[\\/][^\\/]+/; function readPackageJson() { return JSON.parse(fs.readFileSync(PACKAGE_JSON_PATH, "utf8")); @@ -89,11 +91,6 @@ function getPackageSpec(packageName) { return `${packageName}@${version.replace(/^[~^]/, "")}`; } -function isRecoverableToolLoadError(error) { - const message = `${error?.code || ""}\n${error?.message || ""}\n${error?.stack || ""}`; - return TOOL_LOAD_ERROR_PATTERNS.some((pattern) => pattern.test(message)); -} - function runToolCommand(command, args, options = {}) { const runner = command === "npm" || command === "npx" ? spawnPlatformCommandSync : childProcess.spawnSync; @@ -104,28 +101,17 @@ function runToolCommand(command, args, options = {}) { }); } -function resolveBundledNpxCliPath({ - execPath = process.execPath, - existsSyncFn = fs.existsSync -} = {}) { - const candidate = path.join(path.dirname(execPath), "node_modules", "npm", "bin", "npx-cli.js"); - return existsSyncFn(candidate) ? candidate : null; -} - function runNpxCommand(args, options = {}) { const { execPath = process.execPath, resolveBundledNpxCliPathFn = resolveBundledNpxCliPath, runToolCommandFn = runToolCommand } = options; - const npxCliPath = resolveBundledNpxCliPathFn({ execPath }); - if (npxCliPath) { - return runToolCommandFn(execPath, [npxCliPath, ...args]); - } - - throw new Error( - "Unable to locate npm's npx-cli.js next to the active Node executable. Run `npm install` in a shell with a complete Node/npm installation, or ensure npm is installed with this Node runtime." - ); + return runBundledNpxCommand(args, { + execPath, + resolveBundledNpxCliPathFn, + runCommandFn: runToolCommandFn + }); } function toImportFileUrl(modulePath) { @@ -140,6 +126,16 @@ function toImportFileUrl(modulePath) { return pathToFileURL(`/${normalized}`).href; } + if (WINDOWS_UNC_ABSOLUTE_PATH_RE.test(modulePath)) { + const windowsUrl = pathToFileURL(modulePath, { windows: true }).href; + if (/^file:\/\/[^/]/.test(windowsUrl)) { + return windowsUrl; + } + + const normalized = modulePath.replace(/\\/g, "/").replace(/^\/+/, "//"); + return new URL(`file:${normalized}`).href; + } + return pathToFileURL(modulePath).href; } @@ -200,28 +196,11 @@ function applyInProcessFixers(absPath, content, modifiedSet) { } async function loadPrettier() { - // Prefer the local devDependency (matches the inlined hook entry's fast - // path). The CJS entry point exposes the same async surface used by - // `prettier --write` internally. - if (fs.existsSync(PRETTIER_LOCAL_MODULE)) { - try { - // eslint-disable-next-line global-require - return require(PRETTIER_LOCAL_MODULE); - } catch (error) { - if (isRecoverableToolLoadError(error)) { - return null; - } - throw error; - } - } - if (fs.existsSync(PRETTIER_LOCAL_BIN)) { - // The bin file is not directly require()-able; if the entry module - // is missing but the bin is present, the install is corrupt. - throw new Error( - "Prettier devDependency layout is unexpected: bin present but index.cjs missing. Run `npm install` to repair." - ); - } - return null; + return loadLocalPrettier({ + localPrettierModulePath: PRETTIER_LOCAL_MODULE, + localPrettierBinPath: PRETTIER_LOCAL_BIN, + isRecoverableToolLoadErrorFn: isRecoverableToolLoadError + }); } function runNpxPrettier(absPath, modifiedSet) { @@ -582,6 +561,8 @@ module.exports = { PRETTIER_LOCAL_BIN, PRETTIER_LOCAL_MODULE, MARKDOWNLINT_CLI2_MODULE, + WINDOWS_ABSOLUTE_PATH_RE, + WINDOWS_UNC_ABSOLUTE_PATH_RE, TOOL_LOAD_ERROR_PATTERNS, readPackageJson, getPackageSpec, diff --git a/scripts/sync-banner-version.js b/scripts/sync-banner-version.js new file mode 100644 index 00000000..859cd7a4 --- /dev/null +++ b/scripts/sync-banner-version.js @@ -0,0 +1,291 @@ +#!/usr/bin/env node +/** + * Cross-platform banner sync. + * Keeps docs/images/DxMessaging-banner.svg aligned with package.json version + * and rounded repository test count. + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { execFileSync } = require("child_process"); + +const VERSION_PATTERN = + /\s*]*>\s*]*\/>\s*]*>v\d+\.\d+\.\d+[^<]*<\/text>\s*<\/g>/s; +const VERSION_VALUE_PATTERN = />v(\d+\.\d+\.\d+[^<]*)<\/text>/; +const TEST_COUNT_PATTERN = + /(]*\bx="20")(?=[^>]*\by="13")(?=[^>]*\bfill="#00d9ff")[^>]*>)(\d+\+ Tests)(<\/text>)/; +const TEST_FILE_NAME_PATTERN = /(?:Test|Tests)\.cs$|\.(?:test|spec)\.js$/; +const TEST_ROOTS = ["Tests", "SourceGenerators", "scripts"]; + +function stripSourceComments(content) { + return content + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:])\/\/.*$/gm, "$1"); +} + +function maskJavaScriptNonCode(content) { + let result = ""; + let state = "code"; + let quote = ""; + let escaped = false; + + const mask = (char) => (char === "\n" || char === "\r" ? char : " "); + + for (let i = 0; i < content.length;) { + const char = content[i]; + const next = content[i + 1] ?? ""; + + if (state === "code") { + if (char === "/" && next === "/") { + result += mask(char) + mask(next); + i += 2; + state = "line-comment"; + continue; + } + if (char === "/" && next === "*") { + result += mask(char) + mask(next); + i += 2; + state = "block-comment"; + continue; + } + if (char === "'" || char === '"' || char === "`") { + result += mask(char); + quote = char; + escaped = false; + i++; + state = "string"; + continue; + } + + result += char; + i++; + continue; + } + + if (state === "line-comment") { + result += mask(char); + i++; + if (char === "\n" || char === "\r") { + state = "code"; + } + continue; + } + + if (state === "block-comment") { + result += mask(char); + if (char === "*" && next === "/") { + result += mask(next); + i += 2; + state = "code"; + continue; + } + i++; + continue; + } + + result += mask(char); + i++; + if (escaped) { + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (char === quote) { + state = "code"; + } + } + + return result; +} + +function countTestMarkers(filePath, content) { + if (filePath.endsWith(".cs")) { + const source = stripSourceComments(content); + return ( + source.match( + /\[(?:UnityTest|Test|TestCase|TestCaseSource|Theory|Fact)\b/g, + ) ?? [] + ).length; + } + + if (/\.(?:test|spec)\.js$/.test(filePath)) { + const source = maskJavaScriptNonCode(content); + return (source.match(/(? + sum + countTestMarkers(filePath, fs.readFileSync(filePath, "utf8")), + 0, + ); +} + +function roundTestCount(testCount) { + const rounded = Math.floor(testCount / 100) * 100; + return rounded < 1 ? testCount : rounded; +} + +function getVersionBadge(version) { + return ` + + + v${version} + `; +} + +function readPackageVersion(packageJsonPath) { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + const version = packageJson?.version; + if (typeof version !== "string" || !/^\d+\.\d+\.\d+/.test(version)) { + throw new Error( + `Invalid version format in package.json: ${String(version)} (expected semver X.Y.Z)`, + ); + } + return version; +} + +function stageFile(repoRoot, filePath, execFileSyncFn = execFileSync) { + try { + execFileSyncFn("git", ["add", filePath], { + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + const stderr = typeof error?.stderr === "string" + ? error.stderr.trim() + : Buffer.isBuffer(error?.stderr) + ? error.stderr.toString("utf8").trim() + : ""; + const suffix = stderr.length > 0 ? ` ${stderr}` : ""; + throw new Error(`Failed to stage ${filePath} with git add.${suffix}`); + } +} + +function syncBanner(options = {}) { + const repoRoot = options.repoRoot ?? path.resolve(__dirname, ".."); + const packageJsonPath = + options.packageJsonPath ?? path.join(repoRoot, "package.json"); + const svgPath = + options.svgPath ?? path.join(repoRoot, "docs", "images", "DxMessaging-banner.svg"); + const stageFileFn = options.stageFileFn ?? stageFile; + + if (!fs.existsSync(packageJsonPath)) { + throw new Error(`package.json not found at: ${packageJsonPath}`); + } + if (!fs.existsSync(svgPath)) { + throw new Error(`SVG banner not found at: ${svgPath}`); + } + + const version = readPackageVersion(packageJsonPath); + const testCount = calculateRepositoryTestCount(repoRoot); + const testCountLabel = `${roundTestCount(testCount)}+ Tests`; + + const svgContent = fs.readFileSync(svgPath, "utf8"); + if (!VERSION_PATTERN.test(svgContent)) { + throw new Error(`Could not find version pattern in: ${svgPath}`); + } + if (!TEST_COUNT_PATTERN.test(svgContent)) { + throw new Error(`Could not find test-count pattern in: ${svgPath}`); + } + + const currentVersionMatch = svgContent.match(VERSION_VALUE_PATTERN); + const currentTestCountMatch = svgContent.match(TEST_COUNT_PATTERN); + + if ( + currentVersionMatch?.[1] === version + && currentTestCountMatch?.[2] === testCountLabel + ) { + return { + updated: false, + version, + testCount, + testCountLabel, + }; + } + + let updatedSvg = svgContent.replace(VERSION_PATTERN, getVersionBadge(version)); + updatedSvg = updatedSvg.replace( + TEST_COUNT_PATTERN, + (_whole, prefix, _oldLabel, suffix) => `${prefix}${testCountLabel}${suffix}`, + ); + + fs.writeFileSync(svgPath, updatedSvg, "utf8"); + stageFileFn(repoRoot, svgPath); + + return { + updated: true, + version, + testCount, + testCountLabel, + }; +} + +function main() { + try { + const result = syncBanner(); + if (!result.updated) { + console.log(`Banner already has correct version: v${result.version}`); + console.log(`Banner already has correct test count: ${result.testCountLabel}`); + return; + } + + console.log(`Updated banner version to: v${result.version}`); + console.log(`Updated banner test count to: ${result.testCountLabel}`); + } catch (error) { + console.error(`Failed to sync banner: ${error.message}`); + process.exit(1); + } +} + +module.exports = { + VERSION_PATTERN, + TEST_COUNT_PATTERN, + stripSourceComments, + maskJavaScriptNonCode, + countTestMarkers, + getRepositoryTestFiles, + calculateRepositoryTestCount, + roundTestCount, + getVersionBadge, + readPackageVersion, + stageFile, + syncBanner, +}; + +if (require.main === module) { + main(); +} diff --git a/scripts/sync-banner-version.js.meta b/scripts/sync-banner-version.js.meta new file mode 100644 index 00000000..6f634d15 --- /dev/null +++ b/scripts/sync-banner-version.js.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: eeef606532c69454fa392320b76c6c8c +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/sync-banner-version.ps1 b/scripts/sync-banner-version.ps1 index b742d477..25a13b40 100644 --- a/scripts/sync-banner-version.ps1 +++ b/scripts/sync-banner-version.ps1 @@ -1,12 +1,14 @@ <# .SYNOPSIS - Syncs the version from package.json to the SVG banner. + Syncs package metadata to the SVG banner. .DESCRIPTION - Reads the version from package.json and updates the version badge in the - DxMessaging-banner.svg file. Automatically stages the SVG if modified. - + Reads the version from package.json, calculates the rounded repository + test count from test files, and updates docs/images/DxMessaging-banner.svg + (the canonical location). The site/ copy is regenerated by MkDocs and is + gitignored, so it is not synced here. + Called by the pre-commit hook before each commit is created. - + NOTE: This script is optional in the pre-commit workflow. If PowerShell is not available, the hook skips banner sync because: - The banner version is purely cosmetic (affects README appearance only) @@ -20,17 +22,173 @@ $ErrorActionPreference = 'Stop' $scriptDir = $PSScriptRoot $repoRoot = Split-Path -Parent $scriptDir -# Construct paths for package.json and SVG +# Construct paths for package.json and the canonical SVG $packageJsonPath = Join-Path $repoRoot "package.json" $svgPath = Join-Path $repoRoot "docs" "images" "DxMessaging-banner.svg" +function Remove-SourceComments { + param([string]$Content) + + $withoutBlockComments = [regex]::Replace($Content, '/\*[\s\S]*?\*/', '') + return [regex]::Replace($withoutBlockComments, '(?m)(^|[^:])//.*$', '$1') +} + +function Add-StrippedJavaScriptChar { + param( + [System.Text.StringBuilder]$Builder, + [char]$Character + ) + + if ($Character -eq [char]10 -or $Character -eq [char]13) { + [void]$Builder.Append($Character) + } else { + [void]$Builder.Append(' ') + } +} + +function Remove-JavaScriptNonCode { + param([string]$Content) + + if ([string]::IsNullOrEmpty($Content)) { + return $Content + } + + $builder = [System.Text.StringBuilder]::new($Content.Length) + $state = 'Code' + $quote = [char]0 + $escaped = $false + $singleQuote = [char]39 + $doubleQuote = [char]34 + $backtick = [char]96 + $slash = [char]47 + $asterisk = [char]42 + $backslash = [char]92 + $lineFeed = [char]10 + $carriageReturn = [char]13 + $i = 0 + + while ($i -lt $Content.Length) { + $char = $Content[$i] + $next = if ($i + 1 -lt $Content.Length) { $Content[$i + 1] } else { [char]0 } + + switch ($state) { + 'Code' { + if ($char -eq $slash -and $next -eq $slash) { + Add-StrippedJavaScriptChar -Builder $builder -Character $char + Add-StrippedJavaScriptChar -Builder $builder -Character $next + $i += 2 + $state = 'LineComment' + continue + } + if ($char -eq $slash -and $next -eq $asterisk) { + Add-StrippedJavaScriptChar -Builder $builder -Character $char + Add-StrippedJavaScriptChar -Builder $builder -Character $next + $i += 2 + $state = 'BlockComment' + continue + } + if ($char -eq $singleQuote -or $char -eq $doubleQuote -or $char -eq $backtick) { + Add-StrippedJavaScriptChar -Builder $builder -Character $char + $quote = $char + $escaped = $false + $i++ + $state = 'String' + continue + } + + [void]$builder.Append($char) + $i++ + continue + } + 'LineComment' { + Add-StrippedJavaScriptChar -Builder $builder -Character $char + $i++ + if ($char -eq $lineFeed -or $char -eq $carriageReturn) { + $state = 'Code' + } + continue + } + 'BlockComment' { + Add-StrippedJavaScriptChar -Builder $builder -Character $char + if ($char -eq $asterisk -and $next -eq $slash) { + Add-StrippedJavaScriptChar -Builder $builder -Character $next + $i += 2 + $state = 'Code' + continue + } + $i++ + continue + } + 'String' { + Add-StrippedJavaScriptChar -Builder $builder -Character $char + $i++ + if ($escaped) { + $escaped = $false + continue + } + if ($char -eq $backslash) { + $escaped = $true + continue + } + if ($char -eq $quote) { + $state = 'Code' + } + continue + } + } + } + + return $builder.ToString() +} + +function Get-RepositoryTestCount { + param([string]$Root) + + $testRoots = @('Tests', 'SourceGenerators', 'scripts') + $count = 0 + + foreach ($relativeRoot in $testRoots) { + $absoluteRoot = Join-Path $Root $relativeRoot + if (-not (Test-Path $absoluteRoot)) { + continue + } + + $files = Get-ChildItem -Path $absoluteRoot -Recurse -File | Where-Object { + $_.Name -match '(?:Test|Tests)\.cs$' -or $_.Name -match '\.(?:test|spec)\.js$' + } + + foreach ($file in $files) { + $content = Remove-SourceComments -Content (Get-Content $file.FullName -Raw) + if ($file.Extension -eq '.cs') { + $count += [regex]::Matches($content, '\[(?:UnityTest|Test|TestCase|TestCaseSource|Theory|Fact)\b').Count + } else { + $content = Remove-JavaScriptNonCode -Content (Get-Content $file.FullName -Raw) + $count += [regex]::Matches($content, '(?' IS allowed (hence .*? for the comment body) # Note: Malformed XML (mismatched quotes) would break this assumption, but such files fail parsing anyway $versionPattern = '\s*]*>\s*]*/>\s*]*>v\d+\.\d+\.\d+[^<]*\s*' +# SYNC: This heredoc must remain BYTE-IDENTICAL to the version-badge block in +# docs/images/DxMessaging-banner.svg. scripts/validate-banner.js enforces the +# byte-equality. If you change attributes here, change them in the SVG in the +# same commit and run validate-banner.js to confirm. $newVersionText = @" - - - v$version + + + v$version "@ +# Pattern to find the feature-row test count label. The label is updated from +# repository test files so it does not drift as coverage changes. +$testCountPattern = '(]*\bx="20")(?=[^>]*\by="13")(?=[^>]*\bfill="#00d9ff")[^>]*>)(\d+\+ Tests)()' + +# Read the SVG content +try { + $svgContent = Get-Content $svgPath -Raw +} catch { + Write-Error "Failed to read SVG file ${svgPath}: $_" + exit 1 +} + # Check if the pattern matches if ($svgContent -notmatch $versionPattern) { - Write-Error "Could not find version pattern in SVG. Banner format may have changed." - Write-Error "Expected pattern: $versionPattern" + Write-Error "Could not find version pattern in: $svgPath" + Write-Error "Banner format may have changed. Expected pattern: $versionPattern" exit 1 } -# Extract the current version from the match -$currentMatch = [regex]::Match($svgContent, $versionPattern).Value +if ($svgContent -notmatch $testCountPattern) { + Write-Error "Could not find test-count feature label in: $svgPath" + Write-Error "Banner format may have changed. Expected pattern: $testCountPattern" + exit 1 +} -# Extract just the version number from the current match for comparison +# Extract just the version number from the match for comparison +$currentMatch = [regex]::Match($svgContent, $versionPattern).Value $currentVersionMatch = [regex]::Match($currentMatch, '>v(\d+\.\d+\.\d+[^<]*)') -if ($currentVersionMatch.Success) { - $currentVersion = $currentVersionMatch.Groups[1].Value - if ($currentVersion -eq $version) { - Write-Host "Banner already has correct version: v$version" - exit 0 - } +$currentTestCountMatch = [regex]::Match($svgContent, $testCountPattern) +if ($currentVersionMatch.Success -and $currentVersionMatch.Groups[1].Value -eq $version -and $currentTestCountMatch.Groups[2].Value -eq $testCountLabel) { + Write-Host "Banner already has correct version: v$version" + Write-Host "Banner already has correct test count: $testCountLabel" + exit 0 } -# Replace the version in the SVG +# Replace the version and test count in the SVG $newSvgContent = $svgContent -replace $versionPattern, $newVersionText +$testCountRegex = [regex]$testCountPattern +$newSvgContent = $testCountRegex.Replace($newSvgContent, "`${1}$testCountLabel`${3}", 1) # Write the updated SVG # Use .NET WriteAllText for UTF-8 without BOM (cross-platform compatible) try { [System.IO.File]::WriteAllText($svgPath, $newSvgContent) } catch { - Write-Error "Failed to write SVG file: $_" + Write-Error "Failed to write SVG file ${svgPath}: $_" exit 1 } @@ -123,11 +303,12 @@ try { throw "git add failed with exit code $LASTEXITCODE" } } catch { - Write-Error "Failed to stage SVG file: $_" + Write-Error "Failed to stage SVG file ${svgPath}: $_" exit 1 } finally { Pop-Location } Write-Host "Updated banner version to: v$version" +Write-Host "Updated banner test count to: $testCountLabel" exit 0 diff --git a/scripts/sync-banner-version.sh b/scripts/sync-banner-version.sh index 98dd151b..a8e90871 100644 --- a/scripts/sync-banner-version.sh +++ b/scripts/sync-banner-version.sh @@ -2,15 +2,20 @@ # # Wrapper script to run sync-banner-version.ps1 with PowerShell. # This script is used by pre-commit hooks to keep YAML lines short. +# If PowerShell is unavailable, it falls back to the Node implementation. # -# If PowerShell is not available, the script exits successfully with a warning -# because the banner version is purely cosmetic. -# + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" if command -v pwsh >/dev/null 2>&1; then - pwsh -NoProfile -File scripts/sync-banner-version.ps1 + pwsh -NoProfile -File "$SCRIPT_DIR/sync-banner-version.ps1" elif command -v powershell >/dev/null 2>&1; then - powershell -NoProfile -ExecutionPolicy Bypass -File scripts/sync-banner-version.ps1 + powershell -NoProfile -ExecutionPolicy Bypass -File "$SCRIPT_DIR/sync-banner-version.ps1" else - echo "PowerShell not found; skipping banner sync (cosmetic only)" + if command -v node >/dev/null 2>&1; then + node "$SCRIPT_DIR/sync-banner-version.js" + else + echo "Neither PowerShell nor Node.js is available; cannot sync banner." >&2 + exit 1 + fi fi diff --git a/scripts/update-llms-txt.js b/scripts/update-llms-txt.js index 006377ef..e114844f 100755 --- a/scripts/update-llms-txt.js +++ b/scripts/update-llms-txt.js @@ -165,8 +165,8 @@ DxMessaging is a high-performance messaging library for Unity (v2021.3+) that re **Version:** ${pkg.version} **License:** MIT -**Repository:** https://github.com/wallstop/DxMessaging -**Documentation:** https://wallstop.github.io/DxMessaging/ +**Repository:** https://github.com/Ambiguous-Interactive/DxMessaging +**Documentation:** https://ambiguous-interactive.github.io/DxMessaging/ ## Quick Facts @@ -276,62 +276,62 @@ public class HealthDisplay : MessageAwareComponent ### Getting Started -- [Overview](https://wallstop.github.io/DxMessaging/getting-started/overview/) -- [Installation](https://wallstop.github.io/DxMessaging/getting-started/install/) -- [Quick Start](https://wallstop.github.io/DxMessaging/getting-started/quick-start/) -- [Visual Guide](https://wallstop.github.io/DxMessaging/getting-started/visual-guide/) +- [Overview](https://ambiguous-interactive.github.io/DxMessaging/getting-started/overview/) +- [Installation](https://ambiguous-interactive.github.io/DxMessaging/getting-started/install/) +- [Quick Start](https://ambiguous-interactive.github.io/DxMessaging/getting-started/quick-start/) +- [Visual Guide](https://ambiguous-interactive.github.io/DxMessaging/getting-started/visual-guide/) ### Concepts -- [Mental Model](https://wallstop.github.io/DxMessaging/concepts/mental-model/) - Core philosophy and design principles -- [Message Types](https://wallstop.github.io/DxMessaging/concepts/message-types/) - Untargeted, Targeted, Broadcast -- [Listening Patterns](https://wallstop.github.io/DxMessaging/concepts/listening-patterns/) -- [Targeting & Context](https://wallstop.github.io/DxMessaging/concepts/targeting-and-context/) -- [Interceptors & Ordering](https://wallstop.github.io/DxMessaging/concepts/interceptors-and-ordering/) +- [Mental Model](https://ambiguous-interactive.github.io/DxMessaging/concepts/mental-model/) - Core philosophy and design principles +- [Message Types](https://ambiguous-interactive.github.io/DxMessaging/concepts/message-types/) - Untargeted, Targeted, Broadcast +- [Listening Patterns](https://ambiguous-interactive.github.io/DxMessaging/concepts/listening-patterns/) +- [Targeting & Context](https://ambiguous-interactive.github.io/DxMessaging/concepts/targeting-and-context/) +- [Interceptors & Ordering](https://ambiguous-interactive.github.io/DxMessaging/concepts/interceptors-and-ordering/) ### Guides -- [Patterns](https://wallstop.github.io/DxMessaging/guides/patterns/) - Best practices and common patterns -- [Unity Integration](https://wallstop.github.io/DxMessaging/guides/unity-integration/) -- [Testing](https://wallstop.github.io/DxMessaging/guides/testing/) - Testing strategies for message-based systems -- [Diagnostics](https://wallstop.github.io/DxMessaging/guides/diagnostics/) - Inspector tools and debugging -- [Memory Reclamation](https://wallstop.github.io/DxMessaging/guides/memory-reclamation/) - Idle eviction, Trim API, occupancy counters -- [Migration Guide](https://wallstop.github.io/DxMessaging/guides/migration-guide/) +- [Patterns](https://ambiguous-interactive.github.io/DxMessaging/guides/patterns/) - Best practices and common patterns +- [Unity Integration](https://ambiguous-interactive.github.io/DxMessaging/guides/unity-integration/) +- [Testing](https://ambiguous-interactive.github.io/DxMessaging/guides/testing/) - Testing strategies for message-based systems +- [Diagnostics](https://ambiguous-interactive.github.io/DxMessaging/guides/diagnostics/) - Inspector tools and debugging +- [Memory Reclamation](https://ambiguous-interactive.github.io/DxMessaging/guides/memory-reclamation/) - Idle eviction, Trim API, occupancy counters +- [Migration Guide](https://ambiguous-interactive.github.io/DxMessaging/guides/migration-guide/) ### Architecture -- [Design & Architecture](https://wallstop.github.io/DxMessaging/architecture/design-and-architecture/) -- [Performance](https://wallstop.github.io/DxMessaging/architecture/performance/) - Benchmarks (10-17M ops/sec) -- [Comparisons](https://wallstop.github.io/DxMessaging/architecture/comparisons/) - vs Events, UnityEvents, other buses +- [Design & Architecture](https://ambiguous-interactive.github.io/DxMessaging/architecture/design-and-architecture/) +- [Performance](https://ambiguous-interactive.github.io/DxMessaging/architecture/performance/) - Benchmarks (10-17M ops/sec) +- [Comparisons](https://ambiguous-interactive.github.io/DxMessaging/architecture/comparisons/) - vs Events, UnityEvents, other buses ### Advanced Topics -- [Emit Shorthands](https://wallstop.github.io/DxMessaging/advanced/emit-shorthands/) -- [Message Bus Providers](https://wallstop.github.io/DxMessaging/advanced/message-bus-providers/) -- [Registration Builders](https://wallstop.github.io/DxMessaging/advanced/registration-builders/) -- [Runtime Configuration](https://wallstop.github.io/DxMessaging/advanced/runtime-configuration/) +- [Emit Shorthands](https://ambiguous-interactive.github.io/DxMessaging/advanced/emit-shorthands/) +- [Message Bus Providers](https://ambiguous-interactive.github.io/DxMessaging/advanced/message-bus-providers/) +- [Registration Builders](https://ambiguous-interactive.github.io/DxMessaging/advanced/registration-builders/) +- [Runtime Configuration](https://ambiguous-interactive.github.io/DxMessaging/advanced/runtime-configuration/) ### Integrations -- [Zenject](https://wallstop.github.io/DxMessaging/integrations/zenject/) - Extenject/Zenject DI integration -- [VContainer](https://wallstop.github.io/DxMessaging/integrations/vcontainer/) - VContainer DI integration -- [Reflex](https://wallstop.github.io/DxMessaging/integrations/reflex/) - Reflex DI integration +- [Zenject](https://ambiguous-interactive.github.io/DxMessaging/integrations/zenject/) - Extenject/Zenject DI integration +- [VContainer](https://ambiguous-interactive.github.io/DxMessaging/integrations/vcontainer/) - VContainer DI integration +- [Reflex](https://ambiguous-interactive.github.io/DxMessaging/integrations/reflex/) - Reflex DI integration ### Reference -- [Quick Reference](https://wallstop.github.io/DxMessaging/reference/quick-reference/) -- [Runtime Settings](https://wallstop.github.io/DxMessaging/reference/runtime-settings/) - DxMessagingRuntimeSettings asset and diagnostic API -- [FAQ](https://wallstop.github.io/DxMessaging/reference/faq/) -- [Glossary](https://wallstop.github.io/DxMessaging/reference/glossary/) -- [Troubleshooting](https://wallstop.github.io/DxMessaging/reference/troubleshooting/) +- [Quick Reference](https://ambiguous-interactive.github.io/DxMessaging/reference/quick-reference/) +- [Runtime Settings](https://ambiguous-interactive.github.io/DxMessaging/reference/runtime-settings/) - DxMessagingRuntimeSettings asset and diagnostic API +- [FAQ](https://ambiguous-interactive.github.io/DxMessaging/reference/faq/) +- [Glossary](https://ambiguous-interactive.github.io/DxMessaging/reference/glossary/) +- [Troubleshooting](https://ambiguous-interactive.github.io/DxMessaging/reference/troubleshooting/) ## Key Files -- [README.md](https://github.com/wallstop/DxMessaging/blob/master/README.md) - 30-second pitch, mental models, quick start -- [CHANGELOG.md](https://github.com/wallstop/DxMessaging/blob/master/CHANGELOG.md) - Version history -- [CONTRIBUTING.md](https://github.com/wallstop/DxMessaging/blob/master/CONTRIBUTING.md) - Contribution guidelines -- [package.json](https://github.com/wallstop/DxMessaging/blob/master/package.json) - Package manifest -- [.llm/context.md](https://github.com/wallstop/DxMessaging/blob/master/.llm/context.md) - Repository guidelines for AI agents +- [README.md](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/README.md) - 30-second pitch, mental models, quick start +- [CHANGELOG.md](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/CHANGELOG.md) - Version history +- [CONTRIBUTING.md](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/CONTRIBUTING.md) - Contribution guidelines +- [package.json](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/package.json) - Package manifest +- [.llm/context.md](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/.llm/context.md) - Repository guidelines for AI agents ## Development @@ -373,8 +373,8 @@ npx cspell "**/*" This repository includes comprehensive AI agent guidance in the \`.llm/\` directory: -- **[.llm/context.md](https://github.com/wallstop/DxMessaging/blob/master/.llm/context.md)** - Repository guidelines, coding standards, testing policies -- **[.llm/skills/](https://github.com/wallstop/DxMessaging/tree/master/.llm/skills)** - ${skillCount}+ specialized skill documents covering: +- **[.llm/context.md](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/.llm/context.md)** - Repository guidelines, coding standards, testing policies +- **[.llm/skills/](https://github.com/Ambiguous-Interactive/DxMessaging/tree/master/.llm/skills)** - ${skillCount}+ specialized skill documents covering: ${skillCategoriesText} ## Common Pitfalls & Solutions @@ -392,7 +392,7 @@ ${skillCategoriesText} ### Wrong Message Type **Problem:** Used Broadcast when Targeted was needed -**Solution:** See [Mental Model](https://wallstop.github.io/DxMessaging/concepts/mental-model/) for type selection guidance +**Solution:** See [Mental Model](https://ambiguous-interactive.github.io/DxMessaging/concepts/mental-model/) for type selection guidance ### Performance Issues @@ -407,7 +407,7 @@ ${skillCategoriesText} - **Registration:** O(1) add/remove with backing dictionary - **Priority Ordering:** Stable sort on registration -See [Performance Documentation](https://wallstop.github.io/DxMessaging/architecture/performance/) for detailed benchmarks. +See [Performance Documentation](https://ambiguous-interactive.github.io/DxMessaging/architecture/performance/) for detailed benchmarks. ## Examples @@ -443,14 +443,14 @@ Demonstrates debugging tools: ## Support & Community -- **Issues:** https://github.com/wallstop/DxMessaging/issues -- **Discussions:** https://github.com/wallstop/DxMessaging/discussions +- **Issues:** https://github.com/Ambiguous-Interactive/DxMessaging/issues +- **Discussions:** https://github.com/Ambiguous-Interactive/DxMessaging/discussions - **Email:** wallstop@wallstopstudios.com - **OpenUPM:** https://openupm.com/packages/com.wallstop-studios.dxmessaging/ ## License -MIT License - see [LICENSE.md](https://github.com/wallstop/DxMessaging/blob/master/LICENSE.md) +MIT License - see [LICENSE.md](https://github.com/Ambiguous-Interactive/DxMessaging/blob/master/LICENSE.md) Copyright (c) 2017-2026 Wallstop Studios diff --git a/scripts/validate-banner.js b/scripts/validate-banner.js new file mode 100644 index 00000000..c9383a69 --- /dev/null +++ b/scripts/validate-banner.js @@ -0,0 +1,1502 @@ +#!/usr/bin/env node +// cspell:words xlink labelledby describedby onbegin onrepeat bbox + +/** + * validate-banner.js + * + * Enforces long-term quality of the DxMessaging banner SVG. Run from the + * pre-commit hook and from CI to prevent regression of issues that took + * eleven iterations to stabilise. + * + * The canonical banner lives at docs/images/DxMessaging-banner.svg. The + * site/ copy is MkDocs build output (gitignored), so it is regenerated and + * not validated here. + * + * Validations (each implemented as an independent helper, all errors + * collected before exit): + * + * Sync / drift + * 1. The version-badge block matches scripts/sync-banner-version.ps1 + * 3. The feature-row test-count label matches the repository test count. + * + * Hard requirements + * 4. viewBox="0 0 800 200", width="800", height="200" on root . + * 5. No external resources (no , no xlink:href to a + * URL, no @import in