diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index f49261fd..32ba6975 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -123,7 +123,7 @@ RUN npm install --global --prefix /usr/local \ # MCP configuration must work before the workspace volume has dependencies. # jq extracts exactly the devDependencies the MCP script needs; the length guard # fails the build when the manifest drops a required package. -COPY package.json /tmp/dxt-tooling-package.json +COPY tooling~/package.json /tmp/dxt-tooling-package.json RUN mkdir -p /opt/dxt-mcp \ && jq '{private: true, dependencies: (.devDependencies | with_entries(select(.key as $k | ["@modelcontextprotocol/sdk", "smol-toml", "jsonc-parser"] | index($k))))}' /tmp/dxt-tooling-package.json > /opt/dxt-mcp/package.json \ && test "$(jq '.dependencies | length' /opt/dxt-mcp/package.json)" -eq 3 \ diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 7060821d..e2d0f201 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -2,7 +2,7 @@ A fast, reliable VS Code devcontainer for this Unity UPM package. The Unity Editor stays on the **host** (licensing + GUI); the container ships every CLI tool, drives the -host editor through the [unity-mcp bridge](../scripts/mcp/README.md), and needs **zero +host editor through the [unity-mcp bridge](../tooling~/scripts/mcp/README.md), and needs **zero sudo** anywhere. ``` @@ -41,7 +41,7 @@ sudo** anywhere. per-project port, so multiple editors never collide): ```bash - npm install # host checkout of this repo (one time) + npm --prefix tooling~ install # host checkout of this repo (one time) npm run unity:mcp # bridge: host editor -> authenticated HTTP ``` @@ -143,7 +143,7 @@ Z.AI overrides: `CODEX_ZAI_MODEL`, `CODEX_ZAI_REASONING_EFFORT`, `CLAUDE_ZAI_*_M `ZAI_API_TIMEOUT_MS` (default 3000000, matching the GLM coding-plan guidance), `CLAUDE_ZAI_CONFIG_DIR`, `AI_BACKENDS_CONTAINER_MODE`, `CLAUDE_ZAI_SUBPROCESS_ENV_SCRUB`. The launchers are regression-tested -(`scripts/tests/test-ai-backends.sh`). +(`tooling~/scripts/tests/test-ai-backends.sh`). ### OpenRouter (API key, any model) @@ -181,7 +181,7 @@ from workspace watching; the workspace itself remains the package repository. ## Verify ```bash -npm test # node --test scripts/mcp/__tests__ -bash scripts/tests/test-ai-backends.sh # Z.AI launcher regression suite +npm test # node --test tooling~/scripts/mcp/__tests__ +bash tooling~/scripts/tests/test-ai-backends.sh # Z.AI launcher regression suite npm run unity:mcp:probe # host editor readiness (bridge running) ``` diff --git a/.devcontainer/cache-contract.sh b/.devcontainer/cache-contract.sh index 81e8a2cc..9a3af225 100644 --- a/.devcontainer/cache-contract.sh +++ b/.devcontainer/cache-contract.sh @@ -49,7 +49,7 @@ readonly CACHE_MOUNT_TARGETS=( "/home/vscode/.local/share/powershell" "/home/vscode/.cache/pip" "/home/vscode/.npm" - "${CACHE_WORKSPACE_ROOT}/node_modules" + "${CACHE_WORKSPACE_ROOT}/tooling~/node_modules" ) cache_contract_validate_shape() { @@ -106,7 +106,7 @@ cache_contract_repair_permissions() { for target in "${CACHE_MOUNT_TARGETS[@]}" "${HOME}/.local"; do cache_contract_repair_directory "$target" "$current_uid" "$current_gid" || return 1 done - for target in "${HOME}/.npmrc" "${CACHE_WORKSPACE_ROOT}/package-lock.json" \ + for target in "${HOME}/.npmrc" "${CACHE_WORKSPACE_ROOT}/tooling~/package-lock.json" \ "${CACHE_WORKSPACE_ROOT}/package.json"; do if [[ -f "$target" && ! -w "$target" ]]; then # Host bind mounts can be writable without supporting ownership changes. diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c5a87572..dd575fbe 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,7 +5,7 @@ // Project: DxCommandTerminal | Unity UPM package (min 2021.3, host editor 6000.4) // The Unity Editor stays on the host; this container ships the .NET / Node / // PowerShell toolchain plus every agent CLI and MCP server, and drives the host -// editor through the unity-mcp bridge (scripts/mcp/unity-mcp.mjs). +// editor through the unity-mcp bridge (tooling~/scripts/mcp/unity-mcp.mjs). // ============================================================================= { "name": "DxCommandTerminal Dev", @@ -41,7 +41,7 @@ "source=dxt-powershell-modules,target=/home/vscode/.local/share/powershell,type=volume", "source=dxt-python-cache,target=/home/vscode/.cache/pip,type=volume", "source=dxt-npm-cache,target=/home/vscode/.npm,type=volume", - "source=dxt-node-modules,target=${containerWorkspaceFolder}/node_modules,type=volume", + "source=dxt-node-modules,target=${containerWorkspaceFolder}/tooling~/node_modules,type=volume", "source=${localWorkspaceFolder}/../..,target=/unity-project,type=bind,consistency=cached" ], // --------------------------------------------------------------------------- diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index a6d9558b..bab51879 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -106,7 +106,7 @@ EOF } configure_agent_mcps() { - local configure=(node "${WORKSPACE_DIR}/scripts/mcp/unity-mcp.mjs" configure --offline) + local configure=(node "${WORKSPACE_DIR}/tooling~/scripts/mcp/unity-mcp.mjs" configure --offline) if command -v flock >/dev/null 2>&1; then flock -w 180 "${MCP_CONFIGURE_LOCK}" "${configure[@]}" return @@ -228,7 +228,8 @@ main() { cd "${WORKSPACE_DIR}" run_optional "Restoring .NET local tools (CSharpier)" dotnet tool restore # npm install reuses the persistent modules tree; npm ci would remove it. - run_optional "Installing workspace npm dependencies" npm install --prefer-offline --no-audit --no-fund + # The npm project lives under tooling~/ so Unity never imports node_modules. + run_optional "Installing workspace npm dependencies" npm --prefix tooling~ install --prefer-offline --no-audit --no-fund run_optional "Configuring MCP servers for every agent front end" configure_agent_mcps run_optional "Installing Z.AI and OpenRouter agent launchers" \ bash "${SCRIPT_DIR}/ai-backends.sh" install diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh index af1d287e..88b6dae4 100755 --- a/.devcontainer/post-start.sh +++ b/.devcontainer/post-start.sh @@ -10,7 +10,7 @@ export NPM_CONFIG_PREFIX="${HOME}/.local" # Configuration is local and works with the image's baked dependencies before npm # install. Finish it before clients start, without probing a sleeping host. -mcp_script="${SCRIPT_DIR}/../scripts/mcp/unity-mcp.mjs" +mcp_script="${SCRIPT_DIR}/../tooling~/scripts/mcp/unity-mcp.mjs" mcp_lock="${TMPDIR:-/tmp}/dxt-mcp-configure.lock" if command -v flock >/dev/null 2>&1; then flock -w 30 "${mcp_lock}" node "${mcp_script}" configure --offline diff --git a/.dockerignore b/.dockerignore index 3089c7c2..292bcd5e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -21,3 +21,4 @@ Editor Tests .llm .qodo +tooling~/node_modules diff --git a/.editorconfig b/.editorconfig index 768fc170..7774a4e0 100644 --- a/.editorconfig +++ b/.editorconfig @@ -184,9 +184,9 @@ indent_size = 4 tab_width = 4 # LLM context and tooling: UTF-8 without BOM + LF for byte-stable cross-OS -# generation (enforced by scripts/lint-llm-instructions.ps1). Overrides the +# generation (enforced by tooling~/scripts/lint-llm-instructions.ps1). Overrides the # repo-wide BOM/CRLF defaults, which remain for C# assets. -[{.llm/**,scripts/**,.devcontainer/**,.github/copilot-instructions.md,AGENTS.md,CLAUDE.md,.cursorrules}] +[{.llm/**,tooling~/**,.devcontainer/**,.github/copilot-instructions.md,AGENTS.md,CLAUDE.md,.cursorrules}] charset = utf-8 end_of_line = lf insert_final_newline = true diff --git a/.gitattributes b/.gitattributes index 86f2c7f4..4de1f557 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,7 +2,7 @@ # Matches the .editorconfig overrides; keeps generated content and shell # scripts intact on CRLF-configured checkouts (autocrlf=true). .llm/** text eol=lf -scripts/** text eol=lf +tooling~/** text eol=lf AGENTS.md text eol=lf CLAUDE.md text eol=lf .cursorrules text eol=lf diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f006b8cc..7cc93879 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,3 +13,6 @@ updates: - wallstop reviewers: - wallstop + # Repo policy: all action refs are pinned to commit SHAs with a "# vX" + # version comment (issue #39). Dependabot updates SHA-pinned refs and + # preserves the comment; keep the comment in sync with each bump. diff --git a/.github/workflows/llm-instructions-lint.yml b/.github/workflows/llm-instructions-lint.yml index da7b376b..5edd5671 100644 --- a/.github/workflows/llm-instructions-lint.yml +++ b/.github/workflows/llm-instructions-lint.yml @@ -11,10 +11,10 @@ on: - "CLAUDE.md" - ".cursorrules" - ".github/copilot-instructions.md" - - "scripts/generate-skills-index.ps1" - - "scripts/lint-llm-instructions.ps1" - - "scripts/lint-skill-sizes.ps1" - - "scripts/tests/**" + - "tooling~/scripts/generate-skills-index.ps1" + - "tooling~/scripts/lint-llm-instructions.ps1" + - "tooling~/scripts/lint-skill-sizes.ps1" + - "tooling~/scripts/tests/**" - ".github/workflows/llm-instructions-lint.yml" pull_request: paths: @@ -23,10 +23,10 @@ on: - "CLAUDE.md" - ".cursorrules" - ".github/copilot-instructions.md" - - "scripts/generate-skills-index.ps1" - - "scripts/lint-llm-instructions.ps1" - - "scripts/lint-skill-sizes.ps1" - - "scripts/tests/**" + - "tooling~/scripts/generate-skills-index.ps1" + - "tooling~/scripts/lint-llm-instructions.ps1" + - "tooling~/scripts/lint-skill-sizes.ps1" + - "tooling~/scripts/tests/**" - ".github/workflows/llm-instructions-lint.yml" permissions: @@ -49,27 +49,27 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Lint LLM instructions shell: pwsh - run: ./scripts/lint-llm-instructions.ps1 -VerboseOutput + run: ./tooling~/scripts/lint-llm-instructions.ps1 -VerboseOutput - name: Check skill file sizes shell: pwsh - run: ./scripts/lint-skill-sizes.ps1 -VerboseOutput + run: ./tooling~/scripts/lint-skill-sizes.ps1 -VerboseOutput - name: Test LLM instructions lint shell: pwsh - run: ./scripts/tests/test-llm-instructions-lint.ps1 -VerboseOutput + run: ./tooling~/scripts/tests/test-llm-instructions-lint.ps1 -VerboseOutput - name: Test skill size linter shell: pwsh - run: ./scripts/tests/test-lint-skill-sizes.ps1 -VerboseOutput + run: ./tooling~/scripts/tests/test-lint-skill-sizes.ps1 -VerboseOutput - name: Test skills index generator shell: pwsh - run: ./scripts/tests/test-generate-skills-index.ps1 -VerboseOutput + run: ./tooling~/scripts/tests/test-generate-skills-index.ps1 -VerboseOutput - name: Validate skills index is up-to-date # Regenerate the file and fail if it differs from the committed bytes. @@ -77,11 +77,11 @@ jobs: # (untracked) index.md, unlike `git diff` which ignores untracked files. shell: pwsh run: | - ./scripts/generate-skills-index.ps1 + ./tooling~/scripts/generate-skills-index.ps1 if ($LASTEXITCODE) { exit $LASTEXITCODE } $status = git status --porcelain -- .llm/skills/index.md if ($status) { - Write-Host "::error::.llm/skills/index.md is out of date or uncommitted. Run: pwsh -NoProfile -File scripts/generate-skills-index.ps1 and commit it." + Write-Host "::error::.llm/skills/index.md is out of date or uncommitted. Run: pwsh -NoProfile -File tooling~/scripts/generate-skills-index.ps1 and commit it." git diff -- .llm/skills/index.md exit 1 } diff --git a/.github/workflows/tooling-tests.yml b/.github/workflows/tooling-tests.yml index 5fbe7102..5c85d7f4 100644 --- a/.github/workflows/tooling-tests.yml +++ b/.github/workflows/tooling-tests.yml @@ -7,12 +7,12 @@ on: - main paths: - "package.json" - - "scripts/**" + - "tooling~/**" - ".github/workflows/tooling-tests.yml" pull_request: paths: - "package.json" - - "scripts/**" + - "tooling~/**" - ".github/workflows/tooling-tests.yml" permissions: @@ -30,7 +30,9 @@ jobs: # # Installs with `npm install`: no lockfile is committed for this Unity # package (reviewer decision), so there is nothing for `npm ci` to pin - # and setup-node's npm cache cannot be keyed. + # and setup-node's npm cache cannot be keyed. The npm project lives under + # `tooling~/` (Unity-hidden via the tilde suffix, so editors never import + # node_modules); root package.json scripts delegate with `npm --prefix`. name: node-tests (${{ matrix.os }}) strategy: fail-fast: false @@ -39,15 +41,35 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: 24 - name: Install dependencies - run: npm install --no-audit --no-fund + run: npm --prefix tooling~ install --no-audit --no-fund - name: Test tooling - run: npm test + run: npm --prefix tooling~ test + + package-content: + # Clean-install guard for the UPM artifact (issue #22, PLAN.md T02 + # "package-content validators"): packs the package exactly like npm/UPM + # consumers receive it and asserts required artifacts ship, tooling and + # repo-internal files do not, and every shipped file carries its .meta. + # Unity-free: node stdlib + npm pack + system tar only. + name: package-content + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Setup Node + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24 + + - name: Validate package contents + run: npm --prefix tooling~ run package:validate diff --git a/.github/workflows/unity-meta-lint.yml b/.github/workflows/unity-meta-lint.yml index 42b2f04b..55e7ce6e 100644 --- a/.github/workflows/unity-meta-lint.yml +++ b/.github/workflows/unity-meta-lint.yml @@ -23,8 +23,8 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Lint Unity meta hygiene shell: pwsh - run: ./scripts/lint-unity-meta.ps1 + run: ./tooling~/scripts/lint-unity-meta.ps1 diff --git a/.gitignore b/.gitignore index cd4756cd..e16ad960 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,8 @@ node_modules/ # npm lockfiles are intentionally not committed for Unity packages (reviewer # decision): the package ships no Node code, and a lockfile would churn with -# every dev environment. Install with `npm install` to work on the tooling. +# every dev environment. Install with `npm --prefix tooling~ install` to work on the tooling (the npm +# project lives under tooling~/ so Unity never imports node_modules). package-lock.json # Local credentials - never commit (preferred credential source; see .env.example) diff --git a/.llm/context.md b/.llm/context.md index 2bf06319..3f49f40e 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -56,16 +56,18 @@ Styles/ # USS/TSS stylesheets consumed by Terminal Tests/Runtime/ # PlayMode tests (asmdef: ...Tests.Runtime) Components/ # Test harness components (TestCommands, TerminalInputHandler) Media/ # Screenshots and demo GIFs -scripts/ # Repo tooling (PowerShell + Node; not shipped in the UPM artifact) - mcp/ # unity-mcp.mjs: Unity MCP bridge/probe/configure/capture + tests - tests/ # Script regression suites (pwsh + bash) +tooling~/ # Unity-hidden tooling (tilde-suffixed; not shipped in the UPM artifact) + package.json # npm manifest (devDependencies; root package.json delegates via --prefix) + scripts/ # Repo tooling (PowerShell + Node) + mcp/ # unity-mcp.mjs: Unity MCP bridge/probe/configure/capture + tests + tests/ # Script regression suites (pwsh + bash) .devcontainer/ # VS Code devcontainer (Dockerfile, lifecycle, Z.AI launchers) ``` -### Dev tooling (scripts/, .devcontainer/) +### Dev tooling (tooling~/scripts/, .devcontainer/) `npm run unity:mcp:probe|configure|bridge|capture` drives the host Unity editor -over the authenticated MCP bridge in `scripts/mcp/unity-mcp.mjs` (see the +over the authenticated MCP bridge in `tooling~/scripts/mcp/unity-mcp.mjs` (see the [unity-mcp](./skills/unity-mcp/SKILL.md) and [capture-unity-state](./skills/capture-unity-state/SKILL.md) skills). Credentials live in gitignored `.env.local` (see `.env.example`); agent MCP configs are @@ -76,8 +78,8 @@ generated, never hand-edited; `npm test` runs the Node tooling suite. ## Skills Reference See the generated [Skills Index](./skills/index.md). Regenerate it after adding or editing any -skill: `pwsh -NoProfile -File scripts/generate-skills-index.ps1` (validated by -`scripts/lint-llm-instructions.ps1`). +skill: `pwsh -NoProfile -File tooling~/scripts/generate-skills-index.ps1` (validated by +`tooling~/scripts/lint-llm-instructions.ps1`). ### SKILL.md Contract @@ -164,13 +166,13 @@ frontmatter validity, index freshness, and pointer-file delegation; see 1. **Line limits**: every authored file under `.llm/` MUST stay at or below 300 lines; 270+ gets a critical warning. The generated `.llm/skills/index.md` is exempt (machine-written). - Enforced by `scripts/lint-skill-sizes.ps1` (pre-commit + CI + tests). + Enforced by `tooling~/scripts/lint-skill-sizes.ps1` (pre-commit + CI + tests). 2. **SKILL.md validity + index freshness + pointer delegation**: enforced by - `scripts/lint-llm-instructions.ps1` (pre-commit + CI + tests). + `tooling~/scripts/lint-llm-instructions.ps1` (pre-commit + CI + tests). 3. **Generated files are byte-stable**: UTF-8 without BOM, LF line endings, ordinal sorting, no timestamps. Never hand-edit `.llm/skills/index.md`. 4. **Encoding overrides**: `.editorconfig` forces UTF-8 (no BOM) + LF for `.llm/**` and - `scripts/**` regardless of the repo defaults for C# assets. + `tooling~/**` regardless of the repo defaults for C# assets. 5. `.editorconfig` charset/line-ending defaults for C# assets remain BOM/CRLF per repo convention; only the LLM-context paths above are overridden. diff --git a/.llm/skills/capture-unity-state/SKILL.md b/.llm/skills/capture-unity-state/SKILL.md index 3aa46a6d..3c65634b 100644 --- a/.llm/skills/capture-unity-state/SKILL.md +++ b/.llm/skills/capture-unity-state/SKILL.md @@ -18,7 +18,7 @@ manifest to complete. Artifacts land inside this package's `.artifacts/unity-sta passing from where you run capture. 2. Capture script compiled in the editor. Install it host-side with `npm run unity:mcp:install-capture -- --project ` (it copies - `scripts/mcp/DxTerminalStateCapture.cs.txt` to `/Assets/Editor/`, + `tooling~/scripts/mcp/DxTerminalStateCapture.cs.txt` to `/Assets/Editor/`, backing up any previous copy under `.artifacts/unity-state/backup/`). `capture` performs this install itself when the project directory is reachable locally (host runs, or the container's `/unity-project` bind mount). diff --git a/.llm/skills/create-unity-meta/SKILL.md b/.llm/skills/create-unity-meta/SKILL.md index 26e2d2ed..295dbd1c 100644 --- a/.llm/skills/create-unity-meta/SKILL.md +++ b/.llm/skills/create-unity-meta/SKILL.md @@ -53,7 +53,7 @@ TextScriptImporter: ## Verification -Run `pwsh -NoProfile -File scripts/lint-unity-meta.ps1` (also enforced by pre-commit and +Run `pwsh -NoProfile -File tooling~/scripts/lint-unity-meta.ps1` (also enforced by pre-commit and CI). It checks, over tracked files only: 1. Every tracked `.meta` has its target tracked (no orphan metas for gitignored or diff --git a/.llm/skills/formatting-and-linting/SKILL.md b/.llm/skills/formatting-and-linting/SKILL.md index c42d2a12..88e88944 100644 --- a/.llm/skills/formatting-and-linting/SKILL.md +++ b/.llm/skills/formatting-and-linting/SKILL.md @@ -33,7 +33,7 @@ on every commit and fails fast when tools are missing. - Modifier order: `public private protected internal file new static abstract virtual sealed readonly override extern unsafe volatile async required`. - Files: UTF-8 BOM + CRLF for C# assets (repo default). Do not "fix" line endings on untouched - files - that pollutes diffs. Exceptions: `.llm/**` and `scripts/**` are UTF-8 no BOM + LF + files - that pollutes diffs. Exceptions: `.llm/**` and `tooling~/scripts/**` are UTF-8 no BOM + LF (enforced by `.editorconfig` overrides and the LLM linters). ## Pre-commit hooks diff --git a/.llm/skills/index.md b/.llm/skills/index.md index f723da09..66d28fd1 100644 --- a/.llm/skills/index.md +++ b/.llm/skills/index.md @@ -1,5 +1,5 @@ - - + + # Skills Index @@ -11,7 +11,7 @@ Agent Skills ([SKILL.md format](https://agentskills.io)) for specific tasks. Inv | --- | --- | | [create-unity-meta](./create-unity-meta/SKILL.md) | Generate correct Unity .meta files for new files and folders in the DxCommandTerminal package, including which paths Unity ignores (dot-directories) and which importer blocks to expect. Use whenever creating, moving, renaming, or deleting any visible file or folder in the package. | | [formatting-and-linting](./formatting-and-linting/SKILL.md) | Run CSharpier and the repo's pre-commit enforcement for DxCommandTerminal (dotnet tools, formatting C# files, fixing format-check failures). Use when formatting code, adding pre-commit hooks, or when a commit is rejected by the csharpier or dotnet-tool-restore hooks. | -| [manage-skills](./manage-skills/SKILL.md) | Create, edit, or remove agentic skills (.llm/skills/*/SKILL.md), regenerate the skills index, or fix lint failures around SKILL.md frontmatter, line limits, or pointer-file delegation. Use when adding new skills, changing skill descriptions/categories, touching .llm/**, or when scripts/lint-llm-instructions.ps1 or lint-skill-sizes.ps1 fail. | +| [manage-skills](./manage-skills/SKILL.md) | Create, edit, or remove agentic skills (.llm/skills/*/SKILL.md), regenerate the skills index, or fix lint failures around SKILL.md frontmatter, line limits, or pointer-file delegation. Use when adding new skills, changing skill descriptions/categories, touching .llm/**, or when tooling~/scripts/lint-llm-instructions.ps1 or lint-skill-sizes.ps1 fail. | ## Performance Skills diff --git a/.llm/skills/manage-skills/SKILL.md b/.llm/skills/manage-skills/SKILL.md index 0e21e8d2..42222b3b 100644 --- a/.llm/skills/manage-skills/SKILL.md +++ b/.llm/skills/manage-skills/SKILL.md @@ -1,6 +1,6 @@ --- name: manage-skills -description: Create, edit, or remove agentic skills (.llm/skills/*/SKILL.md), regenerate the skills index, or fix lint failures around SKILL.md frontmatter, line limits, or pointer-file delegation. Use when adding new skills, changing skill descriptions/categories, touching .llm/**, or when scripts/lint-llm-instructions.ps1 or lint-skill-sizes.ps1 fail. +description: Create, edit, or remove agentic skills (.llm/skills/*/SKILL.md), regenerate the skills index, or fix lint failures around SKILL.md frontmatter, line limits, or pointer-file delegation. Use when adding new skills, changing skill descriptions/categories, touching .llm/**, or when tooling~/scripts/lint-llm-instructions.ps1 or lint-skill-sizes.ps1 fail. metadata: category: Core --- @@ -15,7 +15,7 @@ metadata: skills/ index.md # GENERATED - never hand-edit /SKILL.md # One directory per skill (agentskills.io format) -scripts/ +tooling~/scripts/ generate-skills-index.ps1 # Index generator (deterministic) lint-llm-instructions.ps1 # Contract linter (-Fix regenerates the index) lint-skill-sizes.ps1 # Line-limit linter @@ -35,12 +35,12 @@ scripts/ 3. Body: instructions, examples, edge cases. Reference sibling files with relative paths from the skill root. Keep the file at or below 300 lines (`lint-skill-sizes.ps1`; 270+ warns). 4. Regenerate the index: - `pwsh -NoProfile -File scripts/generate-skills-index.ps1` + `pwsh -NoProfile -File tooling~/scripts/generate-skills-index.ps1` 5. Run both linters; commit `.llm/skills/index.md` together with the skill. ```sh -pwsh -NoProfile -File scripts/lint-llm-instructions.ps1 -Fix -pwsh -NoProfile -File scripts/lint-skill-sizes.ps1 -VerboseOutput +pwsh -NoProfile -File tooling~/scripts/lint-llm-instructions.ps1 -Fix +pwsh -NoProfile -File tooling~/scripts/lint-skill-sizes.ps1 -VerboseOutput ``` ## Editing a skill diff --git a/.llm/skills/unity-mcp/SKILL.md b/.llm/skills/unity-mcp/SKILL.md index 6750e19e..923a2970 100644 --- a/.llm/skills/unity-mcp/SKILL.md +++ b/.llm/skills/unity-mcp/SKILL.md @@ -8,7 +8,7 @@ metadata: # Unity MCP Bridge and Agent Backends Unity runs on the host; agents (in the devcontainer or on the host) reach it through -`scripts/mcp/unity-mcp.mjs`. Full reference: `scripts/mcp/README.md`. +`tooling~/scripts/mcp/unity-mcp.mjs`. Full reference: `tooling~/scripts/mcp/README.md`. ## Commands @@ -35,7 +35,7 @@ automatically); `GET /healthz` is the only unauthenticated endpoint. `.env.local`; the same `unity-mcp` server name in each checkout then reaches that checkout's editor. - Keep the bridge alive under the host's service manager (launchd on macOS) if it - must survive terminal closure: host `node /scripts/mcp/unity-mcp.mjs bridge`. + must survive terminal closure: host `node /tooling~/scripts/mcp/unity-mcp.mjs bridge`. - Backend selection: `--backend cli` (default; Unity 6 CLI `unity mcp --project-path`) or `--backend relay` (legacy AI Assistant under `~/.unity/relay/`). If `unity list --project-path --format json` shows no Pipeline tools, use relay. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 11702960..4dbb8293 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,7 +22,7 @@ repos: description: CSharpier is an opinionated C# formatter inspired by Prettier. - id: llm-instructions-lint name: Lint .llm instructions (SKILL.md spec, index freshness, pointer delegation) - entry: pwsh -NoProfile -File scripts/lint-llm-instructions.ps1 + entry: pwsh -NoProfile -File tooling~/scripts/lint-llm-instructions.ps1 language: system always_run: true pass_filenames: false @@ -35,7 +35,7 @@ repos: structure, and front-end pointer-file delegation to .llm/context.md. - id: llm-skill-sizes name: Enforce .llm line limits (300 hard, 270 critical) - entry: pwsh -NoProfile -File scripts/lint-skill-sizes.ps1 + entry: pwsh -NoProfile -File tooling~/scripts/lint-skill-sizes.ps1 language: system always_run: true pass_filenames: false @@ -47,7 +47,7 @@ repos: below 300 lines (270+ is a critical warning); the generated skills index is exempt. - id: unity-meta-lint name: Lint Unity .meta hygiene (orphans, missing metas, guid uniqueness) - entry: pwsh -NoProfile -File scripts/lint-unity-meta.ps1 + entry: pwsh -NoProfile -File tooling~/scripts/lint-unity-meta.ps1 language: system always_run: true pass_filenames: false diff --git a/Runtime/CommandTerminal/Backend/CommandHistory.cs b/Runtime/CommandTerminal/Backend/CommandHistory.cs index 895a35d8..7925a6d3 100644 --- a/Runtime/CommandTerminal/Backend/CommandHistory.cs +++ b/Runtime/CommandTerminal/Backend/CommandHistory.cs @@ -13,6 +13,17 @@ public sealed class CommandHistory private readonly CyclicBuffer<(string text, bool? success, bool? errorFree)> _history; + /* + Commands already shown during the current traversal direction. Next + and Previous skip entries listed here, so neither adjacent runs nor + non-adjacent repeats get re-displayed in one sweep. The set resets + on Push, Clear, Resize, and whenever the traversal direction + flips, replaying the passed entries on the way back. + */ + private readonly HashSet _seenInDirection = new(StringComparer.OrdinalIgnoreCase); + + private int _direction; + private int _position; public CommandHistory(int capacity) @@ -31,6 +42,8 @@ public IEnumerable GetHistory(bool onlySuccess, bool onlyErrorFree) public void Resize(int newCapacity) { _history.Resize(newCapacity); + _seenInDirection.Clear(); + _direction = 0; } public bool Push(string commandString, bool? success, bool? errorFree) @@ -42,41 +55,35 @@ public bool Push(string commandString, bool? success, bool? errorFree) _history.Add((commandString, success, errorFree)); _position = _history.Count; + _seenInDirection.Clear(); + _direction = 0; return true; } public string Next(bool skipSameCommands) { - int initialPosition = _position; ++_position; + if (_direction != 1) + { + _seenInDirection.Clear(); + } + _direction = 1; while ( skipSameCommands - && 0 <= initialPosition - && initialPosition < _history.Count && 0 <= _position && _position < _history.Count + && _seenInDirection.Contains(_history[_position].text) ) { - if ( - string.Equals( - _history[initialPosition].text, - _history[_position].text, - StringComparison.OrdinalIgnoreCase - ) - ) - { - ++_position; - } - else - { - break; - } + ++_position; } if (0 <= _position && _position < _history.Count) { - return _history[_position].text; + string text = _history[_position].text; + _seenInDirection.Add(text); + return text; } _position = _history.Count; @@ -85,36 +92,28 @@ public string Next(bool skipSameCommands) public string Previous(bool skipSameCommands) { - int initialPosition = _position; --_position; + if (_direction != -1) + { + _seenInDirection.Clear(); + } + _direction = -1; while ( skipSameCommands - && 0 <= initialPosition - && initialPosition < _history.Count && 0 <= _position && _position < _history.Count + && _seenInDirection.Contains(_history[_position].text) ) { - if ( - string.Equals( - _history[initialPosition].text, - _history[_position].text, - StringComparison.OrdinalIgnoreCase - ) - ) - { - --_position; - } - else - { - break; - } + --_position; } if (0 <= _position && _position < _history.Count) { - return _history[_position].text; + string text = _history[_position].text; + _seenInDirection.Add(text); + return text; } _position = -1; @@ -126,6 +125,8 @@ public int Clear() int count = _history.Count; _history.Clear(); _position = 0; + _seenInDirection.Clear(); + _direction = 0; return count; } } diff --git a/Runtime/CommandTerminal/Backend/CommandShell.cs b/Runtime/CommandTerminal/Backend/CommandShell.cs index eb986f1d..38c036d4 100644 --- a/Runtime/CommandTerminal/Backend/CommandShell.cs +++ b/Runtime/CommandTerminal/Backend/CommandShell.cs @@ -13,8 +13,6 @@ namespace WallstopStudios.DxCommandTerminal.Backend public sealed class CommandShell { - private static readonly string[] IgnoredTypes = { "JetBrains.Rider" }; - public static readonly Lazy<( MethodInfo method, RegisterCommandAttribute attribute @@ -106,11 +104,6 @@ is not RegisterCommandAttribute attribute } catch (Exception e) { - if (ShouldIgnoreExceptionForType(type)) - { - continue; - } - Debug.LogError( $"Failed to resolve method {method.Name} of type {type.FullName} with exception {e}" ); @@ -119,11 +112,6 @@ is not RegisterCommandAttribute attribute } catch (Exception e) { - if (ShouldIgnoreExceptionForType(type)) - { - continue; - } - Debug.LogError( $"Failed to resolve methods for type {type.FullName} with exception {e}" ); @@ -185,19 +173,6 @@ public CommandShell(CommandHistory history) public bool HasErrors => 0 < _errorMessages.Count; - private static bool ShouldIgnoreExceptionForType(Type type) - { - foreach (string ignoredType in IgnoredTypes) - { - if (type.FullName?.IndexOf(ignoredType, StringComparison.OrdinalIgnoreCase) >= 0) - { - return true; - } - } - - return false; - } - // Internal for test coverage of the discovery filter (see // WallstopStudios.DxCommandTerminal.Tests.Runtime). internal static bool MayContainCommands(Assembly assembly, AssemblyName self) diff --git a/Tests/Runtime/CommandHistoryTests.cs b/Tests/Runtime/CommandHistoryTests.cs index 97d2f09b..c3cd4ca2 100644 --- a/Tests/Runtime/CommandHistoryTests.cs +++ b/Tests/Runtime/CommandHistoryTests.cs @@ -1,336 +1,436 @@ -namespace WallstopStudios.DxCommandTerminal.Tests.Runtime -{ - using System.Collections; - using System.Linq; - using Backend; - using NUnit.Framework; - using UI; - using UnityEngine; - using UnityEngine.TestTools; - - public sealed class CommandHistoryTests - { - [TearDown] - public void TearDown() - { - if (TerminalUI.Instance != null) - { - Object.Destroy(TerminalUI.Instance.gameObject); - } - } - - [Test] - public void CountReflectsNumberOfEntries() - { - CommandHistory history = new(10); - Assert.AreEqual(0, history.Count, "New history should have count 0"); - - history.Push("command1", true, true); - Assert.AreEqual(1, history.Count, "Count should be 1 after one push"); - - history.Push("command2", true, true); - Assert.AreEqual(2, history.Count, "Count should be 2 after two pushes"); - - history.Push("command3", false, false); - Assert.AreEqual(3, history.Count, "Count should be 3 after three pushes"); - } - - [Test] - public void ClearEmptiesHistory() - { - CommandHistory history = new(10); - history.Push("command1", true, true); - history.Push("command2", true, true); - history.Push("command3", true, true); - - history.Clear(); - - Assert.AreEqual(0, history.Count, "Count should be 0 after clear"); - string[] entries = history.GetHistory(false, false).ToArray(); - Assert.AreEqual(0, entries.Length, "GetHistory should return no entries after clear"); - } - - [Test] - public void ClearReturnsCorrectCount() - { - CommandHistory history = new(10); - Assert.AreEqual(0, history.Clear(), "Clear on empty history should return 0"); - - history.Push("command1", true, true); - history.Push("command2", true, true); - history.Push("command3", true, true); - - Assert.AreEqual(3, history.Clear(), "Clear should return the number of entries cleared"); - } - - [Test] - public void ClearOnEmptyHistoryReturnsZero() - { - CommandHistory history = new(10); - int count = history.Clear(); - Assert.AreEqual(0, count, "Clear on empty history should return 0"); - Assert.AreEqual(0, history.Count, "Count should remain 0 after clearing empty history"); - } - - [Test] - public void ClearResetsNavigationPosition() - { - CommandHistory history = new(10); - history.Push("command1", true, true); - history.Push("command2", true, true); - history.Push("command3", true, true); - - // Navigate to verify position is set - string prev = history.Previous(false); - Assert.AreEqual("command3", prev, "Previous should return last command before clear"); - - history.Clear(); - - // After clear, Previous and Next should return empty - Assert.AreEqual(string.Empty, history.Previous(false), "Previous should return empty after clear"); - Assert.AreEqual(string.Empty, history.Next(false), "Next should return empty after clear"); - } - - [Test] - public void PushAfterClearWorksCorrectly() - { - CommandHistory history = new(10); - history.Push("old1", true, true); - history.Push("old2", true, true); - - history.Clear(); - - history.Push("new1", true, true); - Assert.AreEqual(1, history.Count, "Count should be 1 after push following clear"); - - string[] entries = history.GetHistory(false, false).ToArray(); - Assert.AreEqual(1, entries.Length, "Should have exactly one entry after push following clear"); - Assert.AreEqual("new1", entries[0], "Entry should be the newly pushed command"); - } - - [Test] - public void NavigationAfterClearAndPushWorksCorrectly() - { - CommandHistory history = new(10); - history.Push("old1", true, true); - history.Push("old2", true, true); - - history.Clear(); - - history.Push("new1", true, true); - history.Push("new2", true, true); - - string prev = history.Previous(false); - Assert.AreEqual("new2", prev, "Previous should return last new command"); - - prev = history.Previous(false); - Assert.AreEqual("new1", prev, "Previous again should return first new command"); - - prev = history.Previous(false); - Assert.AreEqual(string.Empty, prev, "Previous beyond beginning should return empty"); - - string next = history.Next(false); - Assert.AreEqual("new1", next, "Next should return first new command"); - - next = history.Next(false); - Assert.AreEqual("new2", next, "Next should return second new command"); - - next = history.Next(false); - Assert.AreEqual(string.Empty, next, "Next beyond end should return empty"); - } - - [Test] - public void PushRejectsNullAndWhitespace() - { - CommandHistory history = new(10); - - Assert.IsFalse(history.Push(null, true, true), "Push should reject null"); - Assert.IsFalse(history.Push("", true, true), "Push should reject empty string"); - Assert.IsFalse(history.Push(" ", true, true), "Push should reject whitespace-only string"); - Assert.AreEqual(0, history.Count, "Count should remain 0 after rejected pushes"); - } - - [Test] - public void GetHistoryFiltersSuccess() - { - CommandHistory history = new(10); - history.Push("success1", true, true); - history.Push("failure1", false, true); - history.Push("success2", true, true); - - string[] successOnly = history.GetHistory(true, false).ToArray(); - Assert.AreEqual(2, successOnly.Length, "Should have 2 successful entries"); - Assert.IsTrue(successOnly.Contains("success1"), "Should contain success1"); - Assert.IsTrue(successOnly.Contains("success2"), "Should contain success2"); - } - - [Test] - public void GetHistoryFiltersErrorFree() - { - CommandHistory history = new(10); - history.Push("clean1", true, true); - history.Push("errored1", true, false); - history.Push("clean2", true, true); - - string[] errorFreeOnly = history.GetHistory(false, true).ToArray(); - Assert.AreEqual(2, errorFreeOnly.Length, "Should have 2 error-free entries"); - Assert.IsTrue(errorFreeOnly.Contains("clean1"), "Should contain clean1"); - Assert.IsTrue(errorFreeOnly.Contains("clean2"), "Should contain clean2"); - } - - [Test] - public void CountHandlesCyclicBufferWrap() - { - CommandHistory history = new(2); - history.Push("a", true, true); - history.Push("b", true, true); - Assert.AreEqual(2, history.Count, "Count should be 2 at capacity."); - history.Push("c", true, true); - Assert.AreEqual(2, history.Count, "Count should remain at capacity after wrap."); - string[] entries = history.GetHistory(false, false).ToArray(); - Assert.AreEqual(2, entries.Length, "History should contain 2 entries after wrap."); - Assert.AreEqual("b", entries[0], "First entry should be 'b' after wrap."); - Assert.AreEqual("c", entries[1], "Second entry should be 'c' after wrap."); - } - - [Test] - public void NavigationAfterClearWithSkipSameCommands() - { - CommandHistory history = new(10); - history.Push("cmd1", true, true); - history.Push("cmd1", true, true); - history.Push("cmd2", true, true); - history.Clear(); - - Assert.AreEqual(string.Empty, history.Previous(true), "Previous with skip should return empty after Clear."); - Assert.AreEqual(string.Empty, history.Next(true), "Next with skip should return empty after Clear."); - - // Push duplicates after clear and navigate with skip - history.Push("cmd1", true, true); - history.Push("cmd1", true, true); - history.Push("cmd2", true, true); - - string prev1 = history.Previous(true); - Assert.AreEqual("cmd2", prev1, "Previous with skip should return 'cmd2'."); - string prev2 = history.Previous(true); - Assert.AreEqual("cmd1", prev2, "Previous with skip should skip duplicate and return 'cmd1'."); - } - - [UnityTest] - public IEnumerator ClearHistoryCommandResultsInEmptyHistory() - { - // clear-history uses AddToHistory = false on its RegisterCommand attribute, - // ensuring the command itself is not recorded in the history it just cleared. - yield return TerminalTests.SpawnTerminal(resetStateOnInit: true); - - CommandShell shell = Terminal.Shell; - Assert.IsNotNull(shell, "Terminal.Shell should not be null after SpawnTerminal"); - CommandHistory history = Terminal.History; - Assert.IsNotNull(history, "Terminal.History should not be null after SpawnTerminal"); - - // Run some commands to populate history - shell.RunCommand("log test1"); - shell.RunCommand("log test2"); - shell.RunCommand("log test3"); - - string[] entries = history.GetHistory(false, false).ToArray(); - Assert.AreEqual(3, entries.Length, "Should have 3 history entries before clear-history"); - - // Run clear-history - shell.RunCommand("clear-history"); - - // After fix: history should be completely empty - entries = history.GetHistory(false, false).ToArray(); - Assert.AreEqual( - 0, - entries.Length, - $"History should be empty after clear-history, but contained: {string.Join(", ", entries)}" - ); - Assert.AreEqual(0, history.Count, "History count should be 0 after clear-history"); - } - - [UnityTest] - public IEnumerator CommandsAfterClearHistoryWorkNormally() - { - yield return TerminalTests.SpawnTerminal(resetStateOnInit: true); - - CommandShell shell = Terminal.Shell; - Assert.IsNotNull(shell, "Terminal.Shell should not be null after SpawnTerminal"); - CommandHistory history = Terminal.History; - Assert.IsNotNull(history, "Terminal.History should not be null after SpawnTerminal"); - - // Populate, clear, then run more commands - shell.RunCommand("log before"); - shell.RunCommand("clear-history"); - - Assert.AreEqual(0, history.Count, "History should be empty after clear-history"); - - shell.RunCommand("log after1"); - shell.RunCommand("log after2"); - - string[] entries = history.GetHistory(false, false).ToArray(); - Assert.AreEqual(2, entries.Length, "Should have 2 entries after clear-history followed by 2 commands"); - Assert.IsTrue(entries.Contains("log after1"), "Should contain 'log after1'"); - Assert.IsTrue(entries.Contains("log after2"), "Should contain 'log after2'"); - } - - [UnityTest] - public IEnumerator CommandWithAddToHistoryFalseAndInvalidArgsDoesNotPushToHistory() - { - // clear-history has AddToHistory = false. When called with wrong args, - // it should still NOT be recorded in history. - yield return TerminalTests.SpawnTerminal(resetStateOnInit: true); - - CommandShell shell = Terminal.Shell; - Assert.IsNotNull(shell, "Terminal.Shell should not be null after SpawnTerminal"); - CommandHistory history = Terminal.History; - Assert.IsNotNull(history, "Terminal.History should not be null after SpawnTerminal"); - - // Run some commands to populate history - shell.RunCommand("log test1"); - shell.RunCommand("log test2"); - - string[] entriesBefore = history.GetHistory(false, false).ToArray(); - Assert.AreEqual(2, entriesBefore.Length, "Should have 2 history entries before invalid clear-history"); - - // Run clear-history with an extra argument (should fail arg validation) - shell.RunCommand("clear-history somearg"); - - string[] entriesAfter = history.GetHistory(false, false).ToArray(); - Assert.IsFalse( - entriesAfter.Contains("clear-history somearg"), - "clear-history somearg should NOT appear in history when AddToHistory is false" - ); - Assert.AreEqual( - 2, - entriesAfter.Length, - $"History should still have 2 entries, but contained: {string.Join(", ", entriesAfter)}" - ); - Assert.IsTrue(entriesAfter.Contains("log test1"), "Should still contain 'log test1'"); - Assert.IsTrue(entriesAfter.Contains("log test2"), "Should still contain 'log test2'"); - } - - [UnityTest] - public IEnumerator CommandWithAddToHistoryTrueAndInvalidArgsPushesToHistory() - { - // set-theme has AddToHistory = true (default). When called with wrong args, - // it should still be recorded in history. - yield return TerminalTests.SpawnTerminal(resetStateOnInit: true); - - CommandShell shell = Terminal.Shell; - Assert.IsNotNull(shell, "Terminal.Shell should not be null after SpawnTerminal"); - CommandHistory history = Terminal.History; - Assert.IsNotNull(history, "Terminal.History should not be null after SpawnTerminal"); - - // Run set-theme with no arguments (it requires exactly 1) - shell.RunCommand("set-theme"); - - string[] entries = history.GetHistory(false, false).ToArray(); - Assert.AreEqual(1, entries.Length, "Should have 1 history entry after invalid set-theme"); - Assert.IsTrue( - entries.Contains("set-theme"), - "set-theme should appear in history when AddToHistory is true" - ); - } - } -} +namespace WallstopStudios.DxCommandTerminal.Tests.Runtime +{ + using System.Collections; + using System.Linq; + using Backend; + using NUnit.Framework; + using UI; + using UnityEngine; + using UnityEngine.TestTools; + + public sealed class CommandHistoryTests + { + [TearDown] + public void TearDown() + { + if (TerminalUI.Instance != null) + { + Object.Destroy(TerminalUI.Instance.gameObject); + } + } + + [Test] + public void CountReflectsNumberOfEntries() + { + CommandHistory history = new(10); + Assert.AreEqual(0, history.Count, "New history should have count 0"); + + history.Push("command1", true, true); + Assert.AreEqual(1, history.Count, "Count should be 1 after one push"); + + history.Push("command2", true, true); + Assert.AreEqual(2, history.Count, "Count should be 2 after two pushes"); + + history.Push("command3", false, false); + Assert.AreEqual(3, history.Count, "Count should be 3 after three pushes"); + } + + [Test] + public void ClearEmptiesHistory() + { + CommandHistory history = new(10); + history.Push("command1", true, true); + history.Push("command2", true, true); + history.Push("command3", true, true); + + history.Clear(); + + Assert.AreEqual(0, history.Count, "Count should be 0 after clear"); + string[] entries = history.GetHistory(false, false).ToArray(); + Assert.AreEqual(0, entries.Length, "GetHistory should return no entries after clear"); + } + + [Test] + public void ClearReturnsCorrectCount() + { + CommandHistory history = new(10); + Assert.AreEqual(0, history.Clear(), "Clear on empty history should return 0"); + + history.Push("command1", true, true); + history.Push("command2", true, true); + history.Push("command3", true, true); + + Assert.AreEqual( + 3, + history.Clear(), + "Clear should return the number of entries cleared" + ); + } + + [Test] + public void ClearOnEmptyHistoryReturnsZero() + { + CommandHistory history = new(10); + int count = history.Clear(); + Assert.AreEqual(0, count, "Clear on empty history should return 0"); + Assert.AreEqual(0, history.Count, "Count should remain 0 after clearing empty history"); + } + + [Test] + public void ClearResetsNavigationPosition() + { + CommandHistory history = new(10); + history.Push("command1", true, true); + history.Push("command2", true, true); + history.Push("command3", true, true); + + // Navigate to verify position is set + string prev = history.Previous(false); + Assert.AreEqual("command3", prev, "Previous should return last command before clear"); + + history.Clear(); + + // After clear, Previous and Next should return empty + Assert.AreEqual( + string.Empty, + history.Previous(false), + "Previous should return empty after clear" + ); + Assert.AreEqual( + string.Empty, + history.Next(false), + "Next should return empty after clear" + ); + } + + [Test] + public void PushAfterClearWorksCorrectly() + { + CommandHistory history = new(10); + history.Push("old1", true, true); + history.Push("old2", true, true); + + history.Clear(); + + history.Push("new1", true, true); + Assert.AreEqual(1, history.Count, "Count should be 1 after push following clear"); + + string[] entries = history.GetHistory(false, false).ToArray(); + Assert.AreEqual( + 1, + entries.Length, + "Should have exactly one entry after push following clear" + ); + Assert.AreEqual("new1", entries[0], "Entry should be the newly pushed command"); + } + + [Test] + public void NavigationAfterClearAndPushWorksCorrectly() + { + CommandHistory history = new(10); + history.Push("old1", true, true); + history.Push("old2", true, true); + + history.Clear(); + + history.Push("new1", true, true); + history.Push("new2", true, true); + + string prev = history.Previous(false); + Assert.AreEqual("new2", prev, "Previous should return last new command"); + + prev = history.Previous(false); + Assert.AreEqual("new1", prev, "Previous again should return first new command"); + + prev = history.Previous(false); + Assert.AreEqual(string.Empty, prev, "Previous beyond beginning should return empty"); + + string next = history.Next(false); + Assert.AreEqual("new1", next, "Next should return first new command"); + + next = history.Next(false); + Assert.AreEqual("new2", next, "Next should return second new command"); + + next = history.Next(false); + Assert.AreEqual(string.Empty, next, "Next beyond end should return empty"); + } + + [Test] + public void PushRejectsNullAndWhitespace() + { + CommandHistory history = new(10); + + Assert.IsFalse(history.Push(null, true, true), "Push should reject null"); + Assert.IsFalse(history.Push("", true, true), "Push should reject empty string"); + Assert.IsFalse( + history.Push(" ", true, true), + "Push should reject whitespace-only string" + ); + Assert.AreEqual(0, history.Count, "Count should remain 0 after rejected pushes"); + } + + [Test] + public void GetHistoryFiltersSuccess() + { + CommandHistory history = new(10); + history.Push("success1", true, true); + history.Push("failure1", false, true); + history.Push("success2", true, true); + + string[] successOnly = history.GetHistory(true, false).ToArray(); + Assert.AreEqual(2, successOnly.Length, "Should have 2 successful entries"); + Assert.IsTrue(successOnly.Contains("success1"), "Should contain success1"); + Assert.IsTrue(successOnly.Contains("success2"), "Should contain success2"); + } + + [Test] + public void GetHistoryFiltersErrorFree() + { + CommandHistory history = new(10); + history.Push("clean1", true, true); + history.Push("errored1", true, false); + history.Push("clean2", true, true); + + string[] errorFreeOnly = history.GetHistory(false, true).ToArray(); + Assert.AreEqual(2, errorFreeOnly.Length, "Should have 2 error-free entries"); + Assert.IsTrue(errorFreeOnly.Contains("clean1"), "Should contain clean1"); + Assert.IsTrue(errorFreeOnly.Contains("clean2"), "Should contain clean2"); + } + + [Test] + public void CountHandlesCyclicBufferWrap() + { + CommandHistory history = new(2); + history.Push("a", true, true); + history.Push("b", true, true); + Assert.AreEqual(2, history.Count, "Count should be 2 at capacity."); + history.Push("c", true, true); + Assert.AreEqual(2, history.Count, "Count should remain at capacity after wrap."); + string[] entries = history.GetHistory(false, false).ToArray(); + Assert.AreEqual(2, entries.Length, "History should contain 2 entries after wrap."); + Assert.AreEqual("b", entries[0], "First entry should be 'b' after wrap."); + Assert.AreEqual("c", entries[1], "Second entry should be 'c' after wrap."); + } + + [Test] + public void NavigationAfterClearWithSkipSameCommands() + { + CommandHistory history = new(10); + history.Push("cmd1", true, true); + history.Push("cmd1", true, true); + history.Push("cmd2", true, true); + history.Clear(); + + Assert.AreEqual( + string.Empty, + history.Previous(true), + "Previous with skip should return empty after Clear." + ); + Assert.AreEqual( + string.Empty, + history.Next(true), + "Next with skip should return empty after Clear." + ); + + // Push duplicates after clear and navigate with skip + history.Push("cmd1", true, true); + history.Push("cmd1", true, true); + history.Push("cmd2", true, true); + + string prev1 = history.Previous(true); + Assert.AreEqual("cmd2", prev1, "Previous with skip should return 'cmd2'."); + string prev2 = history.Previous(true); + Assert.AreEqual( + "cmd1", + prev2, + "Previous with skip should skip duplicate and return 'cmd1'." + ); + } + + [Test] + public void PreviousSkipsNonAdjacentDuplicates() + { + CommandHistory history = new(10); + history.Push("a", true, true); + history.Push("b", true, true); + history.Push("a", true, true); + + Assert.AreEqual( + "a", + history.Previous(true), + "First Previous should return the newest entry" + ); + Assert.AreEqual("b", history.Previous(true), "Second Previous should return 'b'"); + Assert.AreEqual( + string.Empty, + history.Previous(true), + "Third Previous should skip the non-adjacent duplicate 'a' and return empty" + ); + } + + [Test] + public void NextSkipsNonAdjacentDuplicates() + { + CommandHistory history = new(10); + history.Push("a", true, true); + history.Push("b", true, true); + history.Push("x", true, true); + history.Push("b", true, true); + history.Push("c", true, true); + + Assert.AreEqual( + "c", + history.Previous(true), + "First Previous should return the newest entry" + ); + Assert.AreEqual("b", history.Previous(true), "Second Previous should return 'b'"); + Assert.AreEqual("x", history.Previous(true), "Third Previous should return 'x'"); + Assert.AreEqual( + "a", + history.Previous(true), + "Fourth Previous should skip duplicate 'b' and return 'a'" + ); + + Assert.AreEqual("b", history.Next(true), "Next should replay entries going forward"); + Assert.AreEqual("x", history.Next(true), "Second Next should return 'x'"); + Assert.AreEqual( + "c", + history.Next(true), + "Third Next should skip the non-adjacent duplicate 'b' and return 'c'" + ); + } + + [UnityTest] + public IEnumerator ClearHistoryCommandResultsInEmptyHistory() + { + // clear-history uses AddToHistory = false on its RegisterCommand attribute, + // ensuring the command itself is not recorded in the history it just cleared. + yield return TerminalTests.SpawnTerminal(resetStateOnInit: true); + + CommandShell shell = Terminal.Shell; + Assert.IsNotNull(shell, "Terminal.Shell should not be null after SpawnTerminal"); + CommandHistory history = Terminal.History; + Assert.IsNotNull(history, "Terminal.History should not be null after SpawnTerminal"); + + // Run some commands to populate history + shell.RunCommand("log test1"); + shell.RunCommand("log test2"); + shell.RunCommand("log test3"); + + string[] entries = history.GetHistory(false, false).ToArray(); + Assert.AreEqual( + 3, + entries.Length, + "Should have 3 history entries before clear-history" + ); + + // Run clear-history + shell.RunCommand("clear-history"); + + // After fix: history should be completely empty + entries = history.GetHistory(false, false).ToArray(); + Assert.AreEqual( + 0, + entries.Length, + $"History should be empty after clear-history, but contained: {string.Join(", ", entries)}" + ); + Assert.AreEqual(0, history.Count, "History count should be 0 after clear-history"); + } + + [UnityTest] + public IEnumerator CommandsAfterClearHistoryWorkNormally() + { + yield return TerminalTests.SpawnTerminal(resetStateOnInit: true); + + CommandShell shell = Terminal.Shell; + Assert.IsNotNull(shell, "Terminal.Shell should not be null after SpawnTerminal"); + CommandHistory history = Terminal.History; + Assert.IsNotNull(history, "Terminal.History should not be null after SpawnTerminal"); + + // Populate, clear, then run more commands + shell.RunCommand("log before"); + shell.RunCommand("clear-history"); + + Assert.AreEqual(0, history.Count, "History should be empty after clear-history"); + + shell.RunCommand("log after1"); + shell.RunCommand("log after2"); + + string[] entries = history.GetHistory(false, false).ToArray(); + Assert.AreEqual( + 2, + entries.Length, + "Should have 2 entries after clear-history followed by 2 commands" + ); + Assert.IsTrue(entries.Contains("log after1"), "Should contain 'log after1'"); + Assert.IsTrue(entries.Contains("log after2"), "Should contain 'log after2'"); + } + + [UnityTest] + public IEnumerator CommandWithAddToHistoryFalseAndInvalidArgsDoesNotPushToHistory() + { + // clear-history has AddToHistory = false. When called with wrong args, + // it should still NOT be recorded in history. + yield return TerminalTests.SpawnTerminal(resetStateOnInit: true); + + CommandShell shell = Terminal.Shell; + Assert.IsNotNull(shell, "Terminal.Shell should not be null after SpawnTerminal"); + CommandHistory history = Terminal.History; + Assert.IsNotNull(history, "Terminal.History should not be null after SpawnTerminal"); + + // Run some commands to populate history + shell.RunCommand("log test1"); + shell.RunCommand("log test2"); + + string[] entriesBefore = history.GetHistory(false, false).ToArray(); + Assert.AreEqual( + 2, + entriesBefore.Length, + "Should have 2 history entries before invalid clear-history" + ); + + // Run clear-history with an extra argument (should fail arg validation) + shell.RunCommand("clear-history somearg"); + + string[] entriesAfter = history.GetHistory(false, false).ToArray(); + Assert.IsFalse( + entriesAfter.Contains("clear-history somearg"), + "clear-history somearg should NOT appear in history when AddToHistory is false" + ); + Assert.AreEqual( + 2, + entriesAfter.Length, + $"History should still have 2 entries, but contained: {string.Join(", ", entriesAfter)}" + ); + Assert.IsTrue(entriesAfter.Contains("log test1"), "Should still contain 'log test1'"); + Assert.IsTrue(entriesAfter.Contains("log test2"), "Should still contain 'log test2'"); + } + + [UnityTest] + public IEnumerator CommandWithAddToHistoryTrueAndInvalidArgsPushesToHistory() + { + // set-theme has AddToHistory = true (default). When called with wrong args, + // it should still be recorded in history. + yield return TerminalTests.SpawnTerminal(resetStateOnInit: true); + + CommandShell shell = Terminal.Shell; + Assert.IsNotNull(shell, "Terminal.Shell should not be null after SpawnTerminal"); + CommandHistory history = Terminal.History; + Assert.IsNotNull(history, "Terminal.History should not be null after SpawnTerminal"); + + // Run set-theme with no arguments (it requires exactly 1) + shell.RunCommand("set-theme"); + + string[] entries = history.GetHistory(false, false).ToArray(); + Assert.AreEqual( + 1, + entries.Length, + "Should have 1 history entry after invalid set-theme" + ); + Assert.IsTrue( + entries.Contains("set-theme"), + "set-theme should appear in history when AddToHistory is true" + ); + } + } +} diff --git a/Tests/Runtime/TerminalKeyboardControllerTests.cs b/Tests/Runtime/TerminalKeyboardControllerTests.cs index 5730d2ef..7d397e74 100644 --- a/Tests/Runtime/TerminalKeyboardControllerTests.cs +++ b/Tests/Runtime/TerminalKeyboardControllerTests.cs @@ -1,191 +1,211 @@ -namespace WallstopStudios.DxCommandTerminal.Tests.Runtime -{ - using System; - using System.Collections; - using System.Collections.Generic; - using System.Linq; - using System.Reflection; - using Input; - using NUnit.Framework; - using UnityEngine; - using UnityEngine.TestTools; - - public sealed class TerminalKeyboardControllerTests - { - private readonly List _gameObjects = new(); - - private static TerminalControlTypes[] GetControlTypes() - { - FieldInfo controlTypesField = typeof(TerminalKeyboardController).GetField( - "ControlTypes", - BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.FlattenHierarchy - ); - return (TerminalControlTypes[])controlTypesField.GetValue(null); - } - - private static FieldInfo GetControlOrderField() - { - return typeof(TerminalKeyboardController).GetField( - "_controlOrder", - BindingFlags.Instance | BindingFlags.NonPublic - ); - } - - private static MethodInfo GetVerifyMethod() - { - return typeof(TerminalKeyboardController).GetMethod( - "VerifyControlOrderIntegrity", - BindingFlags.Instance | BindingFlags.NonPublic - ); - } - - [TearDown] - public void TearDown() - { - foreach (GameObject go in _gameObjects) - { - if (go != null) - { - UnityEngine.Object.Destroy(go); - } - } - _gameObjects.Clear(); - } - - [Test] - public void ControlTypesContainsAllNonNoneEnumValues() - { - TerminalControlTypes[] expected = Enum.GetValues(typeof(TerminalControlTypes)) - .OfType() -#pragma warning disable CS0612 // Type or member is obsolete - .Except(new[] { TerminalControlTypes.None }) -#pragma warning restore CS0612 // Type or member is obsolete - .ToArray(); - - TerminalControlTypes[] actual = GetControlTypes(); - Assert.IsNotNull(actual, "ControlTypes should not be null"); - Assert.AreEqual( - expected.Length, - actual.Length, - $"ControlTypes length mismatch. Expected: [{string.Join(", ", expected)}], Actual: [{string.Join(", ", actual)}]" - ); - - foreach (TerminalControlTypes controlType in expected) - { - Assert.IsTrue( - actual.Contains(controlType), - $"ControlTypes is missing {controlType}. Contents: [{string.Join(", ", actual)}]" - ); - } - } - - [UnityTest] - public IEnumerator DefaultControlOrderProducesNoWarning() - { - // Default _controlOrder contains all TerminalControlTypes, so no warning should fire. - // Awake will log an error about missing TerminalUI -- expect that. - LogAssert.Expect(LogType.Error, "Failed to find TerminalUI, Input will not work."); - GameObject go = new("TerminalKeyboardControllerTest"); - _gameObjects.Add(go); - go.AddComponent(); - yield return null; - - // LogAssert will fail the test if any unexpected warnings were emitted. - LogAssert.NoUnexpectedReceived(); - } - - [UnityTest] - public IEnumerator ControlOrderWithDuplicatesButAllTypesPresent_ProducesNoWarning() - { - // Regression test: if _controlOrder has duplicates but still covers all control types, - // VerifyControlOrderIntegrity should NOT produce a warning. - LogAssert.Expect(LogType.Error, "Failed to find TerminalUI, Input will not work."); - GameObject go = new("TerminalKeyboardControllerTest"); - _gameObjects.Add(go); - TerminalKeyboardController controller = go.AddComponent(); - yield return null; - - // Now modify _controlOrder to have duplicates but still include all types, then invoke VerifyControlOrderIntegrity. - FieldInfo controlOrderField = GetControlOrderField(); - Assert.IsNotNull(controlOrderField, "_controlOrder field should exist on TerminalKeyboardController"); - - List orderWithDuplicates = GetControlTypes().ToList(); - // Add duplicates - orderWithDuplicates.Add(orderWithDuplicates[0]); - orderWithDuplicates.Add(orderWithDuplicates[1]); - controlOrderField.SetValue(controller, orderWithDuplicates); - - MethodInfo verifyMethod = GetVerifyMethod(); - Assert.IsNotNull(verifyMethod, "VerifyControlOrderIntegrity method should exist on TerminalKeyboardController"); - - verifyMethod.Invoke(controller, null); - - // LogAssert will fail the test if any unexpected warnings were emitted. - LogAssert.NoUnexpectedReceived(); - } - - [UnityTest] - public IEnumerator ControlOrderMissingType_ProducesWarning() - { - // When _controlOrder is missing a control type, a warning should be emitted. - LogAssert.Expect(LogType.Error, "Failed to find TerminalUI, Input will not work."); - GameObject go = new("TerminalKeyboardControllerTest"); - _gameObjects.Add(go); - TerminalKeyboardController controller = go.AddComponent(); - yield return null; - - FieldInfo controlOrderField = GetControlOrderField(); - Assert.IsNotNull(controlOrderField, "_controlOrder field should exist on TerminalKeyboardController"); - - // Remove the last type from the list - TerminalControlTypes[] allTypes = GetControlTypes(); - TerminalControlTypes removedType = allTypes[^1]; - List incompleteOrder = allTypes.Take(allTypes.Length - 1).ToList(); - controlOrderField.SetValue(controller, incompleteOrder); - - MethodInfo verifyMethod = GetVerifyMethod(); - Assert.IsNotNull(verifyMethod, "VerifyControlOrderIntegrity method should exist on TerminalKeyboardController"); - - LogAssert.Expect( - LogType.Warning, - $"Control Order is missing the following controls: [{removedType}]. " - + "Input for these will not be handled. Is this intentional?" - ); - verifyMethod.Invoke(controller, null); - - LogAssert.NoUnexpectedReceived(); - } - - [UnityTest] - public IEnumerator EmptyControlOrder_ProducesWarningForAllTypes() - { - // When _controlOrder is empty, VerifyControlOrderIntegrity should warn about all missing types. - // Note: Awake fires during AddComponent with the default (full) control order, so - // we only modify _controlOrder afterward and invoke VerifyControlOrderIntegrity directly. - LogAssert.Expect(LogType.Error, "Failed to find TerminalUI, Input will not work."); - GameObject go = new("TerminalKeyboardControllerTest"); - _gameObjects.Add(go); - TerminalKeyboardController controller = go.AddComponent(); - yield return null; - - FieldInfo controlOrderField = GetControlOrderField(); - Assert.IsNotNull(controlOrderField, "_controlOrder field should exist on TerminalKeyboardController"); - controlOrderField.SetValue(controller, new List()); - - MethodInfo verifyMethod = GetVerifyMethod(); - Assert.IsNotNull(verifyMethod, "VerifyControlOrderIntegrity method should exist on TerminalKeyboardController"); - - TerminalControlTypes[] allTypes = GetControlTypes(); - string expectedMissing = string.Join(", ", allTypes); - - LogAssert.Expect( - LogType.Warning, - $"Control Order is missing the following controls: [{expectedMissing}]. " - + "Input for these will not be handled. Is this intentional?" - ); - verifyMethod.Invoke(controller, null); - - LogAssert.NoUnexpectedReceived(); - } - } -} +namespace WallstopStudios.DxCommandTerminal.Tests.Runtime +{ + using System; + using System.Collections; + using System.Collections.Generic; + using System.Linq; + using System.Reflection; + using Input; + using NUnit.Framework; + using UnityEngine; + using UnityEngine.TestTools; + + public sealed class TerminalKeyboardControllerTests + { + private readonly List _gameObjects = new(); + + private static TerminalControlTypes[] GetControlTypes() + { + FieldInfo controlTypesField = typeof(TerminalKeyboardController).GetField( + "ControlTypes", + BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.FlattenHierarchy + ); + return (TerminalControlTypes[])controlTypesField.GetValue(null); + } + + private static FieldInfo GetControlOrderField() + { + return typeof(TerminalKeyboardController).GetField( + "_controlOrder", + BindingFlags.Instance | BindingFlags.NonPublic + ); + } + + private static MethodInfo GetVerifyMethod() + { + return typeof(TerminalKeyboardController).GetMethod( + "VerifyControlOrderIntegrity", + BindingFlags.Instance | BindingFlags.NonPublic + ); + } + + [TearDown] + public void TearDown() + { + foreach (GameObject go in _gameObjects) + { + if (go != null) + { + UnityEngine.Object.Destroy(go); + } + } + _gameObjects.Clear(); + } + + [Test] + public void ControlTypesContainsAllNonNoneEnumValues() + { + TerminalControlTypes[] expected = Enum.GetValues(typeof(TerminalControlTypes)) + .OfType() +#pragma warning disable CS0612 // Type or member is obsolete + .Except(new[] { TerminalControlTypes.None }) +#pragma warning restore CS0612 // Type or member is obsolete + .ToArray(); + + TerminalControlTypes[] actual = GetControlTypes(); + Assert.IsNotNull(actual, "ControlTypes should not be null"); + Assert.AreEqual( + expected.Length, + actual.Length, + $"ControlTypes length mismatch. Expected: [{string.Join(", ", expected)}], Actual: [{string.Join(", ", actual)}]" + ); + + foreach (TerminalControlTypes controlType in expected) + { + Assert.IsTrue( + actual.Contains(controlType), + $"ControlTypes is missing {controlType}. Contents: [{string.Join(", ", actual)}]" + ); + } + } + + [UnityTest] + public IEnumerator DefaultControlOrderProducesNoWarning() + { + // Default _controlOrder contains all TerminalControlTypes, so no warning should fire. + // Awake will log an error about missing TerminalUI -- expect that. + LogAssert.Expect(LogType.Error, "Failed to find TerminalUI, Input will not work."); + GameObject go = new("TerminalKeyboardControllerTest"); + _gameObjects.Add(go); + go.AddComponent(); + yield return null; + + // LogAssert will fail the test if any unexpected warnings were emitted. + LogAssert.NoUnexpectedReceived(); + } + + [UnityTest] + public IEnumerator ControlOrderWithDuplicatesButAllTypesPresent_ProducesNoWarning() + { + // Regression test: if _controlOrder has duplicates but still covers all control types, + // VerifyControlOrderIntegrity should NOT produce a warning. + LogAssert.Expect(LogType.Error, "Failed to find TerminalUI, Input will not work."); + GameObject go = new("TerminalKeyboardControllerTest"); + _gameObjects.Add(go); + TerminalKeyboardController controller = go.AddComponent(); + yield return null; + + // Now modify _controlOrder to have duplicates but still include all types, then invoke VerifyControlOrderIntegrity. + FieldInfo controlOrderField = GetControlOrderField(); + Assert.IsNotNull( + controlOrderField, + "_controlOrder field should exist on TerminalKeyboardController" + ); + + List orderWithDuplicates = GetControlTypes().ToList(); + // Add duplicates + orderWithDuplicates.Add(orderWithDuplicates[0]); + orderWithDuplicates.Add(orderWithDuplicates[1]); + controlOrderField.SetValue(controller, orderWithDuplicates); + + MethodInfo verifyMethod = GetVerifyMethod(); + Assert.IsNotNull( + verifyMethod, + "VerifyControlOrderIntegrity method should exist on TerminalKeyboardController" + ); + + verifyMethod.Invoke(controller, null); + + // LogAssert will fail the test if any unexpected warnings were emitted. + LogAssert.NoUnexpectedReceived(); + } + + [UnityTest] + public IEnumerator ControlOrderMissingType_ProducesWarning() + { + // When _controlOrder is missing a control type, a warning should be emitted. + LogAssert.Expect(LogType.Error, "Failed to find TerminalUI, Input will not work."); + GameObject go = new("TerminalKeyboardControllerTest"); + _gameObjects.Add(go); + TerminalKeyboardController controller = go.AddComponent(); + yield return null; + + FieldInfo controlOrderField = GetControlOrderField(); + Assert.IsNotNull( + controlOrderField, + "_controlOrder field should exist on TerminalKeyboardController" + ); + + // Remove the last type from the list + TerminalControlTypes[] allTypes = GetControlTypes(); + TerminalControlTypes removedType = allTypes[^1]; + List incompleteOrder = allTypes + .Take(allTypes.Length - 1) + .ToList(); + controlOrderField.SetValue(controller, incompleteOrder); + + MethodInfo verifyMethod = GetVerifyMethod(); + Assert.IsNotNull( + verifyMethod, + "VerifyControlOrderIntegrity method should exist on TerminalKeyboardController" + ); + + LogAssert.Expect( + LogType.Warning, + $"Control Order is missing the following controls: [{removedType}]. " + + "Input for these will not be handled. Is this intentional?" + ); + verifyMethod.Invoke(controller, null); + + LogAssert.NoUnexpectedReceived(); + } + + [UnityTest] + public IEnumerator EmptyControlOrder_ProducesWarningForAllTypes() + { + // When _controlOrder is empty, VerifyControlOrderIntegrity should warn about all missing types. + // Note: Awake fires during AddComponent with the default (full) control order, so + // we only modify _controlOrder afterward and invoke VerifyControlOrderIntegrity directly. + LogAssert.Expect(LogType.Error, "Failed to find TerminalUI, Input will not work."); + GameObject go = new("TerminalKeyboardControllerTest"); + _gameObjects.Add(go); + TerminalKeyboardController controller = go.AddComponent(); + yield return null; + + FieldInfo controlOrderField = GetControlOrderField(); + Assert.IsNotNull( + controlOrderField, + "_controlOrder field should exist on TerminalKeyboardController" + ); + controlOrderField.SetValue(controller, new List()); + + MethodInfo verifyMethod = GetVerifyMethod(); + Assert.IsNotNull( + verifyMethod, + "VerifyControlOrderIntegrity method should exist on TerminalKeyboardController" + ); + + TerminalControlTypes[] allTypes = GetControlTypes(); + string expectedMissing = string.Join(", ", allTypes); + + LogAssert.Expect( + LogType.Warning, + $"Control Order is missing the following controls: [{expectedMissing}]. " + + "Input for these will not be handled. Is this intentional?" + ); + verifyMethod.Invoke(controller, null); + + LogAssert.NoUnexpectedReceived(); + } + } +} diff --git a/Tests/Runtime/TryEatArgumentTests.cs b/Tests/Runtime/TryEatArgumentTests.cs index 5a2dbc8f..9613a61f 100644 --- a/Tests/Runtime/TryEatArgumentTests.cs +++ b/Tests/Runtime/TryEatArgumentTests.cs @@ -1,93 +1,213 @@ -namespace WallstopStudios.DxCommandTerminal.Tests.Runtime -{ - using Backend; - using NUnit.Framework; - - public sealed class TryEatArgumentTests - { - [TestCase("", false, null, null, null, Description = "Empty string returns false")] - [TestCase(" ", false, null, null, null, Description = "Whitespace-only returns false")] - [TestCase("hello", true, "hello", null, null, Description = "Simple unquoted word")] - [TestCase("hello world", true, "hello", null, null, Description = "First word of multi-word input")] - [TestCase("\"quoted\"", true, "quoted", '"', '"', Description = "Double-quoted argument")] - [TestCase("'quoted'", true, "quoted", '\'', '\'', Description = "Single-quoted argument")] - [TestCase("\"unclosed", true, "unclosed", '"', null, Description = "Unclosed double quote consumes rest")] - [TestCase("'unclosed", true, "unclosed", '\'', null, Description = "Unclosed single quote consumes rest")] - [TestCase("\"\"", true, "", '"', '"', Description = "Empty double-quoted string")] - [TestCase("''", true, "", '\'', '\'', Description = "Empty single-quoted string")] - [TestCase("\"", true, "", '"', null, Description = "Lone double quote")] - [TestCase("'", true, "", '\'', null, Description = "Lone single quote")] - [TestCase("\"hello world\"", true, "hello world", '"', '"', Description = "Quoted string with space")] - [TestCase("'hello world'", true, "hello world", '\'', '\'', Description = "Single-quoted string with space")] - [TestCase("\"hello world\" rest", true, "hello world", '"', '"', Description = "Quoted with remainder")] - [TestCase("'hello' rest", true, "hello", '\'', '\'', Description = "Single-quoted with remainder")] - [TestCase(" hello", true, "hello", null, null, Description = "Leading whitespace trimmed")] - [TestCase(" \"hello\"", true, "hello", '"', '"', Description = "Leading whitespace before quote")] - [TestCase("'hello \"world\"'", true, "hello \"world\"", '\'', '\'', Description = "Double quotes inside single quotes")] - [TestCase("\"hello 'world'\"", true, "hello 'world'", '"', '"', Description = "Single quotes inside double quotes")] - [TestCase("hello world", true, "hello", null, null, Description = "Multiple consecutive spaces returns first word")] - [TestCase("hello\tworld", true, "hello\tworld", null, null, Description = "Tab character is not a space delimiter")] - [TestCase("\"quoted\"extra", true, "quoted", '"', '"', Description = "Text after closing quote stops at quote")] - public void ParsesCorrectly( - string input, - bool expectedResult, - string expectedContents, - char? expectedStartQuote, - char? expectedEndQuote - ) - { - string remaining = input; - bool result = CommandShell.TryEatArgument(ref remaining, out CommandArg arg); - - Assert.AreEqual(expectedResult, result, $"TryEatArgument return value mismatch for input: \"{input}\""); - - if (expectedResult) - { - Assert.AreEqual( - expectedContents, - arg.contents, - $"Contents mismatch for input: \"{input}\". Expected: \"{expectedContents}\", Got: \"{arg.contents}\"" - ); - Assert.AreEqual( - expectedStartQuote, - arg.startQuote, - $"Start quote mismatch for input: \"{input}\". Expected: '{expectedStartQuote}', Got: '{arg.startQuote}'" - ); - Assert.AreEqual( - expectedEndQuote, - arg.endQuote, - $"End quote mismatch for input: \"{input}\". Expected: '{expectedEndQuote}', Got: '{arg.endQuote}'" - ); - } - } - - [TestCase("hello world", "hello", "world", Description = "Remainder after unquoted word has space consumed")] - [TestCase("\"quoted\" rest", "quoted", " rest", Description = "Remainder after quoted arg preserves leading space")] - [TestCase("'unclosed arg", "unclosed arg", "", Description = "Unclosed quote consumes all")] - [TestCase("word", "word", "", Description = "Single word leaves empty remainder")] - [TestCase("hello world", "hello", " world", Description = "Multiple spaces leaves remaining spaces minus one")] - [TestCase("\"quoted\"extra", "quoted", "extra", Description = "Text after closing quote is remainder")] - [TestCase("\"unclosed with spaces", "unclosed with spaces", "", Description = "Unclosed quote with spaces consumes all")] - public void RemainingStringIsCorrect( - string input, - string expectedContents, - string expectedRemaining - ) - { - string remaining = input; - bool result = CommandShell.TryEatArgument(ref remaining, out CommandArg arg); - - Assert.IsTrue(result, $"Expected TryEatArgument to return true for input: \"{input}\""); - Assert.AreEqual( - expectedContents, - arg.contents, - $"Contents mismatch for input: \"{input}\"" - ); - Assert.AreEqual( - expectedRemaining, - remaining, - $"Remaining string mismatch for input: \"{input}\". Expected: \"{expectedRemaining}\", Got: \"{remaining}\"" - ); - } - } -} +namespace WallstopStudios.DxCommandTerminal.Tests.Runtime +{ + using Backend; + using NUnit.Framework; + + public sealed class TryEatArgumentTests + { + [TestCase("", false, null, null, null, Description = "Empty string returns false")] + [TestCase(" ", false, null, null, null, Description = "Whitespace-only returns false")] + [TestCase("hello", true, "hello", null, null, Description = "Simple unquoted word")] + [TestCase( + "hello world", + true, + "hello", + null, + null, + Description = "First word of multi-word input" + )] + [TestCase("\"quoted\"", true, "quoted", '"', '"', Description = "Double-quoted argument")] + [TestCase("'quoted'", true, "quoted", '\'', '\'', Description = "Single-quoted argument")] + [TestCase( + "\"unclosed", + true, + "unclosed", + '"', + null, + Description = "Unclosed double quote consumes rest" + )] + [TestCase( + "'unclosed", + true, + "unclosed", + '\'', + null, + Description = "Unclosed single quote consumes rest" + )] + [TestCase("\"\"", true, "", '"', '"', Description = "Empty double-quoted string")] + [TestCase("''", true, "", '\'', '\'', Description = "Empty single-quoted string")] + [TestCase("\"", true, "", '"', null, Description = "Lone double quote")] + [TestCase("'", true, "", '\'', null, Description = "Lone single quote")] + [TestCase( + "\"hello world\"", + true, + "hello world", + '"', + '"', + Description = "Quoted string with space" + )] + [TestCase( + "'hello world'", + true, + "hello world", + '\'', + '\'', + Description = "Single-quoted string with space" + )] + [TestCase( + "\"hello world\" rest", + true, + "hello world", + '"', + '"', + Description = "Quoted with remainder" + )] + [TestCase( + "'hello' rest", + true, + "hello", + '\'', + '\'', + Description = "Single-quoted with remainder" + )] + [TestCase(" hello", true, "hello", null, null, Description = "Leading whitespace trimmed")] + [TestCase( + " \"hello\"", + true, + "hello", + '"', + '"', + Description = "Leading whitespace before quote" + )] + [TestCase( + "'hello \"world\"'", + true, + "hello \"world\"", + '\'', + '\'', + Description = "Double quotes inside single quotes" + )] + [TestCase( + "\"hello 'world'\"", + true, + "hello 'world'", + '"', + '"', + Description = "Single quotes inside double quotes" + )] + [TestCase( + "hello world", + true, + "hello", + null, + null, + Description = "Multiple consecutive spaces returns first word" + )] + [TestCase( + "hello\tworld", + true, + "hello\tworld", + null, + null, + Description = "Tab character is not a space delimiter" + )] + [TestCase( + "\"quoted\"extra", + true, + "quoted", + '"', + '"', + Description = "Text after closing quote stops at quote" + )] + public void ParsesCorrectly( + string input, + bool expectedResult, + string expectedContents, + char? expectedStartQuote, + char? expectedEndQuote + ) + { + string remaining = input; + bool result = CommandShell.TryEatArgument(ref remaining, out CommandArg arg); + + Assert.AreEqual( + expectedResult, + result, + $"TryEatArgument return value mismatch for input: \"{input}\"" + ); + + if (expectedResult) + { + Assert.AreEqual( + expectedContents, + arg.contents, + $"Contents mismatch for input: \"{input}\". Expected: \"{expectedContents}\", Got: \"{arg.contents}\"" + ); + Assert.AreEqual( + expectedStartQuote, + arg.startQuote, + $"Start quote mismatch for input: \"{input}\". Expected: '{expectedStartQuote}', Got: '{arg.startQuote}'" + ); + Assert.AreEqual( + expectedEndQuote, + arg.endQuote, + $"End quote mismatch for input: \"{input}\". Expected: '{expectedEndQuote}', Got: '{arg.endQuote}'" + ); + } + } + + [TestCase( + "hello world", + "hello", + "world", + Description = "Remainder after unquoted word has space consumed" + )] + [TestCase( + "\"quoted\" rest", + "quoted", + " rest", + Description = "Remainder after quoted arg preserves leading space" + )] + [TestCase("'unclosed arg", "unclosed arg", "", Description = "Unclosed quote consumes all")] + [TestCase("word", "word", "", Description = "Single word leaves empty remainder")] + [TestCase( + "hello world", + "hello", + " world", + Description = "Multiple spaces leaves remaining spaces minus one" + )] + [TestCase( + "\"quoted\"extra", + "quoted", + "extra", + Description = "Text after closing quote is remainder" + )] + [TestCase( + "\"unclosed with spaces", + "unclosed with spaces", + "", + Description = "Unclosed quote with spaces consumes all" + )] + public void RemainingStringIsCorrect( + string input, + string expectedContents, + string expectedRemaining + ) + { + string remaining = input; + bool result = CommandShell.TryEatArgument(ref remaining, out CommandArg arg); + + Assert.IsTrue(result, $"Expected TryEatArgument to return true for input: \"{input}\""); + Assert.AreEqual( + expectedContents, + arg.contents, + $"Contents mismatch for input: \"{input}\"" + ); + Assert.AreEqual( + expectedRemaining, + remaining, + $"Remaining string mismatch for input: \"{input}\". Expected: \"{expectedRemaining}\", Got: \"{remaining}\"" + ); + } + } +} diff --git a/package.json b/package.json index 46ff847d..bfda0824 100644 --- a/package.json +++ b/package.json @@ -25,29 +25,38 @@ }, "author": "wallstop studios (https://wallstopstudios.com)", "homepage": "https://github.com/wallstop/DxCommandTerminal/blob/master/README.md", - "main": "README.md", - "engines": { - "node": ">=22.7.0" - }, - "scripts": { - "test": "node --test \"scripts/mcp/__tests__/*.test.mjs\"", - "unity:mcp:probe": "node scripts/mcp/unity-mcp.mjs probe", - "unity:mcp:configure": "node scripts/mcp/unity-mcp.mjs configure", - "unity:mcp:bridge": "node scripts/mcp/unity-mcp.mjs bridge", - "unity:mcp:install-capture": "node scripts/mcp/unity-mcp.mjs install-capture", - "unity:capture": "node scripts/mcp/unity-mcp.mjs capture", - "ai:backends": "bash .devcontainer/ai-backends.sh" - }, - "devDependencies": { - "@modelcontextprotocol/sdk": "1.30.0", - "jsonc-parser": "3.3.1", - "smol-toml": "1.8.0" - } + "main": "README.md", + "files": [ + "Runtime", + "Runtime.meta", + "Editor", + "Editor.meta", + "Packs", + "Packs.meta", + "Styles", + "Styles.meta", + "Tests", + "Tests.meta", + "Media", + "Media.meta", + "Fonts", + "Fonts.meta", + "CHANGELOG.md", + "CHANGELOG.md.meta", + "LICENSE", + "LICENSE.meta", + "README.md", + "README.md.meta", + "package.json.meta" + ], + "scripts": { + "test": "npm --prefix tooling~ run test --", + "package:validate": "npm --prefix tooling~ run package:validate --", + "unity:mcp:probe": "npm --prefix tooling~ run unity:mcp:probe --", + "unity:mcp:configure": "npm --prefix tooling~ run unity:mcp:configure --", + "unity:mcp:bridge": "npm --prefix tooling~ run unity:mcp:bridge --", + "unity:mcp:install-capture": "npm --prefix tooling~ run unity:mcp:install-capture --", + "unity:capture": "npm --prefix tooling~ run unity:capture --", + "ai:backends": "npm --prefix tooling~ run ai:backends --" + } } - - - - - - - diff --git a/scripts.meta b/scripts.meta deleted file mode 100644 index ee8ea9bc..00000000 --- a/scripts.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 3bbaaf40ed0e843608e67f60a6325b4e -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/generate-skills-index.ps1.meta b/scripts/generate-skills-index.ps1.meta deleted file mode 100644 index d67a4f0b..00000000 --- a/scripts/generate-skills-index.ps1.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 62da9b55915fc4a50b925f418da87827 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/lint-llm-instructions.ps1.meta b/scripts/lint-llm-instructions.ps1.meta deleted file mode 100644 index 22f24a0d..00000000 --- a/scripts/lint-llm-instructions.ps1.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: b56eaf37a4399426d83290d1c94c8d31 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/lint-skill-sizes.ps1.meta b/scripts/lint-skill-sizes.ps1.meta deleted file mode 100644 index 1b28850c..00000000 --- a/scripts/lint-skill-sizes.ps1.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: e7e7539b705684e16830e648d06c5177 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/lint-unity-meta.ps1.meta b/scripts/lint-unity-meta.ps1.meta deleted file mode 100644 index 3037823e..00000000 --- a/scripts/lint-unity-meta.ps1.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 2d318c664e5d484da9510d6a9b3f06b4 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/mcp.meta b/scripts/mcp.meta deleted file mode 100644 index 0f847bdf..00000000 --- a/scripts/mcp.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 500d903f64dc7643f8d39f0646e3f558 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/mcp/DxTerminalStateCapture.cs.txt.meta b/scripts/mcp/DxTerminalStateCapture.cs.txt.meta deleted file mode 100644 index 0ec8d4ca..00000000 --- a/scripts/mcp/DxTerminalStateCapture.cs.txt.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 5d379f410b53fde49576f988f8b93fd3 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/mcp/README.md.meta b/scripts/mcp/README.md.meta deleted file mode 100644 index 5ec2640e..00000000 --- a/scripts/mcp/README.md.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: ddfac236c87aa7fd03b4af215e614672 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/mcp/__tests__.meta b/scripts/mcp/__tests__.meta deleted file mode 100644 index 25e7fa85..00000000 --- a/scripts/mcp/__tests__.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 60f1d96a90d7a49603b18c53eac4c473 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/mcp/__tests__/client-configs.test.mjs.meta b/scripts/mcp/__tests__/client-configs.test.mjs.meta deleted file mode 100644 index 39861c6c..00000000 --- a/scripts/mcp/__tests__/client-configs.test.mjs.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 37313b5cfe5836f41f9291c5f1cd6c90 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/mcp/__tests__/dotenv.test.mjs.meta b/scripts/mcp/__tests__/dotenv.test.mjs.meta deleted file mode 100644 index 73801986..00000000 --- a/scripts/mcp/__tests__/dotenv.test.mjs.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 40f44fe0a996236ab3eda9ccf978acc9 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/mcp/__tests__/env-and-capture.test.mjs.meta b/scripts/mcp/__tests__/env-and-capture.test.mjs.meta deleted file mode 100644 index b9885c2d..00000000 --- a/scripts/mcp/__tests__/env-and-capture.test.mjs.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 2fca72540f450288772cb10ab6b4d41a -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/mcp/__tests__/ports.test.mjs.meta b/scripts/mcp/__tests__/ports.test.mjs.meta deleted file mode 100644 index 5f5ca8e0..00000000 --- a/scripts/mcp/__tests__/ports.test.mjs.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 8691d6fb510cbe1549d25cf0d79fe5bb -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/mcp/unity-mcp.mjs.meta b/scripts/mcp/unity-mcp.mjs.meta deleted file mode 100644 index 3c02c8b7..00000000 --- a/scripts/mcp/unity-mcp.mjs.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 7e65ba3e9ebace1af2b8f9e1bbc60cc5 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/tests.meta b/scripts/tests.meta deleted file mode 100644 index db80d82c..00000000 --- a/scripts/tests.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: b4609f220a8d54767ab633c4c48809df -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/tests/fixtures.meta b/scripts/tests/fixtures.meta deleted file mode 100644 index 9c59b608..00000000 --- a/scripts/tests/fixtures.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: a41b2b4252cda40dc994a8d07a9a6cbc -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/tests/fixtures/valid-skill.md.meta b/scripts/tests/fixtures/valid-skill.md.meta deleted file mode 100644 index e18c2659..00000000 --- a/scripts/tests/fixtures/valid-skill.md.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 18b8c5d9c96be4e849193ada00285bd5 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/tests/test-ai-backends.sh.meta b/scripts/tests/test-ai-backends.sh.meta deleted file mode 100644 index cad0f020..00000000 --- a/scripts/tests/test-ai-backends.sh.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: b31fe7463a33dc606b7b5dfafe57018c -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/tests/test-generate-skills-index.ps1.meta b/scripts/tests/test-generate-skills-index.ps1.meta deleted file mode 100644 index c8ad1240..00000000 --- a/scripts/tests/test-generate-skills-index.ps1.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 4b129089b767e4b85aca8eca30e6df45 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/tests/test-helpers.ps1.meta b/scripts/tests/test-helpers.ps1.meta deleted file mode 100644 index b34b8d1b..00000000 --- a/scripts/tests/test-helpers.ps1.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 222a76b64cb0944b8899e2e2d7cf4348 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/tests/test-lint-skill-sizes.ps1.meta b/scripts/tests/test-lint-skill-sizes.ps1.meta deleted file mode 100644 index 6647d110..00000000 --- a/scripts/tests/test-lint-skill-sizes.ps1.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: ac1efb338cd1d4ae599444572bd3ee6c -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/tests/test-llm-instructions-lint.ps1.meta b/scripts/tests/test-llm-instructions-lint.ps1.meta deleted file mode 100644 index eee0b768..00000000 --- a/scripts/tests/test-llm-instructions-lint.ps1.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: e09b8c2a1e8544d25aa0b7b6133b1b1e -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/tooling~/package.json b/tooling~/package.json new file mode 100644 index 00000000..3a2bfae9 --- /dev/null +++ b/tooling~/package.json @@ -0,0 +1,23 @@ +{ + "name": "dxcommandterminal-tooling", + "private": true, + "description": "npm tooling for the DxCommandTerminal repository. Lives under tooling~/ (tilde-suffixed) so Unity never imports it or its node_modules when the repository is used as a local package. The root UPM package.json delegates here via `npm --prefix tooling~`.", + "engines": { + "node": ">=22.7.0" + }, + "scripts": { + "test": "node --test \"scripts/mcp/__tests__/*.test.mjs\"", + "package:validate": "node scripts/release/validate-package-contents.mjs", + "unity:mcp:probe": "node scripts/mcp/unity-mcp.mjs probe", + "unity:mcp:configure": "node scripts/mcp/unity-mcp.mjs configure", + "unity:mcp:bridge": "node scripts/mcp/unity-mcp.mjs bridge", + "unity:mcp:install-capture": "node scripts/mcp/unity-mcp.mjs install-capture", + "unity:capture": "node scripts/mcp/unity-mcp.mjs capture", + "ai:backends": "bash ../.devcontainer/ai-backends.sh" + }, + "devDependencies": { + "@modelcontextprotocol/sdk": "1.30.0", + "jsonc-parser": "3.3.1", + "smol-toml": "1.8.0" + } +} diff --git a/scripts/generate-skills-index.ps1 b/tooling~/scripts/generate-skills-index.ps1 similarity index 93% rename from scripts/generate-skills-index.ps1 rename to tooling~/scripts/generate-skills-index.ps1 index 573c5520..0d88291e 100644 --- a/scripts/generate-skills-index.ps1 +++ b/tooling~/scripts/generate-skills-index.ps1 @@ -22,7 +22,7 @@ - The file is written as UTF-8 WITHOUT a BOM and with LF line endings via [System.IO.File]::WriteAllText. - Descriptions MUST be single-line and ASCII; scripts/lint-llm-instructions.ps1 + Descriptions MUST be single-line and ASCII; tooling~/scripts/lint-llm-instructions.ps1 enforces this so stray em-dashes / smart quotes can never reintroduce drift. .PARAMETER OutputPath @@ -36,8 +36,8 @@ Emit progress messages. .EXAMPLE - pwsh -NoProfile -File scripts/generate-skills-index.ps1 - pwsh -NoProfile -File scripts/generate-skills-index.ps1 -Stdout + pwsh -NoProfile -File tooling~/scripts/generate-skills-index.ps1 + pwsh -NoProfile -File tooling~/scripts/generate-skills-index.ps1 -Stdout #> Param( [string]$OutputPath, @@ -50,7 +50,7 @@ Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' if (-not $RepoRoot) { - $RepoRoot = (Get-Item $PSScriptRoot).Parent.FullName + $RepoRoot = (Get-Item $PSScriptRoot).Parent.Parent.FullName } $skillsDir = Join-Path -Path $RepoRoot -ChildPath '.llm/skills' $indexFileName = 'index.md' @@ -178,8 +178,8 @@ function Get-SkillsIndexContent { $lf = "`n" $sb = New-Object System.Text.StringBuilder - [void]$sb.Append("$lf") - [void]$sb.Append("$lf") + [void]$sb.Append("$lf") + [void]$sb.Append("$lf") [void]$sb.Append($lf) [void]$sb.Append("# Skills Index$lf") [void]$sb.Append($lf) diff --git a/scripts/lint-llm-instructions.ps1 b/tooling~/scripts/lint-llm-instructions.ps1 similarity index 97% rename from scripts/lint-llm-instructions.ps1 rename to tooling~/scripts/lint-llm-instructions.ps1 index 3b3f0136..7c9c56e8 100644 --- a/scripts/lint-llm-instructions.ps1 +++ b/tooling~/scripts/lint-llm-instructions.ps1 @@ -29,8 +29,8 @@ Repository root. Defaults to the parent of this script's directory. .EXAMPLE - pwsh -NoProfile -File scripts/lint-llm-instructions.ps1 - pwsh -NoProfile -File scripts/lint-llm-instructions.ps1 -Fix + pwsh -NoProfile -File tooling~/scripts/lint-llm-instructions.ps1 + pwsh -NoProfile -File tooling~/scripts/lint-llm-instructions.ps1 -Fix #> Param( [switch]$Fix, @@ -54,7 +54,7 @@ function Write-SuccessMsg($msg) { } if (-not $RepoRoot) { - $RepoRoot = (Get-Item $PSScriptRoot).Parent.FullName + $RepoRoot = (Get-Item $PSScriptRoot).Parent.Parent.FullName } $skillsDir = Join-Path -Path $RepoRoot -ChildPath '.llm/skills' @@ -253,7 +253,7 @@ try { Write-SuccessMsg "Generated missing $indexFileName" } else { - Write-ErrorMsg "$indexFileName does not exist. Run: pwsh -NoProfile -File scripts/generate-skills-index.ps1" + Write-ErrorMsg "$indexFileName does not exist. Run: pwsh -NoProfile -File tooling~/scripts/generate-skills-index.ps1" exit 1 } } @@ -286,7 +286,7 @@ try { $shown++ } } - Write-Host "Run: pwsh -NoProfile -File scripts/generate-skills-index.ps1 (or this lint with -Fix)" -ForegroundColor Cyan + Write-Host "Run: pwsh -NoProfile -File tooling~/scripts/generate-skills-index.ps1 (or this lint with -Fix)" -ForegroundColor Cyan $exitCode = 1 } } diff --git a/scripts/lint-skill-sizes.ps1 b/tooling~/scripts/lint-skill-sizes.ps1 similarity index 92% rename from scripts/lint-skill-sizes.ps1 rename to tooling~/scripts/lint-skill-sizes.ps1 index b590737e..f5f5c001 100644 --- a/scripts/lint-skill-sizes.ps1 +++ b/tooling~/scripts/lint-skill-sizes.ps1 @@ -10,7 +10,7 @@ - <=269 lines: OK The generated .llm/skills/index.md is exempt: it is machine-written by - scripts/generate-skills-index.ps1, not an authored file. + tooling~/scripts/generate-skills-index.ps1, not an authored file. Skill/context messages use [skill-sizes] / [context-size] prefixes. @@ -24,8 +24,8 @@ Repository root. Defaults to the parent of this script's directory. .EXAMPLE - pwsh -NoProfile -File scripts/lint-skill-sizes.ps1 - pwsh -NoProfile -File scripts/lint-skill-sizes.ps1 -VerboseOutput + pwsh -NoProfile -File tooling~/scripts/lint-skill-sizes.ps1 + pwsh -NoProfile -File tooling~/scripts/lint-skill-sizes.ps1 -VerboseOutput #> Param( [switch]$VerboseOutput, @@ -49,7 +49,7 @@ function Write-SuccessMsg($msg, $prefix = "[skill-sizes]") { } if (-not $RepoRoot) { - $RepoRoot = (Get-Item $PSScriptRoot).Parent.FullName + $RepoRoot = (Get-Item $PSScriptRoot).Parent.Parent.FullName } $llmDir = Join-Path -Path $RepoRoot -ChildPath '.llm' diff --git a/scripts/lint-unity-meta.ps1 b/tooling~/scripts/lint-unity-meta.ps1 similarity index 96% rename from scripts/lint-unity-meta.ps1 rename to tooling~/scripts/lint-unity-meta.ps1 index 98ec033c..6ebaa0fe 100644 --- a/scripts/lint-unity-meta.ps1 +++ b/tooling~/scripts/lint-unity-meta.ps1 @@ -27,8 +27,8 @@ Repository root. Defaults to the parent of this script's directory. .EXAMPLE - pwsh -NoProfile -File scripts/lint-unity-meta.ps1 - pwsh -NoProfile -File scripts/lint-unity-meta.ps1 -VerboseOutput + pwsh -NoProfile -File tooling~/scripts/lint-unity-meta.ps1 + pwsh -NoProfile -File tooling~/scripts/lint-unity-meta.ps1 -VerboseOutput #> Param( [switch]$VerboseOutput, @@ -47,7 +47,7 @@ function Write-SuccessMsg($msg) { } if (-not $RepoRoot) { - $RepoRoot = (Get-Item $PSScriptRoot).Parent.FullName + $RepoRoot = (Get-Item $PSScriptRoot).Parent.Parent.FullName } Push-Location $RepoRoot diff --git a/scripts/mcp/DxTerminalStateCapture.cs.txt b/tooling~/scripts/mcp/DxTerminalStateCapture.cs.txt similarity index 100% rename from scripts/mcp/DxTerminalStateCapture.cs.txt rename to tooling~/scripts/mcp/DxTerminalStateCapture.cs.txt diff --git a/scripts/mcp/README.md b/tooling~/scripts/mcp/README.md similarity index 94% rename from scripts/mcp/README.md rename to tooling~/scripts/mcp/README.md index 5d4efcee..98db965e 100644 --- a/scripts/mcp/README.md +++ b/tooling~/scripts/mcp/README.md @@ -3,7 +3,7 @@ `unity-mcp.mjs` is the single entry point for agent access to the host Unity editor: ``` -node scripts/mcp/unity-mcp.mjs [options] +node tooling~/scripts/mcp/unity-mcp.mjs [options] ``` Unity stays on the host. The `bridge` command (host) exposes the editor through an @@ -71,5 +71,5 @@ Details: [capture-unity-state](../../.llm/skills/capture-unity-state/SKILL.md). ## Tests ```bash -npm test # node --test scripts/mcp/__tests__/ (dotenv, ports, configs, capture paths) +npm test # node --test tooling~/scripts/mcp/__tests__/ (dotenv, ports, configs, capture paths) ``` diff --git a/scripts/mcp/__tests__/client-configs.test.mjs b/tooling~/scripts/mcp/__tests__/client-configs.test.mjs similarity index 100% rename from scripts/mcp/__tests__/client-configs.test.mjs rename to tooling~/scripts/mcp/__tests__/client-configs.test.mjs diff --git a/scripts/mcp/__tests__/dotenv.test.mjs b/tooling~/scripts/mcp/__tests__/dotenv.test.mjs similarity index 100% rename from scripts/mcp/__tests__/dotenv.test.mjs rename to tooling~/scripts/mcp/__tests__/dotenv.test.mjs diff --git a/scripts/mcp/__tests__/env-and-capture.test.mjs b/tooling~/scripts/mcp/__tests__/env-and-capture.test.mjs similarity index 98% rename from scripts/mcp/__tests__/env-and-capture.test.mjs rename to tooling~/scripts/mcp/__tests__/env-and-capture.test.mjs index c14e77ff..40638ff6 100644 --- a/scripts/mcp/__tests__/env-and-capture.test.mjs +++ b/tooling~/scripts/mcp/__tests__/env-and-capture.test.mjs @@ -13,7 +13,7 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../.."); test("github token aliases resolve in documented order with env beating file", () => { const env = (token) => (token ? { GITHUB_TOKEN: token } : {}); diff --git a/scripts/mcp/__tests__/ports.test.mjs b/tooling~/scripts/mcp/__tests__/ports.test.mjs similarity index 100% rename from scripts/mcp/__tests__/ports.test.mjs rename to tooling~/scripts/mcp/__tests__/ports.test.mjs diff --git a/scripts/mcp/unity-mcp.mjs b/tooling~/scripts/mcp/unity-mcp.mjs similarity index 99% rename from scripts/mcp/unity-mcp.mjs rename to tooling~/scripts/mcp/unity-mcp.mjs index 79324f4c..a40d06fa 100755 --- a/scripts/mcp/unity-mcp.mjs +++ b/tooling~/scripts/mcp/unity-mcp.mjs @@ -42,7 +42,7 @@ const [ "jsonc-parser" ].map(dependency) ); -export const REPO_ROOT = path.resolve(fileURLToPath(new URL("../..", import.meta.url))); +export const REPO_ROOT = path.resolve(fileURLToPath(new URL("../../..", import.meta.url))); export const GITHUB_MCP_URL = "https://api.githubcopilot.com/mcp/"; export const DEFAULTS = Object.freeze({ bindHost: "0.0.0.0", @@ -1478,7 +1478,7 @@ const CAPTURE_TYPE_PROBE = const CAPTURE_REFRESH_EXPRESSION = "UnityEditor.AssetDatabase.Refresh()"; export function captureScriptSourcePath(repoRoot = REPO_ROOT) { - return path.join(repoRoot, "scripts", "mcp", CAPTURE_SOURCE_NAME); + return path.join(repoRoot, "tooling~", "scripts", "mcp", CAPTURE_SOURCE_NAME); } export function captureInstallTarget(projectPath) { @@ -1724,7 +1724,7 @@ async function waitForEditorIdle(client, evalCall, deadline) { function usage() { return [ - "Usage: node scripts/mcp/unity-mcp.mjs [options]", + "Usage: node tooling~/scripts/mcp/unity-mcp.mjs [options]", "", " probe Discover Unity tools and check editor readiness.", " configure Configure agent MCP servers, discovering Unity unless --offline is set.", diff --git a/tooling~/scripts/release/validate-package-contents.mjs b/tooling~/scripts/release/validate-package-contents.mjs new file mode 100644 index 00000000..ff2181b9 --- /dev/null +++ b/tooling~/scripts/release/validate-package-contents.mjs @@ -0,0 +1,214 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO_ROOT = path.resolve(fileURLToPath(new URL("../../..", import.meta.url))); + +const REQUIRED_FILES = [ + "README.md", + "README.md.meta", + "LICENSE", + "LICENSE.meta", + "CHANGELOG.md", + "CHANGELOG.md.meta", + "package.json.meta", + "Runtime/WallstopStudios.DxCommandTerminal.asmdef", + "Runtime/WallstopStudios.DxCommandTerminal.asmdef.meta", + "Editor/WallstopStudios.DxCommandTerminal.Editor.asmdef", + "Editor/WallstopStudios.DxCommandTerminal.Editor.asmdef.meta", + "Tests/Runtime/WallstopStudios.DxCommandTerminal.Tests.Runtime.asmdef", + "Tests/Runtime/WallstopStudios.DxCommandTerminal.Tests.Runtime.asmdef.meta" +]; + +const FORBIDDEN_EXACT = new Set([ + "AGENTS.md", + "CLAUDE.md", + "doc.md", + ".cursorrules", + ".editorconfig", + ".gitattributes", + ".gitignore", + ".dockerignore", + ".pre-commit-config.yaml", + ".env.example", + "package-lock.json", + ".DS_Store" +]); + +const FORBIDDEN_PREFIXES = [ + "tooling~", + "node_modules", + "scripts", + ".git", + ".github", + ".llm", + ".devcontainer", + ".artifacts", + "progress", + ".env", + ".vscode", + ".codex", + ".cursor", + ".copilot", + ".nanocoder" +]; + +const NPM = process.platform === "win32" ? "npm.cmd" : "npm"; + +function fail(message) { + console.error(`[package-validate] ERROR: ${message}`); + process.exitCode = 1; +} + +function pack() { + const destination = fs.mkdtempSync(path.join(os.tmpdir(), "dxt-pack-")); + const stdout = execFileSync(NPM, ["pack", "--pack-destination", destination], { + cwd: REPO_ROOT, + encoding: "utf8" + }); + return path.join(destination, stdout.trim().split(/\r?\n/).at(-1)); +} + +function listTarball(tarball) { + const stdout = execFileSync("tar", ["-tzf", tarball], { encoding: "utf8" }); + return stdout + .split(/\r?\n/) + .filter(Boolean) + .map((entry) => entry.replace(/^package\//, "")) + .filter((entry) => entry.length > 0 && !entry.endsWith("/")); +} + +function extractFile(tarball, entry) { + return execFileSync("tar", ["-xzf", tarball, "-O", `package/${entry}`], { + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024 + }); +} + +function trackedFiles() { + const stdout = execFileSync("git", ["ls-files"], { cwd: REPO_ROOT, encoding: "utf8" }); + return new Set(stdout.split(/\r?\n/).filter(Boolean)); +} + +function main() { + const rootManifest = JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, "package.json"), "utf8") + ); + const tarball = pack(); + const entries = listTarball(tarball); + const entrySet = new Set(entries); + let problems = 0; + + const check = (ok, message) => { + if (!ok) { + fail(message); + problems += 1; + } + }; + + const shippedManifest = JSON.parse(extractFile(tarball, "package.json")); + check( + shippedManifest.name === rootManifest.name && shippedManifest.version === rootManifest.version, + `shipped manifest identity mismatch: expected ${rootManifest.name}@${rootManifest.version}, ` + + `got ${shippedManifest.name}@${shippedManifest.version}` + ); + + const allowlist = rootManifest.files; + check( + Array.isArray(allowlist) && 0 < allowlist.length, + "package.json must declare an npm files allowlist" + ); + if (Array.isArray(allowlist)) { + const tracked = trackedFiles(); + const expected = new Set(["package.json"]); + for (const entry of tracked) { + if (allowlist.some((root) => entry === root || entry.startsWith(`${root}/`))) { + expected.add(entry); + } + } + for (const missing of expected) { + if (!entrySet.has(missing)) { + fail(`tracked file missing from tarball: ${missing}`); + problems += 1; + } + } + for (const extra of entrySet) { + if (!expected.has(extra)) { + fail(`untracked or unallowed file shipped in tarball: ${extra}`); + problems += 1; + } + } + } + + for (const required of REQUIRED_FILES) { + check(entrySet.has(required), `required file missing from tarball: ${required}`); + } + + for (const entry of entries) { + if (FORBIDDEN_EXACT.has(entry)) { + fail(`non-shippable file in tarball: ${entry}`); + problems += 1; + continue; + } + if (FORBIDDEN_PREFIXES.some((prefix) => entry.startsWith(`${prefix}/`))) { + fail(`non-shippable path in tarball: ${entry}`); + problems += 1; + } + } + + const shippedFiles = entries.filter((entry) => !entry.endsWith("/")); + const impliedDirectories = new Set(); + for (const file of shippedFiles) { + let current = path.dirname(file); + while (current && current !== "." && !impliedDirectories.has(current)) { + impliedDirectories.add(current); + current = path.dirname(current); + } + } + + for (const directory of impliedDirectories) { + check( + entrySet.has(`${directory}.meta`), + `shipped directory without its folder meta: ${directory}` + ); + } + + for (const file of shippedFiles) { + if (file.endsWith(".meta")) { + const target = file.slice(0, -".meta".length); + check( + entrySet.has(target) || impliedDirectories.has(target), + `orphan meta in tarball: ${file}` + ); + } else if (file !== "package.json") { + check(entrySet.has(`${file}.meta`), `shipped file without meta: ${file}`); + } + } + + for (const asmdef of REQUIRED_FILES.filter((candidate) => candidate.endsWith(".asmdef"))) { + let parsed; + try { + parsed = JSON.parse(extractFile(tarball, asmdef)); + } catch (error) { + check(false, `asmdef is not valid JSON: ${asmdef} (${error.message})`); + continue; + } + check( + typeof parsed.name === "string" && 0 < parsed.name.length, + `asmdef missing a name: ${asmdef}` + ); + } + + console.log( + `[package-validate] ${path.basename(tarball)}\n` + + `[package-validate] ${entries.length} entries checked; ` + + `${problems === 0 ? "all content checks passed" : `${problems} problem(s) found`}` + ); + if (0 < problems) { + process.exitCode = 1; + } +} + +main(); diff --git a/scripts/tests/fixtures/valid-skill.md b/tooling~/scripts/tests/fixtures/valid-skill.md similarity index 79% rename from scripts/tests/fixtures/valid-skill.md rename to tooling~/scripts/tests/fixtures/valid-skill.md index f0451af9..d87e4e63 100644 --- a/scripts/tests/fixtures/valid-skill.md +++ b/tooling~/scripts/tests/fixtures/valid-skill.md @@ -1,6 +1,6 @@ --- name: valid-skill -description: Valid fixture skill used by the automated linter tests. Use only inside scripts/tests fixtures. +description: Valid fixture skill used by the automated linter tests. Use only inside tooling~/scripts/tests fixtures. metadata: category: Feature --- diff --git a/scripts/tests/test-ai-backends.sh b/tooling~/scripts/tests/test-ai-backends.sh similarity index 99% rename from scripts/tests/test-ai-backends.sh rename to tooling~/scripts/tests/test-ai-backends.sh index 64adbb5e..f14d5f43 100755 --- a/scripts/tests/test-ai-backends.sh +++ b/tooling~/scripts/tests/test-ai-backends.sh @@ -19,7 +19,7 @@ set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -BACKENDS="${SCRIPT_DIR}/../../.devcontainer/ai-backends.sh" +BACKENDS="${SCRIPT_DIR}/../../../.devcontainer/ai-backends.sh" WORK="$(mktemp -d)" trap 'rm -rf "${WORK}"' EXIT @@ -470,7 +470,7 @@ out="${WORK}/env-precedence.txt" assert_contains "${out}" "^export ZAI_API_KEY='env-zai-wins'\$" "env honors environment precedence" echo "== env-autoload: rc snippet exports .env.local credentials for native agents ==" -AUTOLOAD="${SCRIPT_DIR}/../../.devcontainer/env-autoload.sh" +AUTOLOAD="${SCRIPT_DIR}/../../../.devcontainer/env-autoload.sh" assert "env-autoload snippet exists" test -f "${AUTOLOAD}" mkdir -p "${WORK}/autoload-root/.devcontainer" cp "${BACKENDS}" "${WORK}/autoload-root/.devcontainer/ai-backends.sh" @@ -514,7 +514,7 @@ touch "${out}/.bashrc" "${out}/.profile" ( # Sourcing post-create.sh only defines functions and enables -euo pipefail # (main is guarded); relax the flags for the assertions below. - . "${SCRIPT_DIR}/../../.devcontainer/post-create.sh" + . "${SCRIPT_DIR}/../../../.devcontainer/post-create.sh" set +e +u HOME="${WORK}/pc-home"; export HOME ensure_env_local_autoload "${WORK}/autoload-root" diff --git a/scripts/tests/test-generate-skills-index.ps1 b/tooling~/scripts/tests/test-generate-skills-index.ps1 similarity index 97% rename from scripts/tests/test-generate-skills-index.ps1 rename to tooling~/scripts/tests/test-generate-skills-index.ps1 index 6ce4e4cd..eb873a16 100644 --- a/scripts/tests/test-generate-skills-index.ps1 +++ b/tooling~/scripts/tests/test-generate-skills-index.ps1 @@ -1,6 +1,6 @@ <# .SYNOPSIS - Red-green tests for scripts/generate-skills-index.ps1. + Red-green tests for tooling~/scripts/generate-skills-index.ps1. .DESCRIPTION Asserts the generator's contract: cross-run byte determinism, ordinal @@ -8,7 +8,7 @@ parsing (quoted strings), and graceful skipping of malformed skills. .EXAMPLE - pwsh -NoProfile -File scripts/tests/test-generate-skills-index.ps1 + pwsh -NoProfile -File tooling~/scripts/tests/test-generate-skills-index.ps1 #> Param( [switch]$VerboseOutput diff --git a/scripts/tests/test-helpers.ps1 b/tooling~/scripts/tests/test-helpers.ps1 similarity index 98% rename from scripts/tests/test-helpers.ps1 rename to tooling~/scripts/tests/test-helpers.ps1 index 7bb76c1f..ec990538 100644 --- a/scripts/tests/test-helpers.ps1 +++ b/tooling~/scripts/tests/test-helpers.ps1 @@ -1,6 +1,6 @@ <# .SYNOPSIS - Shared helpers for the pwsh test scripts under scripts/tests/. + Shared helpers for the pwsh test scripts under tooling~/scripts/tests/. .DESCRIPTION Minimal test harness: named test cases with automatic pass/fail tracking, diff --git a/scripts/tests/test-lint-skill-sizes.ps1 b/tooling~/scripts/tests/test-lint-skill-sizes.ps1 similarity index 97% rename from scripts/tests/test-lint-skill-sizes.ps1 rename to tooling~/scripts/tests/test-lint-skill-sizes.ps1 index bcdca656..1c9097db 100644 --- a/scripts/tests/test-lint-skill-sizes.ps1 +++ b/tooling~/scripts/tests/test-lint-skill-sizes.ps1 @@ -1,6 +1,6 @@ <# .SYNOPSIS - Red-green tests for scripts/lint-skill-sizes.ps1. + Red-green tests for tooling~/scripts/lint-skill-sizes.ps1. .DESCRIPTION Builds isolated fixture repos and asserts boundary behavior of the line @@ -8,7 +8,7 @@ exemption, recursive coverage, and the -FailOnCritical switch. .EXAMPLE - pwsh -NoProfile -File scripts/tests/test-lint-skill-sizes.ps1 + pwsh -NoProfile -File tooling~/scripts/tests/test-lint-skill-sizes.ps1 #> Param( [switch]$VerboseOutput diff --git a/scripts/tests/test-llm-instructions-lint.ps1 b/tooling~/scripts/tests/test-llm-instructions-lint.ps1 similarity index 98% rename from scripts/tests/test-llm-instructions-lint.ps1 rename to tooling~/scripts/tests/test-llm-instructions-lint.ps1 index 1b9709f2..da50e8d0 100644 --- a/scripts/tests/test-llm-instructions-lint.ps1 +++ b/tooling~/scripts/tests/test-llm-instructions-lint.ps1 @@ -1,6 +1,6 @@ <# .SYNOPSIS - Red-green tests for scripts/lint-llm-instructions.ps1. + Red-green tests for tooling~/scripts/lint-llm-instructions.ps1. .DESCRIPTION Builds isolated fixture repos and asserts both failure paths (red) and the @@ -9,7 +9,7 @@ pointer-file delegation. Also verifies -Fix repairs a stale index. .EXAMPLE - pwsh -NoProfile -File scripts/tests/test-llm-instructions-lint.ps1 + pwsh -NoProfile -File tooling~/scripts/tests/test-llm-instructions-lint.ps1 #> Param( [switch]$VerboseOutput