From 2d2193e7ba2fa675f1f716ec6469f664f477e2e7 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Tue, 4 Aug 2026 20:40:28 +0800 Subject: [PATCH 01/15] feat: unified VS Code extension for the Rstack toolchain One extension (rstack.rstack) replacing the standalone rstack.rslint and rstack.rstest extensions: a thin shell (activation, per-folder detection, status bar, settings migration) hosting the Rslint and Rstest stacks as near-verbatim upstream copies, plus a detection-only stub for rstack-cli formatting. - pnpm monorepo with a single package (packages/vscode) - rstack-cli powers the repo harness: rs lint --type-check, rs fmt, rs setup git hooks and rs staged on pre-commit - E2E suites ported from both upstream extensions, running a real VS Code (8 shell/detection + 8 rstest + 133 lint tests), plus 107 unit tests - CI (Linux + Windows) and a 6-target VSIX release workflow - Contributor scaffolding: AGENTS.md/CLAUDE.md, CONTRIBUTING.md, issue/PR templates --- .github/ISSUE_TEMPLATE/1-bug-report.yml | 51 + .github/ISSUE_TEMPLATE/2-feature-request.yml | 18 + .github/ISSUE_TEMPLATE/config.yml | 5 + .github/PULL_REQUEST_TEMPLATE.md | 12 + .github/workflows/ci.yml | 119 + .github/workflows/release.yml | 102 + .gitignore | 21 + .nvmrc | 1 + .rstack/hooks/pre-commit | 1 + .vscode/extensions.json | 6 + .vscode/launch.json | 42 + .vscode/settings.json | 8 + .vscode/tasks.json | 35 + AGENTS.md | 18 + CLAUDE.md | 1 + CONTRIBUTING.md | 30 + README.md | 13 + package.json | 24 + packages/vscode/.vscodeignore | 6 + packages/vscode/AGENTS.md | 39 + packages/vscode/CLAUDE.md | 1 + packages/vscode/README.md | 114 + packages/vscode/icon.png | Bin 0 -> 113325 bytes packages/vscode/package.json | 467 ++ packages/vscode/rslib.config.mts | 116 + packages/vscode/rstest.config.mts | 14 + packages/vscode/scripts/packageTargets.mjs | 64 + packages/vscode/src/channels.ts | 45 + packages/vscode/src/detection.test.ts | 162 + packages/vscode/src/detection.ts | 366 ++ packages/vscode/src/extension.ts | 349 ++ packages/vscode/src/migration.test.ts | 381 ++ packages/vscode/src/migration.ts | 655 +++ .../src/shared/vendored/loadRstackConfig.ts | 244 + packages/vscode/src/shared/versionCheck.ts | 90 + packages/vscode/src/stacks/fmt/index.ts | 28 + .../stacks/lint/ConfigTransactionAdapter.ts | 291 ++ .../stacks/lint/LanguageServerProcessOwner.ts | 178 + .../vscode/src/stacks/lint/PluginLintPool.ts | 504 ++ packages/vscode/src/stacks/lint/Rslint.ts | 1202 +++++ .../stacks/lint/WorkspaceDocumentRouter.ts | 504 ++ .../stacks/lint/WorkspaceRslintCoordinator.ts | 504 ++ .../vscode/src/stacks/lint/configLoader.ts | 104 + packages/vscode/src/stacks/lint/index.ts | 341 ++ .../vscode/src/stacks/lint/jitiPreflight.ts | 119 + packages/vscode/src/stacks/lint/logger.ts | 84 + .../vscode/src/stacks/lint/projectModules.ts | 42 + packages/vscode/src/stacks/lint/resolution.ts | 306 ++ packages/vscode/src/stacks/lint/utils.ts | 57 + .../vscode/src/stacks/test/bridge.test.ts | 158 + packages/vscode/src/stacks/test/bridge.ts | 130 + packages/vscode/src/stacks/test/config.ts | 91 + .../src/stacks/test/coreResolution.test.ts | 69 + .../vscode/src/stacks/test/coreResolution.ts | 39 + .../src/stacks/test/diagnostics.test.ts | 113 + .../vscode/src/stacks/test/diagnostics.ts | 102 + .../vscode/src/stacks/test/errorStore.test.ts | 41 + packages/vscode/src/stacks/test/errorStore.ts | 32 + packages/vscode/src/stacks/test/global.d.ts | 9 + packages/vscode/src/stacks/test/index.ts | 537 ++ packages/vscode/src/stacks/test/logger.ts | 36 + .../vscode/src/stacks/test/master.test.ts | 217 + packages/vscode/src/stacks/test/master.ts | 562 ++ .../vscode/src/stacks/test/nodeRequire.ts | 20 + .../vscode/src/stacks/test/parse.fixture.txt | 9 + packages/vscode/src/stacks/test/parse.test.ts | 416 ++ packages/vscode/src/stacks/test/parserTest.ts | 113 + .../vscode/src/stacks/test/project.test.ts | 166 + packages/vscode/src/stacks/test/project.ts | 762 +++ .../src/stacks/test/projectCoverage.test.ts | 173 + .../vscode/src/stacks/test/projectCoverage.ts | 125 + .../vscode/src/stacks/test/shared/logger.ts | 39 + packages/vscode/src/stacks/test/status.ts | 50 + .../vscode/src/stacks/test/terminal.test.ts | 32 + packages/vscode/src/stacks/test/terminal.ts | 48 + .../vscode/src/stacks/test/testRunReporter.ts | 388 ++ .../vscode/src/stacks/test/testTree.test.ts | 170 + packages/vscode/src/stacks/test/testTree.ts | 251 + packages/vscode/src/stacks/test/types.ts | 9 + packages/vscode/src/stacks/test/utils.test.ts | 17 + packages/vscode/src/stacks/test/utils.ts | 8 + .../src/stacks/test/vendored/coreInternals.ts | 76 + .../vscode/src/stacks/test/worker/index.ts | 106 + .../vscode/src/stacks/test/worker/logger.ts | 13 + .../vscode/src/stacks/test/worker/reporter.ts | 105 + packages/vscode/src/statusBar.ts | 190 + packages/vscode/src/types.ts | 134 + .../tests/e2e/fixtures/e2e.code-workspace | 16 + .../e2e/fixtures/rslint/local-plugin.mjs | 34 + .../tests/e2e/fixtures/rslint/package.json | 10 + .../e2e/fixtures/rslint/rslint.config.mjs | 16 + .../tests/e2e/fixtures/rslint/src/index.ts | 6 + .../vscode/tests/e2e/fixtures/rstack/.npmrc | 9 + .../tests/e2e/fixtures/rstack/package.json | 13 + .../e2e/fixtures/rstack/rstack.config.ts | 23 + .../tests/e2e/fixtures/rstack/src/index.ts | 6 + .../e2e/fixtures/rstack/tests/basic.test.ts | 7 + .../tests/e2e/fixtures/rstest/package.json | 10 + .../e2e/fixtures/rstest/rstest.config.ts | 6 + .../e2e/fixtures/rstest/tests/basic.test.ts | 7 + .../tests/e2e/lint/fixtures/basic/.gitignore | 1 + .../e2e/lint/fixtures/basic/rslint.config.mjs | 34 + .../e2e/lint/fixtures/basic/src/autofix.ts | 10 + .../e2e/lint/fixtures/basic/src/close-test.ts | 3 + .../lint/fixtures/basic/src/disable-file.ts | 9 + .../e2e/lint/fixtures/basic/src/disable.ts | 9 + .../fixtures/basic/src/error-transitions.ts | 2 + .../lint/fixtures/basic/src/fixall-cascade.ts | 7 + .../e2e/lint/fixtures/basic/src/fixall.ts | 7 + .../e2e/lint/fixtures/basic/src/index.ts | 21 + .../e2e/lint/fixtures/basic/src/styles.css | 5 + .../e2e/lint/fixtures/basic/tsconfig.json | 7 + .../fixtures/eslint-plugins/local-plugin.mjs | 63 + .../fixtures/eslint-plugins/rslint.config.mjs | 16 + .../lint/fixtures/eslint-plugins/src/index.ts | 8 + .../fixtures/eslint-plugins/tsconfig.json | 11 + .../lint/fixtures/jsconfig/rslint.config.js | 16 + .../e2e/lint/fixtures/jsconfig/rslint.json | 16 + .../e2e/lint/fixtures/jsconfig/src/index.ts | 2 + .../e2e/lint/fixtures/jsconfig/tsconfig.json | 8 + .../monorepo/packages/bar/src/index.ts | 2 + .../monorepo/packages/broken/rslint.config.js | 2 + .../monorepo/packages/broken/src/index.ts | 2 + .../monorepo/packages/foo/rslint.config.js | 16 + .../monorepo/packages/foo/src/index.ts | 2 + .../lint/fixtures/monorepo/rslint.config.js | 16 + .../e2e/lint/fixtures/monorepo/src/index.ts | 2 + .../e2e/lint/fixtures/monorepo/tsconfig.json | 8 + .../multiroot/multiroot.code-workspace | 8 + .../multiroot/nested-initial.code-workspace | 7 + .../multiroot/parent/nested/rslint.config.js | 9 + .../multiroot/parent/nested/src/index.ts | 2 + .../multiroot/parent/rslint.config.js | 9 + .../fixtures/multiroot/parent/src/index.ts | 2 + .../multiroot/sentinel/rslint.config.js | 9 + .../fixtures/multiroot/sentinel/src/index.ts | 2 + .../multiroot/twins/left/app/rslint.config.js | 9 + .../multiroot/twins/left/app/src/index.ts | 2 + .../twins/right/app/rslint.config.js | 9 + .../multiroot/twins/right/app/src/index.ts | 2 + .../e2e/lint/fixtures/noconfig/src/index.ts | 2 + .../e2e/lint/fixtures/noconfig/tsconfig.json | 8 + .../tests/e2e/lint/fixtures/package.json | 10 + .../project-service-scope/rslint.config.js | 22 + .../project-service-scope/src/covered.ts | 4 + .../template-nested/orphan.ts | 10 + .../template-nested/rslint.config.js | 21 + .../project-service-scope/test/skills.test.ts | 13 + .../project-service-scope/tsconfig.json | 4 + .../packages/cli/src/preview.ts | 3 + .../packages/core/src/dependency.ts | 1 + .../packages/core/src/index.ts | 6 + .../packages/core/src/session.ts | 1 + .../packages/core/tsconfig.json | 4 + .../packages/core/tsconfig.lint.json | 4 + .../type-aware-scope/rslint.config.js | 17 + packages/vscode/tests/e2e/lint/runSuite.ts | 382 ++ packages/vscode/tests/e2e/lint/runTest.ts | 321 ++ .../eslint-plugins.test.ts | 146 + .../e2e/lint/suite-eslint-plugins/index.ts | 3 + .../suite-eslint-plugins/plugin-pool.test.ts | 429 ++ .../suite-jsconfig/config-transaction.test.ts | 514 ++ .../tests/e2e/lint/suite-jsconfig/index.ts | 3 + .../e2e/lint/suite-jsconfig/jsconfig.test.ts | 835 +++ .../suite-jsconfig/rslint-lifecycle.test.ts | 196 + .../workspace-coordinator.test.ts | 340 ++ .../suite-jsconfig/workspace-router.test.ts | 445 ++ .../tests/e2e/lint/suite-monorepo/index.ts | 3 + .../e2e/lint/suite-monorepo/monorepo.test.ts | 527 ++ .../tests/e2e/lint/suite-multiroot/index.ts | 3 + .../lint/suite-multiroot/multiroot.test.ts | 203 + .../tests/e2e/lint/suite-noconfig/index.ts | 5 + .../e2e/lint/suite-noconfig/noconfig.test.ts | 298 ++ .../lint/suite-project-service-scope/index.ts | 3 + .../project-service-scope.test.ts | 125 + .../e2e/lint/suite-type-aware-scope/index.ts | 3 + .../type-aware-scope.test.ts | 176 + .../tests/e2e/lint/suite/extension.test.ts | 649 +++ .../e2e/lint/suite/fixall-cascade.test.ts | 97 + .../tests/e2e/lint/suite/fixall-error.test.ts | 88 + .../tests/e2e/lint/suite/fixall-helpers.ts | 205 + .../e2e/lint/suite/fixall-onsave.test.ts | 215 + .../tests/e2e/lint/suite/fixall.test.ts | 279 + packages/vscode/tests/e2e/lint/suite/index.ts | 3 + .../e2e/lint/suite/registry-harness.test.ts | 282 + .../e2e/lint/utils/codeActionRegistry.ts | 304 ++ .../tests/e2e/lint/utils/configuration.ts | 215 + .../vscode/tests/e2e/lint/utils/deadline.ts | 39 + .../tests/e2e/lint/utils/diagnostics.ts | 118 + .../vscode/tests/e2e/lint/utils/documents.ts | 87 + .../vscode/tests/e2e/lint/utils/extension.ts | 57 + .../config/trailing-slash.config.ts | 6 + .../rstest/fixtures/workspace-1/package.json | 9 + .../fixtures/workspace-1/rstest.config.ts | 3 + .../rstest/fixtures/workspace-1/src/foo.ts | 2 + .../rstest/fixtures/workspace-1/src/index.ts | 1 + .../fixtures/workspace-1/test/each.test.ts | 9 + .../fixtures/workspace-1/test/foo.test.ts | 24 + .../fixtures/workspace-1/test/index.test.ts | 12 + .../fixtures/workspace-1/test/jsFile.spec.js | 8 + .../workspace-1/test/jsFile.spec.js.txt | 8 + .../workspace-1/test/jsxFile.test.jsx | 8 + .../workspace-1/test/progress.test.ts | 28 + .../workspace-1/test/tsxFile.test.tsx | 8 + .../rstest/fixtures/workspace-1/tsconfig.json | 15 + .../folder/project-2/rstest.config.ts | 3 + .../folder/project-2/test/foo.test.ts | 0 .../rstest/fixtures/workspace-2/package.json | 9 + .../workspace-2/project-1/rstest.config.ts | 3 + .../workspace-2/project-1/test/foo.test.ts | 0 packages/vscode/tests/e2e/rstest/runTest.ts | 100 + .../vscode/tests/e2e/rstest/suite/helpers.ts | 147 + .../tests/e2e/rstest/suite/index.test.ts | 123 + .../vscode/tests/e2e/rstest/suite/index.ts | 47 + .../tests/e2e/rstest/suite/progress.test.ts | 258 + .../e2e/rstest/suite/runtimeList.test.ts | 163 + .../tests/e2e/rstest/suite/uxCommands.test.ts | 79 + .../tests/e2e/rstest/suite/workspace.test.ts | 297 ++ packages/vscode/tests/e2e/runTest.ts | 82 + packages/vscode/tests/e2e/setupFixtures.mjs | 97 + .../tests/e2e/smoke/rslintPluginHost.mjs | 137 + .../vscode/tests/e2e/suite/detection.test.ts | 133 + packages/vscode/tests/e2e/suite/helpers.ts | 37 + packages/vscode/tests/e2e/suite/index.ts | 38 + packages/vscode/tests/e2e/suite/shell.test.ts | 76 + .../tests/unit/loadRstackConfig.test.ts | 72 + .../vscode/tests/unit/versionCheck.test.ts | 52 + packages/vscode/tsconfig.e2e.json | 45 + packages/vscode/tsconfig.json | 32 + pnpm-lock.yaml | 4614 +++++++++++++++++ pnpm-workspace.yaml | 17 + rstack.config.ts | 54 + 232 files changed, 28562 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/1-bug-report.yml create mode 100644 .github/ISSUE_TEMPLATE/2-feature-request.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .nvmrc create mode 100644 .rstack/hooks/pre-commit create mode 100644 .vscode/extensions.json create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 .vscode/tasks.json create mode 100644 AGENTS.md create mode 120000 CLAUDE.md create mode 100644 CONTRIBUTING.md create mode 100644 README.md create mode 100644 package.json create mode 100644 packages/vscode/.vscodeignore create mode 100644 packages/vscode/AGENTS.md create mode 120000 packages/vscode/CLAUDE.md create mode 100644 packages/vscode/README.md create mode 100644 packages/vscode/icon.png create mode 100644 packages/vscode/package.json create mode 100644 packages/vscode/rslib.config.mts create mode 100644 packages/vscode/rstest.config.mts create mode 100644 packages/vscode/scripts/packageTargets.mjs create mode 100644 packages/vscode/src/channels.ts create mode 100644 packages/vscode/src/detection.test.ts create mode 100644 packages/vscode/src/detection.ts create mode 100644 packages/vscode/src/extension.ts create mode 100644 packages/vscode/src/migration.test.ts create mode 100644 packages/vscode/src/migration.ts create mode 100644 packages/vscode/src/shared/vendored/loadRstackConfig.ts create mode 100644 packages/vscode/src/shared/versionCheck.ts create mode 100644 packages/vscode/src/stacks/fmt/index.ts create mode 100644 packages/vscode/src/stacks/lint/ConfigTransactionAdapter.ts create mode 100644 packages/vscode/src/stacks/lint/LanguageServerProcessOwner.ts create mode 100644 packages/vscode/src/stacks/lint/PluginLintPool.ts create mode 100644 packages/vscode/src/stacks/lint/Rslint.ts create mode 100644 packages/vscode/src/stacks/lint/WorkspaceDocumentRouter.ts create mode 100644 packages/vscode/src/stacks/lint/WorkspaceRslintCoordinator.ts create mode 100644 packages/vscode/src/stacks/lint/configLoader.ts create mode 100644 packages/vscode/src/stacks/lint/index.ts create mode 100644 packages/vscode/src/stacks/lint/jitiPreflight.ts create mode 100644 packages/vscode/src/stacks/lint/logger.ts create mode 100644 packages/vscode/src/stacks/lint/projectModules.ts create mode 100644 packages/vscode/src/stacks/lint/resolution.ts create mode 100644 packages/vscode/src/stacks/lint/utils.ts create mode 100644 packages/vscode/src/stacks/test/bridge.test.ts create mode 100644 packages/vscode/src/stacks/test/bridge.ts create mode 100644 packages/vscode/src/stacks/test/config.ts create mode 100644 packages/vscode/src/stacks/test/coreResolution.test.ts create mode 100644 packages/vscode/src/stacks/test/coreResolution.ts create mode 100644 packages/vscode/src/stacks/test/diagnostics.test.ts create mode 100644 packages/vscode/src/stacks/test/diagnostics.ts create mode 100644 packages/vscode/src/stacks/test/errorStore.test.ts create mode 100644 packages/vscode/src/stacks/test/errorStore.ts create mode 100644 packages/vscode/src/stacks/test/global.d.ts create mode 100644 packages/vscode/src/stacks/test/index.ts create mode 100644 packages/vscode/src/stacks/test/logger.ts create mode 100644 packages/vscode/src/stacks/test/master.test.ts create mode 100644 packages/vscode/src/stacks/test/master.ts create mode 100644 packages/vscode/src/stacks/test/nodeRequire.ts create mode 100644 packages/vscode/src/stacks/test/parse.fixture.txt create mode 100644 packages/vscode/src/stacks/test/parse.test.ts create mode 100644 packages/vscode/src/stacks/test/parserTest.ts create mode 100644 packages/vscode/src/stacks/test/project.test.ts create mode 100644 packages/vscode/src/stacks/test/project.ts create mode 100644 packages/vscode/src/stacks/test/projectCoverage.test.ts create mode 100644 packages/vscode/src/stacks/test/projectCoverage.ts create mode 100644 packages/vscode/src/stacks/test/shared/logger.ts create mode 100644 packages/vscode/src/stacks/test/status.ts create mode 100644 packages/vscode/src/stacks/test/terminal.test.ts create mode 100644 packages/vscode/src/stacks/test/terminal.ts create mode 100644 packages/vscode/src/stacks/test/testRunReporter.ts create mode 100644 packages/vscode/src/stacks/test/testTree.test.ts create mode 100644 packages/vscode/src/stacks/test/testTree.ts create mode 100644 packages/vscode/src/stacks/test/types.ts create mode 100644 packages/vscode/src/stacks/test/utils.test.ts create mode 100644 packages/vscode/src/stacks/test/utils.ts create mode 100644 packages/vscode/src/stacks/test/vendored/coreInternals.ts create mode 100644 packages/vscode/src/stacks/test/worker/index.ts create mode 100644 packages/vscode/src/stacks/test/worker/logger.ts create mode 100644 packages/vscode/src/stacks/test/worker/reporter.ts create mode 100644 packages/vscode/src/statusBar.ts create mode 100644 packages/vscode/src/types.ts create mode 100644 packages/vscode/tests/e2e/fixtures/e2e.code-workspace create mode 100644 packages/vscode/tests/e2e/fixtures/rslint/local-plugin.mjs create mode 100644 packages/vscode/tests/e2e/fixtures/rslint/package.json create mode 100644 packages/vscode/tests/e2e/fixtures/rslint/rslint.config.mjs create mode 100644 packages/vscode/tests/e2e/fixtures/rslint/src/index.ts create mode 100644 packages/vscode/tests/e2e/fixtures/rstack/.npmrc create mode 100644 packages/vscode/tests/e2e/fixtures/rstack/package.json create mode 100644 packages/vscode/tests/e2e/fixtures/rstack/rstack.config.ts create mode 100644 packages/vscode/tests/e2e/fixtures/rstack/src/index.ts create mode 100644 packages/vscode/tests/e2e/fixtures/rstack/tests/basic.test.ts create mode 100644 packages/vscode/tests/e2e/fixtures/rstest/package.json create mode 100644 packages/vscode/tests/e2e/fixtures/rstest/rstest.config.ts create mode 100644 packages/vscode/tests/e2e/fixtures/rstest/tests/basic.test.ts create mode 100644 packages/vscode/tests/e2e/lint/fixtures/basic/.gitignore create mode 100644 packages/vscode/tests/e2e/lint/fixtures/basic/rslint.config.mjs create mode 100644 packages/vscode/tests/e2e/lint/fixtures/basic/src/autofix.ts create mode 100644 packages/vscode/tests/e2e/lint/fixtures/basic/src/close-test.ts create mode 100644 packages/vscode/tests/e2e/lint/fixtures/basic/src/disable-file.ts create mode 100644 packages/vscode/tests/e2e/lint/fixtures/basic/src/disable.ts create mode 100644 packages/vscode/tests/e2e/lint/fixtures/basic/src/error-transitions.ts create mode 100644 packages/vscode/tests/e2e/lint/fixtures/basic/src/fixall-cascade.ts create mode 100644 packages/vscode/tests/e2e/lint/fixtures/basic/src/fixall.ts create mode 100644 packages/vscode/tests/e2e/lint/fixtures/basic/src/index.ts create mode 100644 packages/vscode/tests/e2e/lint/fixtures/basic/src/styles.css create mode 100644 packages/vscode/tests/e2e/lint/fixtures/basic/tsconfig.json create mode 100644 packages/vscode/tests/e2e/lint/fixtures/eslint-plugins/local-plugin.mjs create mode 100644 packages/vscode/tests/e2e/lint/fixtures/eslint-plugins/rslint.config.mjs create mode 100644 packages/vscode/tests/e2e/lint/fixtures/eslint-plugins/src/index.ts create mode 100644 packages/vscode/tests/e2e/lint/fixtures/eslint-plugins/tsconfig.json create mode 100644 packages/vscode/tests/e2e/lint/fixtures/jsconfig/rslint.config.js create mode 100644 packages/vscode/tests/e2e/lint/fixtures/jsconfig/rslint.json create mode 100644 packages/vscode/tests/e2e/lint/fixtures/jsconfig/src/index.ts create mode 100644 packages/vscode/tests/e2e/lint/fixtures/jsconfig/tsconfig.json create mode 100644 packages/vscode/tests/e2e/lint/fixtures/monorepo/packages/bar/src/index.ts create mode 100644 packages/vscode/tests/e2e/lint/fixtures/monorepo/packages/broken/rslint.config.js create mode 100644 packages/vscode/tests/e2e/lint/fixtures/monorepo/packages/broken/src/index.ts create mode 100644 packages/vscode/tests/e2e/lint/fixtures/monorepo/packages/foo/rslint.config.js create mode 100644 packages/vscode/tests/e2e/lint/fixtures/monorepo/packages/foo/src/index.ts create mode 100644 packages/vscode/tests/e2e/lint/fixtures/monorepo/rslint.config.js create mode 100644 packages/vscode/tests/e2e/lint/fixtures/monorepo/src/index.ts create mode 100644 packages/vscode/tests/e2e/lint/fixtures/monorepo/tsconfig.json create mode 100644 packages/vscode/tests/e2e/lint/fixtures/multiroot/multiroot.code-workspace create mode 100644 packages/vscode/tests/e2e/lint/fixtures/multiroot/nested-initial.code-workspace create mode 100644 packages/vscode/tests/e2e/lint/fixtures/multiroot/parent/nested/rslint.config.js create mode 100644 packages/vscode/tests/e2e/lint/fixtures/multiroot/parent/nested/src/index.ts create mode 100644 packages/vscode/tests/e2e/lint/fixtures/multiroot/parent/rslint.config.js create mode 100644 packages/vscode/tests/e2e/lint/fixtures/multiroot/parent/src/index.ts create mode 100644 packages/vscode/tests/e2e/lint/fixtures/multiroot/sentinel/rslint.config.js create mode 100644 packages/vscode/tests/e2e/lint/fixtures/multiroot/sentinel/src/index.ts create mode 100644 packages/vscode/tests/e2e/lint/fixtures/multiroot/twins/left/app/rslint.config.js create mode 100644 packages/vscode/tests/e2e/lint/fixtures/multiroot/twins/left/app/src/index.ts create mode 100644 packages/vscode/tests/e2e/lint/fixtures/multiroot/twins/right/app/rslint.config.js create mode 100644 packages/vscode/tests/e2e/lint/fixtures/multiroot/twins/right/app/src/index.ts create mode 100644 packages/vscode/tests/e2e/lint/fixtures/noconfig/src/index.ts create mode 100644 packages/vscode/tests/e2e/lint/fixtures/noconfig/tsconfig.json create mode 100644 packages/vscode/tests/e2e/lint/fixtures/package.json create mode 100644 packages/vscode/tests/e2e/lint/fixtures/project-service-scope/rslint.config.js create mode 100644 packages/vscode/tests/e2e/lint/fixtures/project-service-scope/src/covered.ts create mode 100644 packages/vscode/tests/e2e/lint/fixtures/project-service-scope/template-nested/orphan.ts create mode 100644 packages/vscode/tests/e2e/lint/fixtures/project-service-scope/template-nested/rslint.config.js create mode 100644 packages/vscode/tests/e2e/lint/fixtures/project-service-scope/test/skills.test.ts create mode 100644 packages/vscode/tests/e2e/lint/fixtures/project-service-scope/tsconfig.json create mode 100644 packages/vscode/tests/e2e/lint/fixtures/type-aware-scope/packages/cli/src/preview.ts create mode 100644 packages/vscode/tests/e2e/lint/fixtures/type-aware-scope/packages/core/src/dependency.ts create mode 100644 packages/vscode/tests/e2e/lint/fixtures/type-aware-scope/packages/core/src/index.ts create mode 100644 packages/vscode/tests/e2e/lint/fixtures/type-aware-scope/packages/core/src/session.ts create mode 100644 packages/vscode/tests/e2e/lint/fixtures/type-aware-scope/packages/core/tsconfig.json create mode 100644 packages/vscode/tests/e2e/lint/fixtures/type-aware-scope/packages/core/tsconfig.lint.json create mode 100644 packages/vscode/tests/e2e/lint/fixtures/type-aware-scope/rslint.config.js create mode 100644 packages/vscode/tests/e2e/lint/runSuite.ts create mode 100644 packages/vscode/tests/e2e/lint/runTest.ts create mode 100644 packages/vscode/tests/e2e/lint/suite-eslint-plugins/eslint-plugins.test.ts create mode 100644 packages/vscode/tests/e2e/lint/suite-eslint-plugins/index.ts create mode 100644 packages/vscode/tests/e2e/lint/suite-eslint-plugins/plugin-pool.test.ts create mode 100644 packages/vscode/tests/e2e/lint/suite-jsconfig/config-transaction.test.ts create mode 100644 packages/vscode/tests/e2e/lint/suite-jsconfig/index.ts create mode 100644 packages/vscode/tests/e2e/lint/suite-jsconfig/jsconfig.test.ts create mode 100644 packages/vscode/tests/e2e/lint/suite-jsconfig/rslint-lifecycle.test.ts create mode 100644 packages/vscode/tests/e2e/lint/suite-jsconfig/workspace-coordinator.test.ts create mode 100644 packages/vscode/tests/e2e/lint/suite-jsconfig/workspace-router.test.ts create mode 100644 packages/vscode/tests/e2e/lint/suite-monorepo/index.ts create mode 100644 packages/vscode/tests/e2e/lint/suite-monorepo/monorepo.test.ts create mode 100644 packages/vscode/tests/e2e/lint/suite-multiroot/index.ts create mode 100644 packages/vscode/tests/e2e/lint/suite-multiroot/multiroot.test.ts create mode 100644 packages/vscode/tests/e2e/lint/suite-noconfig/index.ts create mode 100644 packages/vscode/tests/e2e/lint/suite-noconfig/noconfig.test.ts create mode 100644 packages/vscode/tests/e2e/lint/suite-project-service-scope/index.ts create mode 100644 packages/vscode/tests/e2e/lint/suite-project-service-scope/project-service-scope.test.ts create mode 100644 packages/vscode/tests/e2e/lint/suite-type-aware-scope/index.ts create mode 100644 packages/vscode/tests/e2e/lint/suite-type-aware-scope/type-aware-scope.test.ts create mode 100644 packages/vscode/tests/e2e/lint/suite/extension.test.ts create mode 100644 packages/vscode/tests/e2e/lint/suite/fixall-cascade.test.ts create mode 100644 packages/vscode/tests/e2e/lint/suite/fixall-error.test.ts create mode 100644 packages/vscode/tests/e2e/lint/suite/fixall-helpers.ts create mode 100644 packages/vscode/tests/e2e/lint/suite/fixall-onsave.test.ts create mode 100644 packages/vscode/tests/e2e/lint/suite/fixall.test.ts create mode 100644 packages/vscode/tests/e2e/lint/suite/index.ts create mode 100644 packages/vscode/tests/e2e/lint/suite/registry-harness.test.ts create mode 100644 packages/vscode/tests/e2e/lint/utils/codeActionRegistry.ts create mode 100644 packages/vscode/tests/e2e/lint/utils/configuration.ts create mode 100644 packages/vscode/tests/e2e/lint/utils/deadline.ts create mode 100644 packages/vscode/tests/e2e/lint/utils/diagnostics.ts create mode 100644 packages/vscode/tests/e2e/lint/utils/documents.ts create mode 100644 packages/vscode/tests/e2e/lint/utils/extension.ts create mode 100644 packages/vscode/tests/e2e/rstest/fixtures/workspace-1/config/trailing-slash.config.ts create mode 100644 packages/vscode/tests/e2e/rstest/fixtures/workspace-1/package.json create mode 100644 packages/vscode/tests/e2e/rstest/fixtures/workspace-1/rstest.config.ts create mode 100644 packages/vscode/tests/e2e/rstest/fixtures/workspace-1/src/foo.ts create mode 100644 packages/vscode/tests/e2e/rstest/fixtures/workspace-1/src/index.ts create mode 100644 packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/each.test.ts create mode 100644 packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/foo.test.ts create mode 100644 packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/index.test.ts create mode 100644 packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/jsFile.spec.js create mode 100644 packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/jsFile.spec.js.txt create mode 100644 packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/jsxFile.test.jsx create mode 100644 packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/progress.test.ts create mode 100644 packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/tsxFile.test.tsx create mode 100644 packages/vscode/tests/e2e/rstest/fixtures/workspace-1/tsconfig.json create mode 100644 packages/vscode/tests/e2e/rstest/fixtures/workspace-2/folder/project-2/rstest.config.ts create mode 100644 packages/vscode/tests/e2e/rstest/fixtures/workspace-2/folder/project-2/test/foo.test.ts create mode 100644 packages/vscode/tests/e2e/rstest/fixtures/workspace-2/package.json create mode 100644 packages/vscode/tests/e2e/rstest/fixtures/workspace-2/project-1/rstest.config.ts create mode 100644 packages/vscode/tests/e2e/rstest/fixtures/workspace-2/project-1/test/foo.test.ts create mode 100644 packages/vscode/tests/e2e/rstest/runTest.ts create mode 100644 packages/vscode/tests/e2e/rstest/suite/helpers.ts create mode 100644 packages/vscode/tests/e2e/rstest/suite/index.test.ts create mode 100644 packages/vscode/tests/e2e/rstest/suite/index.ts create mode 100644 packages/vscode/tests/e2e/rstest/suite/progress.test.ts create mode 100644 packages/vscode/tests/e2e/rstest/suite/runtimeList.test.ts create mode 100644 packages/vscode/tests/e2e/rstest/suite/uxCommands.test.ts create mode 100644 packages/vscode/tests/e2e/rstest/suite/workspace.test.ts create mode 100644 packages/vscode/tests/e2e/runTest.ts create mode 100644 packages/vscode/tests/e2e/setupFixtures.mjs create mode 100644 packages/vscode/tests/e2e/smoke/rslintPluginHost.mjs create mode 100644 packages/vscode/tests/e2e/suite/detection.test.ts create mode 100644 packages/vscode/tests/e2e/suite/helpers.ts create mode 100644 packages/vscode/tests/e2e/suite/index.ts create mode 100644 packages/vscode/tests/e2e/suite/shell.test.ts create mode 100644 packages/vscode/tests/unit/loadRstackConfig.test.ts create mode 100644 packages/vscode/tests/unit/versionCheck.test.ts create mode 100644 packages/vscode/tsconfig.e2e.json create mode 100644 packages/vscode/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 rstack.config.ts diff --git a/.github/ISSUE_TEMPLATE/1-bug-report.yml b/.github/ISSUE_TEMPLATE/1-bug-report.yml new file mode 100644 index 0000000..7ad758f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-bug-report.yml @@ -0,0 +1,51 @@ +name: '🐞 Bug Report' +description: Report a bug in the Rstack VS Code extension +title: '[Bug]: ' +labels: ['🐞 bug'] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report this issue! Before submitting, please note: + + - Make sure you searched in the [Issues](https://github.com/rstackjs/rstack-editor/issues) and didn't find the same issue. + - If the problem reproduces with the CLI too (`rs lint` / `rs test` / `rs fmt`), it likely belongs to the tool's own repo β€” file it against [rslint](https://github.com/web-infra-dev/rslint), [rstest](https://github.com/web-infra-dev/rstest) or [rstack-cli](https://github.com/rstackjs/rstack-cli) instead. + + - type: dropdown + id: area + attributes: + label: Affected area + options: + - Linting (Rslint) + - Testing (Rstest) + - Formatting + - Detection / status bar / settings + - Other + validations: + required: true + + - type: textarea + id: versions + attributes: + label: Versions + description: | + Extension version, VS Code version + platform (Help β†’ About), and the tool versions resolved from your project (`npm ls @rslint/core @rstest/core rstack`). + render: sh + validations: + required: true + + - type: textarea + id: details + attributes: + label: Details + description: Describe the bug, including screenshots and relevant output-channel logs (Output panel β†’ Rstack channels). + validations: + required: true + + - type: textarea + id: reproduce-steps + attributes: + label: Reproduce steps + description: The simplest steps (ideally with a minimal repo link) so we can quickly reproduce the problem. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/2-feature-request.yml b/.github/ISSUE_TEMPLATE/2-feature-request.yml new file mode 100644 index 0000000..32a984b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2-feature-request.yml @@ -0,0 +1,18 @@ +name: 'πŸ’‘ Feature Request' +description: Suggest a feature for the Rstack VS Code extension +title: '[Feature]: ' +labels: ['πŸ’‘ feature'] +body: + - type: textarea + id: problem + attributes: + label: What problem does this feature solve? + validations: + required: true + + - type: textarea + id: solution + attributes: + label: What does the proposed API/UX look like? + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..42b7434 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Ask a question + url: https://github.com/rstackjs/rstack-editor/discussions + about: Ask a question about the Rstack editor extensions diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..b2c6727 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,12 @@ +## Summary + +## Related Links + + + +## Checklist + + + +- [ ] Tests updated (or not required). +- [ ] Documentation updated (or not required). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bc4a955 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,119 @@ +name: CI + +on: + push: + branches: [main] + + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: ${{ github.ref_name != 'main' }} + +permissions: + contents: read + +jobs: + # ======== linux ======== + test-linux: + name: Test (ubuntu-latest) + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + fetch-depth: 1 + + - name: Setup pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - name: Setup Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: 22 + cache: 'pnpm' + cache-dependency-path: pnpm-lock.yaml + + - name: Install Dependencies + run: pnpm install --frozen-lockfile --prefer-offline + + - name: Build + run: pnpm run build + + - name: Lint + run: pnpm run lint + + - name: Format Check + run: pnpm run fmt:check + + - name: Unit Test + run: pnpm run test:unit + + # `@vscode/test-electron` downloads a full VS Code into + # `packages/vscode/.vscode-test`. Cache only the immutable distribution + # directories, keyed on the extension manifest (which carries + # `engines.vscode`); the restore-keys fallback keeps older downloads + # available while `version: 'stable'` moves forward. + - name: Cache VS Code Download + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: packages/vscode/.vscode-test/vscode-* + key: vscode-test-${{ runner.os }}-${{ hashFiles('packages/vscode/package.json') }} + restore-keys: | + vscode-test-${{ runner.os }}- + + # The E2E fixtures install published npm packages at test time + # (tests/e2e/setupFixtures.mjs, invoked by test:e2e), so this step needs + # network access. The VS Code Extension Host needs a display on Linux, + # hence xvfb (preinstalled on GitHub-hosted Ubuntu runners). + - name: E2E Test + run: xvfb-run -a pnpm run test:e2e + + # ======== windows ======== + # A dedicated GitHub-hosted Windows job: upstream rslint notes the VS Code + # extension E2E suite is unreliable on self-hosted Windows runners. + test-windows: + name: Test (windows-latest) + runs-on: windows-latest + timeout-minutes: 40 + + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + fetch-depth: 1 + + - name: Setup pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - name: Setup Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: 22 + cache: 'pnpm' + cache-dependency-path: pnpm-lock.yaml + + - name: Install Dependencies + run: pnpm install --frozen-lockfile --prefer-offline + + - name: Build + run: pnpm run build + + - name: Lint + run: pnpm run lint + + - name: Unit Test + run: pnpm run test:unit + + - name: Cache VS Code Download + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: packages/vscode/.vscode-test/vscode-* + key: vscode-test-${{ runner.os }}-${{ hashFiles('packages/vscode/package.json') }} + restore-keys: | + vscode-test-${{ runner.os }}- + + - name: E2E Test + run: pnpm run test:e2e diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..893a883 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,102 @@ +name: Release + +on: + workflow_dispatch: + inputs: + dry_run: + description: 'Dry run: package and upload the VSIX artifacts only, skip publishing' + required: false + type: boolean + default: false + +permissions: + contents: read + +jobs: + release_vscode_extension: + name: Release VS Code Extension (${{ matrix.vsce-target }}) + if: github.repository == 'rstackjs/rstack-editor' && github.event_name == 'workflow_dispatch' + runs-on: ${{ matrix.runner }} + environment: vscode-marketplace + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + vsce-target: linux-x64 + arch: x64 + - runner: ubuntu-22.04 + vsce-target: linux-arm64 + arch: arm64 + - runner: macos-15 + vsce-target: darwin-x64 + arch: x64 + - runner: macos-15 + vsce-target: darwin-arm64 + arch: arm64 + - runner: windows-2022 + vsce-target: win32-x64 + arch: x64 + - runner: windows-latest + vsce-target: win32-arm64 + arch: arm64 + defaults: + run: + shell: bash + env: + # rslib.config.mts keys the staged `@yuku-parser/binding-*` napi payload + # off this variable (Linux targets get a `-gnu` suffix appended there). + VSCE_TARGET: ${{ matrix.vsce-target }} + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + fetch-depth: 1 + + - name: Setup pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - name: Setup Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: 22 + cache: 'pnpm' + cache-dependency-path: pnpm-lock.yaml + + # `supportedArchitectures` must be set BEFORE `pnpm install` so pnpm also + # fetches the cross-target optional `@yuku-parser/binding-*` package (the + # Linux runners' current libc is glibc, matching the `-gnu` bindings the + # linux-* VSIX targets need). + - name: Install Dependencies + run: | + pnpm config set --location=project --json supportedArchitectures '{"cpu":["current","${{ matrix.arch }}"]}' + pnpm install + + - name: Build + run: pnpm run build + + - name: Package VS Code Extension + working-directory: packages/vscode + run: pnpm exec vsce package --target ${{ matrix.vsce-target }} -o rstack-${{ matrix.vsce-target }}.vsix + + - name: Upload VSIX Artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: rstack-${{ matrix.vsce-target }} + path: packages/vscode/rstack-${{ matrix.vsce-target }}.vsix + if-no-files-found: error + + - name: Publish VS Code Extension + if: ${{ !inputs.dry_run }} + working-directory: packages/vscode + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + run: pnpm exec vsce publish --target ${{ matrix.vsce-target }} --skip-duplicate + + - name: Publish OVSX Extension + if: ${{ !inputs.dry_run }} + working-directory: packages/vscode + env: + OVSX_PAT: ${{ secrets.OVSX_PAT }} + run: pnpm exec ovsx publish --target ${{ matrix.vsce-target }} --skip-duplicate diff --git a/.gitignore b/.gitignore index 872d5f6..c5a1087 100644 --- a/.gitignore +++ b/.gitignore @@ -141,3 +141,24 @@ dist vite.config.js.timestamp-* vite.config.ts.timestamp-* .vite/ + +# rstack-editor +*.vsix +tests-dist/ +.rsdoctor/ + +# E2E fixtures install published npm versions on demand; only +# their manifests and configs are tracked. +packages/vscode/tests/e2e/fixtures/*/node_modules/ +packages/vscode/tests/e2e/fixtures/*/pnpm-lock.yaml +packages/vscode/tests/e2e/lint/fixtures/pnpm-lock.yaml +packages/vscode/tests/e2e/rstest/fixtures/*/pnpm-lock.yaml + +# Build-time copy of the workspace root LICENSE (see rslib.config.mts) +packages/vscode/LICENSE + +# Design documents are working notes, not part of the published repo +/DESIGN*.md + +# Claude Code local worktrees +.claude/worktrees/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..6f4247a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +26 diff --git a/.rstack/hooks/pre-commit b/.rstack/hooks/pre-commit new file mode 100644 index 0000000..8890b96 --- /dev/null +++ b/.rstack/hooks/pre-commit @@ -0,0 +1 @@ +rs staged diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..c230334 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + // Rslint powers `pnpm lint` in this repo; the extension surfaces the same + // diagnostics in the editor. To be replaced by `rstack.rstack` (this very + // extension) once it is published. + "recommendations": ["rstack.rslint"] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..6cd956a --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,42 @@ +{ + "version": "0.2.0", + "configurations": [ + { + // F5: pick a fixture from the dropdown, then an Extension Development + // Host opens on it. Fixtures are real projects on published + // `@rslint/core` / `@rstest/core` / `rstack` β€” run + // `pnpm test:e2e:fixtures` once first to install their dependencies. + "name": "Run Extension (playground)", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}/packages/vscode", + "--disable-extensions", + "${workspaceFolder}/packages/vscode/${input:playgroundTarget}" + ], + "outFiles": ["${workspaceFolder}/packages/vscode/dist/**/*.js"], + "preLaunchTask": "extension watch", + // Off by default: attaching to every spawned worker slows runs down and + // child-process sourcemaps are unreliable (same setting upstream). + "autoAttachChildProcesses": false + } + ], + "inputs": [ + { + "id": "playgroundTarget", + "type": "pickString", + "description": "Which fixture should the dev host open?", + "default": "tests/e2e/fixtures/e2e.code-workspace", + "options": [ + { + "label": "all three fixtures (multi-root)", + "value": "tests/e2e/fixtures/e2e.code-workspace" + }, + { "label": "rslint fixture", "value": "tests/e2e/fixtures/rslint" }, + { "label": "rstest fixture", "value": "tests/e2e/fixtures/rstest" }, + { "label": "rstack fixture", "value": "tests/e2e/fixtures/rstack" } + ] + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..2a1213c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "search.exclude": { + "**/dist": true, + "**/tests-dist": true, + "**/.vscode-test": true, + "**/node_modules": true + } +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..9825b23 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,35 @@ +{ + "version": "2.0.0", + "tasks": [ + { + // Background watch build the F5 launch depends on. SOURCEMAP=true + // (`watch:local`) so breakpoints in src/ bind inside the dev host. + "label": "extension watch", + "type": "shell", + "command": "pnpm", + "args": ["-C", "packages/vscode", "watch:local"], + "isBackground": true, + "group": "build", + "problemMatcher": { + "owner": "rslib", + "pattern": { + // rslib prints rspack errors with file:line:column on their own + // lines; a permissive pattern keeps the matcher valid without + // parsing them β€” build failures still show in the terminal. + "regexp": "^\\s*(ERROR|error)\\s+(.*)$", + "severity": 1, + "message": 2 + }, + "background": { + "activeOnStart": true, + "beginsPattern": "build started\\.\\.\\.", + "endsPattern": "built in" + } + }, + "presentation": { + "reveal": "never", + "panel": "dedicated" + } + } + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9c6ffe0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ +# AGENTS.md + +Unified editor support for the [Rstack](https://rstack.rs) toolchain. pnpm workspace; the VS Code extension lives in `packages/vscode` (which has its own AGENTS.md β€” read it before touching extension code). The layout leaves room for other editors (e.g. Zed) as sibling packages. + +## Conventions + +- All code, comments, commit messages, PRs and docs are in English, regardless of the conversation language. +- Root scripts are thin `pnpm -r run` fan-outs. Never make a root script reach into a package's internals β€” add the script to the package instead. +- READMEs are user-facing only. Contributor/agent material goes in AGENTS.md files, not READMEs. +- Sibling checkouts of rslint / rstest / rstack-cli (`../rslint` etc.) are read-only references. Their working trees may be stale: `git fetch origin` and read via `git show origin/main:`. +- Verify claims about upstream behavior or published packages against the actual source or registry β€” do not answer from memory. + +## Workflow + +- Before considering a change done: `pnpm lint && pnpm test:unit` (`lint` runs `rs lint --type-check`, which covers type checking β€” there is no separate typecheck script). +- If extension source changed, also run the E2E slice covering the change (see `packages/vscode/AGENTS.md`). E2E launches a real VS Code and is the ground truth for editor behavior β€” unit tests are not a substitute. +- Never delete `packages/vscode/.vscode-test/` β€” it caches the VS Code download the E2E suites reuse. +- Report real command results only; never claim green without running. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..601a0ab --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,30 @@ +# Contributing + +Thanks for your interest in contributing to Rstack Editor! + +## Setup + +- Node.js: the version is pinned in [`.nvmrc`](./.nvmrc) (`nvm use` / `fnm use`). Node >= 22.12 is required. +- pnpm: pinned via the `packageManager` field β€” run `corepack enable pnpm` once and the right version is used automatically. + +```bash +pnpm install # also installs the git hooks (rs setup) +``` + +## Development + +| Command | What it does | +| --- | --- | +| `pnpm build` | Build every package | +| `pnpm lint` | Lint + type check (`rs lint --type-check`) | +| `pnpm fmt` | Format the repo (`rs fmt`) | +| `pnpm test:unit` | Unit tests | +| `pnpm test:e2e` | Full E2E chain (installs fixtures, launches a real VS Code) | + +To try the extension: press F5 in VS Code at the repo root β€” the playground launch config starts a watch build, lets you pick a fixture project, and opens an Extension Development Host on it. Run `pnpm --filter rstack test:e2e:fixtures` once beforehand to install the fixture dependencies. + +## Submitting changes + +- The pre-commit hook formats and lints staged files (`rs staged`). +- CI runs build, lint, format check, unit tests and the E2E suites on Linux and Windows β€” please run `pnpm lint && pnpm test:unit` locally before pushing. +- Keep PRs focused; fill in the PR template. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ab7a8a --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# Rstack Editor + +Rstack Editor provides unified editor support for [Rstack](https://rstack.rs), the fast, unified JavaScript toolchain for developers and agents. It integrates [Rslint](https://github.com/web-infra-dev/rslint), [Rstest](https://github.com/web-infra-dev/rstest) and [rstack-cli](https://github.com/rstackjs/rstack-cli) into a single extension, so one install covers the whole toolchain. + +## Packages + +| Name | Description | +| --- | --- | +| [`packages/vscode`](./packages/vscode) | The VS Code extension (`rstack.rstack`) | + +## License + +Rstack Editor is licensed under the [MIT License](./LICENSE). diff --git a/package.json b/package.json new file mode 100644 index 0000000..0ad79dc --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "rstack-editor", + "private": true, + "type": "module", + "scripts": { + "build": "pnpm -r run build", + "fmt": "rs fmt", + "fmt:check": "rs fmt --check", + "lint": "rs lint --type-check", + "package": "pnpm -r run package", + "prepare": "rs setup", + "test": "pnpm -r run test", + "test:e2e": "pnpm -r run test:e2e", + "test:unit": "pnpm -r run test:unit" + }, + "devDependencies": { + "rstack": "^0.3.2" + }, + "packageManager": "pnpm@11.20.0", + "engines": { + "node": ">=22.12.0", + "pnpm": ">=11.0.0" + } +} diff --git a/packages/vscode/.vscodeignore b/packages/vscode/.vscodeignore new file mode 100644 index 0000000..4d27022 --- /dev/null +++ b/packages/vscode/.vscodeignore @@ -0,0 +1,6 @@ +** +!dist +!icon.png +!LICENSE +!README.md +!package.json diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md new file mode 100644 index 0000000..23c1b18 --- /dev/null +++ b/packages/vscode/AGENTS.md @@ -0,0 +1,39 @@ +# AGENTS.md β€” `rstack.rstack` VS Code extension + +One extension replacing the standalone `rstack.rslint` and `rstack.rstest` extensions: a thin shell (activation, detection, status bar, settings migration) hosting one stack per tool under `src/stacks/`. + +## The copies are intentional + +- `stacks/lint` and `stacks/test` are deliberate near-verbatim copies of the upstream extensions, kept close to upstream so changes can be synced by diffing. Do NOT deduplicate or refactor across the two stacks β€” the duplication is the point; consolidation is a later, explicit phase. +- The copies diverge from upstream in exactly five ways (the "adaptations" below). When syncing upstream, preserve them. A sixth divergence is either a bug or must be added to this list. + +## The five adaptations + +1. **Shell activation** β€” stacks never self-activate; `register()` returns fast and never blocks on starting a server/worker. +2. **Namespace** β€” everything user-visible is `rstack.*`. Legacy `rslint.*` / `rstest.*` names appear only in the migration mapping. Command IDs were renamed without aliases (breaking old keybindings was an accepted cost). +3. **Resolve-from-project** β€” no tool binaries or tool packages in the VSIX; everything resolves from the user's project so the editor runs the CLI's exact versions. Version floors surface as a status, never a crash. All cooperating lint pieces (binary, config loader, plugin host) must come from one resolution root. +4. **Status aggregation** β€” stacks own no UI chrome; they report to the shell's single status bar item, which always exists. +5. **Worker-cwd decoupling** (test) β€” a project's cwd is explicit, not derived from the config file path; for native configs behavior stays byte-identical to upstream. + +## Rules + +- One stack failing to register or crashing must never take another stack (or the shell) down. +- The shell always activates; per-folder config detection decides which stacks start, and re-runs on config/lockfile changes without a window reload. Enable-settings are coarse kill switches only. +- Deprecated `rslint.json` / `rslint.jsonc` are unsupported by decision, not omission β€” never make them detection signals. +- Never share a child process across stacks: the tools have incompatible cwd semantics (lint LSP anchors on spawn cwd; test worker pins to project root; `rs fmt` resolves config from spawn cwd with no upward walk). +- In Restricted Mode (workspace trust), only the status bar runs β€” no process spawns, no project code loaded. +- The activation exports exist only for the E2E suites; they are not a stable API and carry no compatibility guarantees. +- Watch-pattern globs must not contain nested brace groups β€” VS Code's glob parser silently fails on them (regression-tested). + +## Gotchas β€” decisions that look wrong but aren't + +- The lint Γ— `rstack.config.*` bridge was built and deliberately removed: a partial editor-side bridge gave wrong results, and a correct one needs upstream work first. `TODO(rstack-bridge)` markers carry the plan. Do not reintroduce a partial bridge. +- The test Γ— `rstack.config.*` bridge stays thin on purpose: it points the upstream machinery at rstack's shipped shim and lets the shim interpret the config inside the worker, same as the CLI. Never re-implement rstack config semantics in the extension. +- The fmt stack is a stub on purpose. The MVP will spawn `rs fmt --stdin-filepath` with cwd = the config directory (forced by rs fmt's cwd-only config resolution); the endgame is an upstream LSP, so do not add a warm-process middle tier or "fix" the stub into an error state. +- The VSIX is platform-targeted for exactly one reason: the test stack's AST collection loads a native parser binding. Do not add another native dependency β€” it multiplies the release matrix. + +## Testing + +- E2E suites ported from upstream keep upstream's assertion semantics; every intentional deviation is documented in a comment in the test itself. A failing ported test is a regression, not a test to adjust. +- E2E fixtures install published npm packages (not workspace links): the extension must work against what users actually install. Fixture `node_modules` are disposable and never committed. +- Prefer running the E2E slice that covers the change (`test:e2e:*` scripts; `RSTACK_LINT_E2E_SUITES=` filters lint suites) over the full chain. diff --git a/packages/vscode/CLAUDE.md b/packages/vscode/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/packages/vscode/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/packages/vscode/README.md b/packages/vscode/README.md new file mode 100644 index 0000000..4c356ef --- /dev/null +++ b/packages/vscode/README.md @@ -0,0 +1,114 @@ +# Rstack for VS Code + +One extension for the whole [Rstack](https://rstack.rs) toolchain: [Rslint](https://github.com/web-infra-dev/rslint) linting, [Rstest](https://github.com/web-infra-dev/rstest) testing, and [rstack-cli](https://github.com/rstackjs/rstack-cli) support (coming soon). It replaces the standalone `rstack.rslint` and `rstack.rstest` extensions. + +## Installation + +- **VS Code**: install from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=rstack.rstack) +- **Cursor / Trae / VSCodium**: install from the [Open VSX Registry](https://open-vsx.org/extension/rstack/rstack) + +The extension ships no tool binaries: `@rslint/core`, `@rstest/core` and `rstack` are resolved from **your project**, so the editor always runs the same versions as your CLI. + +## Features + +- **Linting (Rslint)** β€” diagnostics, quick fixes and auto-fix on save via Rslint's language server. +- **Testing (Rstest)** β€” a Test Explorer tree built from your test files: run or debug individual tests, suites or files; the tree stays in sync as files change; failed tests show up as editor diagnostics. +- **rstack-cli** β€” detected today, integration lands in upcoming releases, starting with formatting. +- **One status bar item** β€” a single `Rstack` entry shows which tools are active in the current workspace and why. + +## Detection + +The extension activates on startup, then decides **per workspace folder** which tools to start: + +| Tool | Started when the folder contains | +| --- | --- | +| Rslint | `rslint.config.{js,mjs,ts,mts}` | +| Rstest | `rstest.config.{mjs,ts,js,cjs,mts,cts}` (configurable) or `rstack.config.*` | +| rstack-cli | `rstack.config.*` or `node_modules/.bin/rs` | + +Config files and lockfiles are watched, so detection re-runs without a window reload. Deprecated `rslint.json` / `rslint.jsonc` configs are **not** detection signals β€” migrate them with `rslint --init`. + +## Supported package versions + +The project-resolved packages are checked against a support matrix at runtime; a mismatch shows up as the `version mismatch` status bar state. + +| Package | Required | +| -------------- | --------- | +| `@rslint/core` | `>=0.7.2` | +| `@rstest/core` | `>=0.6.0` | +| `rstack` | `>=0.3.2` | + +## Auto-fix on save (Rslint) + +To automatically fix lint issues when saving, add this to your VS Code settings (`.vscode/settings.json`): + +```json +{ + "editor.codeActionsOnSave": { + "source.fixAll.rslint": "explicit" + } +} +``` + +- `"explicit"` β€” fix on manual save only (Ctrl+S / Cmd+S) β€” **recommended** +- `"always"` β€” fix on every save, including auto-save +- `"never"` β€” disable auto-fix on save + +The generic `source.fixAll` kind is honored too. + +## Error Lens compatibility + +If you use the Error Lens extension and want to avoid duplicated inline messages for failed tests, add this to your `settings.json`: + +```json +{ + "errorLens.excludeBySource": ["rstest"] +} +``` + +## Settings + +All settings live under the unified `rstack.*` namespace. There are no `rslint.*` / `rstest.*` settings any more. + +| Setting | Default | Description | +| --- | --- | --- | +| `rstack.enable` | `true` | Master switch for the whole extension. | +| `rstack.rslint.enable` | `true` | Enable/disable the Rslint integration. | +| `rstack.rslint.binPath` | `local` | `local` (project `node_modules`, incl. Yarn PnP) or `custom`. | +| `rstack.rslint.customBinPath` | β€” | Binary path used when `binPath` is `custom`. | +| `rstack.rslint.trace.server` | `off` | LSP trace level (`off` / `messages` / `verbose`). | +| `rstack.rstest.enable` | `true` | Enable/disable the Rstest integration. | +| `rstack.rstest.configFileGlobPattern` | `["**/rstest.config.{mjs,ts,js,cjs,mts,cts}"]` | Glob patterns used to discover config files. | +| `rstack.rstest.testCaseCollectMethod` | `ast` | `ast` (fast) or `runtime` (supports dynamic test generation). | +| `rstack.rstest.applyDiagnostic` | `true` | Show diagnostics in the editor and Problems panel for failures. | +| `rstack.rstest.rstestPackagePath` | β€” | Explicit `@rstest/core` `package.json`, last-resort override. | +| `rstack.rstest.nodeExecutable` | β€” | Node binary used for the test worker. | +| `rstack.rstest.nodeExecArgs` | `[]` | Extra Node args for the test worker. | +| `rstack.rstest.nodeEnv` | `null` | Extra env for the test worker. | +| `rstack.rstest.debugNodeEnv` | `null` | Extra env when debugging tests. | +| `rstack.rstest.debugExclude` | `["/**"]` | Debug `skipFiles`. | +| `rstack.rstest.debugOutFiles` | `[]` | Debug `outFiles`. | +| `rstack.rstest.debuggerPort` | β€” | Debugger port. | +| `rstack.rstest.debuggerAddress` | β€” | Debugger address. | +| `rstack.rstest.terminalShellPath` | β€” | Shell used by **Run in Terminal**. | +| `rstack.rstest.terminalShellArgs` | `[]` | Shell args for **Run in Terminal**. | +| `rstack.fmt.enable` | `true` | Enable/disable the formatter integration (upcoming). | +| `rstack.fmt.suggestDefaultFormatter` | `true` | Offer to set `editor.defaultFormatter` once rstack-cli is detected. | + +## Migrating from the standalone extensions + +Run **Rstack: Migrate Rslint/Rstest Settings** from the Command Palette (it is also offered once, dismissibly, when legacy keys are found). + +- Settings are migrated per layer (User, Workspace, Workspace Folder), and the legacy keys are removed after they are copied. Workspace and folder layers touch files inside your repository, so nothing is written before you confirm the previewed key mapping. +- `rslint.binPath: "built-in"` becomes `rstack.rslint.binPath: "local"`: this extension ships no binary and always resolves it from your project. +- **Keybindings are not migrated.** Command ids were renamed to `rstack.*` with no aliases, and VS Code has no keybindings API, so any keybinding bound to an old `rslint.*` / `rstest.*` command id has to be re-bound by hand. +- Projects with only `rslint.json` / `rslint.jsonc` are reported as `not detected`; run `rslint --init` to migrate to a JS/TS config. + +## Community + +- [GitHub](https://github.com/rstackjs/rstack-editor) β€” report bugs and request features +- [Discord](https://discord.gg/uPSudkun2b) β€” chat with the team and community + +## License + +[MIT](https://github.com/rstackjs/rstack-editor/blob/main/LICENSE) diff --git a/packages/vscode/icon.png b/packages/vscode/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..44582d731b040edb37ae9ce6c4defc42b11223d1 GIT binary patch literal 113325 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 literal 0 HcmV?d00001 diff --git a/packages/vscode/package.json b/packages/vscode/package.json new file mode 100644 index 0000000..380ae84 --- /dev/null +++ b/packages/vscode/package.json @@ -0,0 +1,467 @@ +{ + "name": "rstack", + "displayName": "Rstack", + "version": "0.1.0", + "private": true, + "description": "Editor support for the Rstack toolchain: Rslint, Rstest and rs fmt", + "categories": [ + "Linters", + "Testing", + "Formatters" + ], + "keywords": [ + "rstack", + "rslint", + "rstest", + "rsfmt", + "typescript" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/rstackjs/rstack-editor.git" + }, + "license": "MIT", + "publisher": "rstack", + "main": "./dist/extension.js", + "scripts": { + "build": "rslib build", + "build:local": "cross-env SOURCEMAP=true rslib build", + "package": "pnpm run build && vsce package", + "package:targets": "node scripts/packageTargets.mjs", + "test": "pnpm run test:unit && pnpm run test:e2e", + "test:e2e": "pnpm run test:e2e:fixtures && pnpm run test:e2e:smoke && pnpm run test:e2e:vscode && pnpm run test:e2e:rstest:run && pnpm run test:e2e:lint:run", + "test:e2e:fixtures": "node ./tests/e2e/setupFixtures.mjs", + "test:e2e:lint": "pnpm run test:e2e:fixtures lint && pnpm run test:e2e:lint:run", + "test:e2e:lint:run": "tsc -p tsconfig.e2e.json && node ./tests-dist/tests/e2e/lint/runTest.js", + "test:e2e:rstest": "pnpm run test:e2e:fixtures rstest-workspace-1 rstest-workspace-2 && pnpm run test:e2e:rstest:run", + "test:e2e:rstest:run": "tsc -p tsconfig.e2e.json && node ./tests-dist/tests/e2e/rstest/runTest.js", + "test:e2e:smoke": "node ./tests/e2e/smoke/rslintPluginHost.mjs", + "test:e2e:vscode": "tsc -p tsconfig.e2e.json && node ./tests-dist/tests/e2e/runTest.js", + "test:unit": "rstest", + "watch": "rslib build --watch", + "watch:local": "cross-env SOURCEMAP=true rslib build --watch" + }, + "contributes": { + "commands": [ + { + "command": "rstack.showMenu", + "title": "Show Menu", + "category": "Rstack" + }, + { + "command": "rstack.showOutput", + "title": "Show Extension Log", + "category": "Rstack" + }, + { + "command": "rstack.migrateSettings", + "title": "Migrate Rslint/Rstest Settings", + "category": "Rstack" + }, + { + "command": "rstack.rslint.output.focus", + "title": "Show Rslint Log", + "category": "Rstack" + }, + { + "command": "rstack.rslint.restart", + "title": "Restart Rslint Language Server", + "category": "Rstack", + "icon": "$(refresh)" + }, + { + "command": "rstack.rstest.output.focus", + "title": "Show Rstest Log", + "category": "Rstack" + }, + { + "command": "rstack.rstest.updateSnapshot", + "title": "Update Snapshot", + "category": "Testing", + "icon": "$(merge)" + }, + { + "command": "rstack.rstest.revealInTestExplorer", + "title": "Reveal in Test Explorer", + "category": "Rstack" + }, + { + "command": "rstack.rstest.copyErrorOutput", + "title": "Copy Error Output", + "category": "Rstack", + "icon": "$(copy)" + }, + { + "command": "rstack.rstest.copyTestItemErrors", + "title": "Copy Test Errors", + "category": "Rstack", + "icon": "$(copy)" + }, + { + "command": "rstack.rstest.runInTerminal", + "title": "Run in Terminal", + "category": "Rstack", + "icon": "$(terminal)" + }, + { + "command": "rstack.fmt.output.focus", + "title": "Show rs fmt Log", + "category": "Rstack" + } + ], + "configuration": [ + { + "type": "object", + "title": "Rstack", + "properties": { + "rstack.enable": { + "order": 0, + "type": "boolean", + "default": true, + "scope": "window", + "markdownDescription": "Master switch for the Rstack extension. When disabled, no stack is registered β€” the status bar item stays visible so the extension can still be told apart from a broken install." + } + } + }, + { + "type": "object", + "title": "Rstack β€Ί Rslint", + "properties": { + "rstack.rslint.enable": { + "order": 0, + "type": "boolean", + "default": true, + "scope": "window", + "markdownDescription": "Enable the Rslint language server for detected workspace folders. Requires `#rstack.enable#`. This is a window-level kill switch; which folders run Rslint is decided by detection." + }, + "rstack.rslint.binPath": { + "order": 1, + "type": "string", + "enum": [ + "local", + "custom" + ], + "default": "local", + "scope": "resource", + "markdownEnumDescriptions": [ + "Resolve the Rslint binary from the workspace `node_modules` (Yarn PnP is supported). This extension ships no binary.", + "Use the binary at `#rstack.rslint.customBinPath#`, e.g. a global installation or a specific version of Rslint." + ], + "description": "How to locate the Rslint executable binary" + }, + "rstack.rslint.customBinPath": { + "order": 2, + "type": "string", + "scope": "resource", + "markdownDescription": "Custom path to the Rslint executable. Only used when `#rstack.rslint.binPath#` is set to `custom`. Requires reloading VS Code to take effect." + }, + "rstack.rslint.trace.server": { + "order": 3, + "type": "string", + "enum": [ + "off", + "messages", + "verbose" + ], + "default": "off", + "scope": "resource", + "description": "Traces the communication between VS Code and the Rslint language server" + } + } + }, + { + "type": "object", + "title": "Rstack β€Ί Rstest", + "properties": { + "rstack.rstest.enable": { + "order": 0, + "type": "boolean", + "default": true, + "scope": "window", + "markdownDescription": "Enable the Rstest test explorer for detected workspace folders. Requires `#rstack.enable#`. This is a window-level kill switch; which folders run Rstest is decided by detection." + }, + "rstack.rstest.rstestPackagePath": { + "order": 1, + "type": "string", + "scope": "resource", + "markdownDescription": "The path to a `package.json` file of an Rstest installation (usually inside `node_modules`) in case the extension cannot find it. It will be used to resolve Rstest API paths. This should be used as a last resort fix. Supports the `${workspaceFolder}` placeholder." + }, + "rstack.rstest.configFileGlobPattern": { + "order": 2, + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "**/rstest.config.{mjs,ts,js,cjs,mts,cts}" + ], + "description": "Glob patterns used to discover Rstest config files. Must be an array of strings." + }, + "rstack.rstest.testCaseCollectMethod": { + "order": 3, + "type": "string", + "default": "ast", + "enum": [ + "ast", + "runtime" + ], + "enumItemLabels": [ + "Static AST Analyze", + "Run Test File" + ], + "enumDescriptions": [ + "Fast, only supports basic test cases.", + "Slow, supports all test cases, including dynamic test generation methods (each/for/extend)." + ] + }, + "rstack.rstest.applyDiagnostic": { + "order": 4, + "type": "boolean", + "default": true, + "description": "Show diagnostics in the editor and Problems panel for failed tests." + }, + "rstack.rstest.nodeExecutable": { + "order": 5, + "type": "string", + "scope": "resource", + "markdownDescription": "Overrides the `node` binary used to spawn the Rstest test worker process. Provide an absolute path to a Node.js executable (for example, a version-manager or custom build). When empty, the `node` binary on `PATH` is used. Supports the `${workspaceFolder}` placeholder." + }, + "rstack.rstest.nodeExecArgs": { + "order": 6, + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "scope": "resource", + "description": "Extra arguments passed to the Node executable when spawning the test worker (e.g. --experimental-vm-modules). Must be an array of strings." + }, + "rstack.rstest.nodeEnv": { + "order": 7, + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "string" + }, + "default": null, + "scope": "resource", + "markdownDescription": "Environment variables passed to the test worker process, in addition to `process.env`." + }, + "rstack.rstest.debugNodeEnv": { + "order": 8, + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "string" + }, + "default": null, + "scope": "resource", + "markdownDescription": "Environment variables passed to the test worker process when debugging, in addition to `process.env` and `#rstack.rstest.nodeEnv#`." + }, + "rstack.rstest.debugExclude": { + "order": 9, + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "/**" + ], + "scope": "resource", + "markdownDescription": "Glob patterns for files to skip while debugging tests (maps to the debug session's `skipFiles`)." + }, + "rstack.rstest.debugOutFiles": { + "order": 10, + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "scope": "resource", + "markdownDescription": "When source maps are enabled, glob patterns locating the generated JavaScript files (maps to the debug session's `outFiles`)." + }, + "rstack.rstest.debuggerPort": { + "order": 11, + "type": "number", + "scope": "resource", + "description": "Port the debugger attaches to. Defaults to Node's inspector behavior (9229, or a free port if taken)." + }, + "rstack.rstest.debuggerAddress": { + "order": 12, + "type": "string", + "scope": "resource", + "description": "TCP/IP address the debugger attaches to. Defaults to localhost." + }, + "rstack.rstest.terminalShellPath": { + "order": 13, + "type": "string", + "markdownDescription": "Path to the shell used by the **Run in Terminal** command. Leave empty to use your default integrated terminal so its profile and environment are honored. Note: command arguments are quoted for POSIX shells; on Windows point this at a POSIX-style shell (e.g. Git Bash) if a test name needs quoting." + }, + "rstack.rstest.terminalShellArgs": { + "order": 14, + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "markdownDescription": "Arguments passed to the shell used by the **Run in Terminal** command." + } + } + }, + { + "type": "object", + "title": "Rstack β€Ί Formatter", + "properties": { + "rstack.fmt.enable": { + "order": 0, + "type": "boolean", + "default": true, + "scope": "window", + "markdownDescription": "Enable the `rs fmt` document formatter for detected workspace folders. Requires `#rstack.enable#`. This is a window-level kill switch; which folders are formatted is decided by detection. Phase 2 β€” the formatter is not registered yet." + }, + "rstack.fmt.suggestDefaultFormatter": { + "order": 1, + "type": "boolean", + "default": true, + "scope": "resource", + "markdownDescription": "Offer a one-time prompt to set Rstack as the workspace `editor.defaultFormatter` when `rs fmt` is detected. The extension never writes `editor.defaultFormatter` without confirmation." + } + } + } + ], + "menus": { + "commandPalette": [ + { + "command": "rstack.rslint.output.focus", + "when": "rstack.rslint.active" + }, + { + "command": "rstack.rslint.restart", + "when": "rstack.rslint.active" + }, + { + "command": "rstack.rstest.output.focus", + "when": "rstack.rstest.active" + }, + { + "command": "rstack.fmt.output.focus", + "when": "rstack.fmt.active" + }, + { + "command": "rstack.rstest.updateSnapshot", + "when": "false" + }, + { + "command": "rstack.rstest.revealInTestExplorer", + "when": "false" + }, + { + "command": "rstack.rstest.copyErrorOutput", + "when": "false" + }, + { + "command": "rstack.rstest.copyTestItemErrors", + "when": "false" + }, + { + "command": "rstack.rstest.runInTerminal", + "when": "false" + } + ], + "editor/title/context": [ + { + "command": "rstack.rstest.revealInTestExplorer", + "when": "resourcePath in rstack.rstest.testFiles" + } + ], + "testing/message/content": [ + { + "command": "rstack.rstest.updateSnapshot", + "when": "testMessage == canUpdateSnapshot" + }, + { + "command": "rstack.rstest.copyErrorOutput", + "when": "resourcePath in rstack.rstest.testFiles" + } + ], + "testing/message/context": [ + { + "command": "rstack.rstest.updateSnapshot", + "group": "inline@1", + "when": "testMessage == canUpdateSnapshot" + }, + { + "command": "rstack.rstest.copyErrorOutput", + "group": "inline@2", + "when": "resourcePath in rstack.rstest.testFiles" + } + ], + "testing/item/context": [ + { + "command": "rstack.rstest.runInTerminal", + "group": "rstack@1", + "when": "controllerId == 'rstack.rstest'" + }, + { + "command": "rstack.rstest.copyTestItemErrors", + "when": "controllerId == 'rstack.rstest'" + } + ], + "testing/item/gutter": [ + { + "command": "rstack.rstest.runInTerminal", + "when": "controllerId == 'rstack.rstest'" + }, + { + "command": "rstack.rstest.copyTestItemErrors", + "when": "controllerId == 'rstack.rstest'" + } + ] + } + }, + "activationEvents": [ + "onStartupFinished" + ], + "devDependencies": { + "@rsbuild/core": "~2.1.9", + "@rslib/core": "^1.0.0-beta.1", + "@rslint/core": "^0.7.2", + "@rstackjs/load-config": "^0.1.2", + "@rstest/core": "^0.11.5", + "@types/istanbul-lib-report": "^3.0.3", + "@types/mocha": "^10.0.10", + "@types/node": "^22.16.5", + "@types/picomatch": "^4.0.3", + "@types/semver": "^7.7.1", + "@types/vscode": "1.97.0", + "@vscode/test-electron": "^3.1.0", + "@vscode/vsce": "^3.9.2", + "birpc": "^4.0.0", + "core-js-pure": "^3.49.0", + "cross-env": "^7.0.3", + "mocha": "^11.7.6", + "ovsx": "^1.0.2", + "picomatch": "^4.0.5", + "semver": "^7.8.5", + "stacktrace-parser": "^0.1.11", + "tinyglobby": "^0.2.17", + "typescript": "^5.9.3", + "valibot": "^1.4.2", + "vscode-languageclient": "^9.0.1", + "yuku-parser": "^0.8.3" + }, + "engines": { + "vscode": "^1.97.0" + }, + "icon": "icon.png", + "capabilities": { + "untrustedWorkspaces": { + "supported": "limited", + "description": "Rstack spawns project-local executables (the Rslint language server, the Rstest worker, `rs fmt`) and loads project configuration files. In Restricted Mode only the status bar item is shown; no process is spawned and no project code is loaded." + } + } +} diff --git a/packages/vscode/rslib.config.mts b/packages/vscode/rslib.config.mts new file mode 100644 index 0000000..015f410 --- /dev/null +++ b/packages/vscode/rslib.config.mts @@ -0,0 +1,116 @@ +import { existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, type LibConfig, rspack } from '@rslib/core'; + +const require = createRequire(import.meta.url); +const rootDir = path.dirname(fileURLToPath(import.meta.url)); + +// The VSIX is platform-targeted for exactly one reason: Rstest's AST test-case +// collection loads the `yuku-parser` napi binding. Everything else in this +// extension is platform neutral (the Rslint Go binary and its napi parser are +// resolved from the project at runtime and never ship in the VSIX). +const vsceTarget = + process.env.VSCE_TARGET ?? `${process.platform}-${process.arch}`; +// The Linux VSIX targets use glibc, whose Yuku bindings have a `-gnu` suffix. +const yukuBindingSuffix = vsceTarget.startsWith('linux-') + ? `${vsceTarget}-gnu` + : vsceTarget; +const yukuRequire = createRequire(require.resolve('yuku-parser')); +// Yuku computes this package name at runtime, so Rspack cannot discover it. +const yukuBindingPath = yukuRequire.resolve( + `@yuku-parser/binding-${yukuBindingSuffix}`, +); + +/** + * Packages that are always resolved from the user's project at runtime, never + * bundled: the extension ships types only. + */ +const RUNTIME_RESOLVED_PACKAGES = /^(@rslint\/core|@rstest\/core|jiti)(\/|$)/; + +const externals: NonNullable['externals']> = [ + { vscode: 'commonjs vscode' }, + ({ request }, callback) => { + if (request && RUNTIME_RESOLVED_PACKAGES.test(request)) { + return callback(undefined, `commonjs ${request}`); + } + return callback(); + }, +]; + +const sourceMap = process.env.SOURCEMAP === 'true'; + +// The Rstest worker entry is owned by the Rstest stack and may not exist yet +// while the stacks are still being copied in. +const workerEntry = './src/stacks/test/worker/index.ts'; +const hasWorkerEntry = existsSync(path.join(rootDir, workerEntry)); + +const libs: LibConfig[] = [ + { + syntax: 'es2023', + format: 'cjs', + source: { + entry: { + extension: './src/extension.ts', + }, + }, + output: { + target: 'node', + externals, + sourceMap, + }, + tools: { + rspack: { + output: { + devtoolModuleFilenameTemplate: '[absolute-resource-path]', + }, + plugins: [ + new rspack.CopyRspackPlugin({ + patterns: [ + { + from: yukuBindingPath, + to: `@yuku-parser/binding-${yukuBindingSuffix}/yuku-parser.node`, + }, + { + // The VSIX must carry a LICENSE (`.vscodeignore` keeps it), and + // the workspace root LICENSE is the single source of truth. The + // copy lands next to package.json β€” not in dist/ β€” because vsce + // packages from the package directory; it is gitignored. + from: path.join(rootDir, '..', '..', 'LICENSE'), + to: path.join(rootDir, 'LICENSE'), + toType: 'file', + }, + ], + }), + ], + }, + }, + }, +]; + +if (hasWorkerEntry) { + libs.push({ + syntax: 'es2023', + format: 'cjs', + source: { + entry: { + worker: workerEntry, + }, + }, + output: { + target: 'node', + externals, + sourceMap, + }, + tools: { + rspack: { + output: { + devtoolModuleFilenameTemplate: '[absolute-resource-path]', + }, + }, + }, + }); +} + +export default defineConfig({ lib: libs }); diff --git a/packages/vscode/rstest.config.mts b/packages/vscode/rstest.config.mts new file mode 100644 index 0000000..bb76e16 --- /dev/null +++ b/packages/vscode/rstest.config.mts @@ -0,0 +1,14 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + include: ['tests/unit/**/*.test.ts', 'src/**/*.test.ts'], + // The E2E suite runs in a VS Code extension host, not in Rstest. + exclude: ['**/tests/e2e/**'], + globals: true, + name: 'rstack-editor', + output: { + externals: { + vscode: 'commonjs vscode', + }, + }, +}); diff --git a/packages/vscode/scripts/packageTargets.mjs b/packages/vscode/scripts/packageTargets.mjs new file mode 100644 index 0000000..39512a7 --- /dev/null +++ b/packages/vscode/scripts/packageTargets.mjs @@ -0,0 +1,64 @@ +// Builds and packages one platform-targeted VSIX per supported target. +// +// The extension is platform targeted for a single reason: Rstest's AST test +// collection loads the `yuku-parser` napi binding, which `rslib.config.mts` +// stages into `dist/` based on `VSCE_TARGET`. +// +// Packaging a target other than the host platform requires the matching +// optional `@yuku-parser/binding-*` package to be installed locally, e.g.: +// +// pnpm config set --location=project --json supportedArchitectures \ +// '{"cpu":["current","arm64"]}' && pnpm install +// +// CI does exactly that, one runner per target. +import { spawnSync } from 'node:child_process'; +import process from 'node:process'; + +const TARGETS = [ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64', + 'linux-x64', + 'win32-arm64', + 'win32-x64', +]; + +const args = process.argv.slice(2); +const requested = args.filter((arg) => !arg.startsWith('-')); +const all = args.includes('--all'); +const hostTarget = `${process.platform}-${process.arch}`; + +const targets = all ? TARGETS : requested.length > 0 ? requested : [hostTarget]; + +for (const target of targets) { + if (!TARGETS.includes(target)) { + console.error( + `Unknown target "${target}". Supported targets: ${TARGETS.join(', ')}`, + ); + process.exit(1); + } +} + +const run = (command, commandArgs, target) => { + const result = spawnSync(command, commandArgs, { + stdio: 'inherit', + shell: process.platform === 'win32', + env: { ...process.env, VSCE_TARGET: target }, + }); + if (result.status !== 0) { + console.error( + `\n"${command} ${commandArgs.join(' ')}" failed for ${target}`, + ); + process.exit(result.status ?? 1); + } +}; + +for (const target of targets) { + console.log(`\n=== packaging ${target} ===`); + run('rslib', ['build'], target); + run( + 'vsce', + ['package', '--target', target, '-o', `rstack-${target}.vsix`], + target, + ); +} diff --git a/packages/vscode/src/channels.ts b/packages/vscode/src/channels.ts new file mode 100644 index 0000000..c435afd --- /dev/null +++ b/packages/vscode/src/channels.ts @@ -0,0 +1,45 @@ +import vscode from 'vscode'; +import { type StackId, STACK_IDS } from './types'; + +const CHANNEL_NAMES: Readonly> = { + shell: 'Rstack', + rslint: 'Rstack: Rslint', + rstest: 'Rstack: Rstest', + fmt: 'Rstack: rs fmt', +}; + +/** + * The extension's four output channels β€” a deliberate cap: one per stack plus + * one for the shell (detection results, state transitions, migration logs). + * + * Stacks never create their own channel β€” a copied stack that used to create + * one per workspace folder has to log into the shared channel instead. + */ +export class Channels implements vscode.Disposable { + readonly shell: vscode.LogOutputChannel; + + readonly #stacks: Record; + + constructor() { + this.shell = vscode.window.createOutputChannel(CHANNEL_NAMES.shell, { + log: true, + }); + this.#stacks = Object.fromEntries( + STACK_IDS.map((stack) => [ + stack, + vscode.window.createOutputChannel(CHANNEL_NAMES[stack], { log: true }), + ]), + ) as Record; + } + + forStack(stack: StackId): vscode.LogOutputChannel { + return this.#stacks[stack]; + } + + dispose(): void { + this.shell.dispose(); + for (const stack of STACK_IDS) { + this.#stacks[stack].dispose(); + } + } +} diff --git a/packages/vscode/src/detection.test.ts b/packages/vscode/src/detection.test.ts new file mode 100644 index 0000000..8469048 --- /dev/null +++ b/packages/vscode/src/detection.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it, rs } from '@rstest/core'; + +// `detection.ts` imports the `vscode` namespace for the watcher/`findFiles` +// paths. `detectionWatchPatterns` is pure, but the module still has to load, so +// the namespace is stubbed away: unit tests run in plain Node, with no +// extension host (unit tests are Rstest, E2E is Electron). +rs.mock('vscode', () => { + const vscode = {}; + return { ...vscode, default: vscode }; +}); + +import { + DEFAULT_RSTEST_CONFIG_GLOBS, + DETECTION_WATCH_NAMES, + detectionWatchPatterns, +} from './detection'; + +/** + * VS Code's glob engine has no nested brace groups. `splitGlobAware` + * (`src/vs/base/common/glob.ts`) tracks `inBraces` as a **boolean**, so a `{` + * inside an already open group does not nest: the first `}` closes the group + * and the pattern is split on the next separator, mid-group. `parseRegExp` then + * compiles the fragments into a regex that matches nothing at all. + * + * Concatenating the fixed name list with the Rstest globs into one pattern + * produced exactly that shape and silently disabled the whole re-detection + * watcher, so the invariant is asserted here rather than left to review. + */ +const hasNestedBraces = (pattern: string): boolean => { + let depth = 0; + for (const char of pattern) { + if (char === '{') { + depth += 1; + if (depth > 1) { + return true; + } + } else if (char === '}') { + depth = Math.max(0, depth - 1); + } + } + return false; +}; + +/** Expands the one brace level VS Code's glob engine actually supports. */ +const expandBraces = (pattern: string): string[] => { + const match = /\{([^{}]*)\}/.exec(pattern); + if (!match) { + return [pattern]; + } + const [group, body] = match; + return body + .split(',') + .map( + (alternative) => + pattern.slice(0, match.index) + + alternative + + pattern.slice(match.index + group.length), + ); +}; + +const globMatches = (pattern: string, relativePath: string): boolean => + expandBraces(pattern).some((expanded) => { + const source = expanded + .split('**/') + .map((segment) => + segment.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*'), + ) + .join('(?:.*/)?'); + return new RegExp(`^${source}$`).test(relativePath); + }); + +/** Every file kind the shell's detection watcher must react to. */ +const WATCHED_CANDIDATES = [ + 'rslint.config.ts', + 'rslint.config.mjs', + 'packages/a/rslint.config.js', + 'rstack.config.ts', + 'packages/a/rstack.config.mts', + 'pnpm-lock.yaml', + 'package-lock.json', + 'yarn.lock', + 'packages/a/pnpm-lock.yaml', + 'rstest.config.mts', + 'sub/rstest.config.mjs', +]; + +describe('hasNestedBraces', () => { + it('flags the single-concatenated-glob shape', () => { + expect( + hasNestedBraces( + '{**/{rslint.config.ts,yarn.lock},**/rstest.config.{mjs,ts}}', + ), + ).toBe(true); + }); + + it('accepts a single-level group', () => { + expect(hasNestedBraces('**/{rslint.config.ts,yarn.lock}')).toBe(false); + }); +}); + +describe('detectionWatchPatterns', () => { + it('emits no pattern with a nested brace group', () => { + for (const rstestGlobs of [ + [...DEFAULT_RSTEST_CONFIG_GLOBS], + ['**/rstest.config.{mjs,ts,js,cjs,mts,cts}', 'apps/*/rstest.config.ts'], + [], + ]) { + for (const pattern of detectionWatchPatterns(rstestGlobs)) { + expect(hasNestedBraces(pattern)).toBe(false); + } + } + }); + + it('keeps the fixed name list and the Rstest globs as separate patterns', () => { + const patterns = detectionWatchPatterns([ + '**/rstest.config.{mjs,ts}', + 'apps/*/rstest.config.ts', + ]); + expect(patterns).toEqual([ + `**/{${DETECTION_WATCH_NAMES.join(',')}}`, + '**/rstest.config.{mjs,ts}', + 'apps/*/rstest.config.ts', + ]); + }); + + it('covers the Rslint config names, `rstack.config.*` and the lockfiles', () => { + const fixed = detectionWatchPatterns([])[0]!; + for (const name of DETECTION_WATCH_NAMES) { + expect(fixed).toContain(name); + } + }); + + it('de-duplicates repeated Rstest globs', () => { + const patterns = detectionWatchPatterns([ + '**/rstest.config.ts', + '**/rstest.config.ts', + ]); + expect(patterns).toHaveLength(2); + }); + + it('matches every detection-relevant file through at least one pattern', () => { + const patterns = detectionWatchPatterns([...DEFAULT_RSTEST_CONFIG_GLOBS]); + for (const candidate of WATCHED_CANDIDATES) { + expect(patterns.some((pattern) => globMatches(pattern, candidate))).toBe( + true, + ); + } + }); + + it('does not match unrelated files', () => { + const patterns = detectionWatchPatterns([...DEFAULT_RSTEST_CONFIG_GLOBS]); + for (const candidate of [ + 'src/index.ts', + 'rslint.json', + 'rstack.config.json', + ]) { + expect(patterns.some((pattern) => globMatches(pattern, candidate))).toBe( + false, + ); + } + }); +}); diff --git a/packages/vscode/src/detection.ts b/packages/vscode/src/detection.ts new file mode 100644 index 0000000..eabe0e7 --- /dev/null +++ b/packages/vscode/src/detection.ts @@ -0,0 +1,366 @@ +import vscode from 'vscode'; +import { + type DetectionSnapshot, + type FolderDetection, + type StackDetection, + type StackId, + STACK_IDS, +} from './types'; + +/** + * `rstack.config.*` is a config source for Rstest and rs fmt: + * an rstack-cli user may only have `rstack.config.ts` with `define.test()` / + * `define.fmt()`. + * + * TODO(rstack-bridge): Rslint is deliberately NOT lit by `rstack.config.*` for + * now β€” the earlier lint bridge had no complete final data path and was + * removed. Rebuilding it needs upstream work: rstack publishing an + * explicit-path config loader plus adapter exports, rslint accepting per-root + * fallback config candidates on `rslint/configRefresh`, and a generic + * evaluator-module seam shared by the config host and plugin workers. + */ +export const RSTACK_CONFIG_NAMES = [ + 'rstack.config.ts', + 'rstack.config.js', + 'rstack.config.mts', + 'rstack.config.mjs', +] as const; + +export const RSTACK_CONFIG_GLOB = '**/rstack.config.{ts,js,mts,mjs}'; + +/** + * JSON configs (`rslint.json` / `rslint.jsonc`) are deliberately not detection + * signals β€” upstream deprecated them and ships `rslint --init` to migrate. + */ +export const RSLINT_CONFIG_GLOB = '**/rslint.config.{js,mjs,ts,mts}'; + +export const DEFAULT_RSTEST_CONFIG_GLOBS = [ + '**/rstest.config.{mjs,ts,js,cjs,mts,cts}', +] as const; + +/** + * Lockfiles are watched as a proxy for dependency changes β€” the pattern Rslint + * already uses. Watching `node_modules` directly is unreliable (pnpm symlinks) + * and is not attempted. + */ +export const LOCKFILE_NAMES = [ + 'package-lock.json', + 'pnpm-lock.yaml', + 'yarn.lock', +] as const; + +/** Both bins of the `rstack` package point at the same launcher. */ +export const FMT_BIN_NAMES = ['rs', 'rstack'] as const; + +const NODE_MODULES_EXCLUDE = '**/node_modules/**'; +const MAX_CONFIG_FILES = 100; +const REDETECT_DEBOUNCE_MS = 300; + +class Snapshot implements DetectionSnapshot { + constructor(readonly folders: readonly FolderDetection[]) {} + + isDetected(stack: StackId): boolean { + return this.folders.some((folder) => folder.stacks[stack].detected); + } + + foldersFor(stack: StackId): readonly FolderDetection[] { + return this.folders.filter((folder) => folder.stacks[stack].detected); + } + + forFolder(folder: vscode.WorkspaceFolder): FolderDetection | undefined { + const key = folder.uri.toString(); + return this.folders.find((entry) => entry.folder.uri.toString() === key); + } +} + +export const emptySnapshot = (): DetectionSnapshot => new Snapshot([]); + +/** + * The fixed part of the watch table: the config names of the Rslint and Rstack + * rows plus the lockfiles. A lockfile change can flip the `rs fmt` bin probe + * and the Rslint/Rstest package resolution, so it counts as a detection input. + */ +export const DETECTION_WATCH_NAMES = [ + 'rslint.config.js', + 'rslint.config.mjs', + 'rslint.config.ts', + 'rslint.config.mts', + ...RSTACK_CONFIG_NAMES, + ...LOCKFILE_NAMES, +] as const; + +/** + * The patterns every detection-relevant file change is watched through. + * + * They stay separate patterns β€” one `FileSystemWatcher` each β€” instead of being + * concatenated into a single glob. VS Code's glob engine does not support + * nested brace groups: `splitGlobAware` (`src/vs/base/common/glob.ts`) tracks + * `inBraces` as a boolean, so the first `}` of an inner group closes the outer + * one and the pattern is split mid-group; the regex `parseRegExp` then builds + * matches nothing. The Rstest globs are user-configurable arbitrary globs and + * cannot be folded into the fixed name list, so the only correct shape is one + * pattern per source. + */ +export const detectionWatchPatterns = ( + rstestGlobs: readonly string[], +): string[] => [ + ...new Set([`**/{${DETECTION_WATCH_NAMES.join(',')}}`, ...rstestGlobs]), +]; + +const readRstestGlobs = (folder: vscode.WorkspaceFolder): readonly string[] => { + const configured = vscode.workspace + .getConfiguration('rstack.rstest', folder.uri) + .get('configFileGlobPattern'); + if ( + Array.isArray(configured) && + configured.length > 0 && + configured.every((entry) => typeof entry === 'string') + ) { + return configured as string[]; + } + return DEFAULT_RSTEST_CONFIG_GLOBS; +}; + +const findFiles = async ( + folder: vscode.WorkspaceFolder, + glob: string, +): Promise => + vscode.workspace.findFiles( + new vscode.RelativePattern(folder, glob), + NODE_MODULES_EXCLUDE, + MAX_CONFIG_FILES, + ); + +const fileExists = async (uri: vscode.Uri): Promise => { + try { + await vscode.workspace.fs.stat(uri); + return true; + } catch { + return false; + } +}; + +/** Probes `node_modules/.bin/{rs,rstack}` for a project-local rstack CLI. */ +const probeFmtBin = async ( + folder: vscode.WorkspaceFolder, +): Promise => { + for (const name of FMT_BIN_NAMES) { + const candidates = + process.platform === 'win32' ? [`${name}.cmd`, name] : [name]; + for (const candidate of candidates) { + const uri = vscode.Uri.joinPath( + folder.uri, + 'node_modules', + '.bin', + candidate, + ); + if (await fileExists(uri)) { + return uri.fsPath; + } + } + } + return undefined; +}; + +export const detectFolder = async ( + folder: vscode.WorkspaceFolder, +): Promise => { + const rstestGlobs = readRstestGlobs(folder); + const [rstackConfigFiles, rslintConfigFiles, binPath, rstestConfigFiles] = + await Promise.all([ + findFiles(folder, RSTACK_CONFIG_GLOB), + findFiles(folder, RSLINT_CONFIG_GLOB), + probeFmtBin(folder), + Promise.all(rstestGlobs.map((glob) => findFiles(folder, glob))).then( + (matches) => matches.flat(), + ), + ] as const); + + const stacks: Record = { + rslint: { + // TODO(rstack-bridge): `rstack.config.*` deliberately does not light + // Rslint (see the RSTACK_CONFIG_NAMES doc comment). + detected: rslintConfigFiles.length > 0, + configFiles: rslintConfigFiles, + rstackConfigFiles, + }, + rstest: { + detected: rstestConfigFiles.length > 0 || rstackConfigFiles.length > 0, + configFiles: rstestConfigFiles, + rstackConfigFiles, + }, + fmt: { + detected: rstackConfigFiles.length > 0 || binPath !== undefined, + configFiles: [], + rstackConfigFiles, + binPath, + }, + }; + + return { folder, stacks }; +}; + +const signatureOf = (snapshot: DetectionSnapshot): string => + snapshot.folders + .map((entry) => { + const stacks = STACK_IDS.map((stack) => { + const detection = entry.stacks[stack]; + const files = [...detection.configFiles, ...detection.rstackConfigFiles] + .map((uri) => uri.toString()) + .sort() + .join(','); + return `${stack}:${detection.detected ? 1 : 0}:${detection.binPath ?? ''}:${files}`; + }).join('|'); + return `${entry.folder.uri.toString()}=>${stacks}`; + }) + .sort() + .join('\n'); + +/** + * Runs detection over every workspace folder and keeps it fresh without a + * window reload: one `FileSystemWatcher` per folder and watch pattern covers + * the config globs plus the lockfiles. + */ +export class DetectionService implements vscode.Disposable { + #snapshot: DetectionSnapshot = emptySnapshot(); + #signature = ''; + #watchers: vscode.Disposable[] = []; + #debounce: ReturnType | undefined; + #running: Promise | undefined; + #rerun = false; + #disposed = false; + + readonly #emitter = new vscode.EventEmitter(); + readonly onDidChange = this.#emitter.event; + readonly #subscriptions: vscode.Disposable[] = []; + + constructor(private readonly output: vscode.LogOutputChannel) { + this.#subscriptions.push( + vscode.workspace.onDidChangeWorkspaceFolders(() => { + this.installWatchers(); + this.schedule(); + }), + vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration('rstack.rstest.configFileGlobPattern')) { + this.installWatchers(); + this.schedule(); + } + }), + ); + } + + get snapshot(): DetectionSnapshot { + return this.#snapshot; + } + + /** Installs the watchers and runs the first detection pass. */ + async initialize(): Promise { + this.installWatchers(); + return this.refresh(); + } + + async refresh(): Promise { + if (this.#running) { + // Coalesce concurrent refreshes: one extra pass covers every caller that + // arrived while the current pass was in flight. + this.#rerun = true; + return this.#running; + } + this.#running = this.runDetection(); + try { + return await this.#running; + } finally { + this.#running = undefined; + if (this.#rerun && !this.#disposed) { + this.#rerun = false; + void this.refresh(); + } + } + } + + private async runDetection(): Promise { + const folders = (vscode.workspace.workspaceFolders ?? []).filter( + // Virtual filesystems cannot host a project-local toolchain. + (folder) => folder.uri.scheme === 'file', + ); + const detections = await Promise.all(folders.map(detectFolder)); + const snapshot = new Snapshot(detections); + const signature = signatureOf(snapshot); + this.#snapshot = snapshot; + if (signature !== this.#signature) { + this.#signature = signature; + this.log(snapshot); + if (!this.#disposed) { + this.#emitter.fire(snapshot); + } + } + return snapshot; + } + + private log(snapshot: DetectionSnapshot): void { + if (snapshot.folders.length === 0) { + this.output.info('Detection: no file-scheme workspace folder is open'); + return; + } + for (const entry of snapshot.folders) { + const detected = STACK_IDS.filter( + (stack) => entry.stacks[stack].detected, + ); + this.output.info( + `Detection: ${entry.folder.name} -> ${ + detected.length > 0 ? detected.join(', ') : 'nothing detected' + }`, + ); + } + } + + private schedule(): void { + if (this.#debounce) { + clearTimeout(this.#debounce); + } + this.#debounce = setTimeout(() => { + this.#debounce = undefined; + void this.refresh(); + }, REDETECT_DEBOUNCE_MS); + } + + private installWatchers(): void { + for (const watcher of this.#watchers) { + watcher.dispose(); + } + this.#watchers = []; + for (const folder of vscode.workspace.workspaceFolders ?? []) { + if (folder.uri.scheme !== 'file') { + continue; + } + const onEvent = () => this.schedule(); + for (const pattern of detectionWatchPatterns(readRstestGlobs(folder))) { + const watcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(folder, pattern), + ); + this.#watchers.push( + watcher, + watcher.onDidCreate(onEvent), + watcher.onDidChange(onEvent), + watcher.onDidDelete(onEvent), + ); + } + } + } + + dispose(): void { + this.#disposed = true; + if (this.#debounce) { + clearTimeout(this.#debounce); + this.#debounce = undefined; + } + for (const watcher of this.#watchers) { + watcher.dispose(); + } + this.#watchers = []; + for (const subscription of this.#subscriptions) { + subscription.dispose(); + } + this.#emitter.dispose(); + } +} diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts new file mode 100644 index 0000000..972a8aa --- /dev/null +++ b/packages/vscode/src/extension.ts @@ -0,0 +1,349 @@ +import vscode from 'vscode'; +import { Channels } from './channels'; +import { DetectionService } from './detection'; +import { maybePromptForMigration, runSettingsMigration } from './migration'; +import { StatusBar } from './statusBar'; +import { + type DetectionSnapshot, + type RstackExtensionExports, + type StackController, + type StackControllerFactory, + type StackId, + type StackState, + STACK_IDS, + STACK_LABELS, +} from './types'; +import { createFmtController } from './stacks/fmt'; +import { createRslintController } from './stacks/lint'; +import { createRstestController } from './stacks/test'; + +const STACK_FACTORIES: Readonly> = { + rslint: createRslintController, + rstest: createRstestController, + fmt: createFmtController, +}; + +const errorMessage = (error: unknown): string => + error instanceof Error ? (error.stack ?? error.message) : String(error); + +type Gate = + { readonly ok: true } | { readonly ok: false; readonly state: StackState }; + +/** + * The extension shell: it always activates on + * `onStartupFinished` and does exactly three things β€” create the status bar + * item and the output channels, run detection, and register the stacks that + * pass the gate `rstack.enable && rstack..enable && detected(stack)`. + * + * A stack failing to register never takes another stack down. + */ +class ExtensionShell { + readonly #channels = new Channels(); + readonly #statusBar = new StatusBar(); + readonly #detection: DetectionService; + readonly #controllers = new Map(); + readonly #subscriptions: vscode.Disposable[] = []; + readonly #detectionEmitter = new vscode.EventEmitter(); + // E2E-facing (RstackExtensionExports): live per-stack exports + waiters. + readonly #stackExports = new Map>(); + readonly #stackExportWaiters = new Map< + StackId, + Array<(value: Record) => void> + >(); + + #reconciling: Promise = Promise.resolve(); + #disposed = false; + + constructor(private readonly context: vscode.ExtensionContext) { + this.#detection = new DetectionService(this.#channels.shell); + } + + async activate(): Promise { + this.registerCommands(); + + this.#subscriptions.push( + this.#detection.onDidChange((snapshot) => { + this.#detectionEmitter.fire(snapshot); + this.scheduleReconcile(); + }), + vscode.workspace.onDidChangeConfiguration((event) => { + const affectsGate = + event.affectsConfiguration('rstack.enable') || + STACK_IDS.some((stack) => + event.affectsConfiguration(`rstack.${stack}.enable`), + ); + if (affectsGate) { + this.scheduleReconcile(); + } + }), + // Restricted Mode shows the status bar only; trust unlocks the stacks + // without a window reload. + vscode.workspace.onDidGrantWorkspaceTrust(() => { + this.#channels.shell.info( + 'Workspace trust granted, re-evaluating the stack gates', + ); + this.scheduleReconcile(); + }), + ); + + this.#channels.shell.info( + `Rstack shell activated (workspace trust: ${ + vscode.workspace.isTrusted ? 'granted' : 'restricted' + })`, + ); + + await this.#detection.initialize(); + await this.reconcile(); + + void maybePromptForMigration(this.context, this.#channels.shell); + } + + private registerCommands(): void { + const register = (command: string, handler: () => unknown) => { + this.#subscriptions.push( + vscode.commands.registerCommand(command, handler), + ); + }; + + register('rstack.showMenu', () => this.#statusBar.showMenu()); + register('rstack.showOutput', () => this.#channels.shell.show()); + register('rstack.migrateSettings', () => + runSettingsMigration(this.#channels.shell), + ); + for (const stack of STACK_IDS) { + register(`rstack.${stack}.output.focus`, () => + this.#channels.forStack(stack).show(), + ); + } + } + + /** + * `rstack.enable && rstack..enable && detected(stack)`. + * + * The two settings are declared `"scope": "window"` in the manifest, so + * reading them without a resource URI is exactly what they promise: they are + * kill switches for the window. Per-folder granularity is detection's job + * and stays inside the controllers (`foldersFor(stack)`). + */ + private gate(stack: StackId, snapshot: DetectionSnapshot): Gate { + if (!snapshot.isDetected(stack)) { + return { ok: false, state: { kind: 'not-detected' } }; + } + if (!vscode.workspace.isTrusted) { + return { + ok: false, + state: { + kind: 'disabled', + reason: 'the workspace is not trusted (Restricted Mode)', + }, + }; + } + if ( + !vscode.workspace.getConfiguration('rstack').get('enable', true) + ) { + return { + ok: false, + state: { kind: 'disabled', reason: '`rstack.enable` is off' }, + }; + } + if ( + !vscode.workspace + .getConfiguration(`rstack.${stack}`) + .get('enable', true) + ) { + return { + ok: false, + state: { + kind: 'disabled', + reason: `\`rstack.${stack}.enable\` is off`, + }, + }; + } + return { ok: true }; + } + + private scheduleReconcile(): void { + if (this.#disposed) { + return; + } + this.#reconciling = this.#reconciling.then( + () => this.reconcile(), + () => this.reconcile(), + ); + } + + private async reconcile(): Promise { + if (this.#disposed) { + return; + } + const snapshot = this.#detection.snapshot; + // Per-stack isolation: one stack throwing must never affect the others. + await Promise.allSettled( + STACK_IDS.map((stack) => this.reconcileStack(stack, snapshot)), + ); + } + + private async reconcileStack( + stack: StackId, + snapshot: DetectionSnapshot, + ): Promise { + await this.setContextKey( + `rstack.${stack}.detected`, + snapshot.isDetected(stack), + ); + + const gate = this.gate(stack, snapshot); + const existing = this.#controllers.get(stack); + + if (!gate.ok) { + if (existing) { + this.#controllers.delete(stack); + await this.disposeController(stack, existing); + await this.setContextKey(`rstack.${stack}.active`, false); + } + this.#statusBar.setState(stack, gate.state); + return; + } + + if (existing) { + return; + } + + const controller = STACK_FACTORIES[stack](); + this.#controllers.set(stack, controller); + this.#statusBar.setState(stack, { kind: 'starting' }); + + try { + const stackExports = await controller.register({ + stack, + extensionContext: this.context, + output: this.#channels.forStack(stack), + status: this.#statusBar.reporterFor(stack), + detection: snapshot, + onDidChangeDetection: this.#detectionEmitter.event, + }); + if (stackExports) { + this.publishStackExports(stack, stackExports); + } + await this.setContextKey(`rstack.${stack}.active`, true); + this.#channels.shell.info(`${STACK_LABELS[stack]} registered`); + } catch (error) { + this.#controllers.delete(stack); + await this.disposeController(stack, controller); + await this.setContextKey(`rstack.${stack}.active`, false); + const message = errorMessage(error); + this.#channels.shell.error( + `${STACK_LABELS[stack]} failed to register: ${message}`, + ); + this.#channels.forStack(stack).error(message); + this.#statusBar.setState(stack, { + kind: 'crashed', + detail: error instanceof Error ? error.message : String(error), + }); + } + } + + private async disposeController( + stack: StackId, + controller: StackController, + ): Promise { + this.#stackExports.delete(stack); + try { + await controller.dispose(); + } catch (error) { + this.#channels.shell.error( + `${STACK_LABELS[stack]} failed to dispose: ${errorMessage(error)}`, + ); + } + } + + private publishStackExports( + stack: StackId, + stackExports: Record, + ): void { + this.#stackExports.set(stack, stackExports); + const waiters = this.#stackExportWaiters.get(stack); + if (waiters) { + this.#stackExportWaiters.delete(stack); + for (const resolve of waiters) { + resolve(stackExports); + } + } + } + + buildExports(): RstackExtensionExports { + return { + getStackExports: (stack) => this.#stackExports.get(stack), + whenStackActive: (stack) => { + const current = this.#stackExports.get(stack); + if (current) { + return Promise.resolve(current); + } + return new Promise((resolve) => { + const waiters = this.#stackExportWaiters.get(stack) ?? []; + waiters.push(resolve); + this.#stackExportWaiters.set(stack, waiters); + }); + }, + }; + } + + private async setContextKey(key: string, value: boolean): Promise { + try { + await vscode.commands.executeCommand('setContext', key, value); + } catch (error) { + this.#channels.shell.warn( + `Failed to set context key ${key}: ${errorMessage(error)}`, + ); + } + } + + async dispose(): Promise { + this.#disposed = true; + for (const [stack, controller] of [...this.#controllers]) { + this.#controllers.delete(stack); + await this.disposeController(stack, controller); + } + for (const subscription of this.#subscriptions) { + subscription.dispose(); + } + this.#subscriptions.length = 0; + this.#detectionEmitter.dispose(); + this.#detection.dispose(); + this.#statusBar.dispose(); + this.#channels.dispose(); + } +} + +let shell: ExtensionShell | undefined; + +export async function activate( + context: vscode.ExtensionContext, +): Promise { + const instance = new ExtensionShell(context); + shell = instance; + try { + await instance.activate(); + } catch (activationError) { + // Dispose whatever activation already created (channels, status bar, + // listeners, partially registered stacks) β€” nothing here is registered in + // `context.subscriptions`, so a leaked half-activation would survive. + shell = undefined; + try { + await instance.dispose(); + } catch (disposeError) { + throw new AggregateError( + [activationError, disposeError], + 'Rstack activation failed, and cleaning up the partial activation failed too', + ); + } + throw activationError; + } + return instance.buildExports(); +} + +export async function deactivate(): Promise { + const current = shell; + shell = undefined; + await current?.dispose(); +} diff --git a/packages/vscode/src/migration.test.ts b/packages/vscode/src/migration.test.ts new file mode 100644 index 0000000..c31a74c --- /dev/null +++ b/packages/vscode/src/migration.test.ts @@ -0,0 +1,381 @@ +import { describe, expect, it, rs } from '@rstest/core'; + +// `migration.ts` imports the `vscode` namespace for the write path. The rules +// under test never touch it, but the module still has to load, so the module is +// stubbed away rather than exercised: these tests must run in plain Node, with +// no extension host (unit tests are Rstest, E2E is Electron). +rs.mock('vscode', () => { + const vscode = { + ConfigurationTarget: { Global: 1, Workspace: 2, WorkspaceFolder: 3 }, + }; + return { ...vscode, default: vscode }; +}); + +import { + formatPreview, + formatValue, + LEGACY_MAPPINGS, + type LegacyReading, + layerLabel, + planMigration, +} from './migration'; + +const reading = (partial: Partial & { key: string }) => + ({ + scopeId: 'user', + layer: 'user', + value: 'value', + ...partial, + }) satisfies LegacyReading; + +const folderReading = ( + scopeId: string, + folderLabel: string, + key: string, + value: unknown, +): LegacyReading => ({ + scopeId, + layer: 'folder', + folderLabel, + key, + value, +}); + +describe('LEGACY_MAPPINGS', () => { + it('covers the full legacy inventory of both retired extensions', () => { + // 4 settings from rslint/packages/vscode-extension + 14 from + // rstest/packages/vscode, verified against their manifests. + expect(LEGACY_MAPPINGS.map((mapping) => mapping.from)).toEqual([ + 'rslint.enable', + 'rslint.binPath', + 'rslint.customBinPath', + 'rslint.trace.server', + 'rstest.rstestPackagePath', + 'rstest.nodeExecutable', + 'rstest.nodeExecArgs', + 'rstest.nodeEnv', + 'rstest.debugNodeEnv', + 'rstest.debugExclude', + 'rstest.debugOutFiles', + 'rstest.debuggerPort', + 'rstest.debuggerAddress', + 'rstest.configFileGlobPattern', + 'rstest.testCaseCollectMethod', + 'rstest.applyDiagnostic', + 'rstest.terminalShellPath', + 'rstest.terminalShellArgs', + ]); + }); + + it('renames every key into the rstack..* namespace', () => { + for (const mapping of LEGACY_MAPPINGS) { + const [stack, ...rest] = mapping.from.split('.'); + expect(mapping.to).toBe(`rstack.${stack}.${rest.join('.')}`); + } + }); + + it('has no duplicate source or target keys', () => { + expect(new Set(LEGACY_MAPPINGS.map((m) => m.from)).size).toBe( + LEGACY_MAPPINGS.length, + ); + expect(new Set(LEGACY_MAPPINGS.map((m) => m.to)).size).toBe( + LEGACY_MAPPINGS.length, + ); + }); + + it('marks the window-scoped Rstest settings as such', () => { + const windowScoped = LEGACY_MAPPINGS.filter( + (mapping) => mapping.targetScope === 'window', + ).map((mapping) => mapping.from); + expect(windowScoped).toEqual([ + 'rstest.configFileGlobPattern', + 'rstest.testCaseCollectMethod', + 'rstest.applyDiagnostic', + 'rstest.terminalShellPath', + 'rstest.terminalShellArgs', + ]); + }); + + it('only rewrites the value of rslint.binPath', () => { + const rewriting = LEGACY_MAPPINGS.filter((mapping) => mapping.mapValue); + expect(rewriting.map((mapping) => mapping.from)).toEqual([ + 'rslint.binPath', + ]); + }); +}); + +describe('planMigration β€” mechanical renames', () => { + it('carries values over untouched', () => { + const plan = planMigration([ + reading({ key: 'rstest.nodeExecArgs', value: ['--flag'] }), + ]); + expect(plan.writeCount).toBe(1); + expect(plan.scopes[0]?.writes[0]).toMatchObject({ + from: 'rstest.nodeExecArgs', + to: 'rstack.rstest.nodeExecArgs', + value: ['--flag'], + rewritten: false, + }); + }); + + it('preserves falsy values that are explicitly set', () => { + const plan = planMigration([ + reading({ key: 'rslint.enable', value: false }), + reading({ key: 'rstest.debuggerPort', value: 0 }), + reading({ key: 'rstest.terminalShellPath', value: '' }), + ]); + expect(plan.writeCount).toBe(3); + expect(plan.scopes[0]?.writes.map((write) => write.value)).toEqual([ + false, + 0, + '', + ]); + }); + + it('ignores keys that are not part of the inventory', () => { + const plan = planMigration([ + reading({ key: 'rslint.somethingElse' }), + reading({ key: 'editor.defaultFormatter' }), + ]); + expect(plan.writeCount).toBe(0); + expect(plan.skips).toEqual([]); + }); + + it('ignores readings whose value is undefined', () => { + // `inspect()` reports `undefined` for a layer that does not set the key; + // migrating it would materialise the default into the settings file. + const plan = planMigration([ + reading({ key: 'rstest.applyDiagnostic', value: undefined }), + ]); + expect(plan.writeCount).toBe(0); + }); +}); + +describe('planMigration β€” rslint.binPath', () => { + it('maps an explicit built-in to local', () => { + const plan = planMigration([ + reading({ key: 'rslint.binPath', value: 'built-in' }), + ]); + expect(plan.scopes[0]?.writes[0]).toMatchObject({ + to: 'rstack.rslint.binPath', + fromValue: 'built-in', + value: 'local', + rewritten: true, + }); + }); + + it('carries local and custom over unchanged', () => { + for (const value of ['local', 'custom']) { + const plan = planMigration([reading({ key: 'rslint.binPath', value })]); + expect(plan.scopes[0]?.writes[0]).toMatchObject({ + value, + rewritten: false, + }); + } + }); + + it('carries the custom path over next to the mode', () => { + const plan = planMigration([ + reading({ key: 'rslint.binPath', value: 'custom' }), + reading({ key: 'rslint.customBinPath', value: '/opt/rslint' }), + ]); + expect( + plan.scopes[0]?.writes.map((write) => [write.to, write.value]), + ).toEqual([ + ['rstack.rslint.binPath', 'custom'], + ['rstack.rslint.customBinPath', '/opt/rslint'], + ]); + }); + + it('never invents a value when the setting was not set', () => { + // The dropped `built-in` default must not leak in as an explicit `local`. + expect(planMigration([]).writeCount).toBe(0); + }); + + it('skips values that are not in the new enum', () => { + for (const value of ['auto', '', 42, null]) { + const plan = planMigration([reading({ key: 'rslint.binPath', value })]); + expect(plan.writeCount).toBe(0); + expect(plan.skips[0]).toMatchObject({ + from: 'rslint.binPath', + reason: 'unsupported-value', + }); + } + }); +}); + +describe('planMigration β€” layers', () => { + it('groups writes per layer and orders them user, workspace, folder', () => { + const plan = planMigration([ + folderReading('file:///w/app', 'app', 'rslint.enable', false), + reading({ + scopeId: 'workspace', + layer: 'workspace', + key: 'rstest.nodeExecArgs', + value: [], + }), + reading({ key: 'rslint.enable', value: true }), + ]); + expect(plan.scopes.map((scope) => scope.label)).toEqual([ + 'User Settings', + 'Workspace Settings', + 'Folder Settings β€” app', + ]); + expect(plan.writeCount).toBe(3); + }); + + it('keeps same-named folders of a multi-root workspace apart', () => { + const plan = planMigration([ + folderReading('file:///a/app', 'app', 'rslint.enable', true), + folderReading('file:///b/app', 'app', 'rslint.enable', false), + ]); + expect(plan.scopes.map((scope) => scope.scopeId)).toEqual([ + 'file:///a/app', + 'file:///b/app', + ]); + expect(plan.scopes.map((scope) => scope.writes[0]?.value)).toEqual([ + true, + false, + ]); + }); + + it('reports whether files inside the repository would be touched', () => { + expect( + planMigration([reading({ key: 'rslint.enable', value: true })]) + .touchesRepositoryFiles, + ).toBe(false); + expect( + planMigration([ + reading({ + scopeId: 'workspace', + layer: 'workspace', + key: 'rslint.enable', + value: true, + }), + ]).touchesRepositoryFiles, + ).toBe(true); + }); + + it('orders the writes of one layer by the inventory, not by input order', () => { + const plan = planMigration([ + reading({ key: 'rstest.applyDiagnostic', value: false }), + reading({ key: 'rslint.enable', value: true }), + ]); + expect(plan.scopes[0]?.writes.map((write) => write.from)).toEqual([ + 'rslint.enable', + 'rstest.applyDiagnostic', + ]); + }); + + it('skips a window-scoped setting at the folder layer only', () => { + const folder = planMigration([ + folderReading('file:///w', 'w', 'rstest.applyDiagnostic', false), + ]); + expect(folder.writeCount).toBe(0); + expect(folder.skips[0]).toMatchObject({ reason: 'not-folder-scoped' }); + + const workspace = planMigration([ + reading({ + scopeId: 'workspace', + layer: 'workspace', + key: 'rstest.applyDiagnostic', + value: false, + }), + ]); + expect(workspace.writeCount).toBe(1); + }); + + it('keeps a resource-scoped setting at the folder layer', () => { + const plan = planMigration([ + folderReading('file:///w', 'w', 'rstest.nodeExecutable', '/usr/bin/node'), + ]); + expect(plan.writeCount).toBe(1); + }); +}); + +describe('planMigration β€” conflicts', () => { + it('never overwrites a new key the user already set in the same layer', () => { + const plan = planMigration([ + reading({ + key: 'rslint.binPath', + value: 'built-in', + targetValue: 'custom', + }), + ]); + expect(plan.writeCount).toBe(0); + expect(plan.skips[0]).toMatchObject({ + from: 'rslint.binPath', + to: 'rstack.rslint.binPath', + reason: 'target-already-set', + }); + }); + + it('treats a conflict as layer-local', () => { + const plan = planMigration([ + reading({ key: 'rslint.enable', value: true, targetValue: false }), + reading({ + scopeId: 'workspace', + layer: 'workspace', + key: 'rslint.enable', + value: true, + }), + ]); + expect(plan.writeCount).toBe(1); + expect(plan.scopes[0]?.layer).toBe('workspace'); + expect(plan.skips).toHaveLength(1); + }); +}); + +describe('formatValue', () => { + it('renders values on a single line', () => { + expect(formatValue('local')).toBe('"local"'); + expect(formatValue(['a', 'b'])).toBe('["a","b"]'); + expect(formatValue(undefined)).toBe('undefined'); + }); + + it('truncates long values', () => { + const formatted = formatValue('x'.repeat(200)); + expect(formatted.length).toBe(60); + expect(formatted.endsWith('…')).toBe(true); + }); +}); + +describe('formatPreview', () => { + it('shows the old -> new mapping under its layer heading', () => { + const preview = formatPreview( + planMigration([ + reading({ key: 'rslint.binPath', value: 'built-in' }), + folderReading('file:///w/app', 'app', 'rslint.enable', false), + ]), + ); + expect(preview).toContain('User Settings'); + expect(preview).toContain('rslint.binPath -> rstack.rslint.binPath'); + expect(preview).toContain('"built-in" -> "local"'); + expect(preview).toContain('Folder Settings β€” app'); + expect(preview).toContain('rslint.enable -> rstack.rslint.enable'); + }); + + it('lists what was left untouched and why', () => { + const preview = formatPreview( + planMigration([ + reading({ key: 'rslint.binPath', value: 'auto' }), + folderReading('file:///w/app', 'app', 'rstest.applyDiagnostic', false), + ]), + ); + expect(preview).toContain('Left untouched'); + expect(preview).toContain('is not a valid value of rstack.rslint.binPath'); + expect(preview).toContain('window-scoped setting'); + }); + + it('is empty when there is nothing to report', () => { + expect(formatPreview(planMigration([]))).toBe(''); + }); +}); + +describe('layerLabel', () => { + it('names every layer', () => { + expect(layerLabel('user')).toBe('User Settings'); + expect(layerLabel('workspace')).toBe('Workspace Settings'); + expect(layerLabel('folder', 'pkg')).toBe('Folder Settings β€” pkg'); + }); +}); diff --git a/packages/vscode/src/migration.ts b/packages/vscode/src/migration.ts new file mode 100644 index 0000000..a84e7ac --- /dev/null +++ b/packages/vscode/src/migration.ts @@ -0,0 +1,655 @@ +import vscode from 'vscode'; + +/** + * Settings migration from the two retired standalone extensions (`rstack.rslint` + * and `rstack.rstest`) into the unified `rstack.*` namespace. + * + * Shape of the feature: + * + * - legacy keys are discovered with `workspace.getConfiguration().inspect()`, + * which reports the *explicitly set* value per layer and never the default β€” + * defaults must not be materialised into the user's settings files; + * - three layers are handled: User, Workspace and WorkspaceFolder, and each one + * is written back separately, against its own `ConfigurationTarget`; + * - Workspace and Folder writes touch files inside the user's repository, so + * nothing is written before a preview (old key -> new key, per layer) has been + * confirmed; + * - detection of legacy keys raises exactly one dismissible prompt; the command + * itself is always available from the Command Palette. + * + * Command ids are deliberately *not* migrated: VS Code exposes no keybindings + * API, so keybindings bound to `rslint.*` / `rstest.*` command ids break + * silently. That cost is deliberately accepted and is restated in the + * confirmation dialog. + * + * Everything above `collectLegacyReadings` is pure and free of the `vscode` + * namespace object, so the mapping rules can be unit tested without an + * extension host (`migration.test.ts`). + */ + +// --------------------------------------------------------------------------- +// Pure layer: the mapping table and the planner +// --------------------------------------------------------------------------- + +/** The configuration layers `inspect()` reports and `update()` can target. */ +export type MigrationLayer = 'user' | 'workspace' | 'folder'; + +/** + * Why a legacy key that *is* set was not carried over. Reported in the preview + * so a skipped key is never silently dropped. + */ +export type SkipReason = + /** The legacy value has no equivalent in the new setting's schema. */ + | 'unsupported-value' + /** The new key already has an explicit value in the same layer. */ + | 'target-already-set' + /** A folder-layer value for a window-scoped target setting. */ + | 'not-folder-scoped'; + +type ValueMapping = + | { readonly kind: 'value'; readonly value: unknown } + | { readonly kind: 'skip'; readonly reason: SkipReason }; + +export interface LegacyMapping { + /** Fully qualified legacy key, e.g. `rslint.binPath`. */ + readonly from: string; + /** Fully qualified new key, e.g. `rstack.rslint.binPath`. */ + readonly to: string; + /** + * Scope of the *new* key in this extension's manifest. A window-scoped + * setting cannot be written to `ConfigurationTarget.WorkspaceFolder`, so a + * folder-layer legacy value for one of those is skipped instead of throwing + * at write time. + */ + readonly targetScope: 'resource' | 'window'; + /** + * Value rewrite. Absent for the mechanical renames, which carry the value + * over untouched. + */ + readonly mapValue?: (value: unknown) => ValueMapping; +} + +/** + * `rslint.binPath` is the one non-mechanical mapping: the old + * default `built-in` no longer exists because the extension ships no Rslint + * binary. The new default is `local`; an explicitly set `built-in` becomes + * `local`, `custom` carries over together with `customBinPath`, and anything + * else is not in the new enum and would poison the setting. + */ +const mapBinPath = (value: unknown): ValueMapping => { + if (value === 'built-in') { + return { kind: 'value', value: 'local' }; + } + if (value === 'local' || value === 'custom') { + return { kind: 'value', value }; + } + return { kind: 'skip', reason: 'unsupported-value' }; +}; + +/** + * Legacy Rstest keys, in manifest order. Every one of them is a mechanical + * `rstest.` -> `rstack.rstest.` rename; only the scope of the *new* + * key differs, and it is the new manifest that decides it. + * + * Derived from `rstest/packages/vscode/package.json` (14 settings) and this + * repository's `contributes.configuration`. Rstest contributes no `enable` + * setting upstream, so `rstack.rstest.enable` has no legacy source. + */ +const RSTEST_KEYS: readonly (readonly [string, 'resource' | 'window'])[] = [ + ['rstestPackagePath', 'resource'], + ['nodeExecutable', 'resource'], + ['nodeExecArgs', 'resource'], + ['nodeEnv', 'resource'], + ['debugNodeEnv', 'resource'], + ['debugExclude', 'resource'], + ['debugOutFiles', 'resource'], + ['debuggerPort', 'resource'], + ['debuggerAddress', 'resource'], + ['configFileGlobPattern', 'window'], + ['testCaseCollectMethod', 'window'], + ['applyDiagnostic', 'window'], + ['terminalShellPath', 'window'], + ['terminalShellArgs', 'window'], +]; + +/** + * The complete legacy inventory: 4 Rslint keys + 14 Rstest keys. Kept in one + * table so the preview, the writer and the tests cannot disagree. + */ +export const LEGACY_MAPPINGS: readonly LegacyMapping[] = [ + { + from: 'rslint.enable', + to: 'rstack.rslint.enable', + targetScope: 'resource', + }, + { + from: 'rslint.binPath', + to: 'rstack.rslint.binPath', + targetScope: 'resource', + mapValue: mapBinPath, + }, + { + from: 'rslint.customBinPath', + to: 'rstack.rslint.customBinPath', + targetScope: 'resource', + }, + { + from: 'rslint.trace.server', + to: 'rstack.rslint.trace.server', + targetScope: 'resource', + }, + ...RSTEST_KEYS.map(([key, targetScope]) => ({ + from: `rstest.${key}`, + to: `rstack.rstest.${key}`, + targetScope, + })), +]; + +const MAPPINGS_BY_KEY = new Map( + LEGACY_MAPPINGS.map((mapping) => [mapping.from, mapping]), +); + +/** One explicitly-set legacy value found in one layer. */ +export interface LegacyReading { + /** + * Opaque, stable identifier of the configuration scope the value was read + * from. The pure layer only groups by it; the `vscode` layer maps it back to + * a workspace folder. Folder names are *not* unique in a multi-root + * workspace, which is why the label is carried separately. + */ + readonly scopeId: string; + readonly layer: MigrationLayer; + /** Display name of the workspace folder; only set for the `folder` layer. */ + readonly folderLabel?: string; + readonly key: string; + readonly value: unknown; + /** + * Explicit value of the *new* key in the same layer, if the user already set + * it. Migrating on top of it would silently overwrite a deliberate choice. + */ + readonly targetValue?: unknown; +} + +export interface PlannedWrite { + readonly scopeId: string; + readonly layer: MigrationLayer; + readonly folderLabel?: string; + readonly from: string; + readonly to: string; + readonly fromValue: unknown; + readonly value: unknown; + /** True when the mapping changed the value (`built-in` -> `local`). */ + readonly rewritten: boolean; +} + +export interface PlannedSkip { + readonly scopeId: string; + readonly layer: MigrationLayer; + readonly folderLabel?: string; + readonly from: string; + readonly to: string; + readonly value: unknown; + readonly reason: SkipReason; +} + +/** All writes that go to one `ConfigurationTarget` in one scope. */ +export interface PlannedScope { + readonly scopeId: string; + readonly layer: MigrationLayer; + readonly folderLabel?: string; + readonly label: string; + readonly writes: readonly PlannedWrite[]; +} + +export interface MigrationPlan { + /** Non-empty scopes, ordered User -> Workspace -> Folders. */ + readonly scopes: readonly PlannedScope[]; + readonly skips: readonly PlannedSkip[]; + readonly writeCount: number; + /** True when at least one write lands in a file inside the user's repo. */ + readonly touchesRepositoryFiles: boolean; +} + +export const layerLabel = ( + layer: MigrationLayer, + folderLabel?: string, +): string => { + switch (layer) { + case 'user': + return 'User Settings'; + case 'workspace': + return 'Workspace Settings'; + case 'folder': + return `Folder Settings β€” ${folderLabel ?? '?'}`; + } +}; + +const LAYER_ORDER: Readonly> = { + user: 0, + workspace: 1, + folder: 2, +}; + +/** + * Turns raw readings into the exact set of writes to perform, grouped by the + * scope they are written to. Pure: same readings in, same plan out. + * + * Readings for keys outside {@link LEGACY_MAPPINGS} and readings whose value is + * `undefined` (i.e. not explicitly set in that layer) are ignored. + */ +export const planMigration = ( + readings: readonly LegacyReading[], +): MigrationPlan => { + const scopes: PlannedScope[] = []; + const writesByScope = new Map(); + const skips: PlannedSkip[] = []; + + const scopeFor = (reading: LegacyReading): PlannedWrite[] => { + const existing = writesByScope.get(reading.scopeId); + if (existing) { + return existing; + } + const writes: PlannedWrite[] = []; + writesByScope.set(reading.scopeId, writes); + scopes.push({ + scopeId: reading.scopeId, + layer: reading.layer, + folderLabel: reading.folderLabel, + label: layerLabel(reading.layer, reading.folderLabel), + writes, + }); + return writes; + }; + + const order = new Map( + LEGACY_MAPPINGS.map((mapping, index) => [mapping.from, index]), + ); + const sorted = [...readings].sort((a, b) => { + const byLayer = LAYER_ORDER[a.layer] - LAYER_ORDER[b.layer]; + if (byLayer !== 0) { + return byLayer; + } + return (order.get(a.key) ?? 0) - (order.get(b.key) ?? 0); + }); + + for (const reading of sorted) { + const mapping = MAPPINGS_BY_KEY.get(reading.key); + if (!mapping || reading.value === undefined) { + continue; + } + + const skip = (reason: SkipReason): void => { + skips.push({ + scopeId: reading.scopeId, + layer: reading.layer, + folderLabel: reading.folderLabel, + from: mapping.from, + to: mapping.to, + value: reading.value, + reason, + }); + }; + + if (reading.targetValue !== undefined) { + skip('target-already-set'); + continue; + } + if (reading.layer === 'folder' && mapping.targetScope === 'window') { + skip('not-folder-scoped'); + continue; + } + + const mapped: ValueMapping = mapping.mapValue + ? mapping.mapValue(reading.value) + : { kind: 'value', value: reading.value }; + if (mapped.kind === 'skip') { + skip(mapped.reason); + continue; + } + + scopeFor(reading).push({ + scopeId: reading.scopeId, + layer: reading.layer, + folderLabel: reading.folderLabel, + from: mapping.from, + to: mapping.to, + fromValue: reading.value, + value: mapped.value, + rewritten: mapped.value !== reading.value, + }); + } + + const nonEmpty = scopes.filter((scope) => scope.writes.length > 0); + return { + scopes: nonEmpty, + skips, + writeCount: nonEmpty.reduce( + (total, scope) => total + scope.writes.length, + 0, + ), + touchesRepositoryFiles: nonEmpty.some((scope) => scope.layer !== 'user'), + }; +}; + +const MAX_VALUE_LENGTH = 60; + +/** Compact, single-line rendering of a settings value for the preview. */ +export const formatValue = (value: unknown): string => { + let text: string; + try { + text = JSON.stringify(value) ?? String(value); + } catch { + text = String(value); + } + return text.length > MAX_VALUE_LENGTH + ? `${text.slice(0, MAX_VALUE_LENGTH - 1)}…` + : text; +}; + +const SKIP_EXPLANATIONS: Readonly< + Record string> +> = { + 'unsupported-value': (skip) => + `${formatValue(skip.value)} is not a valid value of ${skip.to}`, + 'target-already-set': (skip) => `${skip.to} is already set here`, + 'not-folder-scoped': (skip) => + `${skip.to} is a window-scoped setting and cannot be set per folder`, +}; + +/** + * The old -> new preview shown before anything is written. Plain text: it is + * rendered in a modal dialog's `detail`, which does not interpret markdown. + */ +export const formatPreview = (plan: MigrationPlan): string => { + const blocks: string[] = []; + + for (const scope of plan.scopes) { + const lines = scope.writes.map((write) => { + const rewrite = write.rewritten + ? ` (${formatValue(write.fromValue)} -> ${formatValue(write.value)})` + : ''; + return ` ${write.from} -> ${write.to}${rewrite}`; + }); + blocks.push([scope.label, ...lines].join('\n')); + } + + if (plan.skips.length > 0) { + const lines = plan.skips.map((skip) => { + const where = layerLabel(skip.layer, skip.folderLabel); + return ` ${skip.from} (${where}): ${SKIP_EXPLANATIONS[skip.reason](skip)}`; + }); + blocks.push(['Left untouched', ...lines].join('\n')); + } + + return blocks.join('\n\n'); +}; + +// --------------------------------------------------------------------------- +// vscode layer: reading, confirming, writing +// --------------------------------------------------------------------------- + +const USER_SCOPE = 'user'; +const WORKSPACE_SCOPE = 'workspace'; + +const targetOf = (layer: MigrationLayer): vscode.ConfigurationTarget => { + switch (layer) { + case 'user': + return vscode.ConfigurationTarget.Global; + case 'workspace': + return vscode.ConfigurationTarget.Workspace; + case 'folder': + return vscode.ConfigurationTarget.WorkspaceFolder; + } +}; + +/** + * Reads every legacy key in every layer. `inspect()` is the only API that + * separates "explicitly set in this layer" from "inherited or defaulted", which + * is exactly the distinction the migration is built on. + */ +export const collectLegacyReadings = (): { + readonly readings: readonly LegacyReading[]; + readonly folders: ReadonlyMap; +} => { + const readings: LegacyReading[] = []; + const folders = new Map(); + // A Folder layer distinct from the Workspace layer only exists once a + // `.code-workspace` file is in play (which is also what multi-root implies β€” + // adding a second folder creates an untitled workspace file). With a single + // folder opened directly, `.vscode/settings.json` *is* the Workspace layer and + // `inspect()` reports its values as both `workspaceValue` and + // `workspaceFolderValue`; scanning the folder layer there would plan every + // reading twice and, for the window-scoped Rstest keys, report a bogus + // "cannot be set per folder" skip next to the write that actually happens. + const hasFolderLayer = vscode.workspace.workspaceFile !== undefined; + const workspaceFolders = hasFolderLayer + ? (vscode.workspace.workspaceFolders ?? []) + : []; + for (const folder of workspaceFolders) { + folders.set(folder.uri.toString(), folder); + } + + for (const mapping of LEGACY_MAPPINGS) { + const legacy = vscode.workspace + .getConfiguration() + .inspect(mapping.from); + const target = vscode.workspace + .getConfiguration() + .inspect(mapping.to); + + if (legacy?.globalValue !== undefined) { + readings.push({ + scopeId: USER_SCOPE, + layer: 'user', + key: mapping.from, + value: legacy.globalValue, + targetValue: target?.globalValue, + }); + } + if (legacy?.workspaceValue !== undefined) { + readings.push({ + scopeId: WORKSPACE_SCOPE, + layer: 'workspace', + key: mapping.from, + value: legacy.workspaceValue, + targetValue: target?.workspaceValue, + }); + } + + for (const folder of workspaceFolders) { + const scoped = vscode.workspace.getConfiguration(undefined, folder.uri); + const value = scoped.inspect(mapping.from)?.workspaceFolderValue; + if (value === undefined) { + continue; + } + readings.push({ + scopeId: folder.uri.toString(), + layer: 'folder', + folderLabel: folder.name, + key: mapping.from, + value, + targetValue: scoped.inspect(mapping.to)?.workspaceFolderValue, + }); + } + } + + return { readings, folders }; +}; + +/** Convenience wrapper: is there anything at all to migrate? */ +export const hasLegacySettings = (): boolean => + planMigration(collectLegacyReadings().readings).writeCount > 0; + +const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +const KEYBINDING_NOTE = + 'Command ids were renamed to rstack.* with no aliases: keybindings bound to the old rslint.* / rstest.* commands are not migrated and have to be re-bound manually.'; + +interface WriteOutcome { + readonly migrated: number; + readonly failed: number; +} + +/** + * Writes one scope. The legacy key is removed only after its replacement has + * been written, so a failure can never lose the value. + */ +const writeScope = async ( + scope: PlannedScope, + folder: vscode.WorkspaceFolder | undefined, + output: vscode.LogOutputChannel, +): Promise => { + const configuration = vscode.workspace.getConfiguration( + undefined, + folder?.uri, + ); + const target = targetOf(scope.layer); + let migrated = 0; + let failed = 0; + + for (const write of scope.writes) { + try { + await configuration.update(write.to, write.value, target); + } catch (error) { + failed += 1; + output.error( + `[${scope.label}] failed to write ${write.to}: ${errorMessage(error)}`, + ); + continue; + } + migrated += 1; + output.info( + `[${scope.label}] ${write.from} -> ${write.to} = ${formatValue(write.value)}`, + ); + try { + await configuration.update(write.from, undefined, target); + } catch (error) { + // The value is already carried over; a leftover legacy key is cosmetic. + output.warn( + `[${scope.label}] migrated ${write.from} but could not remove it: ${errorMessage(error)}`, + ); + } + } + + return { migrated, failed }; +}; + +export interface MigrationRunOptions { + /** Skip the "nothing to migrate" notification (used by the prompt path). */ + readonly silentWhenEmpty?: boolean; +} + +/** + * The `rstack.migrateSettings` command. Always available from the Command + * Palette; safe to run repeatedly (a second run finds nothing left to do). + * + * Returns `true` when at least one setting was written. + */ +export const runSettingsMigration = async ( + output: vscode.LogOutputChannel, + { silentWhenEmpty = false }: MigrationRunOptions = {}, +): Promise => { + const { readings, folders } = collectLegacyReadings(); + const plan = planMigration(readings); + + if (plan.writeCount === 0) { + const detail = plan.skips.length > 0 ? `\n${formatPreview(plan)}` : ''; + output.info(`No legacy settings to migrate.${detail}`); + if (!silentWhenEmpty) { + void vscode.window.showInformationMessage( + plan.skips.length > 0 + ? 'Rstack: nothing left to migrate. See the Rstack output channel for the settings that were left untouched.' + : 'Rstack: no legacy rslint.* / rstest.* settings were found.', + ); + } + return false; + } + + const preview = formatPreview(plan); + output.info(`Settings migration preview:\n${preview}`); + + // Workspace and Folder writes land in files inside the user's + // repository, so the preview must be confirmed first. The User layer is + // confirmed along with them β€” one dialog is both simpler and more honest + // than silently rewriting half of the plan. + const scopeNote = plan.touchesRepositoryFiles + ? 'Workspace and folder settings files in this repository will be modified.' + : 'Only your user settings will be modified.'; + const confirmed = await vscode.window.showInformationMessage( + `Migrate ${plan.writeCount} setting${plan.writeCount === 1 ? '' : 's'} to the rstack.* namespace?`, + { + modal: true, + detail: `${preview}\n\n${scopeNote} The legacy keys are removed once their replacement has been written.\n\n${KEYBINDING_NOTE}`, + }, + 'Migrate', + ); + if (confirmed !== 'Migrate') { + output.info('Settings migration cancelled by the user.'); + return false; + } + + let migrated = 0; + let failed = 0; + // Each layer is written back separately, against its own target. + for (const scope of plan.scopes) { + const folder = + scope.layer === 'folder' ? folders.get(scope.scopeId) : undefined; + if (scope.layer === 'folder' && !folder) { + // The folder disappeared between the preview and the confirmation. + output.warn(`[${scope.label}] workspace folder is gone, skipped.`); + continue; + } + const outcome = await writeScope(scope, folder, output); + migrated += outcome.migrated; + failed += outcome.failed; + } + + const summary = + failed > 0 + ? `Rstack: migrated ${migrated} setting(s), ${failed} failed β€” see the Rstack output channel.` + : `Rstack: migrated ${migrated} setting(s). ${KEYBINDING_NOTE}`; + void vscode.window.showInformationMessage(summary); + output.info( + `Settings migration finished: ${migrated} written, ${failed} failed.`, + ); + return migrated > 0; +}; + +export const PROMPT_DISMISSED_KEY = 'rstack.migration.dismissed'; + +/** + * The single dismissible prompt. Shown at most once per user + * once "Don't ask again" is chosen; "Not now" leaves it for the next window, + * and the command stays available from the palette either way. + */ +export const maybePromptForMigration = async ( + context: vscode.ExtensionContext, + output: vscode.LogOutputChannel, +): Promise => { + if (context.globalState.get(PROMPT_DISMISSED_KEY) === true) { + return; + } + + const plan = planMigration(collectLegacyReadings().readings); + if (plan.writeCount === 0) { + return; + } + output.info( + `Found ${plan.writeCount} legacy setting(s) from the standalone Rslint/Rstest extensions.`, + ); + + const migrate = 'Migrate…'; + const dismiss = "Don't ask again"; + const choice = await vscode.window.showInformationMessage( + 'Rstack found settings from the standalone Rslint/Rstest extensions. Migrate them to the rstack.* namespace?', + migrate, + 'Not now', + dismiss, + ); + if (choice === migrate) { + await runSettingsMigration(output, { silentWhenEmpty: true }); + } else if (choice === dismiss) { + await context.globalState.update(PROMPT_DISMISSED_KEY, true); + } +}; diff --git a/packages/vscode/src/shared/vendored/loadRstackConfig.ts b/packages/vscode/src/shared/vendored/loadRstackConfig.ts new file mode 100644 index 0000000..a52a706 --- /dev/null +++ b/packages/vscode/src/shared/vendored/loadRstackConfig.ts @@ -0,0 +1,244 @@ +// TODO: revisit asking rstack-cli to export loadRstackConfig from the root entry. +// An official export would delete this vendored copy, whose globalThis session key is internal API. +// +// NOTE(rstack-bridge): the rslint bridge β€” this loader's main consumer β€” was +// deliberately removed; rebuilding it needs upstream work (rstack publishing an +// explicit-path config loader plus adapter exports, rslint accepting per-root +// fallback config candidates on `rslint/configRefresh`, and a generic +// evaluator-module seam shared by the config host and plugin workers). The +// loader is kept for the phase-2 fmt stack (evaluating `define.fmt()`) and +// possible future status refinements; today only +// `nativeTypeStrippingAvailable` is consumed. +// +// Vendored from rstackjs/rstack-cli `packages/rstack/src/config.ts` +// (origin/main @ 6494ba2, rstack@0.3.2). Only three things differ from upstream: +// +// 1. the type imports of configs this extension does not care about are widened +// to `unknown` so the extension does not depend on @rsbuild/@rslib/@rspress; +// 2. `loadRstackConfig` accepts a `cwd` and a `loader` in addition to +// `configFilePath` β€” see the comment on `RstackConfigLoader`; +// 3. `define` is exported for completeness but is never used by the extension: +// the user's own `rstack.config.*` imports `define` from *their* `rstack` +// install. Interop is safe by construction because the session storage lives +// on `globalThis` under the exact upstream key, so both module instances +// share one active session. +// +// The `globalThis.__rstackConfigSessionStorage` key is internal API of +// rstack-cli. It must stay byte-identical to upstream β€” it is the entire +// interop mechanism. +import { AsyncLocalStorage } from 'node:async_hooks'; +import { loadConfig } from '@rstackjs/load-config'; +import type { RslintConfig } from '@rslint/core'; +import type { RstestConfigExport } from '@rstest/core'; + +export type RslintConfigDefinition = + RslintConfig | (() => Promise); + +export type Configs = { + /** `define.app` β€” an Rsbuild config; not consumed by the extension. */ + app?: unknown; + /** `define.lib` β€” an Rslib config; not consumed by the extension. */ + lib?: unknown; + /** `define.doc` β€” an Rspress config; not consumed by the extension. */ + doc?: unknown; + test?: RstestConfigExport; + lint?: RslintConfigDefinition; + /** `define.fmt` β€” a Prettier config; consumed by `rs fmt` itself. */ + fmt?: unknown; + /** `define.staged` β€” a lint-staged config; not consumed by the extension. */ + staged?: unknown; +}; + +export type LoadedRstackConfig = { + configs: Configs; + filePath: string | null; + /** + * Absolute paths of the statically imported relative dependencies of the + * config file β€” usable as extra watch targets. + */ + dependencies: string[]; +}; + +/** + * Divergence from upstream, deliberate and isolated: rstack-cli hardcodes + * `loader: 'native'`, whose failure path rethrows instead of falling back to + * jiti. The CLI runs on the user's Node (`engines.node >= 22.12`), but this + * loader runs on the extension host's Node, whose version is fixed by VS Code. + * On a host without native TypeScript type stripping, `'native'` hard-fails on + * every `rstack.config.ts`, so the default here is `'auto'`. + * + * `'auto'` falls back to jiti, which has to be resolvable β€” see + * `nativeTypeStrippingAvailable`, the preflight the caller is expected to use + * for an actionable status bar hint. + */ +export type RstackConfigLoader = 'native' | 'auto'; + +export type LoadRstackConfigOptions = { + /** + * The path to the Rstack config file, can be a relative or absolute path. + * If `configFilePath` is not provided, the config path set by the CLI is used. + * If neither path is provided, the function will search for the config file in the current working directory. + */ + configFilePath?: string; + /** + * Directory the default `rstack.config.*` probe runs in. Upstream never sets + * it (the CLI relies on `process.cwd()`); the extension host's cwd is + * meaningless, so a caller without an explicit `configFilePath` must pass it. + */ + cwd?: string; + loader?: RstackConfigLoader; +}; + +type ConfigSession = { + configs: Configs; + active: boolean; +}; + +type ConfigState = { + configPath?: string; +}; + +declare global { + // rslint-disable-next-line no-var + var __rstackConfigSessionStorage: + AsyncLocalStorage | undefined; + // rslint-disable-next-line no-var + var __rstackCliState: ConfigState | undefined; +} + +const getConfigSessionStorage = (): AsyncLocalStorage => { + // Rsbuild's fresh import loader can load this module more than once when it + // imports the internal Rstack config. Keep the storage on globalThis so + // every module instance reads and writes the same active session. + if (!globalThis.__rstackConfigSessionStorage) { + globalThis.__rstackConfigSessionStorage = + new AsyncLocalStorage(); + } + + return globalThis.__rstackConfigSessionStorage; +}; + +export const getConfigState = (): ConfigState => { + // The CLI and its internal tool config can also be loaded as separate module + // instances. Keep only the CLI config path in its own global state. + if (!globalThis.__rstackCliState) { + globalThis.__rstackCliState = {}; + } + + return globalThis.__rstackCliState; +}; + +type Define = { + app: (config: unknown) => void; + lib: (config: unknown) => void; + doc: (config: unknown) => void; + test: (config: RstestConfigExport) => void; + lint: (config: RslintConfigDefinition) => void; + fmt: (config: unknown) => void; + staged: (config: unknown) => void; +}; + +const setConfig = ( + type: T, + config: Configs[T], +): void => { + const session = getConfigSessionStorage().getStore(); + + if (!session?.active) { + throw new Error( + `The "${type}" config must be defined while loading an Rstack config.`, + ); + } + + if (type in session.configs) { + throw new Error(`The "${type}" config has already been defined.`); + } + session.configs[type] = config; +}; + +export const define: Define = { + app: (config) => setConfig('app', config), + lib: (config) => setConfig('lib', config), + doc: (config) => setConfig('doc', config), + test: (config) => setConfig('test', config), + lint: (config) => setConfig('lint', config), + fmt: (config) => setConfig('fmt', config), + staged: (config) => setConfig('staged', config), +}; + +export const RSTACK_CONFIG_FILE_NAMES = [ + 'rstack.config.ts', + 'rstack.config.js', + 'rstack.config.mts', + 'rstack.config.mjs', +]; + +export const loadRstackConfig = async ({ + configFilePath, + cwd, + loader = 'auto', +}: LoadRstackConfigOptions = {}): Promise => { + const state = getConfigState(); + const configPath = configFilePath ?? state.configPath; + const session: ConfigSession = { + configs: {}, + active: true, + }; + + return getConfigSessionStorage().run(session, async () => { + try { + const { filePath, dependencies } = await loadConfig({ + loader, + exportName: false, + fresh: true, + ...(cwd !== undefined ? { cwd } : {}), + ...(configPath !== undefined + ? { path: configPath } + : { + configFileNames: RSTACK_CONFIG_FILE_NAMES, + }), + }); + + return { + configs: session.configs, + filePath, + dependencies, + }; + } finally { + session.active = false; + session.configs = {}; + } + }); +}; + +/** + * Preflight for the loader: when this is false, a `.ts`/`.mts` Rstack config + * can only be loaded through jiti, and a missing jiti has to be surfaced as an + * actionable hint instead of a generic config-load failure. + * + * Verified on a VS Code-class host (Node 20, no type stripping): loading a + * `.ts` config through this loader ends in `@rstackjs/load-config`'s + * `The "jiti" package is required to load this config.` β€” and because this + * loader is *bundled into the extension*, its `import('jiti')` resolves from + * the extension, not from the user's project. Installing jiti in the project + * therefore does not fix it on its own. Callers must preflight with + * `nativeTypeStrippingAvailable()` and show the hint below. + */ +export const nativeTypeStrippingAvailable = (): boolean => + Boolean((process.features as { typescript?: unknown }).typescript); + +export const JITI_REQUIRED_HINT = + 'This VS Code build cannot strip TypeScript types, so an rstack.config.ts can only be loaded through jiti. Use rstack.config.mjs/js, or run VS Code on a Node build with type stripping.'; + +/** + * Applies the logic of rstack-cli's `dist/rslintConfig.js` shim to a loaded + * Rstack config. The Rslint path deliberately does not import that shim: it + * would call `loadRstackConfig()` with no arguments inside the extension host, + * whose cwd is meaningless. + */ +export const resolveRslintConfig = async ( + configs: Configs, +): Promise => { + const lintExports = configs.lint ?? []; + return typeof lintExports === 'function' ? await lintExports() : lintExports; +}; diff --git a/packages/vscode/src/shared/versionCheck.ts b/packages/vscode/src/shared/versionCheck.ts new file mode 100644 index 0000000..a1ad97d --- /dev/null +++ b/packages/vscode/src/shared/versionCheck.ts @@ -0,0 +1,90 @@ +import semver from 'semver'; +import type { StatusReporter } from '../types'; + +/** + * The version-compatibility contract. A VSIX has no npm install step, + * so the packages below are resolved from the user's project at runtime and + * checked against this matrix; a mismatch surfaces as the `version mismatch` + * status bar state with actual vs required versions. + * + * Launch floors (verified against npm): + * - `@rslint/core >= 0.7.2` β€” first version whose package exports + * `./config-loader` and `./eslint-plugin`. + * - `@rstest/core >= 0.6.0` β€” the existing `MIN_CORE_VERSION` upstream. + * - `rstack >= 0.3.2` β€” first release containing `rs fmt --stdin-filepath`. + */ +export const SUPPORT_MATRIX = { + '@rslint/core': '>=0.7.2', + '@rstest/core': '>=0.6.0', + rstack: '>=0.3.2', +} as const; + +export type SupportedPackage = keyof typeof SUPPORT_MATRIX; + +export type VersionCheckResult = + | { readonly kind: 'ok'; readonly version: string } + /** The version could not be read; never treated as a hard failure. */ + | { readonly kind: 'unknown'; readonly version?: string } + | { + readonly kind: 'mismatch'; + readonly version: string; + readonly required: string; + }; + +export const checkPackageVersion = ( + packageName: SupportedPackage, + version: string | undefined, +): VersionCheckResult => { + if (!version || !semver.valid(semver.coerce(version) ?? '')) { + return { kind: 'unknown', version }; + } + const required = SUPPORT_MATRIX[packageName]; + // Prereleases of a supported range (e.g. `1.0.0-beta.1`) are accepted: the + // ecosystem ships them and refusing them would strand early adopters. + if (semver.satisfies(version, required, { includePrerelease: true })) { + return { kind: 'ok', version }; + } + return { kind: 'mismatch', version, required }; +}; + +export const formatVersionMismatch = ( + packageName: SupportedPackage, + result: Extract, +): string => + `${packageName} ${result.version} is not supported, this extension requires ${result.required}`; + +/** + * Checks one project-resolved package and reports a mismatch through the + * shared status reporter. Returns `true` when the stack may keep going. + */ +export const reportVersionCheck = ( + status: StatusReporter, + packageName: SupportedPackage, + version: string | undefined, +): boolean => { + const result = checkPackageVersion(packageName, version); + if (result.kind === 'mismatch') { + status.versionMismatch(formatVersionMismatch(packageName, result)); + return false; + } + return true; +}; + +/** + * The reverse config-discovery protocol between the Rslint Go server and the + * project-resolved `@rslint/core/config-loader` is versioned independently of + * the package version, so `semver.satisfies` is necessary but + * not sufficient: the client additionally validates the `protocolVersion` + * carried by `rslint/configRefresh`. + */ +export const SUPPORTED_CONFIG_DISCOVERY_PROTOCOL_VERSIONS: ReadonlySet = + new Set([1]); + +export const isSupportedConfigDiscoveryProtocolVersion = ( + version: number, +): boolean => SUPPORTED_CONFIG_DISCOVERY_PROTOCOL_VERSIONS.has(version); + +export const formatProtocolVersionMismatch = (version: number): string => + `Rslint config-discovery protocol version ${version} is not supported (supported: ${[ + ...SUPPORTED_CONFIG_DISCOVERY_PROTOCOL_VERSIONS, + ].join(', ')})`; diff --git a/packages/vscode/src/stacks/fmt/index.ts b/packages/vscode/src/stacks/fmt/index.ts new file mode 100644 index 0000000..ecdfdc1 --- /dev/null +++ b/packages/vscode/src/stacks/fmt/index.ts @@ -0,0 +1,28 @@ +import type { StackContext, StackController } from '../../types'; + +/** + * `rs fmt` is phase 2. Detection already lights the stack so + * the status bar can tell the user it was found, but nothing is registered: + * the MVP is a `DocumentFormattingEditProvider` spawning + * `rs fmt --stdin-filepath ` with cwd = the directory containing + * `rstack.config.*`, later replaced by the `rs fmt` LSP. + */ +class FmtController implements StackController { + readonly id = 'fmt' as const; + + async register(context: StackContext): Promise { + context.output.info( + 'rs fmt detected, but formatting support is phase 2 and is not registered yet', + ); + context.status.report({ + kind: 'disabled', + reason: 'rs fmt support arrives in phase 2', + }); + } + + dispose(): void { + // Nothing registered yet (phase 2). + } +} + +export const createFmtController = (): StackController => new FmtController(); diff --git a/packages/vscode/src/stacks/lint/ConfigTransactionAdapter.ts b/packages/vscode/src/stacks/lint/ConfigTransactionAdapter.ts new file mode 100644 index 0000000..acbfff0 --- /dev/null +++ b/packages/vscode/src/stacks/lint/ConfigTransactionAdapter.ts @@ -0,0 +1,291 @@ +// Copied from web-infra-dev/rslint +// `packages/vscode-extension/src/ConfigTransactionAdapter.ts` (origin/main). +// +// Adapted for the resolve-from-project adaptation: `@rslint/core/config-loader` is not bundled, so +// `CONFIG_DISCOVERY_PROTOCOL_VERSION` is no longer a compile-time constant. It +// is read from the project-resolved loader module and injected here, and a +// mismatch reported by the Go server is routed to `onProtocolMismatch` so the +// status bar can show the `version mismatch` state. +import type { + ActivateConfigsRequest, + ActivateConfigsResponse, + ConfigModuleActivationPlan, + ConfigModuleEslintPluginEntry, + ConfigModulePluginDescriptor, + LoadConfigsRequest, + LoadConfigsResponse, +} from '@rslint/core/config-loader'; + +interface ConfigActivationWireResponse { + transactionId: string; + /** Empty when no matching worker generation could be staged. */ + eslintPluginEntries: ConfigModuleEslintPluginEntry[]; + /** False lets Go preserve its last-good catalog instead of committing. */ + pluginHostReady: boolean; +} + +export interface ConfigTransactionControlRequest { + protocolVersion: number; + transactionId: string; +} + +interface ConfigCommitWireResponse { + transactionId: string; + committed: true; +} + +interface ConfigAbortWireResponse { + transactionId: string; + aborted: true; +} + +/** Structural seams keep the JSON-RPC transaction adapter independently testable. */ +interface ConfigModuleHostAdapter { + loadConfigs( + request: LoadConfigsRequest, + signal?: AbortSignal, + ): Promise; + activateConfigs( + request: ActivateConfigsRequest, + signal?: AbortSignal, + prepare?: (plan: ConfigModuleActivationPlan) => Promise, + ): Promise; + deleteSession(transactionId: string): boolean; +} + +interface PluginLintPoolAdapter { + prepare( + descriptors: ConfigModulePluginDescriptor[], + fingerprint: string, + generation: string, + ): Promise; + commit(generation: string): Promise; + abort(generation: string): Promise; +} + +function throwIfAborted(signal?: AbortSignal): void { + if (!signal?.aborted) return; + if (signal.reason instanceof Error) throw signal.reason; + throw new Error('config transaction was cancelled'); +} + +/** + * Raised when the Go server speaks a config-discovery protocol version other + * than the one the project-resolved loader implements. This must be + * surfaced as the `version mismatch` status-bar state rather than as a generic + * transaction failure. + */ +export class ConfigTransactionProtocolMismatchError extends Error { + constructor( + readonly serverProtocolVersion: unknown, + readonly loaderProtocolVersion: number, + ) { + super( + `unsupported config transaction protocol ${String(serverProtocolVersion)} (the project's @rslint/core config-loader implements ${loaderProtocolVersion})`, + ); + this.name = 'ConfigTransactionProtocolMismatchError'; + } +} + +function assertTransactionControlRequest( + request: ConfigTransactionControlRequest, + protocolVersion: number, +): void { + if (!request || typeof request !== 'object') { + throw new Error('config transaction request must be an object'); + } + if (request.protocolVersion !== protocolVersion) { + throw new ConfigTransactionProtocolMismatchError( + request.protocolVersion, + protocolVersion, + ); + } + if ( + typeof request.transactionId !== 'string' || + request.transactionId.length === 0 + ) { + throw new Error('config transactionId must be a non-empty string'); + } +} + +/** + * LSP transport adapter for the shared config module host. + * + * Go owns discovery, ignore semantics, last-good selection and catalog commit. + * This adapter only evaluates Go's candidates, stages the matching plugin host, + * and mirrors Go's final commit/abort for the same transaction ID. + */ +export class LspConfigTransactionAdapter { + private readonly transactions = new Set(); + private disposed = false; + + constructor( + private readonly host: ConfigModuleHostAdapter, + private readonly pluginLintPool: PluginLintPoolAdapter, + private readonly fingerprint: (plan: ConfigModuleActivationPlan) => string, + /** `CONFIG_DISCOVERY_PROTOCOL_VERSION` of the project-resolved loader. */ + private readonly protocolVersion: number, + private readonly onProtocolMismatch?: ( + error: ConfigTransactionProtocolMismatchError, + ) => void, + ) {} + + private assertRequest(request: ConfigTransactionControlRequest): void { + try { + assertTransactionControlRequest(request, this.protocolVersion); + } catch (error) { + if (error instanceof ConfigTransactionProtocolMismatchError) { + this.onProtocolMismatch?.(error); + } + throw error; + } + } + + async loadConfigs( + request: LoadConfigsRequest, + signal?: AbortSignal, + ): Promise { + this.assertActive(); + this.assertRequest(request); + throwIfAborted(signal); + const transactionId = request.transactionId; + this.transactions.add(transactionId); + try { + // Editor reloads must not reuse the config entry module. Go still sends + // the shared envelope, but the LSP transport makes that entry-freshness + // invariant explicit for every frontier. Static transitive imports retain + // Node's normal module-cache semantics; full graph isolation requires a + // separate evaluator realm rather than query-busting only the entry URL. + const response = await this.host.loadConfigs( + { ...request, loadMode: 'fresh' }, + signal, + ); + this.assertActive(); + throwIfAborted(signal); + return response; + } catch (error) { + this.cleanup(transactionId); + throw error; + } + } + + async activateConfigs( + request: ActivateConfigsRequest, + signal?: AbortSignal, + ): Promise { + this.assertActive(); + this.assertRequest(request); + throwIfAborted(signal); + const transactionId = request.transactionId; + try { + let pluginHostReady = false; + const activation = await this.host.activateConfigs( + request, + signal, + async (candidate) => { + this.assertActive(); + throwIfAborted(signal); + pluginHostReady = await this.pluginLintPool.prepare( + candidate.pluginConfigs, + this.fingerprint(candidate), + transactionId, + ); + this.assertActive(); + throwIfAborted(signal); + }, + ); + this.assertActive(); + throwIfAborted(signal); + return { + transactionId: activation.transactionId, + // Never ask Go to register/dispatch placeholder rules without the + // matching worker generation. On first startup Go may still commit the + // ordinary native config as a degraded no-host generation; with a + // last-good generation it instead aborts this transaction. + eslintPluginEntries: pluginHostReady + ? activation.eslintPluginEntries + : [], + pluginHostReady, + }; + } catch (error) { + await this.pluginLintPool.abort(transactionId).catch(() => undefined); + this.cleanup(transactionId); + throw error; + } + } + + async commitConfigs( + request: ConfigTransactionControlRequest, + ): Promise { + this.assertActive(); + this.assertRequest(request); + const transactionId = request.transactionId; + if (!(await this.pluginLintPool.commit(transactionId))) { + throw new Error( + `failed to commit plugin-host generation ${JSON.stringify(transactionId)}`, + ); + } + this.cleanup(transactionId); + return { + transactionId, + committed: true, + }; + } + + async abortConfigs( + request: ConfigTransactionControlRequest, + ): Promise { + this.assertRequest(request); + const transactionId = request.transactionId; + try { + await this.pluginLintPool.abort(transactionId); + } finally { + this.cleanup(transactionId); + } + return { + transactionId, + aborted: true, + }; + } + + /** + * Drop transactions orphaned by a native-server restart while keeping the + * adapter reusable for the replacement process. A transaction that reached + * PluginLintPool.commit but whose response was lost is compensated by abort; + * an older fully committed host is not in this set and remains available + * until the replacement server commits its first catalog. + */ + async resetForServerRestart(): Promise { + this.assertActive(); + const orphaned = [...this.transactions]; + this.transactions.clear(); + for (const transactionId of orphaned) { + this.host.deleteSession(transactionId); + } + await Promise.allSettled( + orphaned.map(async (transactionId) => + this.pluginLintPool.abort(transactionId), + ), + ); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + for (const transactionId of this.transactions) { + this.host.deleteSession(transactionId); + } + this.transactions.clear(); + } + + private cleanup(transactionId: string): void { + this.host.deleteSession(transactionId); + this.transactions.delete(transactionId); + } + + private assertActive(): void { + if (this.disposed) { + throw new Error('config transaction adapter is disposed'); + } + } +} diff --git a/packages/vscode/src/stacks/lint/LanguageServerProcessOwner.ts b/packages/vscode/src/stacks/lint/LanguageServerProcessOwner.ts new file mode 100644 index 0000000..63a9928 --- /dev/null +++ b/packages/vscode/src/stacks/lint/LanguageServerProcessOwner.ts @@ -0,0 +1,178 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; + +const GRACEFUL_EXIT_TIMEOUT_MS = 500; +const FORCED_EXIT_TIMEOUT_MS = 1_500; + +function hasExited(process: ChildProcessWithoutNullStreams): boolean { + return process.exitCode !== null || process.signalCode !== null; +} + +async function waitForClose( + closed: Promise, + timeoutMs: number, +): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = (closedInTime: boolean): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(closedInTime); + }; + const timer = setTimeout(() => { + finish(false); + }, timeoutMs); + void closed.then(() => { + finish(true); + }); + }); +} + +async function terminateProcess( + process: ChildProcessWithoutNullStreams, + closed: Promise, +): Promise { + if (!hasExited(process)) { + try { + process.kill('SIGTERM'); + } catch { + // The close check below distinguishes a process that raced to completion + // from one that still needs a forced termination attempt. + } + } + if (await waitForClose(closed, GRACEFUL_EXIT_TIMEOUT_MS)) return; + + if (!hasExited(process)) { + try { + process.kill('SIGKILL'); + } catch { + // Report only if the transport remains open after the bounded wait. + } + } + if (await waitForClose(closed, FORCED_EXIT_TIMEOUT_MS)) return; + + throw new Error( + `language server process ${String(process.pid)} did not close its transports after SIGKILL`, + ); +} + +/** + * Owns every native server child created for one workspace runtime, including + * children created by vscode-languageclient's automatic restart path. + */ +export class LanguageServerProcessOwner { + private readonly processes = new Map< + ChildProcessWithoutNullStreams, + Promise + >(); + private startTail: Promise = Promise.resolve(); + private closePromise: Promise | undefined; + private closing = false; + + public constructor( + private readonly command: string, + private readonly args: readonly string[], + private readonly cwd: string, + private readonly env?: NodeJS.ProcessEnv, + ) {} + + public beginClose(): void { + this.closing = true; + } + + public async start(): Promise { + const operation = this.startTail.then( + async () => this.startImpl(), + async () => this.startImpl(), + ); + this.startTail = operation.then( + () => undefined, + () => undefined, + ); + const child = await operation; + return child; + } + + private async startImpl(): Promise { + if (this.closing) { + throw new Error('language server process owner is closing'); + } + + // A transport can close before its process exits. Automatic restart must + // never create a second native generation while that old child is alive. + await this.terminateTrackedProcesses(); + if (this.closing) { + throw new Error('language server process owner is closing'); + } + + const child = spawn(this.command, [...this.args], { + cwd: this.cwd, + env: this.env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const closed = new Promise((resolve) => { + child.once('close', () => { + resolve(); + }); + }); + this.processes.set(child, closed); + void closed.then(() => { + this.processes.delete(child); + }); + + let spawned = false; + let resolveStarted!: () => void; + let rejectStarted!: (error: unknown) => void; + const started = new Promise((resolve, reject) => { + resolveStarted = resolve; + rejectStarted = reject; + }); + + child.once('spawn', () => { + spawned = true; + if (this.closing) { + rejectStarted(new Error('language server process owner is closing')); + } else { + resolveStarted(); + } + }); + child.on('error', (error) => { + if (spawned) return; + rejectStarted(error); + }); + + await started; + return child; + } + + public async close(): Promise { + await (this.closePromise ??= this.closeImpl()); + } + + private async closeImpl(): Promise { + this.beginClose(); + await this.startTail; + await this.terminateTrackedProcesses(); + } + + private async terminateTrackedProcesses(): Promise { + const results = await Promise.allSettled( + [...this.processes].map(async ([process, closed]) => { + await terminateProcess(process, closed); + }), + ); + const errors: unknown[] = []; + for (const result of results) { + if (result.status === 'rejected') { + const reason: unknown = result.reason; + errors.push(reason); + } + } + if (errors.length > 0) { + throw new AggregateError( + errors, + 'failed to terminate language server processes', + ); + } + } +} diff --git a/packages/vscode/src/stacks/lint/PluginLintPool.ts b/packages/vscode/src/stacks/lint/PluginLintPool.ts new file mode 100644 index 0000000..36904e5 --- /dev/null +++ b/packages/vscode/src/stacks/lint/PluginLintPool.ts @@ -0,0 +1,504 @@ +/** + * VS Code-side host for ESLint-plugin lint requests. + * + * The Go LSP server lints natively, but rules mounted via a config's + * object-form `plugins` run in JS. So when Go encounters such a rule it sends a + * serverβ†’client `rslint/pluginLint` request back to this extension; + * we answer it from an in-process WorkerPool owned by `@rslint/core`'s + * `createPluginLintHost`. This file is a thin lifecycle wrapper over that + * host β€” the requestβ†’tasksβ†’result boundary itself lives in `@rslint/core` + * (`buildPluginLintTasks` / `buildPluginLintResult`), shared with the CLI + * engine so the two paths never drift. + * + * Copied from web-infra-dev/rslint `packages/vscode-extension/src/PluginLintPool.ts` + * (origin/main), with the module-load path adapted for the resolve-from-project + * adaptation. + * + * Upstream loads the host through the RELATIVE specifier + * `./eslint-plugin/index.js`, which resolves to the extension's own staged + * `dist/eslint-plugin/`. This extension stages nothing: the host is resolved + * from the *project*, out of the same `@rslint/core` install as the Go binary + * (`resolution.ts`), and handed in through the mandatory `createHost` factory. + * This is a verified non-requirement β€” a plain project + * install runs the whole pipeline unpatched, worker and napi parser included β€” + * so the relative-import fix is the only change needed, and it lives here. + * + * The host is ESM (it spawns its sibling `lint-worker.js` via + * `import.meta.url`) while this extension is bundled to CJS, so the factory + * must reach it through a dynamic `import()`, never `require`. + */ + +import { window } from 'vscode'; +import type { CancellationToken } from 'vscode'; +import type { Logger } from './logger'; +import type { + ConfigDescriptor, + PluginLintHost, + EslintPluginLintRequest, + EslintPluginLintResult, +} from '@rslint/core/eslint-plugin'; + +export type PluginHostFactory = ( + configs: ConfigDescriptor[], + onLog: (rec: { level: string; source: string; text: string }) => void, +) => Promise; + +// One predecessor is retained without a timer so an active commit can be +// rolled back if its JSON-RPC response is lost and Go subsequently aborts. +// Keep one additional grace generation for already-dispatched requests: the +// bound remains two old pools plus the active pool. Hosts with an acquired +// lint lease may temporarily exceed this bound until requests drain. +const MAX_GRACE_GENERATIONS = 1; + +/** + * Latches the one-shot "host failed to load" warning at MODULE scope (not + * per-instance) so a persistent failure (e.g. a broken vsix that didn't ship + * the worker payload) surfaces once per session β€” not once per workspace folder + * in a multi-root window, where each folder owns its own PluginLintPool. + */ +let warnedOnce = false; + +export class PluginLintPool { + private readonly logger: Logger; + private readonly generations = new Map(); + private readonly generationRetirementTimers = new Map< + string, + ReturnType + >(); + private activeGeneration: string | undefined; + private activeState: HostGeneration | undefined; + /** + * The active generation's compensating rollback record. JSON-RPC has no + * response acknowledgement, so commit cannot discard this predecessor: Go + * may keep last-good and send abort when the commit response is lost or + * invalid. A later successful commit proves Go accepted this generation and + * moves its predecessor into the ordinary grace-retirement queue. + */ + private activeCommitRollback: ActiveCommitRollback | undefined; + private readonly liveStates = new Set(); + private readonly shutdowns = new Set>(); + /** + * Serializes every lifecycle op (prepare/commit/abort/dispose). Each op + * chains onto the previous one's settlement, so concurrent config reloads + * cannot race host installation or map mutation. Lint requests for an + * installed generation take a lease immediately; only a generation that is + * not installed yet waits for this chain and checks again. + */ + private opChain: Promise = Promise.resolve(); + private disposed = false; + private readonly createHost: PluginHostFactory; + private readonly retirementDelayMs: number; + + constructor( + logger: Logger, + // No default: there is no extension-local host to fall back to. The caller + // supplies a factory that loads the project-resolved module. + createHost: PluginHostFactory, + retirementDelayMs = 30_000, + ) { + this.logger = logger; + this.createHost = createHost; + this.retirementDelayMs = retirementDelayMs; + } + + /** Append `op` to the serialized lifecycle chain and await its turn. */ + private async enqueue(op: () => Promise): Promise { + const run = this.opChain.then(op, op); + // Keep the chain alive even if `op` throws β€” swallow on the chain copy so a + // single failed op doesn't poison every subsequent one. Callers awaiting + // the returned promise still observe the rejection. + this.opChain = run.catch(() => undefined); + return run; + } + + /** + * Prepare a generation without making it the active fallback for requests + * without a key. The transport commits it at the matching config + * transaction's commit point; + * an abort after commit can still compensate for a lost response and return + * to the prior Go last-good generation. + * + * Returns whether the requested host state is active. Rebuilds are + * transactional: a failed replacement leaves the previous host available so + * the caller can preserve the matching last-good config payload. + * + * Empty `descriptors` needs no host, avoiding a module load and worker-pool + * allocation when no object-form community plugins are configured. The + * matching activation publishes no plugin metadata, so Go must never issue + * a plugin-lint request for that generation without a host. + */ + async prepare( + descriptors: ConfigDescriptor[], + fingerprint: string, + generation: string, + ): Promise { + let ready = false; + await this.enqueue(async () => { + if (this.disposed || generation === '') return; + + const existing = this.generations.get(generation); + if (existing) { + ready = existing.ready; + return; + } + + // Config-only changes can reuse the same plugin host. The new generation + // is still staged separately and is not routable as active until commit. + if ( + this.activeState?.ready && + this.activeState.fingerprint === fingerprint + ) { + this.generations.set(generation, this.activeState); + ready = true; + return; + } + + if (descriptors.length === 0) { + const state: HostGeneration = { + fingerprint, + ready: true, + activeLints: 0, + retiring: false, + }; + this.liveStates.add(state); + this.generations.set(generation, state); + ready = true; + return; + } + + try { + const replacement = await this.createHost(descriptors, (rec) => { + const text = `[rslint:plugin] ${rec.text}`; + if (rec.level === 'error') this.logger.error(text); + else this.logger.debug(text); + }); + if (this.disposed) { + // Disposed while initializing β€” shut the fresh pool back down. + await replacement.shutdown().catch(() => undefined); + return; + } + const state: HostGeneration = { + fingerprint, + host: replacement, + ready: true, + activeLints: 0, + retiring: false, + }; + this.liveStates.add(state); + this.generations.set(generation, state); + ready = true; + } catch (err: unknown) { + // Init failed: either the host module couldn't be loaded (a broken or + // partial `@rslint/core` install in the project β€” see `resolution.ts`), + // or a referenced plugin failed to import. Keep the previous + // active host intact. Record an unavailable staged generation so the + // first valid config can still be committed and serve native rules; + // later prepares retry instead of caching this failure as ready. + const state: HostGeneration = { + fingerprint, + ready: false, + activeLints: 0, + retiring: false, + }; + this.liveStates.add(state); + this.generations.set(generation, state); + this.logger.error('Failed to initialize ESLint-plugin host', err); + // Make the failure visible β€” but ONLY when a config actually mounted + // plugins (an empty-descriptor host builds no worker and failing is + // not a user-facing problem), and only once per session so a + // persistent failure doesn't re-warn on every reload. + if (descriptors.length > 0 && !warnedOnce) { + warnedOnce = true; + void window.showWarningMessage( + 'Rstack: failed to load the Rslint ESLint-plugin host; rules mounted via a config’s `plugins` will report no diagnostics. See the "Rstack: Rslint" output channel for details.', + ); + } + } + }); + return ready; + } + + /** Commit a previously prepared generation after Go accepts its config. */ + async commit(generation: string): Promise { + let committed = false; + await this.enqueue(async () => { + if (this.disposed) return; + const next = this.generations.get(generation); + if (!next) return; + if (generation === this.activeGeneration) { + committed = true; + return; + } + + const previousGeneration = this.activeGeneration; + const previous = this.activeState; + this.finalizeActiveCommitRollback(); + if (previousGeneration) { + this.cancelGenerationRetirement(previousGeneration); + } + this.activeGeneration = generation; + this.activeState = next; + this.activeCommitRollback = { + generation, + previousGeneration, + previousState: previous, + }; + committed = true; + }); + return committed; + } + + /** Discard a staged generation when source validation or Go commit fails. */ + async abort(generation: string): Promise { + await this.enqueue(async () => { + if (generation === this.activeGeneration) { + const rollback = this.activeCommitRollback; + if (!rollback || rollback.generation !== generation) return; + const aborted = this.activeState; + this.activeGeneration = rollback.previousGeneration; + this.activeState = rollback.previousState; + this.activeCommitRollback = undefined; + if (rollback.previousGeneration) { + this.cancelGenerationRetirement(rollback.previousGeneration); + } + this.generations.delete(generation); + if ( + aborted && + aborted !== this.activeState && + !this.hasGenerationReference(aborted) + ) { + this.retire(aborted); + } + return; + } + const state = this.generations.get(generation); + if (!state) return; + this.generations.delete(generation); + if (state !== this.activeState && !this.hasGenerationReference(state)) { + this.retire(state); + } + }); + } + + /** Answer one reverse `rslint/pluginLint` request. */ + async lint( + req: EslintPluginLintRequest, + token?: CancellationToken, + ): Promise { + if (this.disposed) return { results: [] }; + + let state = req.generation + ? this.generations.get(req.generation) + : this.activeState; + + // A reverse request may arrive after Go accepts a config but just before + // Node installs that generation. Wait only in that case. Existing + // generations must remain routable while an unrelated prepare is slow. + if (req.generation && !state) { + if (!(await this.waitForLifecycle(token))) return { results: [] }; + if (this.disposed) return { results: [] }; + state = this.generations.get(req.generation); + } + if (req.generation && !state) { + throw new Error( + `unknown ESLint-plugin config generation: ${req.generation}`, + ); + } + if (!state) return { results: [] }; + const host = state.host; + if (!host) { + // Generations without a host are valid committed states for native-only or + // degraded catalogs, but their activation exposes no plugin metadata. + // Reaching this branch therefore means Go and the extension disagree on + // the committed lifecycle. Do not turn that protocol failure into a + // false-green empty diagnostic set. Cancellation remains benign. + if (token?.isCancellationRequested) return { results: [] }; + const generation = req.generation ?? this.activeGeneration; + throw new Error( + `LSP pluginLint requested for config generation ${JSON.stringify(generation)} without an activated plugin host`, + ); + } + + // Take the lease before yielding. Retirement removes future routing + // references, but cannot shut this state down until the lease is released. + state.activeLints++; + // Bridge the LSP CancellationToken β†’ AbortSignal for the core host, so a + // superseding keystroke / close (Go sends $/cancelRequest) stops the worker + // instead of letting it run to completion. + let signal: AbortSignal | undefined; + let cancellationSubscription: { dispose(): unknown } | undefined; + try { + if (token) { + const ac = new AbortController(); + if (token.isCancellationRequested) ac.abort(); + else + cancellationSubscription = token.onCancellationRequested(() => { + ac.abort(); + }); + signal = ac.signal; + } + return await host.lint(req, signal); + } finally { + cancellationSubscription?.dispose(); + state.activeLints--; + if (state.retiring && state.activeLints === 0) { + this.startShutdown(state); + } + } + } + + /** Wait for the lifecycle snapshot that could be installing a generation. */ + private async waitForLifecycle(token?: CancellationToken): Promise { + const pending = this.opChain; + if (!token) { + await pending; + return true; + } + if (token.isCancellationRequested) return false; + + let cancellationSubscription: { dispose(): unknown } | undefined; + const cancelled = new Promise((resolve) => { + cancellationSubscription = token.onCancellationRequested(() => { + resolve(false); + }); + }); + try { + return await Promise.race([pending.then(() => true as const), cancelled]); + } finally { + cancellationSubscription?.dispose(); + } + } + + private hasGenerationReference(state: HostGeneration): boolean { + for (const candidate of this.generations.values()) { + if (candidate === state) return true; + } + return false; + } + + private retire(state: HostGeneration): void { + state.retiring = true; + if (state.activeLints === 0) this.startShutdown(state); + } + + private finalizeActiveCommitRollback(): void { + const rollback = this.activeCommitRollback; + if (!rollback) return; + this.activeCommitRollback = undefined; + if (rollback.previousGeneration) { + this.scheduleGenerationRetirement( + rollback.previousGeneration, + rollback.previousState, + ); + } + } + + private cancelGenerationRetirement(generation: string): void { + const timer = this.generationRetirementTimers.get(generation); + if (!timer) return; + clearTimeout(timer); + this.generationRetirementTimers.delete(generation); + } + + private scheduleGenerationRetirement( + generation: string, + state: HostGeneration | undefined, + ): void { + const existing = this.generationRetirementTimers.get(generation); + if (existing) clearTimeout(existing); + const timer = setTimeout(() => { + this.completeGenerationRetirement(generation, state); + }, this.retirementDelayMs); + this.generationRetirementTimers.set(generation, timer); + + // A burst of config updates must not retain one complete WorkerPool per + // generation for the full production grace period. Expire the oldest + // routing generation immediately once the bounded history is full. + while (this.generationRetirementTimers.size > MAX_GRACE_GENERATIONS) { + const oldest = this.generationRetirementTimers.keys().next().value; + if (oldest === undefined) break; + this.completeGenerationRetirement(oldest, this.generations.get(oldest)); + } + } + + private completeGenerationRetirement( + generation: string, + state: HostGeneration | undefined, + ): void { + const timer = this.generationRetirementTimers.get(generation); + if (!timer) return; + clearTimeout(timer); + this.generationRetirementTimers.delete(generation); + if (this.activeGeneration === generation) return; + if (this.generations.get(generation) !== state) return; + + this.generations.delete(generation); + if ( + state && + state !== this.activeState && + !this.hasGenerationReference(state) + ) { + this.retire(state); + } + } + + private startShutdown(state: HostGeneration): void { + if (state.shutdown) return; + for (const [generation, candidate] of this.generations) { + if (candidate === state) { + this.generations.delete(generation); + const timer = this.generationRetirementTimers.get(generation); + if (timer) clearTimeout(timer); + this.generationRetirementTimers.delete(generation); + } + } + const shutdown = state.host + ? state.host.shutdown().catch((err: unknown) => { + this.logger.error('Error shutting down previous plugin host', err); + }) + : Promise.resolve(); + state.shutdown = shutdown; + this.shutdowns.add(shutdown); + void shutdown.finally(() => { + this.shutdowns.delete(shutdown); + this.liveStates.delete(state); + }); + } + + /** Shut down the worker pool. Idempotent. */ + async dispose(): Promise { + this.disposed = true; + await this.enqueue(async () => { + const states = [...this.liveStates]; + this.generations.clear(); + for (const timer of this.generationRetirementTimers.values()) { + clearTimeout(timer); + } + this.generationRetirementTimers.clear(); + this.activeGeneration = undefined; + this.activeState = undefined; + this.activeCommitRollback = undefined; + for (const state of states) { + // Terminal disposal intentionally forces shutdown even if a request is + // still active; WorkerPool turns those tasks into benign cancellation. + this.startShutdown(state); + } + }); + await Promise.all([...this.shutdowns]); + } +} + +interface HostGeneration { + fingerprint: string; + host?: PluginLintHost; + ready: boolean; + activeLints: number; + retiring: boolean; + shutdown?: Promise; +} + +interface ActiveCommitRollback { + generation: string; + previousGeneration: string | undefined; + previousState: HostGeneration | undefined; +} diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts new file mode 100644 index 0000000..5acb2bc --- /dev/null +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -0,0 +1,1202 @@ +// Copied from web-infra-dev/rslint `packages/vscode-extension/src/Rslint.ts` +// (origin/main). This is the most heavily adapted file of the port; +// every divergence from upstream is one of: +// +// 1. the shell-activation adaptation β€” nothing here creates a status bar item, +// registers a command or activates the extension; the shell owns the +// lifecycle and this class is a per-workspace-folder runtime driven by +// `WorkspaceRslintCoordinator`. +// 2. the namespace adaptation β€” every setting is read from `rstack.rslint.*`. +// 3. the resolve-from-project adaptation β€” the `built-in` binary mode is gone, +// the binary/config-loader/eslint-plugin all come from one project +// resolution root (`resolution.ts`), and `@rslint/core/config-loader` +// contributes types only at compile time; `CONFIG_DISCOVERY_PROTOCOL_VERSION` +// and `ConfigModuleHost` are injected from the project-resolved module. +// 4. the status-aggregation adaptation β€” `reportStatus` instead of an own +// status bar. +// 5. watch glob β€” `CONFIG_REFRESH_WATCH_GLOB` kept verbatim. +// +// Plus the two compatibility diagnostics: the config-discovery protocol +// handshake and the jiti preflight. +// +// TODO(rstack-bridge): Rslint deliberately does NOT consume `rstack.config.*` +// (`define.lint()`) for now. The earlier bridge was removed because it had no +// complete final data path β€” the LSP has no explicit-config channel, the shim +// resolves its config from a meaningless extension-host cwd, and plugin +// workers would re-import the wrong source shape. Rebuilding it needs upstream +// work: rstack publishing an explicit-path config loader plus adapter exports, +// rslint accepting per-root fallback config candidates on +// `rslint/configRefresh`, and a generic evaluator-module seam shared by the +// config host and plugin workers. + +import { + workspace, + Uri, + Disposable, + FileSystemWatcher, + RelativePattern, + WorkspaceFolder, + OutputChannel, + TextDocument, + type CancellationToken, +} from 'vscode'; +import { + CloseAction, + DidCloseTextDocumentNotification, + DidOpenTextDocumentNotification, + ErrorAction, + LanguageClient, + LanguageClientOptions, + type ErrorHandler, + type Middleware, + ServerOptions, + State, + Trace, +} from 'vscode-languageclient/node'; +import type { Logger } from './logger'; +import path from 'node:path'; +import fs from 'node:fs'; +import type { + ActivateConfigsRequest, + ConfigModuleActivationPlan, + LoadConfigsRequest, +} from '@rslint/core/config-loader'; +import { PluginLintPool } from './PluginLintPool'; +import type { + ConfigDescriptor, + EslintPluginLintRequest, + PluginLintHost, +} from '@rslint/core/eslint-plugin'; +import { + ConfigTransactionProtocolMismatchError, + LspConfigTransactionAdapter, + type ConfigTransactionControlRequest, +} from './ConfigTransactionAdapter'; +import { + createWorkspaceDocumentSelector, + type WorkspaceDocumentRouter, +} from './WorkspaceDocumentRouter'; +import { LanguageServerProcessOwner } from './LanguageServerProcessOwner'; +import type { StackState } from '../../types'; +import { + checkPackageVersion, + formatVersionMismatch, +} from '../../shared/versionCheck'; +import { + ConfigDiscoveryProtocolMismatchError, + loadConfigLoaderModule, + loadEslintPluginModule, + type ConfigLoaderModule, +} from './configLoader'; +import { + RslintResolutionError, + resolveRslint, + type RslintResolution, +} from './resolution'; +import { + describeJitiPreflight, + isJitiMissingError, + JITI_INSTALL_HINT, +} from './jitiPreflight'; +/** + * Workspace-relative lockfiles whose individual metadata feeds the + * plugin-host fingerprint. A dependency install can swap a plugin's + * implementation without touching the config file, so the host must rebuild. + */ +const LOCKFILE_NAMES = [ + 'package-lock.json', + 'pnpm-lock.yaml', + 'yarn.lock', +] as const; + +/** + * Kept verbatim from upstream, JSON names included: they are not detection + * signals but watching them is harmless, and the Go server does + * load `rslint.json` from its cwd as a no-JS-config fallback. + */ +const RSLINT_CONFIG_WATCH_NAMES = [ + 'rslint.config.js', + 'rslint.config.mjs', + 'rslint.config.ts', + 'rslint.config.mts', + 'rslint.json', + 'rslint.jsonc', +] as const; + +// Upstream's glob kept verbatim. `rstack.config.*` is +// deliberately absent: see the TODO(rstack-bridge) note in the file header. +export const CONFIG_REFRESH_WATCH_GLOB = `**/{${[ + ...RSLINT_CONFIG_WATCH_NAMES, + ...LOCKFILE_NAMES, +].join(',')}}`; + +/** + * The project's `@rslint/core` is outside the support matrix. + * Distinct from a crash so the status bar can show `version mismatch` with the + * actual and the required version. + */ +export class RslintVersionMismatchError extends Error { + constructor(message: string) { + super(message); + this.name = 'RslintVersionMismatchError'; + } +} + +export type ConfigRefreshReason = + 'initial' | 'config-change' | 'dependency-change'; + +interface ConfigRefreshRequest { + /** + * The protocol version is no longer a compile-time constant of + * a bundled loader β€” it is read from the project-resolved + * `@rslint/core/config-loader`, so the value the Go server sees is always the + * one the JS side actually implements. + */ + protocolVersion: number; + reason: ConfigRefreshReason; +} + +export type ConfigRefreshRequester = ( + reason: ConfigRefreshReason, + beforeRequest?: (adapter: LspConfigTransactionAdapter) => Promise, +) => Promise; + +/** + * Recover the extension-side transaction host when LanguageClient restarts its + * native server. The listener using this helper is installed only after the + * initial Running transition, so a later Running state unambiguously means the + * replacement process needs a new initial catalog. + */ +export function recoverConfigDiscoveryOnServerState( + newState: State, + requestConfigRefresh: ConfigRefreshRequester, +): Promise | undefined { + if (newState !== State.Running) return undefined; + return requestConfigRefresh('initial', async (adapter) => + adapter.resetForServerRestart(), + ); +} + +export function shouldResetDocumentSessionOnServerState( + oldState: State, + newState: State, +): boolean { + return oldState === State.Running && newState !== State.Running; +} + +/** Bind each language client to the workspace whose Go process owns discovery. */ +export function createLanguageClientOptions( + workspaceFolder: WorkspaceFolder, + outputChannel: OutputChannel | undefined, + middleware?: Middleware, +): LanguageClientOptions { + const documentSelector = createWorkspaceDocumentSelector(workspaceFolder); + return { + workspaceFolder, + // languageclient v9 types this client-only selector as the LSP shape, + // whose pattern is string-only. Its converter forwards the pattern to + // VS Code's DocumentFilter, which supports RelativePattern and preserves + // an unambiguous workspace base even when the path contains glob syntax. + documentSelector: + // rslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + documentSelector as unknown as LanguageClientOptions['documentSelector'], + outputChannel, + middleware, + }; +} + +export function configRefreshReasonForPath( + filePath: string, +): Exclude { + const basename = path.basename(filePath); + if ((LOCKFILE_NAMES as readonly string[]).includes(basename)) { + return 'dependency-change'; + } + return 'config-change'; +} + +export function isConfigSourceChangeDuringTransaction(error: unknown): boolean { + if (!isRecord(error)) return false; + return ( + error.code === 'CONFIG_CHANGED_DURING_LOAD' || + (typeof error.message === 'string' && + error.message.includes('config changed while')) + ); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export async function retryConfigRefreshOnSourceChange( + initial: () => Promise, + retry: () => Promise, +): Promise { + try { + await initial(); + return false; + } catch (error) { + if (!isConfigSourceChangeDuringTransaction(error)) throw error; + await retry(); + return true; + } +} + +async function withCancellationSignal( + token: CancellationToken, + operation: (signal: AbortSignal) => Promise, +): Promise { + const controller = new AbortController(); + if (token.isCancellationRequested) controller.abort(); + const subscription = token.onCancellationRequested(() => { + controller.abort(); + }); + try { + return await operation(controller.signal); + } finally { + subscription.dispose(); + } +} + +function abortError(signal: AbortSignal): Error { + if (signal.reason instanceof Error) return signal.reason; + const error = new Error('Rslint workspace start was cancelled'); + error.name = 'AbortError'; + return error; +} + +function throwIfAborted(signal: AbortSignal): void { + if (signal.aborted) throw abortError(signal); +} + +async function raceWithAbort( + operation: Promise, + signal: AbortSignal, +): Promise { + throwIfAborted(signal); + return new Promise((resolve, reject) => { + const onAbort = () => { + reject(abortError(signal)); + }; + signal.addEventListener('abort', onAbort, { once: true }); + operation.then( + (value) => { + signal.removeEventListener('abort', onAbort); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort); + reject(error); + }, + ); + }); +} + +export interface LanguageClientCloseTarget { + readonly state: State; + readonly diagnostics: Disposable | undefined; + dispose(): Promise; +} + +/** + * vscode-languageclient calls stop() without observing its promise when an + * initialize request fails. Its base stop rejects for non-Running states; the + * process owner handles those states, so normalize only that inactive case and + * preserve actionable failures from a Running shutdown. + */ +export class ManagedLanguageClient extends LanguageClient { + public override async stop(timeout?: number): Promise { + const stateBeforeStop = this.state; + try { + await super.stop(timeout); + } catch (error) { + if (stateBeforeStop === State.Running) throw error; + } + } +} + +/** + * Disposes a LanguageClient without waiting for a possibly hung initialize + * request. Its outer LanguageServerProcessOwner blocks restarts and terminates + * the callback-owned child after an inactive-state rejection; failures from a + * Running client remain independently actionable. + */ +export async function disposeLanguageClient( + client: LanguageClientCloseTarget, +): Promise { + const diagnostics = client.diagnostics; + const reportDisposeFailure = client.state === State.Running; + const errors: unknown[] = []; + try { + await client.dispose(); + } catch (error) { + if (reportDisposeFailure) errors.push(error); + } + try { + diagnostics?.dispose(); + } catch (error) { + errors.push(error); + } + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) { + throw new AggregateError(errors, 'failed to dispose language client'); + } +} + +export async function waitForPromiseSettlement( + promise: Promise, + timeoutMs: number, + description: string, +): Promise { + let timer: ReturnType | undefined; + try { + const settled = await Promise.race([ + promise.then( + () => true, + () => true, + ), + new Promise((resolve) => { + timer = setTimeout(() => { + resolve(false); + }, timeoutMs); + }), + ]); + if (!settled) { + throw new Error(`${description} did not settle within ${timeoutMs}ms`); + } + } finally { + clearTimeout(timer); + } +} + +interface ClientStoppedObservation { + readonly promise: Promise; + dispose(): void; +} + +function observeClientStopped( + client: LanguageClient, +): ClientStoppedObservation { + if (client.state === State.Stopped) { + return { promise: Promise.resolve(), dispose: () => undefined }; + } + let subscription: Disposable | undefined; + const promise = new Promise((resolve) => { + subscription = client.onDidChangeState((event) => { + if (event.newState === State.Stopped) resolve(); + }); + }); + return { + promise, + dispose() { + subscription?.dispose(); + subscription = undefined; + }, + }; +} + +/** Config files detection found for this folder, read at every start. */ +export interface RslintFolderConfigPaths { + /** Native `rslint.config.{js,mjs,ts,mts}` files. */ + readonly rslintConfigPaths: readonly string[]; +} + +/** + * The status-aggregation adaptation: this runtime owns no status bar item. It pushes + * its folder-level state to the stack controller, which aggregates every folder + * into the one shared status bar entry. + */ +export type RslintStatusSink = (state: StackState) => void; + +export interface RslintOptions { + readonly rootKey: string; + readonly workspaceFolder: WorkspaceFolder; + /** The shell-owned `Rstack: Rslint` channel. */ + readonly outputChannel: OutputChannel; + /** + * Trace sink for `rstack.rslint.trace.server`. The extension deliberately + * caps itself at four channels, so this is the same channel as `outputChannel` + * unless a caller wants them split. + */ + readonly lspOutputChannel: OutputChannel; + readonly router: WorkspaceDocumentRouter; + /** Folder-scoped view of the shell's shared output channel. */ + readonly logger: Logger; + readonly reportStatus: RslintStatusSink; + /** Read lazily so a re-start after a detection change sees current paths. */ + readonly getConfigPaths: () => RslintFolderConfigPaths; +} + +export class Rslint implements Disposable { + private client: LanguageClient | undefined; + private readonly logger: Logger; + public readonly rootKey: string; + public readonly workspaceFolder: WorkspaceFolder; + private readonly router: WorkspaceDocumentRouter; + private readonly reportStatus: RslintStatusSink; + private readonly getConfigPaths: () => RslintFolderConfigPaths; + /** Set by `startImpl` before anything can use a project-resolved module. */ + private resolution: RslintResolution | undefined; + private configLoader: ConfigLoaderModule | undefined; + /** Non-fatal notes appended to the folder's status detail. */ + private statusNotes: string[] = []; + private readonly lspOutputChannel: OutputChannel | undefined; + private readonly outputChannel: OutputChannel | undefined; + private configWatcher: FileSystemWatcher | undefined; + private configReloadTimer: ReturnType | undefined; + private configReloadChain: Promise = Promise.resolve(); + private serverRestartWatcher: Disposable | undefined; + private serverProcessOwner: LanguageServerProcessOwner | undefined; + private stateWatcher: Disposable | undefined; + private readonly requestHandlers: Disposable[] = []; + private lifecycleEpoch = 0; + private pluginDependencyRevision = 0; + private pluginLintPoolDisposed = false; + private configTransactionAdapter: LspConfigTransactionAdapter | undefined; + private startPromise: Promise | undefined; + private startOperation: Promise | undefined; + private clientStartPromise: Promise | undefined; + private closePromise: Promise | undefined; + private closing = false; + /** + * Hosts the in-process WorkerPool that answers Go's reverse + * `rslint/pluginLint` requests for rules mounted via a config's + * object-form `plugins`. It stays uninitialized until a config actually + * mounts plugins. + */ + private readonly pluginLintPool: PluginLintPool; + + constructor(options: RslintOptions) { + this.rootKey = options.rootKey; + this.workspaceFolder = options.workspaceFolder; + this.router = options.router; + this.reportStatus = options.reportStatus; + this.getConfigPaths = options.getConfigPaths; + const logger = options.logger; + this.logger = logger; + this.lspOutputChannel = options.lspOutputChannel; + this.outputChannel = options.outputChannel; + try { + // The resolve-from-project adaptation: the ESLint-plugin host is loaded from the project, + // out of the same `@rslint/core` install as the Go binary. The factory is + // only invoked once a config actually mounts plugins, which is always + // after `startImpl` published `this.resolution`. + this.pluginLintPool = new PluginLintPool(logger, async (configs, onLog) => + this.createPluginHost(configs, onLog), + ); + } catch (error) { + logger.dispose(); + throw error; + } + } + + private async createPluginHost( + configs: ConfigDescriptor[], + onLog: (rec: { level: string; source: string; text: string }) => void, + ): Promise { + const resolution = this.resolution; + if (!resolution) { + throw new Error( + 'the ESLint-plugin host was requested before @rslint/core was resolved from the project', + ); + } + const module = await loadEslintPluginModule(resolution); + return module.createPluginLintHost(configs, onLog); + } + + /** Folder-level status, aggregated by the stack controller (the status-aggregation adaptation). */ + private report(state: StackState): void { + this.reportStatus(state); + } + + private runningDetail(): string | undefined { + return this.statusNotes.length > 0 + ? this.statusNotes.join('; ') + : undefined; + } + + private addStatusNote(note: string): void { + if (!this.statusNotes.includes(note)) { + this.statusNotes.push(note); + } + if (this.isRunning()) { + this.report({ kind: 'running', detail: this.runningDetail() }); + } + } + + public async start(signal: AbortSignal): Promise { + if (this.startPromise) { + await this.startPromise; + return; + } + if (this.closing || signal.aborted) { + throw abortError(signal); + } + this.startOperation = this.startImpl(signal).catch((error: unknown) => { + this.reportStartFailure(error); + throw error; + }); + // The abort facade releases the per-URI coordinator even when JavaScript + // module evaluation itself cannot be interrupted. startImpl retains its + // own rejection handler and epoch checks so a late completion is harmless. + this.startPromise = raceWithAbort(this.startOperation, signal); + void this.startOperation.catch(() => undefined); + await this.startPromise; + } + + /** + * Resolution failure surfaces in the status bar; no silent fallback. + * The two version seams (the support matrix and the config-discovery + * protocol) are additionally separated from an ordinary crash. + */ + private reportStartFailure(error: unknown): void { + if ( + this.closing || + (error instanceof Error && error.name === 'AbortError') + ) { + return; + } + if ( + error instanceof RslintVersionMismatchError || + error instanceof ConfigDiscoveryProtocolMismatchError + ) { + this.report({ kind: 'version-mismatch', detail: error.message }); + return; + } + const detail = + error instanceof RslintResolutionError || error instanceof Error + ? error.message + : String(error); + this.report({ kind: 'crashed', detail }); + } + + private async startImpl(signal: AbortSignal): Promise { + this.configReloadChain = Promise.resolve(); + this.lifecycleEpoch++; + const epoch = this.lifecycleEpoch; + const pluginLintPool = this.pluginLintPool; + this.pluginDependencyRevision = 0; + this.statusNotes = []; + this.report({ kind: 'starting' }); + + // One resolution root for the binary, the config-loader and + // the ESLint-plugin host β€” asserted inside `resolveRslint`. A failure here + // is terminal and user-visible; there is no built-in binary to fall back to. + const resolution = await resolveRslint(this.workspaceFolder, this.logger); + this.assertStartCurrent(epoch, signal); + this.resolution = resolution; + this.logger.info( + `Rslint resolved from the project (${resolution.kind}): ${resolution.coreDir}` + + ` (version ${resolution.coreVersion ?? 'unknown'})`, + ); + + // Compatibility seam (1): the support matrix. `@rslint/core` older than the + // launch floor has no `./config-loader` / `./eslint-plugin` export at all, + // so this check must precede the module load to produce the better message. + const versionCheck = checkPackageVersion( + '@rslint/core', + resolution.coreVersion, + ); + if (versionCheck.kind === 'mismatch') { + throw new RslintVersionMismatchError( + formatVersionMismatch('@rslint/core', versionCheck), + ); + } + + // Compatibility seam (2): the config-discovery protocol handshake. The value + // this yields is what every `rslint/configRefresh` request carries and what + // every server-initiated transaction is validated against, so the Go + // binary and the JS loader can never silently disagree. + const configLoader = await loadConfigLoaderModule(resolution); + this.assertStartCurrent(epoch, signal); + this.configLoader = configLoader; + this.logger.debug( + `Config-discovery protocol version ${configLoader.CONFIG_DISCOVERY_PROTOCOL_VERSION} (project loader: ${resolution.configLoaderPath})`, + ); + + // Compatibility seam (3): the jiti preflight. `@rslint/core`'s config-file-loader + // falls back to jiti when the host's Node cannot strip types, and the host + // Node version is fixed by VS Code rather than by the user. + const jitiNote = describeJitiPreflight({ + folder: this.workspaceFolder, + coreDir: resolution.coreDir, + rslintConfigPaths: this.getConfigPaths().rslintConfigPaths, + }); + if (jitiNote) { + this.logger.warn(jitiNote); + this.statusNotes.push(jitiNote); + } + + const binPath = resolution.binPath; + this.logger.info('Rslint binary path:', binPath); + + const serverProcessOwner = new LanguageServerProcessOwner( + binPath, + ['--lsp'], + this.workspaceFolder.uri.fsPath, + ); + this.serverProcessOwner = serverProcessOwner; + const serverOptions: ServerOptions = async () => { + const process = await serverProcessOwner.start(); + return process; + }; + + // Check if LSP tracing is enabled + const traceServer = workspace + .getConfiguration('rstack.rslint', this.workspaceFolder.uri) + .get('trace.server', 'off'); + const traceEnabled = traceServer !== 'off'; + + const clientOptions = createLanguageClientOptions( + this.workspaceFolder, + this.outputChannel, + this.router.createMiddleware(this), + ); + const errorHandlerHolder: { current?: ErrorHandler } = {}; + clientOptions.errorHandler = { + error: async (error, message, count) => { + const result = await Promise.resolve( + errorHandlerHolder.current?.error(error, message, count) ?? { + action: ErrorAction.Shutdown, + }, + ); + return result; + }, + closed: async () => { + if (this.closing) { + return { action: CloseAction.DoNotRestart, handled: true }; + } + const result = await Promise.resolve( + errorHandlerHolder.current?.closed() ?? { + action: CloseAction.DoNotRestart, + }, + ); + return result; + }, + }; + + if (traceEnabled) { + clientOptions.traceOutputChannel = this.lspOutputChannel; + this.logger.info( + 'LSP tracing enabled, the trace is written to the "Rstack: Rslint" output channel', + ); + } else { + this.logger.debug('LSP tracing disabled by configuration'); + } + + const client = new ManagedLanguageClient( + 'rslint', + `Rslint Language Server (${this.workspaceFolder.name})`, + serverOptions, + clientOptions, + ); + errorHandlerHolder.current = client.createDefaultErrorHandler(); + this.client = client; + this.stateWatcher = client.onDidChangeState((event) => { + this.logger.debug( + `Language client state ${event.oldState} -> ${event.newState}`, + ); + if (this.closing || client !== this.client) { + return; + } + if (event.newState === State.Stopped) { + // The process owner and languageclient's error handler decide whether + // a restart happens; either way the user must see that this folder is + // currently not linting. + this.report({ + kind: 'crashed', + detail: 'the Rslint language server stopped', + }); + } else if (event.newState === State.Running) { + this.report({ kind: 'running', detail: this.runningDetail() }); + } + }); + + try { + const clientStartPromise = client.start(); + this.clientStartPromise = clientStartPromise; + await clientStartPromise; + this.assertStartCurrent(epoch, signal, client); + + const adapter = new LspConfigTransactionAdapter( + new configLoader.ConfigModuleHost(), + pluginLintPool, + (activation) => this.computeActivationFingerprint(activation), + configLoader.CONFIG_DISCOVERY_PROTOCOL_VERSION, + (error) => { + this.report({ kind: 'version-mismatch', detail: error.message }); + }, + ); + this.configTransactionAdapter = adapter; + + this.requestHandlers.push( + client.onRequest( + 'rslint/loadConfigs', + async (params: LoadConfigsRequest, token: CancellationToken) => + this.observeConfigTransaction(async () => + withCancellationSignal(token, async (requestSignal) => + adapter.loadConfigs(params, requestSignal), + ), + ), + ), + ); + this.requestHandlers.push( + client.onRequest( + 'rslint/activateConfigs', + async (params: ActivateConfigsRequest, token: CancellationToken) => + this.observeConfigTransaction(async () => + withCancellationSignal(token, async (requestSignal) => + adapter.activateConfigs(params, requestSignal), + ), + ), + ), + ); + this.requestHandlers.push( + client.onRequest( + 'rslint/commitConfigs', + async (params: ConfigTransactionControlRequest) => + adapter.commitConfigs(params), + ), + ); + this.requestHandlers.push( + client.onRequest( + 'rslint/abortConfigs', + async (params: ConfigTransactionControlRequest) => + adapter.abortConfigs(params), + ), + ); + + // Answer Go's reverse `rslint/pluginLint` requests: Go lints + // natively but dispatches rules mounted via a config's object-form + // `plugins` back to us, where the JS WorkerPool runs them. The generic + // string-method overload of `onRequest` handles server-initiated custom + // requests. The handler's CancellationToken β€” fired when Go sends + // $/cancelRequest for a superseded keystroke / closed document β€” is + // threaded through to the pool, which bridges it to an AbortSignal and + // cancels the in-flight worker tasks. + this.requestHandlers.push( + client.onRequest( + 'rslint/pluginLint', + async (params: EslintPluginLintRequest, token: CancellationToken) => + pluginLintPool.lint(params, token), + ), + ); + + // client.start() has already emitted the initial Running transition. Any + // later Running event belongs to an automatic native-server restart. + // Reset router-side server-open state before LanguageClient replays open + // documents, then rebuild the replacement Go process's config catalog. + this.serverRestartWatcher = client.onDidChangeState((event) => { + if ( + shouldResetDocumentSessionOnServerState( + event.oldState, + event.newState, + ) + ) { + this.router.resetServerSession(this).catch((error: unknown) => { + this.logger.error( + 'Failed to reset documents after server exit', + error, + ); + }); + } + const recovery = recoverConfigDiscoveryOnServerState( + event.newState, + async (reason, beforeRequest) => { + await this.router.resetServerSession(this); + await this.requestConfigRefresh(reason, beforeRequest); + }, + ); + recovery?.then( + () => { + this.logger.info( + 'Documents and config discovery recovered after server restart', + ); + }, + (error: unknown) => { + this.logger.error('Failed to recover after server restart', error); + }, + ); + }); + + if (traceEnabled) { + const traceLevel = + traceServer === 'verbose' ? Trace.Verbose : Trace.Messages; + await client.setTrace(traceLevel); + this.assertStartCurrent(epoch, signal, client); + this.logger.info(`LSP trace level set to: ${traceServer}`); + } + + this.installConfigRefreshWatcher(); + // The watcher is live before initial discovery, so a mutation during a + // slow module evaluation schedules a second serialized transaction. + // A plugin worker is prepared between two config fingerprints. If the + // initial source changes in that window, Go correctly aborts the + // generation. Retry once from the now-current bytes instead of tearing + // down the language client before the already-live watcher can recover. + const retried = await retryConfigRefreshOnSourceChange( + async () => { + await this.requestConfigRefresh('initial'); + }, + async () => { + await this.requestConfigRefresh('config-change'); + }, + ); + this.assertStartCurrent(epoch, signal, client); + if (retried) { + this.logger.warn( + 'Config changed during initial activation; discovery recovered on retry', + ); + } + + this.logger.info('Rslint language client started successfully'); + // Any bridge note was already collected before the client started, so it + // is part of the very first `running` detail. + this.report({ kind: 'running', detail: this.runningDetail() }); + } catch (err: unknown) { + this.logger.error('Failed to start Rslint language client', err); + throw err; + } + } + + /** + * Surfaces the two failure classes that must stay actionable + * instead of generic: a config-discovery protocol disagreement and the + * config-file-loader's "Install jiti as a dependency" error. + */ + private async observeConfigTransaction( + operation: () => Promise, + ): Promise { + try { + return await operation(); + } catch (error) { + if (error instanceof ConfigTransactionProtocolMismatchError) { + // Already reported through the adapter's `onProtocolMismatch`. + throw error; + } + if (isJitiMissingError(error)) { + this.addStatusNote( + 'a TypeScript Rslint config could not be loaded: ' + + JITI_INSTALL_HINT, + ); + } + throw error; + } + } + + private assertStartCurrent( + epoch: number, + signal: AbortSignal, + client?: LanguageClient, + ): void { + throwIfAborted(signal); + if ( + this.closing || + epoch !== this.lifecycleEpoch || + (client !== undefined && client !== this.client) + ) { + throw abortError(signal); + } + } + + private installConfigRefreshWatcher(): void { + this.configWatcher = workspace.createFileSystemWatcher( + // Go owns the config-scoped .gitignore watcher and refresh transaction. + // Keeping it out of this direct watcher prevents one mutation from + // starting both a didChangeWatchedFiles and a configRefresh transaction. + new RelativePattern(this.workspaceFolder, CONFIG_REFRESH_WATCH_GLOB), + ); + const refreshConfig = (uri: Uri) => { + const reason = configRefreshReasonForPath(uri.fsPath); + if (reason === 'dependency-change') { + // The actual package contents can change while config source bytes stay + // identical. Feed a monotonic dependency revision into the staged host + // fingerprint so any lockfile mutation forces a worker rebuild. + this.pluginDependencyRevision++; + } + this.logger.debug(`${reason}: ${uri.fsPath}`); + clearTimeout(this.configReloadTimer); + this.configReloadTimer = setTimeout(() => { + this.configReloadTimer = undefined; + this.requestConfigRefresh(reason).catch((err: unknown) => { + this.logger.error('Failed to refresh config discovery', err); + }); + }, 300); + }; + this.configWatcher.onDidChange(refreshConfig); + this.configWatcher.onDidCreate(refreshConfig); + this.configWatcher.onDidDelete(refreshConfig); + } + + private async requestConfigRefresh( + reason: ConfigRefreshReason, + beforeRequest?: (adapter: LspConfigTransactionAdapter) => Promise, + ): Promise { + const epoch = this.lifecycleEpoch; + const client = this.client; + const pluginLintPool = this.pluginLintPool; + const adapter = this.configTransactionAdapter; + const configLoader = this.configLoader; + if (!client || !adapter || !configLoader || this.pluginLintPoolDisposed) { + return; + } + const refresh = this.configReloadChain.then(async () => { + if ( + !this.isLifecycleCurrent(epoch, client, pluginLintPool) || + adapter !== this.configTransactionAdapter + ) { + return; + } + await beforeRequest?.(adapter); + if ( + !this.isLifecycleCurrent(epoch, client, pluginLintPool) || + adapter !== this.configTransactionAdapter + ) { + return; + } + const request: ConfigRefreshRequest = { + protocolVersion: configLoader.CONFIG_DISCOVERY_PROTOCOL_VERSION, + reason, + }; + await client.sendRequest('rslint/configRefresh', request); + }); + this.configReloadChain = refresh.catch(() => undefined); + await refresh; + } + + private isLifecycleCurrent( + epoch: number, + client: LanguageClient, + pluginLintPool: PluginLintPool, + ): boolean { + return ( + epoch === this.lifecycleEpoch && + client === this.client && + pluginLintPool === this.pluginLintPool && + !this.pluginLintPoolDisposed && + !this.closing + ); + } + + /** + * Fingerprint the inputs that decide whether the plugin host must rebuild: + * Go's selected config snapshots plus each workspace-root lockfile's + * existence, mtime, and size. A dependency install can replace plugin code + * without changing config, so the lockfile also feeds the key. + */ + private computeMetadataFingerprint(filePath: string): string { + try { + const stat = fs.statSync(filePath); + return `${stat.mtimeMs}:${stat.size}`; + } catch { + return 'absent'; + } + } + + private computeActivationFingerprint( + activation: ConfigModuleActivationPlan, + ): string { + const sourceFingerprint = this.computeFingerprint( + activation.pluginConfigs.map((config) => config.configPath), + activation.configs, + ); + return `${sourceFingerprint}|dependency-revision:${this.pluginDependencyRevision}`; + } + + private computeFingerprint( + configPaths: string[], + configs: ReadonlyArray<{ + configPath: string; + sourceFingerprint: string; + }>, + ): string { + const parts: string[] = []; + const sourceFingerprintByPath = new Map( + configs.map((config) => [ + path.normalize(config.configPath), + config.sourceFingerprint, + ]), + ); + for (const p of [...configPaths].sort()) { + const sourceFingerprint = sourceFingerprintByPath.get(path.normalize(p)); + if (sourceFingerprint === undefined) { + throw new Error(`missing source fingerprint for plugin config ${p}`); + } + parts.push(`${p}:${sourceFingerprint}`); + } + for (const name of LOCKFILE_NAMES) { + const lockPath = path.join(this.workspaceFolder.uri.fsPath, name); + parts.push(`lock:${name}:${this.computeMetadataFingerprint(lockPath)}`); + } + return parts.join('|'); + } + + public async close(): Promise { + await (this.closePromise ??= this.closeImpl()); + } + + private async closeImpl(): Promise { + const errors: unknown[] = []; + const disposeSafely = (resource: Disposable | undefined): void => { + if (!resource) return; + try { + resource.dispose(); + } catch (error) { + errors.push(error); + } + }; + + this.closing = true; + this.lifecycleEpoch++; + clearTimeout(this.configReloadTimer); + this.configReloadTimer = undefined; + disposeSafely(this.serverRestartWatcher); + this.serverRestartWatcher = undefined; + disposeSafely(this.configWatcher); + this.configWatcher = undefined; + disposeSafely(this.configTransactionAdapter); + this.configTransactionAdapter = undefined; + for (const handler of this.requestHandlers.splice(0)) { + disposeSafely(handler); + } + disposeSafely(this.stateWatcher); + this.stateWatcher = undefined; + // Do not await startOperation/configReloadChain: user module evaluation can + // contain a non-settling top-level await. Epoch/closing checks fence every + // late continuation from publishing resources or state. + this.configReloadChain = Promise.resolve(); + this.pluginLintPoolDisposed = true; + + const client = this.client; + this.client = undefined; + const clientStartPromise = this.clientStartPromise; + this.clientStartPromise = undefined; + const clientStopped = + client?.state === State.Starting + ? observeClientStopped(client) + : undefined; + const serverProcessOwner = this.serverProcessOwner; + this.serverProcessOwner = undefined; + // Block vscode-languageclient's automatic restart callback before its + // graceful client shutdown begins. The owner force-terminates and awaits + // any surviving child after the bounded protocol shutdown finishes. + serverProcessOwner?.beginClose(); + const asynchronousCleanups: Promise[] = [ + (async () => { + await this.pluginLintPool.dispose(); + })(), + ]; + if (client) { + asynchronousCleanups.push( + (async () => { + const clientErrors: unknown[] = []; + try { + await disposeLanguageClient(client); + } catch (error) { + clientErrors.push(error); + } + try { + await serverProcessOwner?.close(); + } catch (error) { + clientErrors.push(error); + } + if (clientStartPromise) { + try { + await waitForPromiseSettlement( + clientStartPromise, + 2_000, + 'language client start', + ); + } catch (error) { + clientErrors.push(error); + } + } + if (clientStopped) { + try { + await waitForPromiseSettlement( + clientStopped.promise, + 2_000, + 'language client terminal state', + ); + } catch (error) { + clientErrors.push(error); + } finally { + clientStopped.dispose(); + } + } + if (clientErrors.length > 0) { + throw new AggregateError( + clientErrors, + 'failed to close language client resources', + ); + } + })(), + ); + } else if (serverProcessOwner) { + asynchronousCleanups.push( + (async () => { + await serverProcessOwner.close(); + })(), + ); + } + const results = await Promise.allSettled(asynchronousCleanups); + this.pluginDependencyRevision = 0; + + for (const result of results) { + if (result.status === 'rejected') { + const reason: unknown = result.reason; + errors.push(reason); + } + } + try { + for (const error of errors) { + this.logger.error('Failed to close Rslint workspace resource', error); + } + if (errors.length === 0) { + this.logger.info('Rslint language client closed'); + } + } catch (error) { + errors.push(error); + } + try { + this.logger.dispose(); + } catch (error) { + errors.push(error); + } + if (errors.length > 0) { + throw new AggregateError(errors, 'failed to close Rslint workspace'); + } + } + + public isRunning(): boolean { + return this.client?.state === State.Running; + } + + public async sendDocumentOpen(document: TextDocument): Promise { + const provider = this.client + ?.getFeature(DidOpenTextDocumentNotification.method) + .getProvider(document); + if (!provider) { + throw new Error(`didOpen provider is unavailable for ${document.uri}`); + } + await provider.send(document); + } + + public async sendDocumentClose(document: TextDocument): Promise { + const provider = this.client + ?.getFeature(DidCloseTextDocumentNotification.method) + .getProvider(document); + if (!provider) { + throw new Error(`didClose provider is unavailable for ${document.uri}`); + } + await provider.send(document); + } + + public clearDocumentDiagnostics(uri: Uri): void { + this.client?.diagnostics?.delete(uri); + } + + public dispose(): void { + void this.close().catch(() => undefined); + } +} diff --git a/packages/vscode/src/stacks/lint/WorkspaceDocumentRouter.ts b/packages/vscode/src/stacks/lint/WorkspaceDocumentRouter.ts new file mode 100644 index 0000000..387683e --- /dev/null +++ b/packages/vscode/src/stacks/lint/WorkspaceDocumentRouter.ts @@ -0,0 +1,504 @@ +import { + languages, + workspace, + RelativePattern, + type CodeAction, + type Command, + type DocumentFilter, + type TextDocument, + type Uri, + type WorkspaceFolder, +} from 'vscode'; +import type { Middleware } from 'vscode-languageclient/node'; + +const SUPPORTED_LANGUAGE_IDS = new Set([ + 'typescript', + 'typescriptreact', + 'javascript', + 'javascriptreact', +]); + +export interface DocumentRoutingRuntime { + readonly rootKey: string; + readonly workspaceFolder: WorkspaceFolder; + sendDocumentOpen(document: TextDocument): Promise; + sendDocumentClose(document: TextDocument): Promise; + clearDocumentDiagnostics(uri: Uri): void; +} + +interface ActiveRuntime { + readonly runtime: DocumentRoutingRuntime; + readonly selector: DocumentFilter[]; +} + +interface ServerOpenDocumentSession { + readonly runtime: DocumentRoutingRuntime; + readonly document: TextDocument; +} + +type TextSyncKind = 'open' | 'close'; + +function documentKey(document: TextDocument): string { + return document.uri.toString(); +} + +function permitKey(kind: TextSyncKind, document: TextDocument): string { + return `${kind}\0${documentKey(document)}`; +} + +function errorList(error: unknown): unknown[] { + if (!(error instanceof AggregateError)) return [error]; + const errors: readonly unknown[] = error.errors; + return [...errors]; +} + +function throwCollectedErrors(errors: unknown[], message: string): void { + if (errors.length === 0) return; + if (errors.length === 1) throw errors[0]; + throw new AggregateError(errors, message); +} + +export function createWorkspaceDocumentSelector( + workspaceFolder: WorkspaceFolder, +): DocumentFilter[] { + const workspacePattern = new RelativePattern(workspaceFolder, '**/*'); + return [...SUPPORTED_LANGUAGE_IDS].map((language) => ({ + scheme: 'file', + language, + pattern: workspacePattern, + })); +} + +export function isSupportedWorkspaceDocument( + document: Pick, +): boolean { + return ( + document.uri.scheme === 'file' && + SUPPORTED_LANGUAGE_IDS.has(document.languageId) + ); +} + +/** + * Owns document-to-root routing independently from root process lifecycle. + * + * Root start/config evaluation never runs on this queue. The queue contains + * only bounded document notification handoffs and middleware gates, so a + * non-settling user config cannot block unrelated workspace lifecycle work. + */ +export class WorkspaceDocumentRouter { + private activeRoots = new Map(); + private readonly serverOpenDocuments = new Map< + string, + ServerOpenDocumentSession + >(); + private readonly documentEpochs = new WeakMap(); + private readonly transferPermits = new Map< + DocumentRoutingRuntime, + Set + >(); + private operationTail: Promise = Promise.resolve(); + + public createMiddleware(runtime: DocumentRoutingRuntime): Middleware { + return { + didOpen: async (document, next) => { + if (this.hasTransferPermit('open', runtime, document)) { + return next(document); + } + return this.enqueue(async () => { + const uri = documentKey(document); + if ( + this.ownerForDocument(document) !== runtime || + this.serverOpenDocuments.has(uri) + ) { + return; + } + await next(document); + this.serverOpenDocuments.set(uri, { runtime, document }); + this.bumpDocumentEpoch(document); + }); + }, + didChange: async (event, next) => + this.enqueue(async () => { + if (!this.isServerOpenOwner(runtime, event.document)) return; + await next(event); + }), + didSave: async (document, next) => + this.enqueue(async () => { + if (!this.isServerOpenOwner(runtime, document)) return; + await next(document); + }), + didClose: async (document, next) => { + if (this.hasTransferPermit('close', runtime, document)) { + return next(document); + } + return this.enqueue(async () => { + const uri = documentKey(document); + if (this.serverOpenDocuments.get(uri)?.runtime !== runtime) return; + const errors: unknown[] = []; + try { + await next(document); + } catch (error) { + errors.push(...errorList(error)); + } + this.releaseDocumentSession(runtime, document, errors); + throwCollectedErrors(errors, 'failed to close routed document'); + }); + }, + provideCodeActions: async ( + document, + range, + context, + token, + next, + ): Promise<(Command | CodeAction)[] | null | undefined> => { + if (!this.isServerOpenOwner(runtime, document)) return undefined; + const epoch = this.documentEpoch(document); + const result = await Promise.resolve( + next(document, range, context, token), + ); + if ( + epoch !== this.documentEpoch(document) || + !this.isServerOpenOwner(runtime, document) + ) { + return undefined; + } + return result; + }, + handleDiagnostics: (uri, diagnostics, next) => { + const document = workspace.textDocuments.find( + (candidate) => candidate.uri.toString() === uri.toString(), + ); + if (!document || !this.isServerOpenOwner(runtime, document)) return; + next(uri, diagnostics); + }, + }; + } + + public async activate(runtime: DocumentRoutingRuntime): Promise { + return this.enqueue(async () => { + const existing = this.activeRoots.get(runtime.rootKey); + if (existing?.runtime === runtime) return; + if (existing) { + throw new Error( + `workspace root ${JSON.stringify(runtime.rootKey)} is already active`, + ); + } + + const before = this.activeRoots; + const after = new Map(before); + after.set(runtime.rootKey, { + runtime, + selector: createWorkspaceDocumentSelector(runtime.workspaceFolder), + }); + await this.transferDocuments(before, after, true); + }); + } + + public async deactivate(rootKey: string): Promise { + return this.enqueue(async () => { + const removed = this.activeRoots.get(rootKey); + if (!removed) return; + const before = this.activeRoots; + const after = new Map(before); + after.delete(rootKey); + const errors: unknown[] = []; + try { + await this.transferDocuments(before, after, false); + } catch (error) { + errors.push(...errorList(error)); + } + + // Removal is forward-only. Drain sessions that no longer appear in + // workspace.textDocuments (for example, a close during a restart + // listener gap) by exact runtime identity before forgetting the root. + this.activeRoots = after; + for (const session of [...this.serverOpenDocuments.values()]) { + if (session.runtime !== removed.runtime) continue; + this.releaseDocumentSession(removed.runtime, session.document, errors); + } + throwCollectedErrors(errors, 'failed to deactivate document owner'); + }); + } + + /** + * Invalidates the document session owned by a native process that exited. + * The LanguageClient re-registers didOpen after its replacement reaches + * Running; those callbacks share this queue and therefore run after reset. + */ + public async resetServerSession( + runtime: DocumentRoutingRuntime, + ): Promise { + return this.enqueue(() => { + if (this.activeRoots.get(runtime.rootKey)?.runtime !== runtime) return; + const errors: unknown[] = []; + for (const session of [...this.serverOpenDocuments.values()]) { + if (session.runtime !== runtime) continue; + this.releaseDocumentSession(runtime, session.document, errors); + } + throwCollectedErrors(errors, 'failed to reset routed server session'); + }); + } + + public async closeAll(): Promise { + return this.enqueue(async () => { + const errors: unknown[] = []; + for (const session of [...this.serverOpenDocuments.values()]) { + try { + await this.sendClose(session.runtime, session.document); + } catch (error) { + errors.push(...errorList(error)); + } + this.releaseDocumentSession(session.runtime, session.document, errors); + } + this.serverOpenDocuments.clear(); + this.activeRoots = new Map(); + throwCollectedErrors(errors, 'failed to close routed documents'); + }); + } + + public getServerOpenOwner(document: TextDocument): string | undefined { + return this.serverOpenDocuments.get(documentKey(document))?.runtime.rootKey; + } + + public ownerKeyForDocument(document: TextDocument): string | undefined { + return this.ownerKeyForDocumentIn(this.activeRoots, document); + } + + private async transferDocuments( + before: Map, + after: Map, + rollbackOnFailure: boolean, + ): Promise { + const transfers = workspace.textDocuments + .filter(isSupportedWorkspaceDocument) + .map((document) => ({ + document, + oldOwnerKey: this.ownerKeyForDocumentIn(before, document), + newOwnerKey: this.ownerKeyForDocumentIn(after, document), + })) + .filter(({ oldOwnerKey, newOwnerKey }) => oldOwnerKey !== newOwnerKey); + + const closedOld: typeof transfers = []; + const openedNew: typeof transfers = []; + const errors: unknown[] = []; + + for (const transfer of transfers) { + if (!transfer.oldOwnerKey) continue; + const oldOwner = before.get(transfer.oldOwnerKey); + if (!oldOwner) continue; + const uri = documentKey(transfer.document); + if (this.serverOpenDocuments.get(uri)?.runtime !== oldOwner.runtime) { + continue; + } + // A rejected notification promise does not prove that no bytes reached + // the server. Treat every attempted close as needing compensation. + closedOld.push(transfer); + try { + await this.sendClose(oldOwner.runtime, transfer.document); + } catch (error) { + errors.push(...errorList(error)); + } + this.releaseDocumentSession(oldOwner.runtime, transfer.document, errors); + } + + if (errors.length > 0 && rollbackOnFailure) { + await this.restoreOldOwners(before, closedOld, errors); + throw new AggregateError( + errors, + 'failed to close previous document owners', + ); + } + + this.activeRoots = after; + + for (const transfer of transfers) { + if (!transfer.newOwnerKey) continue; + const newOwner = after.get(transfer.newOwnerKey); + if (!newOwner) continue; + try { + await this.sendOpen(newOwner.runtime, transfer.document); + this.serverOpenDocuments.set(documentKey(transfer.document), { + runtime: newOwner.runtime, + document: transfer.document, + }); + this.bumpDocumentEpoch(transfer.document); + openedNew.push(transfer); + } catch (error) { + errors.push(...errorList(error)); + } + } + + if (errors.length === 0) return; + if (!rollbackOnFailure) { + throw new AggregateError( + errors, + 'failed to open fallback document owners', + ); + } + + for (const transfer of openedNew.reverse()) { + if (!transfer.newOwnerKey) continue; + const newOwner = after.get(transfer.newOwnerKey); + if (!newOwner) continue; + try { + await this.sendClose(newOwner.runtime, transfer.document); + } catch (error) { + errors.push(...errorList(error)); + } + this.releaseDocumentSession(newOwner.runtime, transfer.document, errors); + } + this.activeRoots = before; + await this.restoreOldOwners(before, closedOld, errors); + throw new AggregateError(errors, 'failed to activate document owner'); + } + + private async restoreOldOwners( + before: Map, + transfers: ReadonlyArray<{ + document: TextDocument; + oldOwnerKey: string | undefined; + }>, + errors: unknown[], + ): Promise { + this.activeRoots = before; + for (const transfer of transfers) { + if (!transfer.oldOwnerKey) continue; + const oldOwner = before.get(transfer.oldOwnerKey); + if (!oldOwner) continue; + try { + await this.sendOpen(oldOwner.runtime, transfer.document); + this.serverOpenDocuments.set(documentKey(transfer.document), { + runtime: oldOwner.runtime, + document: transfer.document, + }); + this.bumpDocumentEpoch(transfer.document); + } catch (error) { + errors.push(...errorList(error)); + } + } + } + + private ownerKeyForDocumentIn( + roots: ReadonlyMap, + document: TextDocument, + ): string | undefined { + if (!isSupportedWorkspaceDocument(document)) return undefined; + let best: { key: string; depth: number } | undefined; + for (const [key, entry] of roots) { + if (languages.match(entry.selector, document) <= 0) continue; + const depth = entry.runtime.workspaceFolder.uri.path + .split('/') + .filter(Boolean).length; + if ( + !best || + depth > best.depth || + (depth === best.depth && key.localeCompare(best.key) < 0) + ) { + best = { key, depth }; + } + } + return best?.key; + } + + private ownerForDocument( + document: TextDocument, + ): DocumentRoutingRuntime | undefined { + const ownerKey = this.ownerKeyForDocument(document); + return ownerKey ? this.activeRoots.get(ownerKey)?.runtime : undefined; + } + + private isServerOpenOwner( + runtime: DocumentRoutingRuntime, + document: TextDocument, + ): boolean { + const session = this.serverOpenDocuments.get(documentKey(document)); + return ( + this.ownerForDocument(document) === runtime && + session?.runtime === runtime && + session.document === document + ); + } + + private releaseDocumentSession( + runtime: DocumentRoutingRuntime, + document: TextDocument, + errors: unknown[], + ): void { + const uri = documentKey(document); + try { + runtime.clearDocumentDiagnostics(document.uri); + } catch (error) { + errors.push(...errorList(error)); + } finally { + if (this.serverOpenDocuments.get(uri)?.runtime === runtime) { + this.serverOpenDocuments.delete(uri); + } + this.bumpDocumentEpoch(document); + } + } + + private async sendOpen( + runtime: DocumentRoutingRuntime, + document: TextDocument, + ): Promise { + const key = permitKey('open', document); + const permits = this.transferPermits.get(runtime) ?? new Set(); + permits.add(key); + this.transferPermits.set(runtime, permits); + let send: Promise; + try { + // getProvider().send() enters middleware synchronously before returning + // its promise, so the narrowly-scoped permit cannot leak across awaits. + send = runtime.sendDocumentOpen(document); + } finally { + permits.delete(key); + if (permits.size === 0) this.transferPermits.delete(runtime); + } + await send; + } + + private async sendClose( + runtime: DocumentRoutingRuntime, + document: TextDocument, + ): Promise { + const key = permitKey('close', document); + const permits = this.transferPermits.get(runtime) ?? new Set(); + permits.add(key); + this.transferPermits.set(runtime, permits); + let send: Promise; + try { + send = runtime.sendDocumentClose(document); + } finally { + permits.delete(key); + if (permits.size === 0) this.transferPermits.delete(runtime); + } + await send; + } + + private hasTransferPermit( + kind: TextSyncKind, + runtime: DocumentRoutingRuntime, + document: TextDocument, + ): boolean { + return ( + this.transferPermits.get(runtime)?.has(permitKey(kind, document)) ?? false + ); + } + + private documentEpoch(document: TextDocument): number { + return this.documentEpochs.get(document) ?? 0; + } + + private bumpDocumentEpoch(document: TextDocument): void { + this.documentEpochs.set(document, this.documentEpoch(document) + 1); + } + + private async enqueue(operation: () => Promise | T): Promise { + const run = this.operationTail.then(operation, operation); + this.operationTail = run.then( + () => undefined, + () => undefined, + ); + return run; + } +} diff --git a/packages/vscode/src/stacks/lint/WorkspaceRslintCoordinator.ts b/packages/vscode/src/stacks/lint/WorkspaceRslintCoordinator.ts new file mode 100644 index 0000000..ce51996 --- /dev/null +++ b/packages/vscode/src/stacks/lint/WorkspaceRslintCoordinator.ts @@ -0,0 +1,504 @@ +import type { WorkspaceFolder, WorkspaceFoldersChangeEvent } from 'vscode'; +import type { DocumentRoutingRuntime } from './WorkspaceDocumentRouter'; + +export interface WorkspaceRuntime extends DocumentRoutingRuntime { + start(signal: AbortSignal): Promise; + close(): Promise; +} + +export type WorkspaceRuntimeFactory = ( + folder: WorkspaceFolder, + rootKey: string, +) => WorkspaceRuntime; + +export interface WorkspaceRootRouter { + activate(runtime: DocumentRoutingRuntime): Promise; + deactivate(rootKey: string): Promise; + closeAll(): Promise; +} + +export interface WorkspaceCoordinatorLogger { + debug(message: string, ...args: unknown[]): void; + info(message: string, ...args: unknown[]): void; + warn(message: string, ...args: unknown[]): void; + error(message: string, error?: unknown, ...args: unknown[]): void; +} + +interface DesiredRoot { + readonly folder: WorkspaceFolder; + readonly generation: number; + readonly readiness: Deferred; +} + +interface CurrentRuntime { + readonly generation: number; + readonly runtime: WorkspaceRuntime; + readonly abortController: AbortController; + phase: 'starting' | 'active' | 'closing' | 'close-failed'; + closeError?: unknown; + closeFailureRecorded?: boolean; +} + +interface RootSlot { + readonly key: string; + current?: CurrentRuntime; + failedGeneration?: number; + worker?: Promise; + rerun: boolean; +} + +interface Deferred { + readonly promise: Promise; + resolve(value: T | PromiseLike): void; + reject(reason?: unknown): void; +} + +function deferred(): Deferred { + let resolvePromise!: (value: T | PromiseLike) => void; + let rejectPromise!: (reason?: unknown) => void; + let settled = false; + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + // Root readiness is also observed by dynamic fire-and-forget reconciliation. + // Keep a rejection handler attached even when no activation caller awaits it. + void promise.catch(() => undefined); + return { + promise, + resolve(value) { + if (settled) return; + settled = true; + resolvePromise(value); + }, + reject(reason) { + if (settled) return; + settled = true; + rejectPromise(reason); + }, + }; +} + +function cancellationError(rootKey: string): Error { + const error = new Error( + `workspace root ${JSON.stringify(rootKey)} was superseded`, + ); + error.name = 'AbortError'; + return error; +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError'; +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function errorReasons(error: unknown): unknown[] { + if (!(error instanceof AggregateError)) return [error]; + const reasons: readonly unknown[] = error.errors; + return [...reasons]; +} + +function folderMetadataChanged( + previous: WorkspaceFolder, + next: WorkspaceFolder, +): boolean { + // index is positional metadata and routinely changes when an unrelated root + // is inserted before this one. It is neither identity nor a restart reason. + return previous.name !== next.name; +} + +export function workspaceRootKey(folder: WorkspaceFolder): string { + return folder.uri.toString(); +} + +/** + * Reconciles VS Code workspace-folder identity with independently-lived root + * runtimes. It never serializes different roots behind config evaluation or + * worker shutdown; only each URI slot is ordered. + */ +export class WorkspaceRslintCoordinator { + private readonly desiredRoots = new Map(); + private readonly slots = new Map(); + private readonly generations = new Map(); + private readonly terminalCloseErrors: unknown[] = []; + private topologyChanged = deferred(); + private closePromise: Promise | undefined; + private closing = false; + + public constructor( + private readonly router: WorkspaceRootRouter, + private readonly runtimeFactory: WorkspaceRuntimeFactory, + private readonly logger: WorkspaceCoordinatorLogger, + /** + * Classifies a start rejection as an expected outcome instead of a failure. + * A root that deliberately does not start (for example a folder with no + * lint configuration at all) still rejects β€” that is how a slot is kept + * from being retried β€” but it must not be logged as an error. + */ + private readonly isExpectedStartFailure: (error: unknown) => boolean = () => + false, + ) {} + + public async initialize(folders: readonly WorkspaceFolder[]): Promise { + this.reconcile(folders); + await this.waitForAnyDesiredRoot(); + } + + public handleWorkspaceFoldersChanged( + event: WorkspaceFoldersChangeEvent, + folders: readonly WorkspaceFolder[], + ): void { + if (this.closing) return; + const removedKeys = new Set(); + for (const folder of event.removed) { + removedKeys.add(workspaceRootKey(folder)); + } + const forceReplace = new Set(); + for (const folder of event.added) { + const key = workspaceRootKey(folder); + // A rename/remove+add replacement can preserve the URI. A plain added + // event for a URI already captured by the activation snapshot is not a + // replacement and must not abort that in-flight initial generation. + if (removedKeys.has(key)) forceReplace.add(key); + } + this.reconcile(folders, forceReplace); + } + + public async close(): Promise { + await (this.closePromise ??= this.closeImpl()); + } + + private reconcile( + folders: readonly WorkspaceFolder[], + forceReplace: ReadonlySet = new Set(), + ): void { + if (this.closing) return; + const nextFolders = new Map( + folders.map((folder) => [workspaceRootKey(folder), folder]), + ); + const changedKeys = new Set(); + + for (const [key, desired] of this.desiredRoots) { + const next = nextFolders.get(key); + if (!next) { + this.desiredRoots.delete(key); + this.nextGeneration(key); + desired.readiness.reject(cancellationError(key)); + changedKeys.add(key); + this.slots + .get(key) + ?.current?.abortController.abort(cancellationError(key)); + continue; + } + if ( + forceReplace.has(key) || + folderMetadataChanged(desired.folder, next) + ) { + desired.readiness.reject(cancellationError(key)); + const replacement = this.createDesiredRoot(next); + this.desiredRoots.set(key, replacement); + changedKeys.add(key); + this.slots + .get(key) + ?.current?.abortController.abort(cancellationError(key)); + } + nextFolders.delete(key); + } + + for (const [key, folder] of nextFolders) { + this.desiredRoots.set(key, this.createDesiredRoot(folder)); + changedKeys.add(key); + } + + for (const key of changedKeys) this.kick(key); + if (changedKeys.size > 0) this.signalTopologyChanged(); + } + + private createDesiredRoot(folder: WorkspaceFolder): DesiredRoot { + return { + folder, + generation: this.nextGeneration(workspaceRootKey(folder)), + readiness: deferred(), + }; + } + + private async waitForAnyDesiredRoot(): Promise { + for (;;) { + const snapshot = [...this.desiredRoots.entries()]; + if (snapshot.length === 0) return; + // Resolve as soon as one independent root is usable. A pending root + // cannot undo another root's successful activation. + const readiness: Promise[] = []; + for (const [, desired] of snapshot) { + readiness.push(desired.readiness.promise); + } + const result = await Promise.race([ + Promise.any(readiness).then( + () => ({ kind: 'ready' as const }), + (error: unknown) => ({ kind: 'failed' as const, error }), + ), + this.topologyChanged.promise.then(() => ({ + kind: 'topology' as const, + })), + ]); + if (result.kind === 'topology') continue; + if (result.kind === 'ready') { + return; + } + if (this.closing) throw result.error; + const topologyChanged = + snapshot.length !== this.desiredRoots.size || + snapshot.some( + ([key, desired]) => + this.desiredRoots.get(key)?.generation !== desired.generation, + ); + // Folder events are installed before initialization. If that topology + // superseded every promise in this snapshot, observe the new desired + // generations instead of treating cancellation as an activation + // failure. + if (topologyChanged) continue; + + const reasons = errorReasons(result.error); + throw new AggregateError( + reasons, + `All Rslint workspace roots failed: ${reasons + .map((reason) => + reason instanceof Error ? reason.message : String(reason), + ) + .join('; ')}`, + ); + } + } + + private signalTopologyChanged(): void { + const previous = this.topologyChanged; + this.topologyChanged = deferred(); + previous.resolve(undefined); + } + + private nextGeneration(rootKey: string): number { + const generation = (this.generations.get(rootKey) ?? 0) + 1; + this.generations.set(rootKey, generation); + return generation; + } + + private kick(rootKey: string): void { + let slot = this.slots.get(rootKey); + if (!slot) { + slot = { key: rootKey, rerun: false }; + this.slots.set(rootKey, slot); + } + slot.rerun = true; + if (slot.worker) return; + slot.worker = this.runSlot(slot).finally(() => { + slot.worker = undefined; + if (slot.rerun && !this.closing) { + this.kick(rootKey); + } else if (!slot.current && !this.desiredRoots.has(rootKey)) { + this.slots.delete(rootKey); + this.generations.delete(rootKey); + } + }); + } + + private async runSlot(slot: RootSlot): Promise { + while (slot.rerun || this.slotNeedsReconcile(slot)) { + slot.rerun = false; + const desired = this.desiredRoots.get(slot.key); + const current = slot.current; + + if ( + current && + (!desired || desired.generation !== current.generation || this.closing) + ) { + if (!(await this.closeCurrent(slot, current))) return; + continue; + } + + if (!current && desired && !this.closing) { + if (slot.failedGeneration === desired.generation) return; + if (!(await this.startDesired(slot, desired))) return; + continue; + } + + return; + } + } + + private slotNeedsReconcile(slot: RootSlot): boolean { + const desired = this.desiredRoots.get(slot.key); + const current = slot.current; + if (this.closing) return current !== undefined; + if (!current) { + return !!desired && slot.failedGeneration !== desired.generation; + } + return !desired || desired.generation !== current.generation; + } + + private async startDesired( + slot: RootSlot, + desired: DesiredRoot, + ): Promise { + const abortController = new AbortController(); + let runtime: WorkspaceRuntime; + try { + runtime = this.runtimeFactory(desired.folder, slot.key); + } catch (error) { + slot.failedGeneration = desired.generation; + desired.readiness.reject(error); + this.logger.error(`Failed to create Rslint workspace ${slot.key}`, error); + return true; + } + const current: CurrentRuntime = { + generation: desired.generation, + runtime, + abortController, + phase: 'starting', + }; + slot.current = current; + this.logger.debug( + `Starting Rslint workspace ${slot.key} generation ${desired.generation}`, + ); + + try { + await runtime.start(abortController.signal); + if ( + this.closing || + abortController.signal.aborted || + this.desiredRoots.get(slot.key)?.generation !== desired.generation + ) { + throw cancellationError(slot.key); + } + await this.router.activate(runtime); + if ( + this.closing || + abortController.signal.aborted || + this.desiredRoots.get(slot.key)?.generation !== desired.generation + ) { + await this.router.deactivate(slot.key).catch((error: unknown) => { + this.logger.error( + `Failed to withdraw stale workspace ${slot.key}`, + error, + ); + }); + throw cancellationError(slot.key); + } + current.phase = 'active'; + desired.readiness.resolve(undefined); + this.logger.info(`Rslint workspace ready: ${slot.key}`); + return true; + } catch (error) { + const stale = + isAbortError(error) || + abortController.signal.aborted || + this.desiredRoots.get(slot.key)?.generation !== desired.generation || + this.closing; + if (!stale) { + slot.failedGeneration = desired.generation; + desired.readiness.reject(error); + if (this.isExpectedStartFailure(error)) { + this.logger.info( + `Rslint workspace ${slot.key} was not started: ${describeError(error)}`, + ); + } else { + this.logger.error( + `Failed to start Rslint workspace ${slot.key}`, + error, + ); + } + } else { + desired.readiness.reject(cancellationError(slot.key)); + } + return this.closeCurrent(slot, current); + } + } + + private async closeCurrent( + slot: RootSlot, + current: CurrentRuntime, + ): Promise { + if (slot.current !== current) return true; + current.abortController.abort(cancellationError(slot.key)); + if (current.phase === 'active') { + try { + await this.router.deactivate(slot.key); + } catch (error) { + this.logger.error( + `Failed to transfer documents away from ${slot.key}`, + error, + ); + } + } + current.phase = 'closing'; + try { + await current.runtime.close(); + } catch (error) { + current.phase = 'close-failed'; + current.closeError = error; + this.logger.error(`Failed to close Rslint workspace ${slot.key}`, error); + if (!current.closeFailureRecorded) { + current.closeFailureRecorded = true; + this.terminalCloseErrors.push(error); + } + + // A runtime whose resources did not close remains the sole owner of its + // URI slot. Starting a replacement here could overlap native processes, + // workers, watchers, and diagnostics for one workspace. Quarantine it + // until an explicit later reconciliation (or terminal close) retries. + const replacement = this.desiredRoots.get(slot.key); + if (replacement && replacement.generation !== current.generation) { + slot.failedGeneration = replacement.generation; + replacement.readiness.reject( + new Error( + `Could not replace Rslint workspace ${JSON.stringify(slot.key)} because the previous runtime failed to close`, + { cause: error }, + ), + ); + } + return false; + } + if (slot.current === current) slot.current = undefined; + this.logger.debug(`Closed Rslint workspace ${slot.key}`); + return true; + } + + private async closeImpl(): Promise { + if (this.closing) return; + this.closing = true; + for (const [key, desired] of this.desiredRoots) { + desired.readiness.reject(cancellationError(key)); + } + this.desiredRoots.clear(); + for (const slot of this.slots.values()) { + slot.current?.abortController.abort(cancellationError(slot.key)); + slot.rerun = true; + } + + const routerResult = await Promise.allSettled([ + Promise.resolve().then(async () => { + await this.router.closeAll(); + }), + ]); + for (const slot of this.slots.values()) this.kick(slot.key); + const slotPromises: Promise[] = []; + for (const slot of this.slots.values()) { + slotPromises.push(slot.worker ?? Promise.resolve()); + } + const slotResults = await Promise.allSettled(slotPromises); + this.slots.clear(); + + const errors = this.terminalCloseErrors.splice(0); + for (const result of [...routerResult, ...slotResults]) { + if (result.status === 'rejected') { + const reason: unknown = result.reason; + errors.push(reason); + } + } + if (errors.length > 0) { + throw new AggregateError(errors, 'failed to close workspace coordinator'); + } + } +} diff --git a/packages/vscode/src/stacks/lint/configLoader.ts b/packages/vscode/src/stacks/lint/configLoader.ts new file mode 100644 index 0000000..de3fb96 --- /dev/null +++ b/packages/vscode/src/stacks/lint/configLoader.ts @@ -0,0 +1,104 @@ +import type { + ConfigModuleHost, + ConfigModuleHostOptions, +} from '@rslint/core/config-loader'; +import type { createPluginLintHost } from '@rslint/core/eslint-plugin'; +import { + formatProtocolVersionMismatch, + isSupportedConfigDiscoveryProtocolVersion, +} from '../../shared/versionCheck'; +import { importProjectModule } from './projectModules'; +import type { RslintResolution } from './resolution'; + +/** + * `@rslint/core/config-loader` is **not bundled**: the types are + * `import type` only and the runtime value comes from the project-resolved + * module, loaded from the same root as the Go binary. + */ +export interface ConfigLoaderModule { + readonly CONFIG_DISCOVERY_PROTOCOL_VERSION: number; + readonly ConfigModuleHost: new ( + options?: ConfigModuleHostOptions, + ) => ConfigModuleHost; +} + +/** The `@rslint/core/eslint-plugin` surface `PluginLintPool` depends on. */ +export interface EslintPluginModule { + readonly createPluginLintHost: typeof createPluginLintHost; +} + +/** + * The config-discovery protocol between the Go server and the JS loader is + * versioned independently of the package version, so `semver.satisfies` is + * necessary but not sufficient. A mismatch is surfaced as the + * `version mismatch` status-bar state. + */ +export class ConfigDiscoveryProtocolMismatchError extends Error { + constructor(readonly protocolVersion: number) { + super(formatProtocolVersionMismatch(protocolVersion)); + this.name = 'ConfigDiscoveryProtocolMismatchError'; + } +} + +const isConfigLoaderModule = (value: unknown): value is ConfigLoaderModule => + value !== null && + typeof value === 'object' && + typeof (value as ConfigLoaderModule).CONFIG_DISCOVERY_PROTOCOL_VERSION === + 'number' && + typeof (value as ConfigLoaderModule).ConfigModuleHost === 'function'; + +/** + * Loads the project's config-loader and performs the protocol handshake. + * + * The `protocolVersion` this returns is the exact value the client then puts + * into every `rslint/configRefresh` request and validates on every + * server-initiated config transaction, so the Go server and the JS loader can + * never silently disagree. + */ +export const loadConfigLoaderModule = async ( + resolution: RslintResolution, +): Promise => { + const module = await importProjectModule( + resolution.configLoaderPath, + ); + if (!isConfigLoaderModule(module)) { + throw new Error( + `${resolution.configLoaderPath} does not export the expected config-loader surface (CONFIG_DISCOVERY_PROTOCOL_VERSION, ConfigModuleHost)`, + ); + } + if ( + !isSupportedConfigDiscoveryProtocolVersion( + module.CONFIG_DISCOVERY_PROTOCOL_VERSION, + ) + ) { + throw new ConfigDiscoveryProtocolMismatchError( + module.CONFIG_DISCOVERY_PROTOCOL_VERSION, + ); + } + return module; +}; + +const isEslintPluginModule = (value: unknown): value is EslintPluginModule => + value !== null && + typeof value === 'object' && + typeof (value as EslintPluginModule).createPluginLintHost === 'function'; + +/** + * Loads the project's ESLint-plugin host. Verified to work unpatched from a + * plain project install (a verified non-requirement): the host spawns its + * sibling `lint-worker.js` and the worker finds `@rslint/native-*` by + * node_modules walk-up, all inside the project. + */ +export const loadEslintPluginModule = async ( + resolution: RslintResolution, +): Promise => { + const module = await importProjectModule( + resolution.eslintPluginPath, + ); + if (!isEslintPluginModule(module)) { + throw new Error( + `${resolution.eslintPluginPath} does not export createPluginLintHost`, + ); + } + return module; +}; diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts new file mode 100644 index 0000000..eeecfba --- /dev/null +++ b/packages/vscode/src/stacks/lint/index.ts @@ -0,0 +1,341 @@ +import vscode from 'vscode'; +import type { + DetectionSnapshot, + StackContext, + StackController, + StackState, +} from '../../types'; +import { Logger } from './logger'; +import { Rslint, type RslintFolderConfigPaths } from './Rslint'; +import { WorkspaceDocumentRouter } from './WorkspaceDocumentRouter'; +import { + WorkspaceRslintCoordinator, + workspaceRootKey, +} from './WorkspaceRslintCoordinator'; + +/** + * Replaces upstream's `main.ts` + `Extension.ts` + `statusBar.ts` + + * `commands.ts` (the shell-activation and status-aggregation adaptations). + * + * Upstream activates the extension itself and `await`s + * `coordinator.initialize()`, which resolves only once a language server root + * is ready β€” the readiness contract its E2E harness depends on. Here the shell + * owns activation: `register()` must return as soon as the runtimes are + * *scheduled*, never block on a Go process start, and every state transition + * flows into the shared status bar instead of an own status bar item. + */ + +const STATE_RANK: Readonly> = { + crashed: 5, + 'version-mismatch': 4, + starting: 3, + running: 2, + disabled: 1, + 'not-detected': 0, +}; + +interface FolderStatus { + readonly name: string; + readonly state: StackState; +} + +const detailOf = (state: StackState): string | undefined => { + switch (state.kind) { + case 'crashed': + case 'version-mismatch': + return state.detail; + case 'starting': + case 'running': + return state.detail; + case 'disabled': + return state.reason; + case 'not-detected': + return undefined; + } +}; + +/** + * Folds every workspace folder's runtime into the one state the status bar + * shows for the Rslint stack. The worst state wins, and the detail names the + * folders it came from β€” with multiple roots, "crashed" without a folder name + * is not actionable. + */ +export const aggregateFolderStates = ( + statuses: readonly FolderStatus[], +): StackState => { + if (statuses.length === 0) { + return { kind: 'starting' }; + } + let worst = statuses[0]!; + for (const candidate of statuses) { + if (STATE_RANK[candidate.state.kind] > STATE_RANK[worst.state.kind]) { + worst = candidate; + } + } + const multiRoot = statuses.length > 1; + const details = statuses + .filter((entry) => entry.state.kind === worst.state.kind) + .map((entry) => { + const detail = detailOf(entry.state); + if (!detail) { + return multiRoot ? entry.name : undefined; + } + return multiRoot ? `${entry.name}: ${detail}` : detail; + }) + .filter((entry): entry is string => entry !== undefined); + const detail = details.length > 0 ? details.join(' | ') : undefined; + + switch (worst.state.kind) { + case 'crashed': + return { + kind: 'crashed', + detail: detail ?? 'the language server failed', + }; + case 'version-mismatch': + return { + kind: 'version-mismatch', + detail: detail ?? 'unsupported @rslint/core version', + }; + case 'starting': + return { kind: 'starting', detail }; + case 'running': + return { kind: 'running', detail }; + case 'disabled': + return { kind: 'disabled', reason: detail }; + case 'not-detected': + return { kind: 'not-detected' }; + } +}; + +class RslintController implements StackController { + readonly id = 'rslint' as const; + + #context: StackContext | undefined; + #logger: Logger | undefined; + #coordinator: WorkspaceRslintCoordinator | undefined; + #snapshot: DetectionSnapshot | undefined; + readonly #subscriptions: vscode.Disposable[] = []; + readonly #folderStates = new Map(); + #pending: Promise = Promise.resolve(); + #disposed = false; + + async register(context: StackContext): Promise> { + this.#context = context; + this.#snapshot = context.detection; + this.#logger = new Logger(context.output); + + this.#subscriptions.push( + vscode.commands.registerCommand('rstack.rslint.restart', () => { + void this.restart(); + }), + context.onDidChangeDetection((snapshot) => { + this.#snapshot = snapshot; + this.reconcileFolders({ added: [], removed: [] }); + }), + vscode.workspace.onDidChangeWorkspaceFolders((event) => { + this.reconcileFolders(event); + }), + // A `binPath`/`customBinPath` change must re-resolve the binary, which + // only happens on a fresh start (upstream documents `customBinPath` as + // requiring a reload; a restart is strictly better). + vscode.workspace.onDidChangeConfiguration((event) => { + if ( + event.affectsConfiguration('rstack.rslint.binPath') || + event.affectsConfiguration('rstack.rslint.customBinPath') || + event.affectsConfiguration('rstack.rslint.trace.server') + ) { + void this.restart(); + } + }), + ); + + this.startCoordinator(); + return this.buildExports(); + } + + /** + * Published through the extension's public exports channel + * (`RstackExtensionExports.whenStackActive('rslint')`). The E2E harness uses + * it as the "the shell registered the lint stack" signal β€” the upstream + * suites relied on `extension.activate()` resolving only once a server root + * was ready, a contract the shell no longer provides (the shell-activation + * adaptation). The folder-state snapshot is exposed for assertions and + * debugging; it is not a stable API. + */ + private buildExports(): Record { + return { + stackId: this.id, + getFolderStates: (): ReadonlyMap => + new Map( + [...this.#folderStates].map(([key, value]) => [key, value.state]), + ), + }; + } + + /** The workspace folders detection lit up for Rslint. */ + private detectedFolders(): vscode.WorkspaceFolder[] { + return (this.#snapshot?.foldersFor('rslint') ?? []).map( + (entry) => entry.folder, + ); + } + + private configPathsFor( + folder: vscode.WorkspaceFolder, + ): RslintFolderConfigPaths { + const detection = this.#snapshot?.forFolder(folder)?.stacks.rslint; + return { + rslintConfigPaths: (detection?.configFiles ?? []).map( + (uri) => uri.fsPath, + ), + }; + } + + private startCoordinator(): void { + const context = this.#context; + const logger = this.#logger; + if (!context || !logger || this.#disposed) { + return; + } + const router = new WorkspaceDocumentRouter(); + const coordinator = new WorkspaceRslintCoordinator( + router, + (workspaceFolder, rootKey) => + new Rslint({ + rootKey, + workspaceFolder, + outputChannel: context.output, + // The extension is capped at four output channels, so the + // LSP trace shares the stack's channel instead of opening a fifth. + lspOutputChannel: context.output, + router, + logger: logger.forScope(workspaceFolder.name), + reportStatus: (state) => { + this.setFolderState(rootKey, workspaceFolder.name, state); + }, + getConfigPaths: () => this.configPathsFor(workspaceFolder), + }), + logger, + ); + this.#coordinator = coordinator; + + const folders = this.detectedFolders(); + for (const folder of folders) { + this.setFolderState(workspaceRootKey(folder), folder.name, { + kind: 'starting', + }); + } + + // Adaptation #1: activation must not wait for a language server. Upstream + // awaits this promise (and rejects activation when every root fails); here + // failures are reported per folder through the status reporter. + void coordinator.initialize(folders).catch((error: unknown) => { + if (coordinator !== this.#coordinator) { + return; + } + logger.error('No Rslint workspace root started', error); + }); + } + + private reconcileFolders(event: vscode.WorkspaceFoldersChangeEvent): void { + const coordinator = this.#coordinator; + if (!coordinator || this.#disposed) { + return; + } + const folders = this.detectedFolders(); + const keys = new Set(folders.map(workspaceRootKey)); + for (const key of [...this.#folderStates.keys()]) { + if (!keys.has(key)) { + this.#folderStates.delete(key); + } + } + for (const folder of folders) { + const key = workspaceRootKey(folder); + if (!this.#folderStates.has(key)) { + this.setFolderState(key, folder.name, { kind: 'starting' }); + } + } + this.publishStatus(); + coordinator.handleWorkspaceFoldersChanged(event, folders); + } + + private setFolderState( + rootKey: string, + name: string, + state: StackState, + ): void { + if (this.#disposed) { + return; + } + this.#folderStates.set(rootKey, { name, state }); + this.publishStatus(); + } + + private publishStatus(): void { + const context = this.#context; + if (!context || this.#disposed) { + return; + } + context.status.report( + aggregateFolderStates([...this.#folderStates.values()]), + ); + } + + /** + * Serializes restart/dispose. Two restarts racing (a settings change plus the + * palette command) would otherwise interleave close and start and leak a + * coordinator that nothing holds a reference to any more. + */ + private enqueue(task: () => Promise): Promise { + this.#pending = this.#pending.then(task, task); + return this.#pending; + } + + /** + * `rstack.rslint.restart`. The coordinator is single-use once closed, so a + * restart replaces it (and the document router) wholesale β€” the same shape + * upstream's commented-out `rslint.restart` would have needed. + */ + private async restart(): Promise { + await this.enqueue(async () => { + if (this.#disposed || !this.#context) { + return; + } + this.#logger?.info('Restarting the Rslint language server'); + await this.closeCoordinator(); + this.#folderStates.clear(); + this.startCoordinator(); + }); + } + + private async closeCoordinator(): Promise { + const coordinator = this.#coordinator; + this.#coordinator = undefined; + if (!coordinator) { + return; + } + try { + await coordinator.close(); + } catch (error) { + this.#logger?.error('Failed to close the Rslint coordinator', error); + } + } + + async dispose(): Promise { + this.#disposed = true; + for (const subscription of this.#subscriptions.splice(0)) { + subscription.dispose(); + } + // Behind the same queue as `restart`, so an in-flight restart finishes + // (or no-ops on `#disposed`) before the language servers are torn down. + await this.enqueue(async () => { + await this.closeCoordinator(); + this.#folderStates.clear(); + this.#logger = undefined; + this.#context = undefined; + this.#snapshot = undefined; + }); + } +} + +export const createRslintController = (): StackController => + new RslintController(); diff --git a/packages/vscode/src/stacks/lint/jitiPreflight.ts b/packages/vscode/src/stacks/lint/jitiPreflight.ts new file mode 100644 index 0000000..6050a14 --- /dev/null +++ b/packages/vscode/src/stacks/lint/jitiPreflight.ts @@ -0,0 +1,119 @@ +import { createRequire } from 'node:module'; +import type { WorkspaceFolder } from 'vscode'; +import { nativeTypeStrippingAvailable } from '../../shared/vendored/loadRstackConfig'; + +/** + * The jiti seam of the version-compatibility contract. + * + * `jiti` is an optional peer of `@rslint/core`, used by its + * `config-file-loader` when native type stripping cannot load a `.ts`/`.mts` + * config. Under resolve-from-project that loader runs on the **extension + * host's** Node β€” whose version is fixed by VS Code, not by the user β€” so the + * jiti branch can trigger in the editor even when the CLI works fine. + * + * Loader behaviour is deliberately unchanged. Two diagnostics only: + * + * 1. `describeJitiPreflight` β€” preflight that jiti resolves from the project + * when a TypeScript config is in play; + * 2. `isJitiMissingError` β€” recognise the loader's "Install jiti as a + * dependency" failure so it can be surfaced as an actionable hint instead of + * a generic config-load failure. + * + * Unlike the vendored Rstack loader (which is bundled into the extension and + * therefore resolves jiti from the *extension*), `@rslint/core`'s + * config-file-loader is itself loaded from the project, so its `import('jiti')` + * resolves from the project β€” which is exactly what this preflight checks. + */ + +// `require.resolve` is rewritten by the bundler; `createRequire` is not. +const nodeRequire = createRequire(__filename); + +const TYPESCRIPT_CONFIG_RE = /\.[cm]?ts$/; + +export const isTypeScriptConfigPath = (configPath: string): boolean => + TYPESCRIPT_CONFIG_RE.test(configPath); + +/** + * True when `jiti` resolves from any of the given roots. + * + * Two roots matter and they are not the same: the loader's own + * `await import('jiti')` resolves relative to `@rslint/core`'s install + * directory (which, under pnpm's strict layout, only sees jiti when the + * optional peer was actually installed and linked), while the workspace folder + * is what a user thinks of as "the project". Either hit means the loader has a + * fair chance of finding jiti, so the preflight only warns when *both* miss β€” + * a false negative is much cheaper than a false alarm. + */ +export const jitiResolvesFrom = (roots: readonly string[]): boolean => { + for (const root of roots) { + try { + nodeRequire.resolve('jiti/package.json', { paths: [root] }); + return true; + } catch { + // Try the next root. + } + } + return false; +}; + +export const JITI_INSTALL_HINT = + 'install jiti in the project (`npm install -D jiti`) or move the config to `.js`/`.mjs`'; + +export interface JitiPreflightInput { + readonly folder: WorkspaceFolder; + /** The project's `@rslint/core` directory β€” the loader's own resolution root. */ + readonly coreDir?: string; + readonly rslintConfigPaths: readonly string[]; +} + +/** + * Returns a status-bar-ready note when a TypeScript Rslint config will need + * jiti on this host and jiti is not installed in the project. Returns + * `undefined` when there is nothing to warn about. + */ +export const describeJitiPreflight = ({ + folder, + coreDir, + rslintConfigPaths, +}: JitiPreflightInput): string | undefined => { + const typescriptConfigs = rslintConfigPaths.filter(isTypeScriptConfigPath); + if (typescriptConfigs.length === 0) { + return undefined; + } + if (nativeTypeStrippingAvailable()) { + return undefined; + } + const roots = coreDir ? [coreDir, folder.uri.fsPath] : [folder.uri.fsPath]; + if (jitiResolvesFrom(roots)) { + return undefined; + } + const subject = + typescriptConfigs.length === 1 + ? typescriptConfigs[0] + : `${String(typescriptConfigs.length)} TypeScript Rslint configs`; + return `this VS Code build cannot strip TypeScript types, so loading ${String(subject)} requires jiti: ${JITI_INSTALL_HINT}`; +}; + +/** + * Recognises the multi-line error `@rslint/core`'s `config-file-loader` throws + * when a TypeScript config cannot be loaded and jiti is absent: + * `Failed to load TypeScript config file: … 2. Install jiti as a dependency: npm install -D jiti`. + */ +export const isJitiMissingError = (error: unknown): boolean => { + const message = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : typeof (error as { message?: unknown } | null)?.message === 'string' + ? String((error as { message: string }).message) + : ''; + if (!message) { + return false; + } + return ( + /install jiti/i.test(message) || + /"?jiti"? package is required/i.test(message) || + /cannot find (module|package) ['"]jiti['"]/i.test(message) + ); +}; diff --git a/packages/vscode/src/stacks/lint/logger.ts b/packages/vscode/src/stacks/lint/logger.ts new file mode 100644 index 0000000..5ea543a --- /dev/null +++ b/packages/vscode/src/stacks/lint/logger.ts @@ -0,0 +1,84 @@ +// Replaces web-infra-dev/rslint `packages/vscode-extension/src/logger.ts`. +// +// Upstream's `Logger` creates one `OutputChannel` per instance β€” one per +// workspace folder plus one for the extension. This extension deliberately +// caps itself at four channels, so this adapter keeps the upstream call surface +// (`debug`/`info`/`warn`/`error`, `WorkspaceCoordinatorLogger`-compatible) but +// writes into the shell-owned `Rstack: Rslint` channel with a per-folder +// prefix. `dispose()` is deliberately a no-op: the channel outlives the stack. + +import type { LogOutputChannel } from 'vscode'; + +const formatArg = (arg: unknown): string => { + if (typeof arg === 'string') { + return arg; + } + if (arg instanceof Error) { + return `\n${arg.stack ?? arg.message}`; + } + try { + return JSON.stringify(arg, null, 2) ?? String(arg); + } catch { + return String(arg); + } +}; + +const formatMessage = (message: string, args: unknown[]): string => + args.length === 0 ? message : `${message} ${args.map(formatArg).join(' ')}`; + +/** + * A prefixing view over the shared Rslint output channel. + * + * Log-level filtering is delegated to VS Code (`LogOutputChannel` honors the + * user's per-channel log level), which replaces upstream's `LogLevel` enum and + * `Logger.setDefaultLogLevel(context)` development-mode switch. + */ +export class Logger { + readonly #channel: LogOutputChannel; + readonly #prefix: string; + + constructor(channel: LogOutputChannel, scope?: string) { + this.#channel = channel; + this.#prefix = scope ? `[${scope}] ` : ''; + } + + /** Derives a logger for one workspace folder from the stack-wide one. */ + forScope(scope: string): Logger { + return new Logger(this.#channel, scope); + } + + trace(message: string, ...args: unknown[]): void { + this.#channel.trace(`${this.#prefix}${formatMessage(message, args)}`); + } + + debug(message: string, ...args: unknown[]): void { + this.#channel.debug(`${this.#prefix}${formatMessage(message, args)}`); + } + + info(message: string, ...args: unknown[]): void { + this.#channel.info(`${this.#prefix}${formatMessage(message, args)}`); + } + + warn(message: string, ...args: unknown[]): void { + this.#channel.warn(`${this.#prefix}${formatMessage(message, args)}`); + } + + error(message: string, error?: unknown, ...args: unknown[]): void { + if (error instanceof Error) { + this.#channel.error( + error, + `${this.#prefix}${formatMessage(message, args)}`, + ); + return; + } + const detail = error === undefined ? args : [error, ...args]; + this.#channel.error(`${this.#prefix}${formatMessage(message, detail)}`); + } + + show(): void { + this.#channel.show(); + } + + /** No-op: the output channel is owned by the extension shell. */ + dispose(): void {} +} diff --git a/packages/vscode/src/stacks/lint/projectModules.ts b/packages/vscode/src/stacks/lint/projectModules.ts new file mode 100644 index 0000000..73953fd --- /dev/null +++ b/packages/vscode/src/stacks/lint/projectModules.ts @@ -0,0 +1,42 @@ +import { pathToFileURL } from 'node:url'; + +/** + * Loads an ESM module from an absolute path resolved out of the *user's* + * project (the resolve-from-project adaptation). + * + * Two constraints shape this helper: + * + * - The extension bundle is CJS while `@rslint/core` is ESM (`"type": + * "module"`), so the module has to be reached through a dynamic `import()`, + * never `require`. + * - The specifier is a fully dynamic expression, which Rspack emits verbatim + * instead of turning into a bundled chunk (verified against the repo's own + * rslib output), so the load really happens at runtime against the project's + * file. A bare specifier would resolve from the extension instead, which is + * exactly the bundling seam the resolve-from-project adaptation deletes. + * - Windows: only `file:`/`data:`/`node:` URLs are accepted by Node's default + * ESM loader, so absolute paths are converted with `pathToFileURL`. + */ +const cache = new Map>(); + +export const importProjectModule = async ( + absolutePath: string, +): Promise => { + let pending = cache.get(absolutePath) as Promise | undefined; + if (!pending) { + const specifier = pathToFileURL(absolutePath).href; + pending = (import(specifier) as Promise).catch((error: unknown) => { + // Never cache a rejected load: a transient failure (a half-installed + // node_modules, a mid-reinstall window) must stay retryable. + cache.delete(absolutePath); + throw error; + }); + cache.set(absolutePath, pending as Promise); + } + return pending; +}; + +/** Test seam / teardown helper: drops the memoized module promises. */ +export const clearProjectModuleCache = (): void => { + cache.clear(); +}; diff --git a/packages/vscode/src/stacks/lint/resolution.ts b/packages/vscode/src/stacks/lint/resolution.ts new file mode 100644 index 0000000..d63c099 --- /dev/null +++ b/packages/vscode/src/stacks/lint/resolution.ts @@ -0,0 +1,306 @@ +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { Uri, workspace, type WorkspaceFolder } from 'vscode'; +import type { Logger } from './logger'; +import { + fileExists, + getPlatformBinRequests, + type RslintBinPath, +} from './utils'; + +/** + * Everything Rslint-related is resolved from the *user's project*: this + * extension ships no Go binary and no `@rslint/core`. The + * `built-in` mode is gone, so a failed resolution is a hard, user-visible + * failure β€” never a silent fallback. + * + * There must additionally be **one resolution root**: the Go binary, + * `@rslint/core/config-loader` and `@rslint/core/eslint-plugin` must all come + * from the same `@rslint/core` install, "never binary from A, loader from B". + * `assertSingleResolutionRoot` enforces that as an assertion, not a + * convention. + */ +export type RslintResolutionKind = 'node-modules' | 'pnp'; + +export interface RslintResolution { + /** How `@rslint/core` itself was found. */ + readonly kind: RslintResolutionKind; + /** The single resolution root: the directory of the project's `@rslint/core`. */ + readonly coreDir: string; + readonly coreVersion: string | undefined; + /** The Go language server executable. */ + readonly binPath: string; + /** True when `binPath` came from `rstack.rslint.customBinPath`. */ + readonly binFromUserSetting: boolean; + /** Absolute path of the project's `@rslint/core/config-loader` entry. */ + readonly configLoaderPath: string; + /** Absolute path of the project's `@rslint/core/eslint-plugin` entry. */ + readonly eslintPluginPath: string; +} + +/** A resolution failure that must surface in the status bar. */ +export class RslintResolutionError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = 'RslintResolutionError'; + } +} + +interface PnpApi { + resolveRequest(request: string, issuer: string): string | null; +} + +// `require.resolve` is rewritten by the bundler; `createRequire` is not. Every +// lookup passes an explicit `paths`/issuer, so the anchor itself is irrelevant. +const nodeRequire = createRequire(__filename); + +const readPackageVersion = (packageJsonPath: string): string | undefined => { + try { + const raw: unknown = JSON.parse( + fs.readFileSync(packageJsonPath, 'utf8'), + ) as unknown; + if (raw && typeof raw === 'object' && 'version' in raw) { + const version = (raw as { version?: unknown }).version; + return typeof version === 'string' ? version : undefined; + } + } catch { + // A malformed package.json is reported by the version check as `unknown`. + } + return undefined; +}; + +const isInside = (child: string, parent: string): boolean => { + const relative = path.relative(parent, child); + return ( + relative.length > 0 && + !relative.startsWith('..') && + !path.isAbsolute(relative) + ); +}; + +/** + * The one-resolution-root rule is enforced as an assertion, not a + * convention: both JS entry points must live inside the very `@rslint/core` + * whose native binary we are about to spawn. + */ +export const assertSingleResolutionRoot = ( + coreDir: string, + entries: ReadonlyArray<{ readonly label: string; readonly path: string }>, +): void => { + for (const entry of entries) { + if (!isInside(entry.path, coreDir)) { + throw new RslintResolutionError( + `Rslint resolution root mismatch: ${entry.label} resolved to ${entry.path}, which is outside the resolved @rslint/core at ${coreDir}`, + ); + } + } +}; + +const loadPnpApi = async (folder: WorkspaceFolder): Promise => { + for (const extension of ['cjs', 'js']) { + const pnpFile = Uri.joinPath(folder.uri, `.pnp.${extension}`); + if (!(await fileExists(pnpFile))) { + continue; + } + try { + return nodeRequire(pnpFile.fsPath) as PnpApi; + } catch { + // Try the next candidate; a broken PnP file is not fatal on its own. + } + } + return null; +}; + +interface CoreLocation { + readonly kind: RslintResolutionKind; + readonly packageJsonPath: string; + readonly coreDir: string; + readonly pnpApi?: PnpApi; +} + +const locateCore = async ( + folder: WorkspaceFolder, + logger: Logger, +): Promise => { + const searchRoot = folder.uri.fsPath; + try { + const packageJsonPath = nodeRequire.resolve('@rslint/core/package.json', { + paths: [searchRoot], + }); + logger.debug(`Found @rslint/core in node_modules: ${packageJsonPath}`); + return { + kind: 'node-modules', + packageJsonPath, + coreDir: path.dirname(packageJsonPath), + }; + } catch { + logger.debug('No @rslint/core in node_modules, trying Yarn PnP'); + } + + const pnpApi = await loadPnpApi(folder); + if (pnpApi) { + try { + const packageJsonPath = pnpApi.resolveRequest( + '@rslint/core/package.json', + searchRoot, + ); + if (packageJsonPath) { + logger.debug(`Found @rslint/core in PnP: ${packageJsonPath}`); + return { + kind: 'pnp', + packageJsonPath, + coreDir: path.dirname(packageJsonPath), + pnpApi, + }; + } + } catch { + // Fall through to the shared failure below. + } + } + + throw new RslintResolutionError( + `Could not resolve @rslint/core from ${searchRoot}. This extension ships no Rslint binary β€” install @rslint/core in the project (this extension requires >= 0.7.2).`, + ); +}; + +const resolveCoreSubpath = ( + location: CoreLocation, + subpath: string, +): string => { + const specifier = `@rslint/core/${subpath}`; + if (location.pnpApi) { + const resolved = location.pnpApi.resolveRequest( + specifier, + location.packageJsonPath, + ); + if (!resolved) { + throw new RslintResolutionError( + `Could not resolve ${specifier} through Yarn PnP from ${location.coreDir}`, + ); + } + return resolved; + } + try { + // Node's self-reference resolution: a request issued from inside the + // package resolves against that package's own `exports` map, which pins + // the answer to this exact install rather than to whatever copy a + // node_modules walk-up would find first. + return createRequire(location.packageJsonPath).resolve(specifier); + } catch (error) { + try { + return nodeRequire.resolve(specifier, { paths: [location.coreDir] }); + } catch { + throw new RslintResolutionError( + `Could not resolve ${specifier} from ${location.coreDir}. Rslint >= 0.7.2 is required (its package exports ./config-loader and ./eslint-plugin).`, + { cause: error }, + ); + } + } +}; + +const resolveNativeBinary = ( + location: CoreLocation, + logger: Logger, +): string => { + // Try each platform-package candidate in order, using the first that + // resolves (linux ships gnu/musl variants β€” only one is installed). + for (const request of getPlatformBinRequests()) { + try { + const binPath = location.pnpApi + ? // PnP's resolveRequest throws (rather than returning null) for a + // candidate absent from the dependency map, so each lookup needs its + // own try/catch to fall through to the next tuple. + location.pnpApi.resolveRequest(request, location.packageJsonPath) + : nodeRequire.resolve(request, { paths: [location.coreDir] }); + if (binPath) { + logger.debug(`Using Rslint binary from the project: ${binPath}`); + return binPath; + } + } catch { + // Candidate not installed; try the next one. + } + } + throw new RslintResolutionError( + `Could not resolve the Rslint native binary (${getPlatformBinRequests().join( + ' or ', + )}) from ${location.coreDir}. The @rslint/native-* package for this platform is not installed.`, + ); +}; + +const resolveUserBinary = async ( + folder: WorkspaceFolder, + logger: Logger, +): Promise => { + const customBinPath = workspace + .getConfiguration('rstack.rslint', folder.uri) + .get('customBinPath') + ?.trim(); + + if (!customBinPath) { + throw new RslintResolutionError( + '`rstack.rslint.binPath` is set to "custom" but `rstack.rslint.customBinPath` is not configured', + ); + } + logger.debug( + `Try using Rslint binary path from user settings: ${customBinPath}`, + ); + if (!(await fileExists(Uri.file(customBinPath)))) { + throw new RslintResolutionError( + `Rslint binary path from user settings does not exist: ${customBinPath}`, + ); + } + logger.debug(`Using Rslint binary from user settings: ${customBinPath}`); + return customBinPath; +}; + +/** + * Binary resolution order: explicit setting β†’ workspace + * `node_modules` β†’ Yarn PnP. `@rslint/core`'s JS entry points always come from + * the project, because the LSP is useless without a config-loader host. + */ +export const resolveRslint = async ( + folder: WorkspaceFolder, + logger: Logger, +): Promise => { + const binPathConfig = workspace + .getConfiguration('rstack.rslint', folder.uri) + .get('binPath', 'local'); + + if (binPathConfig !== 'local' && binPathConfig !== 'custom') { + throw new RslintResolutionError( + `Unsupported rstack.rslint.binPath setting: ${String(binPathConfig)}`, + ); + } + + const location = await locateCore(folder, logger); + const configLoaderPath = resolveCoreSubpath(location, 'config-loader'); + const eslintPluginPath = resolveCoreSubpath(location, 'eslint-plugin'); + assertSingleResolutionRoot(location.coreDir, [ + { label: '@rslint/core/config-loader', path: configLoaderPath }, + { label: '@rslint/core/eslint-plugin', path: eslintPluginPath }, + ]); + + const binFromUserSetting = binPathConfig === 'custom'; + const binPath = binFromUserSetting + ? await resolveUserBinary(folder, logger) + : resolveNativeBinary(location, logger); + + if (binFromUserSetting) { + // The user explicitly waived the one-root invariant for the binary only. + // Say so loudly: a binary/loader protocol drift shows up here first. + logger.warn( + `Rslint binary comes from rstack.rslint.customBinPath (${binPath}) while the config-loader comes from ${location.coreDir}. The single-resolution-root invariant is waived by this explicit setting.`, + ); + } + + return { + kind: location.kind, + coreDir: location.coreDir, + coreVersion: readPackageVersion(location.packageJsonPath), + binPath, + binFromUserSetting, + configLoaderPath, + eslintPluginPath, + }; +}; diff --git a/packages/vscode/src/stacks/lint/utils.ts b/packages/vscode/src/stacks/lint/utils.ts new file mode 100644 index 0000000..b94ca01 --- /dev/null +++ b/packages/vscode/src/stacks/lint/utils.ts @@ -0,0 +1,57 @@ +// Copied from web-infra-dev/rslint `packages/vscode-extension/src/utils.ts` +// (origin/main). Adapted: the `built-in` binary mode is +// dropped entirely (this extension ships no Go binary), so `RslintBinPath` +// loses that member. +// +// Reference: https://github.com/biomejs/biome-vscode/blob/8fa2ca19e612575479c840bd58f6d31e4e503b13/src/utils.ts + +import { arch } from 'node:os'; +import { FileType, Uri, workspace } from 'vscode'; + +/** + * Checks whether a file exists + * + * This function checks whether a file exists at the given URI using VS Code's + * FileSystem API. + * + * @param uri URI of the file to check + * @returns Whether the file exists + */ +export const fileExists = async (uri: Uri): Promise => { + try { + const stat = await workspace.fs.stat(uri); + return (stat.type & FileType.File) > 0; + } catch { + return false; + } +}; + +/** + * Returns the ordered list of platform-package requests to try-resolve when + * locating the Go binary, mirroring `packages/rslint/bin/rslint.js`. + * + * The Go binary lives in the `@rslint/native-{tuple}` subpackage, reached via + * its `./bin` export. npm installs only the subpackage matching the host + * os/cpu/libc, so on linux we try gnu then musl and use whichever got + * installed β€” no libc sniffing (Go binaries are static, the gnu/musl + * distinction doesn't matter to them). Callers should resolve each candidate + * in order and use the first that succeeds. + */ +export const getPlatformBinRequests = (): string[] => { + const cpu = arch(); + const tuples = + process.platform === 'linux' + ? [`linux-${cpu}-gnu`, `linux-${cpu}-musl`] + : process.platform === 'win32' + ? [`win32-${cpu}-msvc`] + : [`${process.platform}-${cpu}`]; + return tuples.map((tuple) => `@rslint/native-${tuple}/bin`); +}; + +/** + * `built-in` is deliberately absent: this extension ships no Go + * binary, so the only resolution order is explicit setting β†’ workspace + * `node_modules` β†’ Yarn PnP, and a failed resolution surfaces in the status bar + * instead of silently falling back. + */ +export type RslintBinPath = 'local' | 'custom'; diff --git a/packages/vscode/src/stacks/test/bridge.test.ts b/packages/vscode/src/stacks/test/bridge.test.ts new file mode 100644 index 0000000..7225351 --- /dev/null +++ b/packages/vscode/src/stacks/test/bridge.test.ts @@ -0,0 +1,158 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from '@rstest/core'; +import type { StackState, StatusReporter } from '../../types'; +import { resolveRstackShim } from './bridge'; +import { logger } from './logger'; +import { status } from './status'; + +// Resolution is exercised for real (a temporary `node_modules/rstack` tree) +// rather than by mocking `nodeRequire`: the whole point of the bridge is that +// Node's own algorithm finds the shim the CLI would inject, so a mocked +// resolver would only assert itself. + +const logged: string[] = []; +const reported: StackState[] = []; + +const channel = { + debug: (message: string) => logged.push(message), + info: (message: string) => logged.push(message), + warn: (message: string) => logged.push(message), + error: (message: string) => logged.push(message), + show: () => {}, + dispose: () => {}, +}; + +const reporter: StatusReporter = { + stack: 'rstest', + report: (state) => reported.push(state), + starting: (detail) => reported.push({ kind: 'starting', detail }), + running: (detail) => reported.push({ kind: 'running', detail }), + crashed: (detail) => reported.push({ kind: 'crashed', detail }), + versionMismatch: (detail) => + reported.push({ kind: 'version-mismatch', detail }), +}; + +const tmpDirs: string[] = []; + +// `os.tmpdir()` is a symlink on macOS (`/var` -> `/private/var`) and Node's +// resolver returns the real path, so every fixture path has to be realpath'd +// before it is compared. +const makeTmpDir = (): string => { + const dir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'rstack-bridge-')), + ); + tmpDirs.push(dir); + return dir; +}; + +/** + * A workspace whose `rstack.config.ts` sits next to a `node_modules/rstack`. + * `version: null` omits the `version` field, `shim: false` omits + * `dist/rstestConfig.js`. + */ +const createWorkspace = ({ + version = '0.3.2', + shim = true, +}: { version?: string | null; shim?: boolean } = {}): string => { + const root = makeTmpDir(); + const configDir = path.join(root, 'app'); + const rstackDir = path.join(configDir, 'node_modules', 'rstack'); + fs.mkdirSync(path.join(rstackDir, 'dist'), { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'rstack.config.ts'), + 'export default {};\n', + ); + fs.writeFileSync( + path.join(rstackDir, 'package.json'), + JSON.stringify({ + name: 'rstack', + ...(version === null ? {} : { version }), + main: 'dist/index.js', + }), + ); + if (shim) { + fs.writeFileSync( + path.join(rstackDir, 'dist', 'rstestConfig.js'), + 'module.exports = {};\n', + ); + } + return configDir; +}; + +beforeEach(() => { + logged.length = 0; + reported.length = 0; + logger.bind(channel as never); + status.bind(reporter); +}); + +afterEach(() => { + logger.unbind(); + status.unbind(); + for (const dir of tmpDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('resolveRstackShim', () => { + it('resolves the shipped Rstest config shim next to the rstack config', () => { + const configDir = createWorkspace(); + + const shim = resolveRstackShim(configDir); + + expect(shim).toBeDefined(); + expect(shim?.version).toBe('0.3.2'); + // The same file `rs test` injects with `--config`. + expect(shim?.configFilePath).toBe( + path.join(configDir, 'node_modules', 'rstack', 'dist', 'rstestConfig.js'), + ); + expect(fs.existsSync(shim!.configFilePath)).toBe(true); + }); + + it('reports nothing when the rstack package is not installed', () => { + const root = makeTmpDir(); + + expect(resolveRstackShim(root)).toBeUndefined(); + expect(logged.join('\n')).toContain('Cannot find the "rstack" package'); + expect(reported).toEqual([]); + }); + + it('stays silent on a repeated failure', () => { + const root = makeTmpDir(); + + expect(resolveRstackShim(root, { silent: true })).toBeUndefined(); + expect(logged).toEqual([]); + }); + + it('refuses an rstack install that ships no Rstest shim', () => { + const configDir = createWorkspace({ shim: false }); + + expect(resolveRstackShim(configDir)).toBeUndefined(); + expect(logged.join('\n')).toContain('rstestConfig.js'); + }); + + it('refuses an rstack older than the support matrix floor', () => { + const configDir = createWorkspace({ version: '0.3.1' }); + + expect(resolveRstackShim(configDir)).toBeUndefined(); + expect(reported).toEqual([ + { + kind: 'version-mismatch', + detail: + 'rstack 0.3.1 is not supported, this extension requires >=0.3.2', + }, + ]); + }); + + it('accepts an rstack whose version cannot be read', () => { + const configDir = createWorkspace({ version: null }); + + const shim = resolveRstackShim(configDir); + + expect(shim).toBeDefined(); + expect(shim?.version).toBeUndefined(); + expect(reported).toEqual([]); + }); +}); diff --git a/packages/vscode/src/stacks/test/bridge.ts b/packages/vscode/src/stacks/test/bridge.ts new file mode 100644 index 0000000..1b13261 --- /dev/null +++ b/packages/vscode/src/stacks/test/bridge.ts @@ -0,0 +1,130 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { + checkPackageVersion, + formatVersionMismatch, +} from '../../shared/versionCheck'; +import { logger } from './logger'; +import { nodeRequire } from './nodeRequire'; +import { status } from './status'; + +/** + * The rstack bridge. + * + * A folder governed by `rstack.config.*` (with `define.test()`) and no + * `rstest.config.*` still has to run tests. `rs test` handles it by injecting + * `--config /dist/rstestConfig.js` + * (`packages/rstack/src/cli/commands.ts`), a shim that calls + * `loadRstackConfig()` and β€” importantly β€” also performs the automatic + * `extends` injection (`define.app` β†’ `@rstest/adapter-rsbuild`, else + * `define.lib` β†’ `@rstest/adapter-rslib`, per inline project). Reimplementing + * that here would drift, so the bridge hands Rstest the *shipped* shim as an + * ordinary JS config file, exactly like the CLI does. + * + * The layering rule holds: Rstest never learns the name `rstack.config.*`; the + * bridge lives in this extension. + * + * The shim calls `loadRstackConfig()` with no arguments, and that probe is a + * single `join(cwd, name)` per candidate with **no parent traversal** + * (`@rstackjs/load-config`). It also runs lazily, inside the worker process, so + * the worker's spawn cwd is the only anchor it has. That is why the synthesized + * `Project` must carry an explicit cwd (adaptation #5): pointing a `Project` at + * the shim without it would cwd the worker into `node_modules/rstack/dist/`, + * where the probe finds nothing and `@rstest/core` would resolve from the wrong + * root. + */ + +/** Relative to the `rstack` package root. Same file `rs test` injects. */ +const SHIM_RELATIVE_PATH = path.join('dist', 'rstestConfig.js'); + +export type RstackShim = { + /** Absolute path of `/dist/rstestConfig.js`. */ + readonly configFilePath: string; + /** The installed `rstack` version, when it could be read. */ + readonly version?: string; +}; + +type RstackPackageJson = { version?: string }; + +/** + * Resolves the rstack shim for a directory containing an `rstack.config.*`. + * + * `rstack`'s exports map has no `./rstestConfig`, no `./config` and no wildcard + * subpath, so the shim is unreachable by bare specifier + * (`ERR_PACKAGE_PATH_NOT_EXPORTED`). `./package.json` *is* exported, which makes + * it the resolution anchor; the shim is then addressed as a filesystem path. + * + * Resolution is anchored on the config directory rather than the workspace + * folder so a monorepo package with its own `rstack` install wins over the root. + */ +export function resolveRstackShim( + configDir: string, + // The caller re-resolves on every tree refresh (a fresh `pnpm install` has to + // be picked up without a reload), so a persistent failure must not re-log. + { silent = false }: { silent?: boolean } = {}, +): RstackShim | undefined { + let packageJsonPath: string | undefined; + try { + packageJsonPath = nodeRequire.resolve('rstack/package.json', { + paths: [configDir], + }); + } catch { + packageJsonPath = undefined; + } + // The shim lives in the project's installed `rstack` package + // (`node_modules/rstack/dist/...`), so anything resolved from + // outside a node_modules tree is not it. This also shields against resolvers + // that self-resolve the enclosing workspace package named "rstack" (this + // extension's own manifest) despite the explicit `paths` override. + if ( + packageJsonPath !== undefined && + !packageJsonPath.includes(`${path.sep}node_modules${path.sep}`) + ) { + packageJsonPath = undefined; + } + if (packageJsonPath === undefined) { + if (!silent) { + logger.warn( + `Cannot find the "rstack" package from ${configDir}. Rstest cannot be driven by "rstack.config.*" until the project dependencies are installed.`, + ); + } + return undefined; + } + + const configFilePath = path.join( + path.dirname(packageJsonPath), + SHIM_RELATIVE_PATH, + ); + if (!existsSync(configFilePath)) { + if (!silent) { + logger.error( + `The installed "rstack" package has no ${SHIM_RELATIVE_PATH} (looked in ${packageJsonPath}). Upgrade "rstack" to a version that ships the Rstest config shim.`, + ); + } + return undefined; + } + + let version: string | undefined; + try { + version = (nodeRequire(packageJsonPath) as RstackPackageJson).version; + } catch { + // A readable `dist/rstestConfig.js` is what the bridge actually needs; an + // unreadable package.json only costs the version check. + } + + const result = checkPackageVersion('rstack', version); + if (result.kind === 'mismatch') { + const message = formatVersionMismatch('rstack', result); + if (!silent) { + logger.error(message); + } + status.versionMismatch(message); + return undefined; + } + + logger.debug('Resolved the rstack Rstest config shim', { + configFilePath, + version, + }); + return { configFilePath, version }; +} diff --git a/packages/vscode/src/stacks/test/config.ts b/packages/vscode/src/stacks/test/config.ts new file mode 100644 index 0000000..f4ee53d --- /dev/null +++ b/packages/vscode/src/stacks/test/config.ts @@ -0,0 +1,91 @@ +import { + array, + boolean, + fallback, + type InferOutput, + literal, + number, + object, + optional, + parse, + record, + string, + union, +} from 'valibot'; +import vscode from 'vscode'; + +/** + * The namespace adaptation: the unified namespace is `rstack.*`, so + * every key below is read from the `rstack.rstest` section instead of the + * legacy `rstest` one. No aliases β€” `rstack.migrateSettings` is the migration + * path. + */ +export const CONFIG_SECTION = 'rstack.rstest'; + +// Centralized configuration types for the extension. +// Add new keys here to extend configuration in a type-safe way. +const configSchema = object({ + // The path to a package.json file of a Rstest executable. + // Used as a last resort if the extension cannot auto-detect @rstest/core. + rstestPackagePath: fallback(optional(string()), undefined), + nodeExecutable: fallback(optional(string()), undefined), + nodeExecArgs: fallback(array(string()), []), + nodeEnv: fallback(optional(record(string(), string())), undefined), + debugNodeEnv: fallback(optional(record(string(), string())), undefined), + debuggerPort: fallback(optional(number()), undefined), + debuggerAddress: fallback(optional(string()), undefined), + debugExclude: fallback(array(string()), ['/**']), + debugOutFiles: fallback(array(string()), []), + configFileGlobPattern: fallback(array(string()), [ + '**/rstest.config.{mjs,ts,js,cjs,mts,cts}', + ]), + testCaseCollectMethod: fallback( + union([literal('ast'), literal('runtime')]), + 'ast', + ), + applyDiagnostic: fallback(boolean(), true), + // Shell used by the "Run in Terminal" command. Empty falls back to the + // user's default integrated terminal (so its profile/env are honored). + terminalShellPath: fallback(optional(string()), undefined), + terminalShellArgs: fallback(array(string()), []), +}); + +type ExtensionConfig = InferOutput; + +// Type-safe getter for a single config value +export function getConfigValue( + key: K, + scope?: vscode.ConfigurationScope | null, +): ExtensionConfig[K] { + const value = vscode.workspace + .getConfiguration(CONFIG_SECTION, scope) + .get(key); + return parse(configSchema.entries[key], value) as ExtensionConfig[K]; +} + +export function watchConfigValue( + key: K, + scope: vscode.ConfigurationScope | null | undefined, + listener: ( + value: ExtensionConfig[K], + token: vscode.CancellationToken, + ) => void, +): vscode.Disposable { + let cancelSource = new vscode.CancellationTokenSource(); + listener(getConfigValue(key, scope), cancelSource.token); + const disposable = vscode.workspace.onDidChangeConfiguration((e) => { + if ( + e.affectsConfiguration(`${CONFIG_SECTION}.${key}`, scope ?? undefined) + ) { + cancelSource.cancel(); + cancelSource = new vscode.CancellationTokenSource(); + listener(getConfigValue(key, scope), cancelSource.token); + } + }); + return { + dispose: () => { + disposable.dispose(); + cancelSource.cancel(); + }, + }; +} diff --git a/packages/vscode/src/stacks/test/coreResolution.test.ts b/packages/vscode/src/stacks/test/coreResolution.test.ts new file mode 100644 index 0000000..4c792b8 --- /dev/null +++ b/packages/vscode/src/stacks/test/coreResolution.test.ts @@ -0,0 +1,69 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from '@rstest/core'; +import { + formatConfiguredCoreNotFoundMessage, + formatCoreNotFoundMessage, + isModuleNotFoundError, +} from './coreResolution'; + +// Resolve for real rather than hand-building an error object: the predicate +// reads a message Node owns, so a fake error would only assert itself. +const resolveError = (specifier: string, from: string): unknown => { + try { + require.resolve(specifier, { paths: [from] }); + } catch (e) { + return e; + } + throw new Error(`expected "${specifier}" not to resolve`); +}; + +describe('isModuleNotFoundError', () => { + it('should detect a package that is not installed', () => { + const specifier = '@rstest/definitely-not-installed'; + expect( + isModuleNotFoundError(resolveError(specifier, __dirname), specifier), + ).toBe(true); + }); + + it('should reject a package whose entry file is missing', () => { + // An interrupted install, or a workspace link that has not been built: + // installed, but unusable. Node reports the missing file, not the package. + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rstest-vscode-')); + const pkgDir = path.join(root, 'node_modules', 'broken-package'); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, 'package.json'), + '{"name":"broken-package","version":"1.0.0","main":"./gone.js"}', + ); + + expect( + isModuleNotFoundError( + resolveError('broken-package', root), + 'broken-package', + ), + ).toBe(false); + + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('should ignore other errors', () => { + expect(isModuleNotFoundError(new Error('boom'), 'boom')).toBe(false); + expect(isModuleNotFoundError('MODULE_NOT_FOUND', 'x')).toBe(false); + expect(isModuleNotFoundError(undefined, 'x')).toBe(false); + }); +}); + +describe('core-not-found messages', () => { + it('should point at the configured package path instead of the install hint', () => { + const message = formatConfiguredCoreNotFoundMessage( + '/repo/vendor/core/package.json', + ); + expect(message).toContain('/repo/vendor/core/package.json'); + expect(message).not.toContain('Install the project dependencies'); + expect(formatCoreNotFoundMessage('/repo/app')).toContain( + 'Install the project dependencies', + ); + }); +}); diff --git a/packages/vscode/src/stacks/test/coreResolution.ts b/packages/vscode/src/stacks/test/coreResolution.ts new file mode 100644 index 0000000..f33dea4 --- /dev/null +++ b/packages/vscode/src/stacks/test/coreResolution.ts @@ -0,0 +1,39 @@ +/** + * Helpers for reporting a failed `@rstest/core` resolution. + * + * Both messages replace Node's own `MODULE_NOT_FOUND` text, which embeds the + * require stack of whoever called `require.resolve` β€” for a bundled extension + * its `dist` path plus the VS Code extension host β€” and says nothing about what + * to do. They differ in where they end up: an uninstalled core is the normal + * state of a freshly cloned repository and is resolved for every config file + * without the user asking, so it is only logged; a `rstestPackagePath` that + * does not resolve is a setting the user has to fix, so it is notified. + */ + +// Whether `specifier` itself is what could not be found. `MODULE_NOT_FOUND` +// alone is too broad: a package that is installed but whose entry file is gone +// (an interrupted install, or a workspace link that has not been built) throws +// it too, and that must not be reported as "not installed". Node names the +// resolved file in that case and the requested specifier in this one, so the +// message is what separates them. A future Node wording change therefore fails +// towards reporting rather than towards silence. +export function isModuleNotFoundError( + error: unknown, + specifier: string, +): boolean { + return ( + error instanceof Error && + (error as NodeJS.ErrnoException).code === 'MODULE_NOT_FOUND' && + error.message.startsWith(`Cannot find module '${specifier}'`) + ); +} + +export function formatCoreNotFoundMessage(searchedFrom: string): string { + return `Cannot find "@rstest/core" from ${searchedFrom}. Install the project dependencies, then refresh the Test Explorer. If Rstest is installed elsewhere, set "rstack.rstest.rstestPackagePath" to its package.json.`; +} + +export function formatConfiguredCoreNotFoundMessage( + configuredPackagePath: string, +): string { + return `Cannot find "@rstest/core" at the configured "rstack.rstest.rstestPackagePath": ${configuredPackagePath}. Update the setting to point at an installed "@rstest/core" package.json.`; +} diff --git a/packages/vscode/src/stacks/test/diagnostics.test.ts b/packages/vscode/src/stacks/test/diagnostics.test.ts new file mode 100644 index 0000000..834e0d5 --- /dev/null +++ b/packages/vscode/src/stacks/test/diagnostics.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, rs } from '@rstest/core'; + +const diagnosticsState = new Map(); +let clearCalls = 0; +let disposeCalls = 0; + +rs.mock('vscode', () => { + const vscode = { + languages: { + createDiagnosticCollection: () => ({ + set: (uri: { toString: () => string }, diagnostics: unknown[]) => { + diagnosticsState.set(uri.toString(), diagnostics); + }, + clear: () => { + diagnosticsState.clear(); + clearCalls++; + }, + dispose: () => { + disposeCalls++; + }, + }), + }, + }; + + return { + ...vscode, + default: vscode, + }; +}); + +import { RstestDiagnostics } from './diagnostics'; + +const createUri = (path: string) => + ({ + toString: () => path, + }) as any; + +const createTestItem = (id: string) => + ({ + id, + }) as any; + +const createEntry = (path: string, message: string) => + ({ + uri: createUri(path), + diagnostic: { message }, + }) as any; + +describe('RstestDiagnostics', () => { + it('should keep diagnostics for different test items with same id', () => { + diagnosticsState.clear(); + clearCalls = 0; + + const diagnostics = new RstestDiagnostics(); + const itemA = createTestItem('duplicate-id'); + const itemB = createTestItem('duplicate-id'); + + diagnostics.setForTest('project-a', itemA, [ + createEntry('file:///a.test.ts', 'error-a'), + ]); + diagnostics.setForTest('project-a', itemB, [ + createEntry('file:///a.test.ts', 'error-b'), + ]); + + expect(diagnosticsState.get('file:///a.test.ts')).toEqual([ + { message: 'error-a' }, + { message: 'error-b' }, + ]); + + diagnostics.clearForTest('project-a', itemA); + expect(diagnosticsState.get('file:///a.test.ts')).toEqual([ + { message: 'error-b' }, + ]); + }); + + it('should clear diagnostics for one project without affecting others', () => { + diagnosticsState.clear(); + clearCalls = 0; + + const diagnostics = new RstestDiagnostics(); + diagnostics.setForTest('project-a', createTestItem('a'), [ + createEntry('file:///a.test.ts', 'error-a'), + createEntry('file:///stale.test.ts', 'stale-a'), + ]); + diagnostics.setForTest('project-b', createTestItem('b'), [ + createEntry('file:///b.test.ts', 'error-b'), + ]); + + diagnostics.clearForProject('project-a'); + + expect(diagnosticsState.get('file:///a.test.ts')).toBeUndefined(); + expect(diagnosticsState.get('file:///stale.test.ts')).toBeUndefined(); + expect(diagnosticsState.get('file:///b.test.ts')).toEqual([ + { message: 'error-b' }, + ]); + }); + + it('should dispose collection safely', () => { + diagnosticsState.clear(); + clearCalls = 0; + disposeCalls = 0; + + const diagnostics = new RstestDiagnostics(); + diagnostics.setForTest('project-a', createTestItem('a'), [ + createEntry('file:///a.test.ts', 'error-a'), + ]); + diagnostics.dispose(); + + expect(disposeCalls).toBe(1); + expect(diagnosticsState.size).toBe(0); + expect(clearCalls).toBeGreaterThan(0); + }); +}); diff --git a/packages/vscode/src/stacks/test/diagnostics.ts b/packages/vscode/src/stacks/test/diagnostics.ts new file mode 100644 index 0000000..6620f6c --- /dev/null +++ b/packages/vscode/src/stacks/test/diagnostics.ts @@ -0,0 +1,102 @@ +import vscode from 'vscode'; + +export type DiagnosticEntry = { + uri: vscode.Uri; + diagnostic: vscode.Diagnostic; +}; + +export class RstestDiagnostics implements vscode.Disposable { + private readonly collection = + vscode.languages.createDiagnosticCollection('rstest'); + + private readonly diagnosticsByProject = new Map< + string, + Map + >(); + + public setForTest( + projectKey: string, + testItem: vscode.TestItem, + diagnostics: DiagnosticEntry[], + ) { + if (!projectKey) { + return; + } + if (diagnostics.length === 0) { + this.clearForTest(projectKey, testItem); + return; + } + + let projectDiagnostics = this.diagnosticsByProject.get(projectKey); + if (!projectDiagnostics) { + projectDiagnostics = new Map(); + this.diagnosticsByProject.set(projectKey, projectDiagnostics); + } + + projectDiagnostics.set(testItem, diagnostics); + this.flush(); + } + + public clearForTest(projectKey: string, testItem: vscode.TestItem) { + const projectDiagnostics = this.diagnosticsByProject.get(projectKey); + if (!projectDiagnostics) { + return; + } + + if (projectDiagnostics.delete(testItem)) { + if (projectDiagnostics.size === 0) { + this.diagnosticsByProject.delete(projectKey); + } + this.flush(); + } + } + + public clearForProject(projectKey: string) { + if (!projectKey) { + return; + } + + if (this.diagnosticsByProject.delete(projectKey)) { + this.flush(); + } + } + + public clear() { + this.diagnosticsByProject.clear(); + this.collection.clear(); + } + + private flush() { + const diagnosticsByFile = new Map< + string, + { uri: vscode.Uri; diagnostics: vscode.Diagnostic[] } + >(); + + for (const projectDiagnostics of this.diagnosticsByProject.values()) { + for (const diagnostics of projectDiagnostics.values()) { + for (const entry of diagnostics) { + const key = entry.uri.toString(); + const fileDiagnostics = diagnosticsByFile.get(key); + if (fileDiagnostics) { + fileDiagnostics.diagnostics.push(entry.diagnostic); + continue; + } + diagnosticsByFile.set(key, { + uri: entry.uri, + diagnostics: [entry.diagnostic], + }); + } + } + } + + this.collection.clear(); + for (const { uri, diagnostics } of diagnosticsByFile.values()) { + this.collection.set(uri, diagnostics); + } + } + + public dispose() { + this.clear(); + this.collection.dispose(); + } +} diff --git a/packages/vscode/src/stacks/test/errorStore.test.ts b/packages/vscode/src/stacks/test/errorStore.test.ts new file mode 100644 index 0000000..50a7781 --- /dev/null +++ b/packages/vscode/src/stacks/test/errorStore.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from '@rstest/core'; +import { TestErrorStore, testMessageText } from './errorStore'; + +const item = (id: string) => ({ id }) as any; +const msg = (message: unknown) => ({ message }) as any; + +describe('TestErrorStore', () => { + it('stores and returns messages per test item', () => { + const store = new TestErrorStore(); + const a = item('a'); + const b = item('b'); + store.set(a, [msg('boom')]); + expect(store.get(a).map((m) => m.message)).toEqual(['boom']); + // unknown item has no errors + expect(store.get(b)).toEqual([]); + }); + + it('clears entries, and an empty set removes them', () => { + const store = new TestErrorStore(); + const a = item('a'); + store.set(a, [msg('boom')]); + store.clear(a); + expect(store.get(a)).toEqual([]); + + store.set(a, [msg('again')]); + store.set(a, []); // overwriting with no messages + expect(store.get(a)).toEqual([]); + }); +}); + +describe('testMessageText', () => { + it('returns string messages verbatim', () => { + expect(testMessageText(msg('plain error'))).toBe('plain error'); + }); + + it('reads the value of a MarkdownString message', () => { + expect(testMessageText(msg({ value: '**bold** error' }))).toBe( + '**bold** error', + ); + }); +}); diff --git a/packages/vscode/src/stacks/test/errorStore.ts b/packages/vscode/src/stacks/test/errorStore.ts new file mode 100644 index 0000000..474796d --- /dev/null +++ b/packages/vscode/src/stacks/test/errorStore.ts @@ -0,0 +1,32 @@ +import type vscode from 'vscode'; + +// Retains the last run's failure messages per test item so the +// `rstack.rstest.copyTestItemErrors` command can surface them. Kept separate from +// RstestDiagnostics because that store is gated on `rstack.rstest.applyDiagnostic` and +// drops messages without a resolvable source location, whereas copying errors +// should work regardless of the diagnostics setting and keep every message. +export class TestErrorStore { + private readonly messagesByTest = new WeakMap< + vscode.TestItem, + vscode.TestMessage[] + >(); + + public set(testItem: vscode.TestItem, messages: vscode.TestMessage[]) { + this.messagesByTest.set(testItem, messages); + } + + public clear(testItem: vscode.TestItem) { + this.messagesByTest.delete(testItem); + } + + public get(testItem: vscode.TestItem): vscode.TestMessage[] { + return this.messagesByTest.get(testItem) ?? []; + } +} + +// Flatten a TestMessage's text (string or MarkdownString) to plain text. +export function testMessageText(message: vscode.TestMessage): string { + return typeof message.message === 'string' + ? message.message + : message.message.value; +} diff --git a/packages/vscode/src/stacks/test/global.d.ts b/packages/vscode/src/stacks/test/global.d.ts new file mode 100644 index 0000000..c355d48 --- /dev/null +++ b/packages/vscode/src/stacks/test/global.d.ts @@ -0,0 +1,9 @@ +declare module 'core-js-pure/actual/regexp/escape' { + /** + * Available from nodejs 24 + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/escape + */ + const regexEscape: (string: string) => string; + export default regexEscape; +} diff --git a/packages/vscode/src/stacks/test/index.ts b/packages/vscode/src/stacks/test/index.ts new file mode 100644 index 0000000..3b5fe3c --- /dev/null +++ b/packages/vscode/src/stacks/test/index.ts @@ -0,0 +1,537 @@ +import vscode from 'vscode'; +import type { + DetectionSnapshot, + StackContext, + StackController, +} from '../../types'; +import { RstestDiagnostics } from './diagnostics'; +import { TestErrorStore, testMessageText } from './errorStore'; +import { logger } from './logger'; +import { runningWorkers } from './master'; +import { Project, WorkspaceManager } from './project'; +import { status } from './status'; +import { disposeTerminal } from './terminal'; +import { RstestFileCoverage } from './testRunReporter'; +import { + gatherTestItems, + ProjectFolder, + TestCase, + TestFile, + TestFolder, + testData, +} from './testTree'; + +/** + * The `TestController` id. Every `controllerId == 'rstack.rstest'` `when` + * clause in `package.json` keys off it (the namespace adaptation). + */ +export const TEST_CONTROLLER_ID = 'rstack.rstest'; + +/** Context key backing the `resourcePath in ...` menu clauses. */ +const TEST_FILES_CONTEXT_KEY = 'rstack.rstest.testFiles'; + +/** + * The copy of `rstest/packages/vscode`'s `Rstest` class (upstream + * `src/extension.ts`), with the mandatory port adaptations: + * + * 1. **Activation** β€” upstream constructed this from `activate()` and pushed + * everything onto `ExtensionContext#subscriptions`. Here the shell owns + * activation and can register/unregister the stack many times in one session + * (a gate flip, a trust grant, a folder losing its last config), so every + * disposable is owned by this instance and released in `dispose()`. + * `ExtensionContext#subscriptions` is never touched β€” a second `register()` + * would otherwise fail with "command already exists". + * 2. **Namespace** β€” controller id, command ids and the context key are + * `rstack.rstest.*`. + * 4. **Status** β€” no own status UI; `context.status` is bound to the module + * singleton the deep call sites (`master.ts`, `bridge.ts`) report through, + * and `context.output` is bound to the shell-owned channel `logger` writes + * into. Upstream disposed the channel here; this one is not ours to dispose. + * + * Detection also scopes the scan: only folders in which the shell detected + * Rstest get a `WorkspaceManager`, and each manager is handed the folder's + * `rstack.config.*` files so it can synthesize bridged projects. + */ +class Rstest implements vscode.Disposable { + private ctrl: vscode.TestController; + private workspaces = new Map(); + private disposables: vscode.Disposable[] = []; + private diagnostics = new RstestDiagnostics(); + private errorStore = new TestErrorStore(); + private detection: DetectionSnapshot; + private runProfile!: vscode.TestRunProfile; + private coverageProfile!: vscode.TestRunProfile; + private disposed = false; + + // Kept for parity with upstream, where the E2E suites reach the tree through + // `extension.exports.testController`. + get testController() { + return this.ctrl; + } + + /** + * What upstream's `activate()` effectively exported (the `Rstest` instance): + * the E2E suites (`tests/e2e/rstest/`) consume `testController`, `runProfile` + * and `startTestRun`. The shell republishes this object through the + * extension's public exports (`RstackExtensionExports.whenStackActive`). + * All three values are stable for the lifetime of one registration; a + * re-registration publishes a fresh object. + */ + buildExports(): Record { + return { + testController: this.ctrl, + runProfile: this.runProfile, + startTestRun: this.startTestRun, + }; + } + + constructor(private context: StackContext) { + this.detection = context.detection; + this.ctrl = vscode.tests.createTestController(TEST_CONTROLLER_ID, 'Rstest'); + this.disposables.push(this.ctrl, this.diagnostics); + + this.setupTestController(); + this.startScanWorkspaces(); + + this.disposables.push( + context.onDidChangeDetection((snapshot) => { + this.detection = snapshot; + this.applyDetection(); + }), + ); + } + + private setupTestController() { + this.ctrl.refreshHandler = () => this.startScanWorkspaces(); + + this.runProfile = this.ctrl.createRunProfile( + 'Run Tests', + vscode.TestRunProfileKind.Run, + this.startTestRun, + true, + undefined, + true, + ); + + this.disposables.push( + vscode.commands.registerCommand( + 'rstack.rstest.updateSnapshot', + (params: { test: vscode.TestItem; message: vscode.TestMessage }) => { + const cancellation = new vscode.CancellationTokenSource(); + return this.startTestRun( + new vscode.TestRunRequest( + [params.test], + undefined, + this.runProfile, + ), + cancellation.token, + true, + ).finally(() => cancellation.dispose()); + }, + ), + ); + + this.registerCommands(); + + this.ctrl.createRunProfile( + 'Debug Tests', + vscode.TestRunProfileKind.Debug, + this.startTestRun, + true, + undefined, + true, + ); + + this.coverageProfile = this.ctrl.createRunProfile( + 'Run Tests with Coverage', + vscode.TestRunProfileKind.Coverage, + this.startTestRun, + true, + undefined, + true, + ); + + this.coverageProfile.loadDetailedCoverage = async (_testRun, coverage) => { + if (coverage instanceof RstestFileCoverage) { + return coverage.details; + } + return []; + }; + } + + private registerCommands() { + const register = ( + command: string, + callback: (...args: any[]) => unknown, + ) => { + this.disposables.push(vscode.commands.registerCommand(command, callback)); + }; + + // Upstream's `rstest.openOutput` is gone: the shell owns the four output + // channels and registers `rstack.rstest.output.focus`. + + register('rstack.rstest.revealInTestExplorer', async (uri?: vscode.Uri) => { + const target = uri ?? vscode.window.activeTextEditor?.document.uri; + const item = target && this.findTestFileItem(target); + if (!item) { + vscode.window.showInformationMessage( + 'This file is not a known Rstest test file.', + ); + return; + } + await vscode.commands.executeCommand('vscode.revealTestInExplorer', item); + }); + + register( + 'rstack.rstest.copyErrorOutput', + async (args?: { test: vscode.TestItem; message: vscode.TestMessage }) => { + if (!args?.message) return; + await vscode.env.clipboard.writeText(testMessageText(args.message)); + }, + ); + + register('rstack.rstest.runInTerminal', (testItem?: vscode.TestItem) => { + if (!testItem) return; + const data = testData.get(testItem); + if (data instanceof TestCase) { + data.api.runInTerminal({ + fileFilter: data.uri.fsPath, + testCaseNamePath: data.parentNames.concat(testItem.label), + isSuite: data.type === 'suite', + }); + } else if (data instanceof TestFile || data instanceof TestFolder) { + data.api.runInTerminal({ fileFilter: data.uri.fsPath }); + } else if (data instanceof Project) { + data.api.runInTerminal({}); + } else { + vscode.window.showInformationMessage( + 'Run in Terminal is not available for this item.', + ); + } + }); + + register( + 'rstack.rstest.copyTestItemErrors', + async (testItem?: vscode.TestItem) => { + if (!testItem) return; + const errors = this.collectErrors(testItem); + if (!errors.length) { + vscode.window.showInformationMessage('No test errors to copy.'); + return; + } + await vscode.env.clipboard.writeText(errors.join('\n\n')); + }, + ); + } + + // Errors for a test item plus any descendants (a file/suite item aggregates + // its leaves' failures). + private collectErrors(item: vscode.TestItem): string[] { + const errors: string[] = []; + for (const test of [item, ...gatherTestItems(item.children)]) { + for (const message of this.errorStore.get(test)) { + errors.push(testMessageText(message)); + } + } + return errors; + } + + private findTestFileItem(uri: vscode.Uri): vscode.TestItem | undefined { + const key = uri.toString(); + for (const workspace of this.workspaces.values()) { + for (const project of workspace.projects.values()) { + const item = project.testFiles.get(key)?.testItem; + if (item) return item; + } + } + return undefined; + } + + private updateTestFilesContext() { + const paths: string[] = []; + for (const workspace of this.workspaces.values()) { + for (const project of workspace.projects.values()) { + for (const file of project.testFiles.values()) { + paths.push(file.uri.fsPath); + } + } + } + vscode.commands.executeCommand('setContext', TEST_FILES_CONTEXT_KEY, paths); + } + + /** + * The folders Rstest was detected in (detection enables a stack + * per folder). Virtual file systems are skipped, as upstream did β€” the worker + * spawn and the package resolution both need a real path. + */ + private detectedFolders(): vscode.WorkspaceFolder[] { + return this.detection + .foldersFor('rstest') + .map((entry) => entry.folder) + .filter((folder) => folder.uri.scheme === 'file'); + } + + private rstackConfigFilesOf( + folder: vscode.WorkspaceFolder, + ): readonly vscode.Uri[] { + return ( + this.detection.forFolder(folder)?.stacks.rstest.rstackConfigFiles ?? [] + ); + } + + /** + * Full rescan. Bound to `refreshHandler`, so it must be safe to call at any + * time: every manager is torn down and rebuilt from the current snapshot. + */ + private startScanWorkspaces() { + // dispose previous data on refresh + for (const [workspacePath, workspace] of this.workspaces) { + workspace.dispose(); + this.workspaces.delete(workspacePath); + } + // collect all detected workspaces + for (const folder of this.detectedFolders()) { + this.handleAddWorkspace(folder); + } + this.refreshAllWorkspaces(); + } + + /** + * Incremental reconcile against a fresh detection snapshot. Folders that kept + * their detection state keep their `WorkspaceManager` (and therefore their + * warm workers); only the `rstack.config.*` list is pushed down, because a + * config appearing or disappearing changes which projects are bridged. + * + * Upstream watched `onDidChangeWorkspaceFolders` itself; the shell's + * detection service already does (folder identity is part of its snapshot + * signature), so this one event covers both. + */ + private applyDetection() { + if (this.disposed) return; + const detected = this.detectedFolders(); + const wanted = new Set(detected.map((folder) => folder.uri.toString())); + + for (const [key, workspace] of this.workspaces) { + if (!wanted.has(key)) { + workspace.dispose(); + this.workspaces.delete(key); + } + } + for (const folder of detected) { + const key = folder.uri.toString(); + const existing = this.workspaces.get(key); + if (existing) { + existing.setRstackConfigFiles(this.rstackConfigFilesOf(folder)); + } else { + this.handleAddWorkspace(folder); + } + } + this.refreshAllWorkspaces(); + } + + private handleAddWorkspace(workspaceFolder: vscode.WorkspaceFolder) { + // ignore virtual file system + if (workspaceFolder.uri.scheme !== 'file') return; + + this.workspaces.set( + workspaceFolder.uri.toString(), + new WorkspaceManager( + workspaceFolder, + this.ctrl, + this.rstackConfigFilesOf(workspaceFolder), + () => this.updateTestFilesContext(), + ), + ); + } + + private refreshAllWorkspaces() { + this.ctrl.items.replace([]); + for (const workspace of this.workspaces.values()) { + // Upstream keyed this off `workspace.workspaceFolders.length`. Detection + // can leave a multi-folder workspace with a single Rstest folder, and + // wrapping a lone folder in a node it does not need is worse than the + // upstream behavior it replaces. + workspace.refresh(this.workspaces.size === 1); + } + this.updateTestFilesContext(); + this.reportStatus(); + } + + private reportStatus() { + const folders = this.workspaces.size; + if (folders === 0) { + // Detection said yes for at least one folder or the shell would not have + // registered us; landing here means every one of them is virtual. + status.starting('no local workspace folder to scan'); + return; + } + status.running( + folders === 1 ? '1 workspace folder' : `${folders} workspace folders`, + ); + } + + private startTestRun = async ( + request: vscode.TestRunRequest, + token: vscode.CancellationToken, + updateSnapshot?: boolean, + // used by e2e tests + createTestRun = this.ctrl.createTestRun.bind(this.ctrl), + ) => { + const run = createTestRun(request); + const enqueuedTests = (tests: readonly vscode.TestItem[]) => { + for (const test of tests) { + if (request.exclude?.includes(test)) { + continue; + } + const data = testData.get(test); + if (data instanceof TestFile || data instanceof TestCase) { + run.enqueued(test); + } + enqueuedTests(gatherTestItems(test.children, false)); + } + }; + + enqueuedTests(request.include ?? gatherTestItems(this.ctrl.items, false)); + + const commonOptions = { + run, + token, + updateSnapshot, + kind: request.profile?.kind, + continuous: request.continuous, + diagnostics: this.diagnostics, + errorStore: this.errorStore, + createTestRun: () => + createTestRun( + new vscode.TestRunRequest( + request.include, + request.exclude, + request.profile, + request.continuous, + request.preserveFocus, + ), + ), + }; + + const discoverTests = async (tests: readonly vscode.TestItem[]) => { + for (const test of tests) { + if (request.exclude?.includes(test)) { + continue; + } + + const data = testData.get(test); + if (data instanceof WorkspaceManager) { + if (data.activeProjects.size === 1) { + const project = data.activeProjects.values().next().value!; + await project.api.runTest({ + ...commonOptions, + }); + } else { + await discoverTests(gatherTestItems(test.children, false)); + } + } else if (data instanceof Project) { + await data.api.runTest({ + ...commonOptions, + }); + } else if (data instanceof ProjectFolder) { + // grouping folder spans multiple projects; recurse into children + await discoverTests(gatherTestItems(test.children, false)); + } else if (data instanceof TestFolder) { + await data.api.runTest({ + ...commonOptions, + fileFilter: data.uri.fsPath, + }); + } else if (data instanceof TestFile) { + await data.api.runTest({ + ...commonOptions, + fileFilter: data.uri.fsPath, + }); + } else if (data instanceof TestCase) { + await data.api.runTest({ + ...commonOptions, + fileFilter: data.uri.fsPath, + testCaseNamePath: data.parentNames.concat(test.label), + isSuite: data.type === 'suite', + }); + } + } + }; + + try { + if (!request.include?.length) { + if (this.workspaces.size === 1) { + const workspace = this.workspaces.values().next().value!; + if (workspace.activeProjects.size === 1) { + const project = workspace.activeProjects.values().next().value!; + await project.api.runTest({ + ...commonOptions, + }); + return; + } + } + } + await discoverTests( + request.include ?? gatherTestItems(this.ctrl.items, false), + ); + } catch (error) { + logger.error('Error running tests:', error); + } finally { + run.end(); + } + }; + + dispose() { + this.disposed = true; + // Upstream's `deactivate()`. A worker is a child process, so it outlives a + // plain `TestController.dispose()` and has to be closed explicitly. + for (const worker of runningWorkers) { + worker.$close(); + } + disposeTerminal(); + for (const workspace of this.workspaces.values()) { + workspace.dispose(); + } + this.workspaces.clear(); + for (const disposable of this.disposables.splice(0)) { + disposable.dispose(); + } + // The menus stay hidden until the stack comes back. + vscode.commands.executeCommand('setContext', TEST_FILES_CONTEXT_KEY, []); + } +} + +class RstestController implements StackController { + readonly id = 'rstest' as const; + + #rstest: Rstest | undefined; + + get testController(): vscode.TestController | undefined { + return this.#rstest?.testController; + } + + async register(context: StackContext): Promise> { + // Both are module singletons the copied files import directly; binding them + // here is what keeps those files diffable against upstream (adaptation #4). + logger.bind(context.output); + status.bind(context.status); + status.starting('scanning workspace folders'); + try { + this.#rstest = new Rstest(context); + } catch (error) { + logger.unbind(); + status.unbind(); + throw error; + } + return this.#rstest.buildExports(); + } + + dispose(): void { + this.#rstest?.dispose(); + this.#rstest = undefined; + status.unbind(); + logger.unbind(); + } +} + +export const createRstestController = (): StackController => + new RstestController(); diff --git a/packages/vscode/src/stacks/test/logger.ts b/packages/vscode/src/stacks/test/logger.ts new file mode 100644 index 0000000..ec65899 --- /dev/null +++ b/packages/vscode/src/stacks/test/logger.ts @@ -0,0 +1,36 @@ +import type vscode from 'vscode'; +import { BaseLogger, type LogLevel } from './shared/logger'; + +/** + * The status-aggregation adaptation: the stack no longer owns an output channel. + * The shell creates the four channels and hands this stack its own one, so + * `MasterLogger` writes into a channel it does not own and must never dispose. + * + * The binding is a module singleton because every upstream module imports the + * `logger` singleton directly; keeping that shape is what makes the copied + * files diffable against `web-infra-dev/rstest`. `bind()` is called once per + * `register()` and `unbind()` on `dispose()`, so a re-registered stack (a gate + * flip, a trust grant) logs into the fresh channel and a disposed stack logs + * nowhere instead of throwing on a disposed channel. + */ +class MasterLogger extends BaseLogger { + #channel: vscode.LogOutputChannel | undefined; + + protected override log(level: LogLevel, message: string) { + this.#channel?.[level](message); + } + + public bind(channel: vscode.LogOutputChannel) { + this.#channel = channel; + } + + public unbind() { + this.#channel = undefined; + } + + public show() { + this.#channel?.show(); + } +} + +export const logger = new MasterLogger(); diff --git a/packages/vscode/src/stacks/test/master.test.ts b/packages/vscode/src/stacks/test/master.test.ts new file mode 100644 index 0000000..63aa6e9 --- /dev/null +++ b/packages/vscode/src/stacks/test/master.test.ts @@ -0,0 +1,217 @@ +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, rs } from '@rstest/core'; +import { logger } from './logger'; +import { RstestApi } from './master'; + +// The Rstest runner injects its own `@rstest/core` into every resolution path so +// that test files can import it, which makes "the project has no @rstest/core" +// impossible to stage in-process. `nodeRequire` is therefore wrapped: a lookup +// of `@rstest/core` that has no package directory above the search path fails +// the way Node would, and every other lookup β€” including the *installed but +// unusable* fixture below β€” goes to the real resolver untouched. +rs.mock('./nodeRequire', () => { + const realRequire = createRequire(__filename); + + const hasInstalledCore = (from: string): boolean => { + let dir = path.resolve(from); + for (;;) { + if (fs.existsSync(path.join(dir, 'node_modules', '@rstest', 'core'))) { + return true; + } + const parent = path.dirname(dir); + if (parent === dir) return false; + dir = parent; + } + }; + + const nodeRequire = ((id: string) => realRequire(id)) as NodeJS.Require; + nodeRequire.resolve = (( + specifier: string, + options?: { paths?: string[] }, + ) => { + const isCore = + specifier === '@rstest/core' || specifier.startsWith('@rstest/core/'); + const from = options?.paths?.[0]; + if (isCore && from && !hasInstalledCore(from)) { + // Same shape as Node's own error; `isModuleNotFoundError` is pinned + // against a real one in `coreResolution.test.ts`. + throw Object.assign(new Error(`Cannot find module '${specifier}'`), { + code: 'MODULE_NOT_FOUND', + }); + } + return realRequire.resolve(specifier, options); + }) as NodeJS.RequireResolve; + + return { nodeRequire }; +}); + +// Everything the extension surfaces: notifications the user cannot miss, the +// output channel, and the terminal a "Run in Terminal" would open. +const shownMessages: string[] = []; +const loggedErrors: string[] = []; +const createdTerminals: string[] = []; +const settings: Record = {}; + +const channel = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: (message: string) => loggedErrors.push(message), + show: () => {}, + dispose: () => {}, +}; + +// Adaptation #4: the output channel belongs to the shell and is handed to the +// stack at `register()`. Upstream's `MasterLogger` created its own, so mocking +// `vscode.window.createOutputChannel` was enough; here the binding has to be +// made explicitly or every `logger.error` is a silent no-op. +logger.bind(channel as never); + +rs.mock('vscode', () => { + const vscode = { + TestRunProfileKind: { Run: 1, Debug: 2, Coverage: 3 }, + FileCoverage: class {}, + Position: class {}, + Range: class {}, + Uri: { + file: (fsPath: string) => ({ + fsPath, + toString: () => `file://${fsPath}`, + }), + }, + extensions: { getExtension: () => undefined }, + window: { + createOutputChannel: () => channel, + createTerminal: (options: { name: string }) => { + createdTerminals.push(options.name); + return { show: () => {}, sendText: () => {}, dispose: () => {} }; + }, + onDidCloseTerminal: () => ({ dispose: () => {} }), + showErrorMessage: (message: string) => shownMessages.push(message), + showWarningMessage: (message: string) => shownMessages.push(message), + showInformationMessage: (message: string) => shownMessages.push(message), + }, + workspace: { + getConfiguration: () => ({ + get: (key: string) => settings[key], + }), + onDidChangeConfiguration: () => ({ dispose: () => {} }), + }, + }; + return { ...vscode, default: vscode }; +}); + +// A directory outside the repository, so Node's upward resolution cannot reach +// the workspace `node_modules` and `@rstest/core` is genuinely missing. +const noCoreDir = os.tmpdir(); + +const createApi = (cwd = noCoreDir) => { + const workspace = { uri: { fsPath: cwd } }; + return new RstestApi( + workspace as any, + cwd, + `${cwd}/rstest.config.ts`, + {} as any, + ); +}; + +describe('RstestApi with a missing @rstest/core', () => { + beforeEach(() => { + shownMessages.length = 0; + loggedErrors.length = 0; + createdTerminals.length = 0; + for (const key of Object.keys(settings)) delete settings[key]; + }); + + it('should log an actionable message instead of notifying, while discovering projects', async () => { + await expect(createApi().getNormalizedConfig()).rejects.toThrow( + 'Failed to resolve rstest path', + ); + expect(shownMessages).toEqual([]); + const logged = loggedErrors.join('\n'); + expect(logged).toContain(`Cannot find "@rstest/core" from ${noCoreDir}`); + expect(logged).toContain('Install the project dependencies'); + expect(logged).not.toContain('Require stack'); + }); + + it('should stay silent while listing tests', async () => { + await expect(createApi().listTests()).rejects.toThrow( + 'Failed to resolve rstest path', + ); + expect(shownMessages).toEqual([]); + }); + + it('should stay silent while running tests', async () => { + await expect( + createApi().runTest({ run: {} as any, token: {} as any }), + ).rejects.toThrow('Failed to resolve rstest path'); + expect(shownMessages).toEqual([]); + }); + + it('should stay silent, and open no terminal, for a terminal run', () => { + createApi().runInTerminal({}); + expect(shownMessages).toEqual([]); + expect(createdTerminals).toEqual([]); + }); +}); + +// Installed but unusable β€” an interrupted install, or a workspace link that +// has not been built. Advising an install would be wrong, and staying silent +// would hide a broken state the user has to repair. +describe('RstestApi with an unusable @rstest/core', () => { + let root: string; + + beforeEach(() => { + shownMessages.length = 0; + loggedErrors.length = 0; + root = fs.mkdtempSync(path.join(os.tmpdir(), 'rstest-vscode-')); + const pkgDir = path.join(root, 'node_modules', '@rstest', 'core'); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, 'package.json'), + '{"name":"@rstest/core","version":"9.9.9","main":"./gone.js"}', + ); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('should notify instead of reporting it as not installed', async () => { + await expect(createApi(root).getNormalizedConfig()).rejects.toThrow(); + expect(shownMessages).toHaveLength(1); + expect(shownMessages[0]).toContain('gone.js'); + expect(loggedErrors.join('\n')).not.toContain( + 'Install the project dependencies', + ); + }); +}); + +// A configured `rstestPackagePath` that does not resolve is not the +// "dependencies are not installed yet" state β€” the user picked that path and +// has to fix it, so silence would strand them. +describe('RstestApi with an unresolvable rstestPackagePath', () => { + const configured = `${noCoreDir}/vendor/core/package.json`; + + beforeEach(() => { + shownMessages.length = 0; + settings.rstestPackagePath = configured; + }); + + it('should notify while discovering projects', async () => { + await expect(createApi().getNormalizedConfig()).rejects.toThrow(); + expect(shownMessages).toHaveLength(1); + expect(shownMessages[0]).toContain('rstack.rstest.rstestPackagePath'); + expect(shownMessages[0]).toContain(configured); + }); + + it('should notify for a terminal run', () => { + createApi().runInTerminal({}); + expect(shownMessages).toHaveLength(1); + expect(shownMessages[0]).toContain('rstack.rstest.rstestPackagePath'); + expect(createdTerminals).toEqual([]); + }); +}); diff --git a/packages/vscode/src/stacks/test/master.ts b/packages/vscode/src/stacks/test/master.ts new file mode 100644 index 0000000..71e9ef5 --- /dev/null +++ b/packages/vscode/src/stacks/test/master.ts @@ -0,0 +1,562 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import net from 'node:net'; +import path, { dirname } from 'node:path'; +import { type BirpcReturn, createBirpc } from 'birpc'; +import regexpEscape from 'core-js-pure/actual/regexp/escape'; +import vscode from 'vscode'; +import { reportVersionCheck } from '../../shared/versionCheck'; +import { CONFIG_SECTION, getConfigValue } from './config'; +import { + formatConfiguredCoreNotFoundMessage, + formatCoreNotFoundMessage, + isModuleNotFoundError, +} from './coreResolution'; +import type { RstestDiagnostics } from './diagnostics'; +import type { TestErrorStore } from './errorStore'; +import { logger } from './logger'; +import { nodeRequire } from './nodeRequire'; +import type { Project } from './project'; +import { status } from './status'; +import { runInTerminal as sendToTerminal, shellQuote } from './terminal'; +import { TestRunReporter } from './testRunReporter'; +import { toErrorMessage } from './utils'; +import type { Worker } from './worker'; + +export const runningWorkers = new Set>(); + +// Default host for a fixed debug port. The spawn (`--inspect-wait`), the port +// preflight, and the attach config must all use the same host: on a dual-stack +// machine `localhost` can resolve to `::1` while the worker listens on IPv4, so +// the debugger would attach to the wrong endpoint. Prefer an explicit IPv4 +// literal over `localhost` so both ends agree. +const DEFAULT_DEBUG_HOST = '127.0.0.1'; + +// The specifier used when `rstestPackagePath` is unset. +const CORE_PACKAGE_JSON = '@rstest/core/package.json'; + +// Probe whether a fixed inspector port can be bound. `--inspect-wait=host:port` +// does not fall back when the port is taken: Node reports address-in-use and +// runs the worker without the inspector, and attaching by that port could hit an +// unrelated process. Preflight so we fail with a clear message instead. +const isPortAvailable = (port: number, host?: string): Promise => + new Promise((resolve) => { + const server = net.createServer(); + server.once('error', () => resolve(false)); + server.once('listening', () => server.close(() => resolve(true))); + server.listen(port, host ?? DEFAULT_DEBUG_HOST); + }); + +export class RstestApi { + private childProcesses = new Set(); + + constructor( + private workspace: vscode.WorkspaceFolder, + /** + * The worker spawn cwd, the `@rstest/core` resolution root, the terminal + * cwd and the base the terminal's `-c` path is relativized against. + * + * The worker-cwd decoupling adaptation: upstream derives this from + * `dirname(configFilePath)` inside `Project`. It is now passed in, so the + * rstack shim (`node_modules/rstack/dist/rstestConfig.js`) can be the + * config file while the worker still runs in the `rstack.config.*` + * directory. For a native `rstest.config.*` the caller passes + * `dirname(configFilePath)`, which is byte-identical to upstream. + */ + private cwd: string, + private configFilePath: string, + private project: Project, + ) {} + + private expandWorkspaceFolder(value: string): string { + return value.replaceAll('${workspaceFolder}', this.workspace.uri.fsPath); + } + + // Regex source that selects a single reported case by its name path. Shared by + // the worker run (wrapped in RegExp) and the terminal `-t` argument so both + // select the same case. + private buildTestNamePattern( + testCaseNamePath: string[], + isSuite?: boolean, + ): string { + return `^${regexpEscape(testCaseNamePath.join(' '))}${isSuite ? ' ' : '$'}`; + } + + // The node executable + exec args used to run a worker or the CLI, honoring + // the `nodeExecutable` / `nodeExecArgs` settings (`${workspaceFolder}` + // expanded). + private resolveNodeCommand(): { + nodeExecutable: string; + nodeExecArgs: string[]; + } { + const configuredExecutable = getConfigValue( + 'nodeExecutable', + this.workspace, + ); + return { + nodeExecutable: configuredExecutable + ? this.expandWorkspaceFolder(configuredExecutable) + : 'node', + nodeExecArgs: getConfigValue('nodeExecArgs', this.workspace).map((arg) => + this.expandWorkspaceFolder(arg), + ), + }; + } + + // The validated absolute path to the package.json a `rstestPackagePath` + // setting points at, or `undefined` when the setting is unset and the bare + // `CORE_PACKAGE_JSON` specifier applies. Shared by the worker resolution and + // the terminal CLI resolution, which both also report the configured path. + private resolveConfiguredPackageJson(): string | undefined { + // TODO: support Yarn PnP + let configuredPackagePath = getConfigValue( + 'rstestPackagePath', + this.workspace, + ); + if (!configuredPackagePath) { + return undefined; + } + configuredPackagePath = this.expandWorkspaceFolder(configuredPackagePath); + if (!configuredPackagePath.endsWith('package.json')) { + throw new Error( + `"${CONFIG_SECTION}.rstestPackagePath" must point to a package.json file, instead got: ${configuredPackagePath}`, + ); + } + return path.isAbsolute(configuredPackagePath) + ? configuredPackagePath + : path.resolve(this.workspace.uri.fsPath, configuredPackagePath); + } + + // Resolve `specifier` from the config file's directory. `undefined` means + // `@rstest/core` is not installed at all β€” the normal state of a repository + // whose dependencies are not installed yet, so it is written to the output + // channel and never raised as a notification. This is the only place that + // policy lives, and it deliberately does not cover `configuredPackagePath`: + // a `rstestPackagePath` that does not resolve is a setting the user got + // wrong, so it is rethrown for the caller to report like any other failure. + private resolveFromCwd( + specifier: string, + configuredPackagePath?: string, + ): string | undefined { + try { + return nodeRequire.resolve(specifier, { paths: [this.cwd] }); + } catch (e) { + if (!isModuleNotFoundError(e, specifier)) throw e; + if (configuredPackagePath) { + throw new Error( + formatConfiguredCoreNotFoundMessage(configuredPackagePath), + ); + } + logger.error(formatCoreNotFoundMessage(this.cwd)); + return undefined; + } + } + + // Returns '' when resolution failed. Every such branch has already reported + // itself β€” silently for a missing core, with a notification otherwise β€” so + // callers must fail quietly rather than report again. + private resolveRstestPath(): string { + try { + const configured = this.resolveConfiguredPackageJson(); + const packageJson = configured ?? CORE_PACKAGE_JSON; + if (configured) { + logger.debug('Using configured rstestPackagePath:', configured); + } + + // `dirname` turns either package.json specifier into its package entry. + const nodeExport = this.resolveFromCwd(dirname(packageJson), configured); + if (!nodeExport) return ''; + + let corePackageJsonPath: string; + try { + corePackageJsonPath = nodeRequire.resolve(packageJson, { + paths: [this.cwd], + }); + } catch (e) { + vscode.window.showErrorMessage( + 'Failed to resolve @rstest/core/package.json. Please upgrade @rstest/core to the latest version.', + ); + logger.error('Failed to resolve @rstest/core/package.json', e); + return ''; + } + const corePackageJson = nodeRequire(corePackageJsonPath) as { + version?: string; + }; + const coreVersion = corePackageJson.version; + + // Upstream also compared the core version against the extension's own + // version, because they were released from one monorepo in lockstep. This + // extension is versioned independently and the support matrix is the + // whole compatibility contract, so the comparison is dropped. + // + // The status-aggregation adaptation: the one-shot `showWarningMessage` + // becomes the shared `version mismatch` status bar state with actual vs + // required versions. The floor is the same `>= 0.6.0`. + if (!reportVersionCheck(status, '@rstest/core', coreVersion)) { + logger.error( + `Unsupported @rstest/core version ${coreVersion ?? 'unknown'} resolved from ${this.cwd}`, + ); + } + + return nodeExport; + } catch (e) { + vscode.window.showErrorMessage(toErrorMessage(e)); + throw e; + } + } + + // Resolve the rstest CLI executable (its package `bin`) for the terminal run + // mode, honoring a configured `rstestPackagePath` the same way as the worker + // resolution above. + private resolveRstestBin(): string | undefined { + const configured = this.resolveConfiguredPackageJson(); + const pkgJsonPath = this.resolveFromCwd( + configured ?? CORE_PACKAGE_JSON, + configured, + ); + if (!pkgJsonPath) return undefined; + const pkg = nodeRequire(pkgJsonPath) as { + bin?: string | Record; + }; + const binRel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.rstest; + if (!binRel) { + throw new Error('Could not resolve the rstest CLI binary'); + } + return path.join(path.dirname(pkgJsonPath), binRel); + } + + public async getNormalizedConfig() { + const worker = await this.createChildProcess(); + const config = await worker.getNormalizedConfig({ + rstestPath: this.resolveRstestPath(), + configFilePath: this.configFilePath, + }); + worker.$close(); + return config; + } + + public async listTests(include?: string[]) { + const worker = await this.createChildProcess(); + const tests = await worker.listTests({ + rstestPath: this.resolveRstestPath(), + configFilePath: this.configFilePath, + include, + includeTaskLocation: true, + }); + worker.$close(); + return tests; + } + + public async runTest({ + run, + token, + updateSnapshot, + fileFilter, + testCaseNamePath, + isSuite, + kind, + continuous, + diagnostics, + errorStore, + createTestRun, + }: { + run: vscode.TestRun; + token: vscode.CancellationToken; + updateSnapshot?: boolean; + fileFilter?: string; + testCaseNamePath?: string[]; + isSuite?: boolean; + kind?: vscode.TestRunProfileKind; + continuous?: boolean; + diagnostics?: RstestDiagnostics; + errorStore?: TestErrorStore; + createTestRun?: () => vscode.TestRun; + }) { + let onFinish!: () => void; + let finished = false; + const promise = new Promise((resolve) => { + onFinish = () => { + if (finished) return; + finished = true; + resolve(); + }; + }); + const coverageEnabled = kind === vscode.TestRunProfileKind.Coverage; + const applyDiagnostic = getConfigValue('applyDiagnostic', this.workspace); + if (!applyDiagnostic) { + diagnostics?.clearForProject(this.configFilePath); + } + + const testRunReporter = new TestRunReporter( + run, + this.project, + testCaseNamePath, + coverageEnabled, + onFinish, + createTestRun, + this.configFilePath, + applyDiagnostic ? diagnostics : undefined, + errorStore, + ); + + const worker = await this.createChildProcess( + testRunReporter, + kind === vscode.TestRunProfileKind.Debug, + run, + ); + token.onCancellationRequested(() => { + worker.$close(); + onFinish(); + }); + + void worker + .runTest({ + command: continuous ? 'watch' : 'run', + fileFilters: fileFilter ? [fileFilter] : undefined, + testNamePattern: testCaseNamePath + ? new RegExp(this.buildTestNamePattern(testCaseNamePath, isSuite)) + : undefined, + update: updateSnapshot, + configFilePath: this.configFilePath, + rstestPath: this.resolveRstestPath(), + coverage: coverageEnabled ? { enabled: true } : undefined, + includeTaskLocation: true, + }) + .catch((error) => { + if (!token.isCancellationRequested) { + const message = toErrorMessage(error); + logger.error('Failed to run tests', error); + run.appendOutput(`\n[rstest] ${message}\n`.replaceAll('\n', '\r\n')); + vscode.window.showErrorMessage(`Rstest test run failed: ${message}`); + } + + if (continuous) { + worker.$close(); + } + onFinish(); + }) + .finally(() => { + if (!continuous) worker.$close(); + }); + + await promise; + } + + private buildCliCommand( + rstestBin: string, + { + fileFilter, + testCaseNamePath, + isSuite, + }: { + fileFilter?: string; + testCaseNamePath?: string[]; + isSuite?: boolean; + }, + ): string { + const { nodeExecutable, nodeExecArgs } = this.resolveNodeCommand(); + + // Prefer a path relative to the run cwd for readability; fall back to the + // absolute path when the target is outside the cwd. + const relativeToCwd = (target: string) => { + const rel = path.relative(this.cwd, target); + return rel && !rel.startsWith('..') ? rel : target; + }; + + const args = ['run']; + if (fileFilter) { + // Keep the positional file filter absolute: Core matches it against the + // resolved config `root`, which can differ from the run cwd (the config + // file's directory) when a config sets a custom `root`. A cwd-relative + // path would then miss the discovered test files. + args.push(fileFilter); + } + if (testCaseNamePath?.length) { + // Terminal run selects the same case as the in-editor run. + args.push('-t', this.buildTestNamePattern(testCaseNamePath, isSuite)); + } + // `-c` is resolved relative to the process cwd, which is `this.cwd`. + args.push('-c', relativeToCwd(this.configFilePath)); + + return [nodeExecutable, ...nodeExecArgs, rstestBin, ...args] + .map(shellQuote) + .join(' '); + } + + public runInTerminal(options: { + fileFilter?: string; + testCaseNamePath?: string[]; + isSuite?: boolean; + }): void { + let command: string; + try { + const rstestBin = this.resolveRstestBin(); + if (!rstestBin) return; + command = this.buildCliCommand(rstestBin, options); + } catch (error) { + vscode.window.showErrorMessage(`Rstest: ${toErrorMessage(error)}`); + return; + } + sendToTerminal(command, { + cwd: this.cwd, + shellPath: getConfigValue('terminalShellPath', this.workspace), + shellArgs: getConfigValue('terminalShellArgs', this.workspace), + }); + } + + public async createChildProcess( + testRunReporter = new TestRunReporter(), + startDebugging?: boolean, + testRun?: vscode.TestRun, + ) { + const rstestPath = this.resolveRstestPath(); + if (!rstestPath) { + throw new Error('Failed to resolve rstest path'); + } + const debuggerPort = getConfigValue('debuggerPort', this.workspace); + const debuggerAddress = getConfigValue('debuggerAddress', this.workspace); + if ( + startDebugging && + debuggerPort && + !(await isPortAvailable(debuggerPort, debuggerAddress)) + ) { + const at = `${debuggerAddress ?? DEFAULT_DEBUG_HOST}:${debuggerPort}`; + const message = `Rstest debug port ${at} is already in use. Set a free "${CONFIG_SECTION}.debuggerPort" or free the port.`; + vscode.window.showErrorMessage(message); + throw new Error(message); + } + const execArgv: string[] = []; + if (startDebugging) { + execArgv.push( + debuggerPort + ? `--inspect-wait=${debuggerAddress ?? DEFAULT_DEBUG_HOST}:${debuggerPort}` + : '--inspect-wait', + ); + } + const workerPath = path.resolve(__dirname, 'worker.js'); + const { nodeExecutable, nodeExecArgs } = this.resolveNodeCommand(); + const nodeEnv = getConfigValue('nodeEnv', this.workspace); + const debugNodeEnv = startDebugging + ? getConfigValue('debugNodeEnv', this.workspace) + : undefined; + logger.debug('Spawning worker process', { + workerPath, + nodeExecutable, + nodeExecArgs, + }); + const rstestProcess = spawn( + nodeExecutable, + [...nodeExecArgs, ...execArgv, workerPath], + { + cwd: this.cwd, + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + serialization: 'advanced', + env: { + // same as packages/core/src/cli/prepare.ts + // if (!process.env.NODE_ENV) process.env.NODE_ENV = 'test' + NODE_ENV: 'test', + ...process.env, + ...nodeEnv, + ...debugNodeEnv, + // process.env.RSTEST = 'true'; + RSTEST: 'true', + FORCE_COLOR: '1', + }, + }, + ); + this.childProcesses.add(rstestProcess); + + rstestProcess.stdout?.on('data', (d) => { + const content = d.toString(); + logger.debug('[worker stdout]', content.trimEnd()); + }); + + rstestProcess.stderr?.on('data', (d) => { + const content = d.toString(); + logger.error('[worker stderr]', content.trimEnd()); + }); + + const worker = createBirpc(testRunReporter, { + // Target the local process rather than the shared field, which is + // reassigned on every spawn; skip once the IPC channel is gone. + post: (data) => { + if (rstestProcess.connected) rstestProcess.send(data); + }, + on: (fn) => rstestProcess.on('message', fn), + bind: 'functions', + timeout: 600_000, + off: () => { + rstestProcess.kill(); + this.childProcesses.delete(rstestProcess); + runningWorkers.delete(worker); + }, + }); + + runningWorkers.add(worker); + + logger.debug('Sent init payload to worker', { + root: this.cwd, + rstestPath, + configFilePath: this.configFilePath, + }); + + rstestProcess.on('error', (error) => { + logger.error('Worker process error', error); + // The status-aggregation adaptation: a worker that never came up is the + // `crashed` state of the shared status bar. The notification is kept because + // a failed spawn is almost always a wrong `nodeExecutable` the user has + // to fix, and the status bar alone is easy to miss mid-run. + status.crashed(`worker process failed: ${error.message}`); + vscode.window.showErrorMessage( + `Rstest worker process failed: ${error.message}`, + ); + // Reject any in-flight birpc calls instead of letting them hang; $close + // runs the `off` handler, which removes the process from the Set. + if (!worker.$closed) worker.$close(); + }); + + rstestProcess.on('exit', (code, signal) => { + logger.debug('Worker process exited', { code, signal }); + // Unblock pending calls when the worker exits before we closed it. + if (!worker.$closed) worker.$close(); + }); + + // Attach the debugger only after the error/exit handlers are wired, so a + // spawn failure (e.g. a misconfigured `nodeExecutable`) during this await is + // handled instead of throwing uncaught in the extension host. + if (startDebugging) { + const debugOutFiles = getConfigValue('debugOutFiles', this.workspace); + const startedDebugging = await vscode.debug.startDebugging( + this.workspace, + { + type: 'node', + name: 'Rstest Debug', + request: 'attach', + skipFiles: getConfigValue('debugExclude', this.workspace), + ...(debugOutFiles.length ? { outFiles: debugOutFiles } : {}), + ...(debuggerPort + ? { + port: debuggerPort, + address: debuggerAddress ?? DEFAULT_DEBUG_HOST, + } + : { processId: rstestProcess.pid }), + }, + { testRun }, + ); + if (!startedDebugging) { + rstestProcess.kill(); + throw new Error( + `Failed to attach debugger to test worker process (PID: ${rstestProcess.pid})`, + ); + } + } + + return worker; + } + + public dispose() { + for (const child of this.childProcesses) { + child.kill(); + } + this.childProcesses.clear(); + } +} diff --git a/packages/vscode/src/stacks/test/nodeRequire.ts b/packages/vscode/src/stacks/test/nodeRequire.ts new file mode 100644 index 0000000..4208b51 --- /dev/null +++ b/packages/vscode/src/stacks/test/nodeRequire.ts @@ -0,0 +1,20 @@ +import { createRequire } from 'node:module'; + +/** + * A genuine Node `require`, rooted at the emitted bundle. + * + * Upstream's `packages/vscode` calls the ambient `require` / `require.resolve` + * directly. In a bundle those are the bundler's own runtime helpers: a + * `require(someVariable)` is an expression dependency (rspack warns "the request + * of a dependency is an expression" and builds a context module over the + * directory), and `require.resolve(specifier, { paths })` is not the Node API at + * all. Both are exactly what the `@rstest/core` resolve-from-project path + * depends on, so the stack goes through `createRequire` instead. + * + * `__filename` is the emitted `dist/extension.js` β€” rspack's `target: 'node'` + * keeps Node's own value β€” which is also what `path.resolve(__dirname, + * 'worker.js')` in `master.ts` relies on. The resolution base only matters for + * bare-specifier lookups without an explicit `paths`, and every call site here + * passes `paths`. + */ +export const nodeRequire = createRequire(__filename); diff --git a/packages/vscode/src/stacks/test/parse.fixture.txt b/packages/vscode/src/stacks/test/parse.fixture.txt new file mode 100644 index 0000000..94b7d66 --- /dev/null +++ b/packages/vscode/src/stacks/test/parse.fixture.txt @@ -0,0 +1,9 @@ +const mockInput = { + userKPWords: + 'ε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Šε•Š', +}; + +describe('outer', () => { + it('inner', () => {}); + it('inner-1', () => {}); +}); diff --git a/packages/vscode/src/stacks/test/parse.test.ts b/packages/vscode/src/stacks/test/parse.test.ts new file mode 100644 index 0000000..6c7b9cc --- /dev/null +++ b/packages/vscode/src/stacks/test/parse.test.ts @@ -0,0 +1,416 @@ +import fs from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from '@rstest/core'; +import { parseTestFile, type Range } from './parserTest'; + +describe('parseTestFile', () => { + it('should detect nested describe and test blocks', () => { + const code = ` + describe('outer', () => { + it('inner test', () => {}); + test('another inner test', () => {}); + describe('inner describe', () => { + test('deeply nested test', () => {}); + }); + }); + `; + + const tests: { name: string; type: string }[] = []; + parseTestFile(code, { + onTest: ( + _range: Range, + name: string, + testType: 'test' | 'it' | 'describe' | 'suite', + ) => { + tests.push({ name, type: testType }); + }, + }); + + // The order of discovery is not guaranteed to be in source order, so we sort. + tests.sort((a, b) => a.name.localeCompare(b.name)); + + expect(tests.map((t) => t.name)).toEqual([ + 'another inner test', + 'deeply nested test', + 'inner describe', + 'inner test', + 'outer', + ]); + + expect(tests.map((t) => t.type)).toEqual([ + 'test', + 'test', + 'describe', + 'it', + 'describe', + ]); + }); + + it('should handle template literals in test names', () => { + const code = ` + const a = 'a'; + describe(\`outer \${a}\`, () => { + it('inner test', () => {}); + }); + `; + + const tests: { name: string; type: string }[] = []; + parseTestFile(code, { + onTest: ( + _range: Range, + name: string, + testType: 'test' | 'it' | 'describe' | 'suite', + ) => { + tests.push({ name, type: testType }); + }, + }); + + tests.sort((a, b) => a.name.localeCompare(b.name)); + + expect(tests.map((t) => t.name)).toEqual(['inner test', 'outer ${...}']); + }); + + it('should detect .only, .skip and .todo variants', () => { + const code = ` + describe.skip('skipped describe', () => { + it('inner', () => {}); + }); + test.only('focused test', () => {}); + test["only"]('computed only', () => {}); + test.todo('has todo'); + `; + + const tests: { name: string; type: string }[] = []; + parseTestFile(code, { + onTest: ( + _range: Range, + name: string, + testType: 'test' | 'it' | 'describe' | 'suite', + ) => { + tests.push({ name, type: testType }); + }, + }); + + tests.sort((a, b) => a.name.localeCompare(b.name)); + + expect(tests.map((t) => t.name)).toEqual([ + 'computed only', + 'focused test', + 'has todo', + 'inner', + 'skipped describe', + ]); + expect(tests.map((t) => t.type)).toEqual([ + 'test', + 'test', + 'test', + 'it', + 'describe', + ]); + }); + + it('should detect suite blocks', () => { + const code = ` + suite('root suite', () => { + test('child test', () => {}); + }); + `; + + const tests: { name: string; type: string }[] = []; + parseTestFile(code, { + onTest: ( + _range: Range, + name: string, + testType: 'test' | 'it' | 'describe' | 'suite', + ) => { + tests.push({ name, type: testType }); + }, + }); + + tests.sort((a, b) => a.name.localeCompare(b.name)); + + expect(tests.map((t) => t.name)).toEqual(['child test', 'root suite']); + expect(tests.map((t) => t.type)).toEqual(['test', 'suite']); + }); + + it('should mark non-literal or missing names as "unnamed test"', () => { + const code = ` + const title = getTitle(); + function getTitle() { return 'x'; } + test(title as any, () => {}); + it(123 as any, () => {}); + describe(() => {}, () => {}); + suite((() => 'x') as any, () => {}); + test(() => {}); + `; + + const tests: { name: string; type: string }[] = []; + parseTestFile(code, { + onTest: ( + _range: Range, + name: string, + testType: 'test' | 'it' | 'describe' | 'suite', + ) => { + tests.push({ name, type: testType }); + }, + }); + + expect(tests.length).toBe(5); + expect(tests.every((t) => t.name === 'unnamed test')).toBe(true); + }); + + it('should handle complex template literals with multiple expressions', () => { + const code = ` + const a = 1, b = 2; + test(\`prefix \${a} middle \${b} suffix\`, () => {}); + `; + + const tests: { name: string; type: string }[] = []; + parseTestFile(code, { + onTest: ( + _range: Range, + name: string, + _testType: 'test' | 'it' | 'describe' | 'suite', + ) => { + tests.push({ name, type: 'test' }); + }, + }); + + expect(tests.map((t) => t.name)).toEqual([ + 'prefix ${...} middle ${...} suffix', + ]); + }); + + it('should parse files with TSX/JSX content in callbacks', () => { + const code = ` + describe('jsx', () => { + it('renders', () => { + const el =
Hello
; + }); + }); + `; + + const tests: { name: string; type: string }[] = []; + parseTestFile(code, { + onTest: ( + _range: Range, + name: string, + testType: 'test' | 'it' | 'describe' | 'suite', + ) => { + tests.push({ name, type: testType }); + }, + }); + + tests.sort((a, b) => a.name.localeCompare(b.name)); + + expect(tests.map((t) => t.name)).toEqual(['jsx', 'renders']); + expect(tests.map((t) => t.type)).toEqual(['describe', 'it']); + }); + + it('should ignore non-test-like calls', () => { + const code = ` + foo('bar', () => {}); + something.test('not recognized', () => {}); + console.log('not a test'); + `; + + const tests: { name: string; type: string }[] = []; + parseTestFile(code, { + onTest: ( + _range: Range, + name: string, + testType: 'test' | 'it' | 'describe' | 'suite', + ) => { + tests.push({ name, type: testType }); + }, + }); + + expect(tests.length).toBe(0); + }); + + it('should compute reasonable ranges for calls', () => { + const code = `describe("top", () => {\n it('child', () => {})\n});`; + + const results: { name: string; type: string; range: Range }[] = []; + parseTestFile(code, { + onTest: (range: Range, name: string, testType) => { + results.push({ name, type: testType, range }); + }, + }); + + const byName = Object.fromEntries(results.map((r) => [r.name, r])); + expect(byName.top.range.startLine).toBe(0); + expect(byName.child.range.startLine).toBe(1); + }); + + it('should compute ranges correctly after leading comments', () => { + const code = `/* eslint-disable max-lines */ +/* eslint-disable max-lines-per-function */ +describe('outer', () => { + it('inner', () => {}); + it('inner-1', () => {}); +});`; + + const results: { name: string; range: Range }[] = []; + parseTestFile(code, { + onTest: (range: Range, name: string) => { + results.push({ name, range }); + }, + }); + + expect( + Object.fromEntries( + results.map(({ name, range }) => [ + name, + { + start: { line: range.startLine, character: range.startChar }, + end: { line: range.endLine, character: range.endChar }, + }, + ]), + ), + ).toEqual({ + outer: { + start: { line: 2, character: 0 }, + end: { line: 5, character: 2 }, + }, + inner: { + start: { line: 3, character: 2 }, + end: { line: 3, character: 23 }, + }, + 'inner-1': { + start: { line: 4, character: 2 }, + end: { line: 4, character: 25 }, + }, + }); + }); + + it('should compute range correctly with chinese characters', () => { + const code = fs.readFileSync( + join(__dirname, './parse.fixture.txt'), + 'utf-8', + ); + + const results: { name: string; type: string; range: Range }[] = []; + parseTestFile(code, { + onTest: (range: Range, name: string, testType) => { + results.push({ name, type: testType, range }); + }, + }); + + const byName = Object.fromEntries(results.map((r) => [r.name, r])); + expect(byName.outer.range.startLine).toBe(5); + expect(byName.outer.range.endLine).toBe(8); + expect(byName.inner.range.startLine).toBe(6); + }); + + it('should compute UTF-16 columns after astral characters', () => { + const code = `const emoji = 'πŸ˜€'; test('unicode', () => {});`; + + const results: { name: string; range: Range }[] = []; + parseTestFile(code, { + onTest: (range: Range, name: string) => { + results.push({ name, range }); + }, + }); + + expect(results).toHaveLength(1); + expect(results[0].name).toBe('unicode'); + expect(results[0].range.startChar).toBe(code.indexOf('test')); + }); + + it('should handle quotes and escaped characters', () => { + const code = ` + describe('he said "hi"', () => { + it('emoji πŸš€', () => {}); + }); + `; + + const tests: { name: string; type: string }[] = []; + parseTestFile(code, { + onTest: (_range: Range, name: string, testType) => { + tests.push({ name, type: testType }); + }, + }); + + tests.sort((a, b) => a.name.localeCompare(b.name)); + expect(tests.map((t) => t.name)).toEqual(['emoji πŸš€', 'he said "hi"']); + }); + + it('should handle comments and whitespace around arguments', () => { + const code = ` + test /* c1 */ ( /* c2 */ 'spaced' /* c3 */ , () => {} ); + it/*a*/(/*b*/"also spaced"/*c*/,() => {}); + `; + + const tests: { name: string; type: string }[] = []; + parseTestFile(code, { + onTest: (_range: Range, name: string, testType) => { + tests.push({ name, type: testType }); + }, + }); + + tests.sort((a, b) => a.name.localeCompare(b.name)); + expect(tests.map((t) => t.name)).toEqual(['also spaced', 'spaced']); + }); + + it('should detect describe without a callback', () => { + const code = ` + describe('name only'); + `; + + const tests: { name: string; type: string }[] = []; + parseTestFile(code, { + onTest: (_range: Range, name: string, testType) => { + tests.push({ name, type: testType }); + }, + }); + + expect(tests).toEqual([{ name: 'name only', type: 'describe' }]); + }); + + it('should find tests inside control flow blocks', () => { + const code = ` + if (true) { + describe('cond', () => { + for (const _ of [1]) { + test('inside loop', () => {}); + } + }); + } + `; + + const tests: { name: string; type: string }[] = []; + parseTestFile(code, { + onTest: (_range: Range, name: string, testType) => { + tests.push({ name, type: testType }); + }, + }); + + tests.sort((a, b) => a.name.localeCompare(b.name)); + expect(tests.map((t) => t.name)).toEqual(['cond', 'inside loop']); + expect(tests.map((t) => t.type)).toEqual(['describe', 'test']); + }); + + it('should detect suite.skip variant', () => { + const code = ` + suite.skip('skipped suite', () => {}); + `; + + const tests: { name: string; type: string }[] = []; + parseTestFile(code, { + onTest: (_range: Range, name: string, testType) => { + tests.push({ name, type: testType }); + }, + }); + + expect(tests).toEqual([{ name: 'skipped suite', type: 'suite' }]); + }); + + it('should reject invalid syntax', () => { + expect(() => + parseTestFile('test(', { + onTest: () => undefined, + }), + ).toThrow(SyntaxError); + }); +}); diff --git a/packages/vscode/src/stacks/test/parserTest.ts b/packages/vscode/src/stacks/test/parserTest.ts new file mode 100644 index 0000000..1d94b4a --- /dev/null +++ b/packages/vscode/src/stacks/test/parserTest.ts @@ -0,0 +1,113 @@ +import { type Node, parse } from 'yuku-parser'; + +export class Range { + constructor( + public startLine: number, + public endLine: number, + public startChar: number, + public endChar: number, + ) {} +} + +const isNode = (value: unknown): value is Node => + typeof value === 'object' && + value !== null && + 'type' in value && + typeof value.type === 'string'; + +export const parseTestFile = ( + code: string, + events: { + onTest( + range: Range, + name: string, + testType: 'test' | 'it' | 'describe' | 'suite', + ): (() => void) | void; + }, +) => { + const result = parse(code, { + lang: 'tsx', + preserveParens: false, + sourceType: 'module', + }); + const error = result.diagnostics.find( + (diagnostic) => diagnostic.severity === 'error', + ); + if (error) { + throw new SyntaxError(error.message); + } + + const offsetToRange = (start: number, end: number): Range => { + const lines = code.substring(0, start).split('\n'); + const startLine = Math.max(0, lines.length - 1); + const startChar = lines[startLine]?.length || 0; + + const endLines = code.substring(0, end).split('\n'); + const endLine = Math.max(0, endLines.length - 1); + const endChar = endLines[endLine]?.length || 0; + + return new Range(startLine, endLine, startChar, endChar); + }; + + const getStringLiteralValue = (node: Node | undefined): string | null => { + if (node?.type === 'Literal' && typeof node.value === 'string') { + return node.value; + } + if (node?.type !== 'TemplateLiteral') { + return null; + } + + return node.quasis + .map((quasi, index) => { + const expression = index < node.expressions.length ? '${...}' : ''; + return `${quasi.value.cooked ?? quasi.value.raw}${expression}`; + }) + .join(''); + }; + + const walkNode = (node: Node): void => { + let exit: (() => void) | void | undefined; + + if (node.type === 'CallExpression') { + let functionName: string | undefined; + + if (node.callee.type === 'Identifier') { + functionName = node.callee.name; + } else if ( + node.callee.type === 'MemberExpression' && + node.callee.object.type === 'Identifier' + ) { + functionName = node.callee.object.name; + } + + if ( + functionName === 'test' || + functionName === 'it' || + functionName === 'describe' || + functionName === 'suite' + ) { + exit = events.onTest( + offsetToRange(node.start, node.end), + getStringLiteralValue(node.arguments[0]) || 'unnamed test', + functionName, + ); + } + } + + for (const value of Object.values(node)) { + if (Array.isArray(value)) { + for (const child of value) { + if (isNode(child)) { + walkNode(child); + } + } + } else if (isNode(value)) { + walkNode(value); + } + } + + exit?.(); + }; + + walkNode(result.program); +}; diff --git a/packages/vscode/src/stacks/test/project.test.ts b/packages/vscode/src/stacks/test/project.test.ts new file mode 100644 index 0000000..ca5913b --- /dev/null +++ b/packages/vscode/src/stacks/test/project.test.ts @@ -0,0 +1,166 @@ +import path from 'node:path'; +import { describe, expect, it, rs } from '@rstest/core'; + +// The worker-cwd decoupling adaptation pinned at its only site: upstream derived the +// worker spawn cwd inside `Project` as `dirname(configFileUri)`, so a `Project` +// pointing at rstack's shim would have cwd'd into `node_modules/rstack/dist/`. +// The three values `RstestApi` is constructed with are therefore what this test +// asserts β€” they are the spawn cwd, the `@rstest/core` resolution root and the +// config file Rstest is asked to load. + +const apiCalls: { cwd: string; configFilePath: string }[] = []; + +rs.mock('./master', () => { + class RstestApi { + constructor( + _workspace: unknown, + cwd: string, + configFilePath: string, + _project: unknown, + ) { + apiCalls.push({ cwd, configFilePath }); + } + // Never settles: the constructor's config-resolution continuation would + // otherwise start watchers this test has no filesystem for. + getNormalizedConfig() { + return new Promise(() => {}); + } + dispose() {} + } + return { RstestApi, runningWorkers: new Set() }; +}); + +rs.mock('vscode', () => { + const vscode = { + Uri: { + file: (fsPath: string) => ({ + scheme: 'file', + fsPath, + path: fsPath, + toString: () => `file://${fsPath}`, + }), + }, + CancellationTokenSource: class { + token = { isCancellationRequested: false }; + cancel() { + this.token.isCancellationRequested = true; + } + dispose() {} + }, + RelativePattern: class { + constructor( + public base: unknown, + public pattern: string, + ) {} + }, + window: { + createOutputChannel: () => ({ + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + show: () => {}, + dispose: () => {}, + }), + }, + workspace: { + fs: {}, + getConfiguration: () => ({ get: () => undefined }), + onDidChangeConfiguration: () => ({ dispose: () => {} }), + createFileSystemWatcher: () => ({ + onDidCreate: () => ({ dispose: () => {} }), + onDidChange: () => ({ dispose: () => {} }), + onDidDelete: () => ({ dispose: () => {} }), + dispose: () => {}, + }), + }, + }; + return { ...vscode, default: vscode }; +}); + +const uri = (fsPath: string) => + ({ + scheme: 'file', + fsPath, + path: fsPath, + toString: () => `file://${fsPath}`, + }) as any; + +const workspaceFolder = { + uri: uri('/repo'), + name: 'repo', + index: 0, +} as any; + +const controller = { + createTestItem: (id: string, label: string) => ({ + id, + label, + children: { replace: () => {}, add: () => {}, forEach: () => {} }, + }), +} as any; + +const collection = { + replace: () => {}, + add: () => {}, + forEach: () => {}, +} as any; + +const createProject = async (source: any) => { + apiCalls.length = 0; + const { Project } = await import('./project'); + const project = new Project(workspaceFolder, source, controller, collection); + return { project, api: apiCalls[0]! }; +}; + +describe('Project config/cwd decoupling', () => { + it('keeps the upstream derivation for a native rstest config', async () => { + const configFile = uri(path.join('/repo', 'pkg', 'rstest.config.ts')); + + const { project, api } = await createProject({ sourceUri: configFile }); + + // Byte-identical to upstream: cwd is the config file's directory. + expect(api.cwd).toBe(path.join('/repo', 'pkg')); + expect(api.configFilePath).toBe(configFile.fsPath); + expect(project.configFilePath).toBe(configFile.fsPath); + expect(project.sourceUri.toString()).toBe(configFile.toString()); + expect(project.isBridge).toBe(false); + expect(project.root.fsPath).toBe(path.join('/repo', 'pkg')); + }); + + it('spawns a bridged project in the rstack config directory, not in the shim directory', async () => { + const rstackConfig = uri(path.join('/repo', 'pkg', 'rstack.config.ts')); + const shim = uri( + path.join( + '/repo', + 'pkg', + 'node_modules', + 'rstack', + 'dist', + 'rstestConfig.js', + ), + ); + + const { project, api } = await createProject({ + sourceUri: rstackConfig, + configFileUri: shim, + cwd: path.join('/repo', 'pkg'), + isBridge: true, + }); + + // The whole point: `dirname(configFile)` would be + // `/node_modules/rstack/dist`, where the shim's single-directory, + // no-parent-walk `loadRstackConfig()` probe finds nothing and + // `@rstest/core` would resolve from rstack's own dependency tree. + expect(api.cwd).toBe(path.join('/repo', 'pkg')); + expect(api.cwd).not.toBe(path.dirname(shim.fsPath)); + // Rstest is still handed the shim as an ordinary JS config file. + expect(api.configFilePath).toBe(shim.fsPath); + expect(project.configFilePath).toBe(shim.fsPath); + // Identity/labelling stays on the user-owned config file, so two bridged + // projects in different directories do not collide on the shared shim path. + expect(project.sourceUri.toString()).toBe(rstackConfig.toString()); + expect(project.isBridge).toBe(true); + expect(project.root.fsPath).toBe(path.join('/repo', 'pkg')); + }); +}); diff --git a/packages/vscode/src/stacks/test/project.ts b/packages/vscode/src/stacks/test/project.ts new file mode 100644 index 0000000..1e3ba72 --- /dev/null +++ b/packages/vscode/src/stacks/test/project.ts @@ -0,0 +1,762 @@ +import path from 'node:path'; +import type { TestInfo } from '@rstest/core'; +import picomatch from 'picomatch'; +import { glob } from 'tinyglobby'; +import vscode from 'vscode'; +import { resolveRstackShim } from './bridge'; +import { watchConfigValue } from './config'; +import { logger } from './logger'; +import { RstestApi } from './master'; +import { type ChildProjectRef, computeCoveredConfigs } from './projectCoverage'; +import { ProjectFolder, TestFile, TestFolder, testData } from './testTree'; + +// The default config file name at the workspace root. A lone project using it +// is shown without a project node (its test files sit directly under the root). +// `rstack.config.*` is included because the rstack bridge makes it an +// equally first-class root config: an rstack-cli user with a single +// `rstack.config.ts` should get the same node-less layout as one with a single +// `rstest.config.ts`. +const DEFAULT_ROOT_CONFIG_RE = /^(?:rstest|rstack)\.config\.[mc]?[tj]s$/; + +/** + * Where a `Project`'s config comes from. The worker-cwd decoupling adaptation + * plus the rstack bridge are both expressed here: upstream had only a config + * file URI and derived everything else from its directory. + */ +export type ProjectSource = { + /** + * The config file the *user* owns, and the `Project`'s identity inside its + * `WorkspaceManager`. Also what the project tree lays out and labels by. For + * a native project this is the `rstest.config.*`; for a bridged one the + * `rstack.config.*` β€” never the shim, which lives under `node_modules` and + * would produce a nonsensical tree label and a key shared by every bridged + * project in the folder. + */ + readonly sourceUri: vscode.Uri; + /** + * The config file handed to Rstest (`-c` / `initCli({ config })`). Defaults + * to `sourceUri`; differs only for the rstack bridge, where it is + * `/dist/rstestConfig.js`. + */ + readonly configFileUri?: vscode.Uri; + /** + * The worker spawn cwd and package-resolution root. Defaults to + * `dirname(configFileUri)` β€” byte-identical to upstream for a native config. + * The bridge sets it to the `rstack.config.*` directory so the shim's + * single-directory, no-parent-walk `loadRstackConfig()` probe finds the + * config and `@rstest/core` resolves from the project rather than from + * `node_modules/rstack/dist/`. + */ + readonly cwd?: string; + /** True for a `Project` synthesized by the rstack bridge. */ + readonly isBridge?: boolean; +}; + +export class WorkspaceManager implements vscode.Disposable { + public projects = new Map(); + // The subset of `projects` currently shown (not suppressed as a duplicate of + // an aggregating parent). Kept in sync by `refreshAllProject`. Run All uses + // this rather than `projects` so it runs the same set the tree displays. + public activeProjects = new Map(); + private workspacePath: string; + private testItem?: vscode.TestItem; + private configValueWatcher: vscode.Disposable; + // `rstack.config.*` files governing this folder, supplied by the shell's + // detection pass (the discovery glob below excludes `node_modules`, and + // `rstack.config.*` is not part of it anyway). + private rstackConfigFiles: readonly vscode.Uri[] = []; + // Directories whose shim resolution already failed and was reported, so a + // project without `rstack` installed does not re-log on every tree refresh. + private reportedShimFailures = new Set(); + // The config-file glob scan is asynchronous, and `refresh()` can run before it + // settles. Bridged projects must not be synthesized against an + // "I found no native config yet" that is really "I have not looked yet" β€” + // each `Project` spawns a worker in its constructor, so the wrong answer + // costs a process, not just a tree node. + private didScanConfigFiles = false; + constructor( + private workspaceFolder: vscode.WorkspaceFolder, + private testController: vscode.TestController, + rstackConfigFiles: readonly vscode.Uri[] = [], + private onDidChangeTestFiles?: () => void, + ) { + this.workspacePath = workspaceFolder.uri.toString(); + this.rstackConfigFiles = rstackConfigFiles; + this.configValueWatcher = this.startWatchingWorkspace(); + } + // if this is the only one workspace, skip create test item + public refresh(isOnlyOne: boolean) { + if (isOnlyOne) { + if (this.testItem) { + this.testItem = undefined; + } + } else { + if (!this.testItem) { + this.testItem = this.testController.createTestItem( + this.workspacePath, + this.workspaceFolder.name, + this.workspaceFolder.uri, + ); + testData.set(this.testItem, this); + } + this.testController.items.add(this.testItem); + } + this.refreshAllProject(); + } + /** + * Called by the stack controller whenever the shell's detection snapshot + * changes. A folder that gains or loses its `rstack.config.*` re-synthesizes + * (or drops) its bridged projects without a window reload. + */ + public setRstackConfigFiles(files: readonly vscode.Uri[]) { + const signature = (uris: readonly vscode.Uri[]) => + uris + .map((uri) => uri.toString()) + .sort() + .join('\n'); + if (signature(files) === signature(this.rstackConfigFiles)) return; + this.rstackConfigFiles = files; + this.refreshAllProject(); + } + public dispose() { + for (const project of this.projects.values()) { + project.dispose(); + } + this.configValueWatcher.dispose(); + } + private startWatchingWorkspace() { + return watchConfigValue( + 'configFileGlobPattern', + this.workspaceFolder, + async (globs, token) => { + const patterns = globs.map( + (glob) => new vscode.RelativePattern(this.workspaceFolder, glob), + ); + + // find all config file + const files = ( + await Promise.all( + patterns.map((pattern) => + vscode.workspace.findFiles( + pattern, + '**/node_modules/**', + undefined, + token, + ), + ), + ) + ).flat(); + + const visited = new Set(); + for (const file of files) { + this.handleAddConfigFile(file); + visited.add(file.toString()); + } + // remove outdated items after glob configuration changed + for (const [configFilePath, project] of this.projects) { + if (project.isBridge) continue; + if (!visited.has(configFilePath)) { + project.dispose(); + this.projects.delete(configFilePath); + } + } + this.didScanConfigFiles = true; + this.refreshAllProject(); + + // start watching config file create and delete event + for (const pattern of patterns) { + const watcher = vscode.workspace.createFileSystemWatcher( + pattern, + false, + false, + false, + ); + token.onCancellationRequested(() => watcher.dispose()); + watcher.onDidCreate((file) => { + this.handleAddConfigFile(file); + this.refreshAllProject(); + }); + watcher.onDidDelete((file) => { + this.handleRemoveConfigFile(file); + this.refreshAllProject(); + }); + watcher.onDidChange((file) => { + this.handleRemoveConfigFile(file); + this.handleAddConfigFile(file); + this.refreshAllProject(); + }); + } + }, + ); + } + private handleAddConfigFile(configFileUri: vscode.Uri) { + const configFilePath = configFileUri.toString(); + if (this.projects.has(configFilePath)) return; + this.projects.set( + configFilePath, + this.createProject({ sourceUri: configFileUri }), + ); + } + private handleRemoveConfigFile(configFileUri: vscode.Uri) { + const configFilePath = configFileUri.toString(); + const project = this.projects.get(configFilePath); + if (!project) return; + project.dispose(); + this.projects.delete(configFilePath); + } + private createProject(source: ProjectSource): Project { + return new Project( + this.workspaceFolder, + source, + this.testController, + this.testItem?.children ?? this.testController.items, + this.onDidChangeTestFiles, + // Re-render once the config resolves: coverage (which project aggregates + // which) is only known then, and it decides which projects to show. + () => this.refreshAllProject(), + ); + } + + /** + * The rstack bridge: when a folder is governed by + * `rstack.config.*` and has **no** tool-native config, synthesize a `Project` + * per `rstack.config.*` whose config file is rstack's shipped Rstest shim and + * whose cwd is the config's own directory. + * + * A native `rstest.config.*` always wins: rstack's own `rs test` would load + * the shim, which reads `define.test()`, but a project that ships both has + * deliberately opted into the tool-native config, and running the same tests + * through two projects would duplicate the whole tree. + */ + private syncBridgeProjects() { + if (!this.didScanConfigFiles) return; + + const hasNativeProject = [...this.projects.values()].some( + (project) => !project.isBridge, + ); + + const wanted = new Map(); + if (!hasNativeProject) { + for (const rstackConfig of this.rstackConfigFiles) { + const key = rstackConfig.toString(); + // Keep an existing bridged project as-is; re-resolving the shim on + // every tree refresh would churn its worker for nothing. + if (this.projects.get(key)?.isBridge) { + wanted.set(key, { sourceUri: rstackConfig }); + continue; + } + const cwd = path.dirname(rstackConfig.fsPath); + const shim = resolveRstackShim(cwd, { + silent: this.reportedShimFailures.has(cwd), + }); + if (!shim) { + this.reportedShimFailures.add(cwd); + continue; + } + this.reportedShimFailures.delete(cwd); + wanted.set(key, { + sourceUri: rstackConfig, + configFileUri: vscode.Uri.file(shim.configFilePath), + cwd, + isBridge: true, + }); + logger.info( + `Driving Rstest from ${rstackConfig.fsPath} through the rstack config shim`, + ); + } + } + + for (const [key, project] of this.projects) { + if (project.isBridge && !wanted.has(key)) { + project.dispose(); + this.projects.delete(key); + } + } + for (const [key, source] of wanted) { + if (this.projects.has(key)) continue; + this.projects.set(key, this.createProject(source)); + } + } + + private refreshAllProject() { + this.syncBridgeProjects(); + + const collection = this.testItem?.children ?? this.testController.items; + collection.replace([]); + + const activeProjects = this.resolveActiveProjects(); + this.activeProjects = activeProjects; + + // Standard single-project setup (one project using the default config name + // at the workspace root): show its test files directly, with no project node. + if (activeProjects.size === 1) { + const [[, project]] = activeProjects; + const relative = path.relative( + this.workspaceFolder.uri.fsPath, + project.sourceUri.fsPath, + ); + if (DEFAULT_ROOT_CONFIG_RE.test(relative)) { + project.refresh(collection, null); + return; + } + } + + this.buildProjectTree(collection, activeProjects); + } + + // A config file aggregated by *another* project via `projects` is shown only + // under that parent; suppress the standalone copy so its test files are not + // duplicated. `this.projects` is keyed by URI string, while matching uses + // fs paths. + private resolveActiveProjects(): Map { + const entries = [...this.projects]; + const covered = computeCoveredConfigs( + entries.map(([uri, project]) => ({ + key: uri, + // The path Core reports for this project in another project's + // `childProjects`, i.e. the config file actually loaded β€” the shim for + // a bridged project, not the map key. + configFilePath: project.configFilePath, + root: project.root.fsPath, + childProjects: project.childProjects, + include: project.include, + })), + ); + + const activeProjects = new Map(); + for (const [uri, project] of entries) { + const isCovered = covered.has(uri); + project.setSuppressed(isCovered); + if (!isCovered) { + activeProjects.set(uri, project); + } + } + return activeProjects; + } + + // Group projects into a folder tree by their config file directory, so the + // project level nests like the file level instead of showing a flat list of + // `dir/rstest.config.ts` entries. A project is shown as its own directory + // (e.g. `packages/core`); the config file name is only used to disambiguate + // a root-level config or multiple configs sharing one directory. + private buildProjectTree( + rootCollection: vscode.TestItemCollection, + projects: Map, + ) { + type TreeNode = { children: Map; project?: Project }; + const root: TreeNode = { children: new Map() }; + + for (const project of projects.values()) { + const relative = path.relative( + this.workspaceFolder.uri.fsPath, + project.sourceUri.fsPath, + ); + let node = root; + for (const segment of relative.split(path.sep)) { + let next = node.children.get(segment); + if (!next) { + next = { children: new Map() }; + node.children.set(segment, next); + } + node = next; + } + node.project = project; + } + + const handleTreeItem = ( + key: string, + node: TreeNode, + mergedParents: string[], + parents: string[], + collection: vscode.TestItemCollection, + ) => { + const children = [...node.children]; + + // Collapse single-child chains: a directory with exactly one child merges + // into it, whether the child is another directory or the project's config + // file, so `packages/core/rstest.config.ts` shows as one `packages/core` + // project node. + if (children.length === 1) { + const [childKey, childNode] = children[0]; + handleTreeItem( + childKey, + childNode, + [...mergedParents, key], + [...parents, key], + collection, + ); + return; + } + + if (node.project) { + // The config file node: label it by its directory (mergedParents), and + // fall back to the file name only when it has no directory of its own + // (a root-level config, or configs sharing a directory). + const label = mergedParents.join(path.sep) || key; + node.project.refresh(collection, label); + return; + } + + const label = [...mergedParents, key].join(path.sep); + const uri = vscode.Uri.file( + path.join(this.workspaceFolder.uri.fsPath, ...parents, key), + ); + const item = this.testController.createTestItem( + uri.toString(), + label, + uri, + ); + collection.add(item); + testData.set(item, new ProjectFolder()); + + for (const [childKey, childNode] of children) { + handleTreeItem( + childKey, + childNode, + [], + [...parents, key], + item.children, + ); + } + }; + + for (const [key, node] of root.children) { + handleTreeItem(key, node, [], [], rootCollection); + } + } +} + +// There is already a concept of 'project' in rstest, so we might consider changing its name here. +export class Project implements vscode.Disposable { + api: RstestApi; + root: vscode.Uri; + testItem?: vscode.TestItem; + cancellationSource: vscode.CancellationTokenSource; + include: string[] = []; + exclude: string[] = []; + testFiles = new Map(); + // Sub-projects this config aggregates via `projects`. Populated once the + // config resolves; empty for a leaf config. + childProjects: ChildProjectRef[] = []; + // A project whose config file is already covered by another (aggregator) + // project is suppressed: it neither watches files nor renders test items, so + // the same tests are not shown twice. + suppressed = false; + // See `ProjectSource`. + readonly sourceUri: vscode.Uri; + readonly configFileUri: vscode.Uri; + readonly cwd: string; + readonly isBridge: boolean; + #watch?: vscode.Disposable; + constructor( + private workspaceFolder: vscode.WorkspaceFolder, + source: ProjectSource, + private testController: vscode.TestController, + public parentCollection: vscode.TestItemCollection, + private onDidChangeTestFiles?: () => void, + private onConfigResolved?: () => void, + ) { + this.sourceUri = source.sourceUri; + this.configFileUri = source.configFileUri ?? source.sourceUri; + // use dirname of config file as default root + this.cwd = source.cwd ?? path.dirname(this.configFileUri.fsPath); + this.isBridge = source.isBridge ?? false; + this.root = vscode.Uri.file(this.cwd); + this.api = new RstestApi( + workspaceFolder, + this.cwd, + this.configFileUri.fsPath, + this, + ); + this.cancellationSource = new vscode.CancellationTokenSource(); + + void this.api + .getNormalizedConfig() + .then((config) => { + if (this.cancellationSource.token.isCancellationRequested) return; + this.root = vscode.Uri.file(config.root); + this.include = config.include; + this.exclude = config.exclude; + this.childProjects = config.childProjects; + this.applyWatch(); + this.onConfigResolved?.(); + }) + .catch((error) => { + if (this.cancellationSource.token.isCancellationRequested) return; + logger.error('Failed to initialize project config', error); + // Let the manager settle its tree even when a config fails to load. + this.onConfigResolved?.(); + }); + } + + /** The config file path Rstest is asked to load (`-c`). */ + get configFilePath(): string { + return this.configFileUri.fsPath; + } + + private applyWatch() { + if (this.suppressed) return; + if (!this.#watch) { + this.#watch = this.startWatchingWorkspace(this.root); + } + } + + // Called by WorkspaceManager once it knows whether another project already + // covers this config file. + public setSuppressed(value: boolean) { + if (this.suppressed === value) return; + this.suppressed = value; + if (value) { + this.#watch?.dispose(); + this.#watch = undefined; + this.testFiles.clear(); + } else { + this.applyWatch(); + } + } + + // `label` is the project node's label, or `null` to hoist its test files + // directly under `parentCollection` with no project node (the standard + // single-project case). The layout decision is owned by `WorkspaceManager`. + public refresh( + parentCollection: vscode.TestItemCollection, + label: string | null, + ) { + this.parentCollection = parentCollection; + if (label === null) { + this.testItem = undefined; + } else { + if (!this.testItem) { + this.testItem = this.testController.createTestItem( + // The user-owned config file, so two bridged projects in different + // directories do not collide on the one shim path they share. + this.sourceUri.toString(), + label, + // Do not set `uri`, so that VSCode’s β€œRun Tests” works correctly. + // https://github.com/microsoft/vscode/blob/3b42759b8b501e68106c72b5683dcc114ed789e1/src/vs/workbench/contrib/testing/common/testService.ts#L278-L280 + ); + testData.set(this.testItem, this); + } + // label may change across refreshes as the surrounding project tree + // gains or loses siblings + this.testItem.label = label; + this.parentCollection.add(this.testItem); + } + this.buildTree(); + } + dispose() { + this.#watch?.dispose(); + this.api.dispose(); + this.cancellationSource.cancel(); + } + get collection() { + return this.testItem?.children || this.parentCollection; + } + private startWatchingWorkspace(root: vscode.Uri): vscode.Disposable { + const matchInclude = picomatch(this.include); + const matchExclude = picomatch(this.exclude); + const isInclude = (uri: vscode.Uri) => { + const relativePath = path.relative(root.fsPath, uri.fsPath); + return matchInclude(relativePath) && !matchExclude(relativePath); + }; + + const watcher = watchConfigValue( + 'testCaseCollectMethod', + this.workspaceFolder, + async (method, token) => { + if (this.testItem) { + this.testItem.busy = true; + } + try { + const files: { uri: vscode.Uri; tests?: TestInfo[] }[] = + method === 'ast' + ? // ast + await glob(this.include, { + cwd: root.fsPath, + ignore: this.exclude, + absolute: true, + dot: true, + expandDirectories: false, + }).then((files) => + files.map((file) => ({ uri: vscode.Uri.file(file) })), + ) + : // runtime + await this.api.listTests().then((files) => + files.map((file) => ({ + uri: vscode.Uri.file(file.testPath), + tests: file.tests, + })), + ); + + if (token.isCancellationRequested) return; + + const visited = new Set(); + for (const { uri, tests } of files) { + this.updateOrCreateFile(uri, tests); + visited.add(uri.toString()); + } + + // remove outdated items after glob configuration changed + for (const file of this.testFiles.keys()) { + if (!visited.has(file)) { + this.testFiles.delete(file); + } + } + this.buildTree(); + + // start watching test file change + // while createFileSystemWatcher don't support same glob syntax with tinyglobby + // we can watch all files and filter with picomatch later + const watcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(root, '**'), + ); + token.onCancellationRequested(() => watcher.dispose()); + + // TODO delay and batch run multiple files + const updateOrCreateByRuntime = (uri: vscode.Uri) => { + void this.api + .listTests([uri.fsPath]) + .then((files) => { + if (token.isCancellationRequested) return; + for (const { testPath, tests } of files) { + const uri = vscode.Uri.file(testPath); + this.updateOrCreateFile(uri, tests); + } + this.buildTree(); + }) + .catch((error) => { + if (!token.isCancellationRequested) { + logger.error('Failed to update runtime test list', error); + } + }); + }; + + watcher.onDidCreate((uri) => { + if (isInclude(uri)) { + if (method === 'ast') { + this.updateOrCreateFile(uri); + this.buildTree(); + } else { + updateOrCreateByRuntime(uri); + } + } + }); + watcher.onDidChange((uri) => { + if (isInclude(uri)) { + if (method === 'ast') { + this.updateOrCreateFile(uri); + this.buildTree(); + } else { + updateOrCreateByRuntime(uri); + } + } + }); + watcher.onDidDelete((uri) => { + if (isInclude(uri)) { + this.testFiles.delete(uri.toString()); + this.buildTree(); + } + }); + } catch (error) { + if (!token.isCancellationRequested) { + logger.error('Failed to collect test files', error); + } + } finally { + if (this.testItem) { + this.testItem.busy = false; + } + } + }, + ); + return watcher; + } + // TODO pass cancellation token to updateFromDisk + private updateOrCreateFile(uri: vscode.Uri, tests?: TestInfo[]) { + let data = this.testFiles.get(uri.toString()); + if (!data) { + data = new TestFile(this.api, uri, this.testController); + this.testFiles.set(uri.toString(), data); + } + if (tests) { + data.updateFromList(tests); + } else { + data.updateFromDisk(); + } + } + + private buildTree() { + // A suppressed project must not render into its collection; its config file + // is already covered by an aggregator project. + if (this.suppressed) return; + + type NestedRecord = { [K: string]: NestedRecord }; + + const tree: NestedRecord = {}; + for (const [uriString] of this.testFiles) { + path + .relative(this.root.fsPath, vscode.Uri.parse(uriString).fsPath) + .split(path.sep) + .reduce((tree, segment) => (tree[segment] ||= {}), tree); + } + + const handleTreeItem = ( + key: string, + value: NestedRecord, + mergedParents: string[], + parents: string[], + collection: vscode.TestItemCollection, + ) => { + const uri = vscode.Uri.file(path.join(this.root.fsPath, ...parents, key)); + const children = Object.entries(value); + + if (children.length === 1) { + // if folder's only child is folder, merge them into one node + const onlyChild = children[0]; + const [childKey, childValue] = onlyChild; + const childIsFolder = Object.entries(childValue).length !== 0; + if (childIsFolder) { + handleTreeItem( + childKey, + childValue, + [...mergedParents, key], + [...parents, key], + collection, + ); + return; + } + } + const item = this.testController.createTestItem( + uri.toString(), + [...mergedParents, key].join(path.sep), + uri, + ); + collection.add(item); + + const file = this.testFiles.get(uri.toString()); + if (file) { + file.setTestItem(item); + testData.set(item, file); + } else { + testData.set(item, new TestFolder(this.api, uri)); + } + + for (const [childKey, childValue] of children) { + handleTreeItem( + childKey, + childValue, + [], + [...parents, key], + item.children, + ); + } + }; + + this.collection.replace([]); + for (const [childKey, childValue] of Object.entries(tree)) { + handleTreeItem(childKey, childValue, [], [], this.collection); + } + // testFiles has settled for this project; let the extension refresh any + // derived state (e.g. the `rstack.rstest.testFiles` context key). + this.onDidChangeTestFiles?.(); + } +} diff --git a/packages/vscode/src/stacks/test/projectCoverage.test.ts b/packages/vscode/src/stacks/test/projectCoverage.test.ts new file mode 100644 index 0000000..99b430e --- /dev/null +++ b/packages/vscode/src/stacks/test/projectCoverage.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from '@rstest/core'; +import { type ChildProjectRef, computeCoveredConfigs } from './projectCoverage'; + +// Paths are absolute in practice; use POSIX-looking paths for readability. +// `include` defaults to a shared pattern so identity/footprint coverage is +// exercised without the include guard getting in the way; pass a distinct one +// to exercise the guard itself. +const DEFAULT_INCLUDE = ['**/*.test.ts']; +// The caller-side key is the config file path itself for readability (in the +// extension it is a URI string). +const p = ( + configFilePath: string, + root: string, + childProjects: ChildProjectRef[] = [], + include: string[] = DEFAULT_INCLUDE, +) => ({ key: configFilePath, configFilePath, root, childProjects, include }); +// A file-based child project (has its own config file). +const file = (configFilePath: string): ChildProjectRef => ({ + configFilePath, + root: null, +}); +// An inline child project (no config file of its own). +const inline = (root: string): ChildProjectRef => ({ + configFilePath: null, + root, +}); + +describe('computeCoveredConfigs', () => { + it('suppresses child configs aggregated by a root config', () => { + // Mirrors rslib: a root config aggregates packages/* and tests, while each + // package also has its own standalone config. + const covered = computeCoveredConfigs([ + p('/repo/rstest.config.ts', '/repo', [ + file('/repo/packages/core/rstest.config.ts'), + file('/repo/packages/dts/rstest.config.ts'), + file('/repo/tests/rstest.config.ts'), + ]), + p('/repo/tests/rstest.config.ts', '/repo/tests', [inline('/repo/tests')]), + p('/repo/packages/core/rstest.config.ts', '/repo/packages/core'), + p('/repo/packages/dts/rstest.config.ts', '/repo/packages/dts'), + ]); + + // Only the aggregator root survives. + expect([...covered].sort()).toEqual([ + '/repo/packages/core/rstest.config.ts', + '/repo/packages/dts/rstest.config.ts', + '/repo/tests/rstest.config.ts', + ]); + }); + + it('keeps independent per-package configs when there is no aggregator', () => { + const covered = computeCoveredConfigs([ + p('/repo/packages/a/rstest.config.ts', '/repo/packages/a'), + p('/repo/packages/b/rstest.config.ts', '/repo/packages/b'), + ]); + expect(covered.size).toBe(0); + }); + + it('does not suppress a lone aggregator of inline children', () => { + // A lone aggregator whose inline children share its own root must not hide + // itself. + const covered = computeCoveredConfigs([ + p('/repo/tests/rstest.config.ts', '/repo/tests', [ + inline('/repo/tests'), + inline('/repo/tests'), + ]), + ]); + expect(covered.size).toBe(0); + }); + + it('suppresses exactly the aggregated config when a directory has several', () => { + // `apps/web` has two standalone configs (e.g. different include/exclude), + // and the root aggregates only one of them by file. Only that file is + // covered; the other config keeps its own tests visible. + const covered = computeCoveredConfigs([ + p('/repo/rstest.config.ts', '/repo', [ + file('/repo/apps/web/rstest.config.ts'), + ]), + p('/repo/apps/web/rstest.config.ts', '/repo/apps/web'), + p('/repo/apps/web/rstest.e2e.config.ts', '/repo/apps/web'), + ]); + expect([...covered]).toEqual(['/repo/apps/web/rstest.config.ts']); + }); + + it('suppresses a nested intermediate config the root also aggregates', () => { + // A root aggregates `sub` (which itself has `projects`) plus another + // project. `initCli` flattens `sub` to its leaf projects, so `sub`'s own + // config file never appears in the root's child list β€” but its leaves do. + const covered = computeCoveredConfigs([ + p('/repo/rstest.config.ts', '/repo', [ + file('/repo/sub/a/rstest.config.ts'), + file('/repo/sub/b/rstest.config.ts'), + file('/repo/other/rstest.config.ts'), + ]), + p('/repo/sub/rstest.config.ts', '/repo/sub', [ + file('/repo/sub/a/rstest.config.ts'), + file('/repo/sub/b/rstest.config.ts'), + ]), + ]); + expect([...covered]).toEqual(['/repo/sub/rstest.config.ts']); + }); + + it('suppresses a nested config even when the root aggregates only it', () => { + // The root aggregates a single nested child with inline leaves, so both + // flatten to the same footprint. The outer (ancestor-root) config wins. + const covered = computeCoveredConfigs([ + p('/repo/rstest.config.ts', '/repo', [ + inline('/repo/sub/a'), + inline('/repo/sub/b'), + ]), + p('/repo/sub/rstest.config.ts', '/repo/sub', [ + inline('/repo/sub/a'), + inline('/repo/sub/b'), + ]), + ]); + expect([...covered]).toEqual(['/repo/sub/rstest.config.ts']); + }); + + it('never lets two unrelated configs with identical footprints hide each other', () => { + const covered = computeCoveredConfigs([ + p('/repo/a/rstest.config.ts', '/repo/a', [ + file('/shared/1/rstest.config.ts'), + ]), + p('/repo/b/rstest.config.ts', '/repo/b', [ + file('/shared/1/rstest.config.ts'), + ]), + ]); + expect(covered.size).toBe(0); + }); + + it('keeps a child config whose include the parent does not match', () => { + // The root aggregates `e2e`, but the child matches only `**/*.e2e.ts`, + // which the root's own include does not glob (AST mode). Suppressing it + // would hide those tests, so the child stays visible. + const covered = computeCoveredConfigs([ + p('/repo/rstest.config.ts', '/repo', [ + file('/repo/e2e/rstest.config.ts'), + ]), + p('/repo/e2e/rstest.config.ts', '/repo/e2e', [], ['**/*.e2e.ts']), + ]); + expect(covered.size).toBe(0); + }); + + it('suppresses a child whose include is a subset of the parent include', () => { + const covered = computeCoveredConfigs([ + p( + '/repo/rstest.config.ts', + '/repo', + [file('/repo/pkg/rstest.config.ts')], + ['**/*.test.ts', '**/*.spec.ts'], + ), + p('/repo/pkg/rstest.config.ts', '/repo/pkg', [], ['**/*.test.ts']), + ]); + expect([...covered]).toEqual(['/repo/pkg/rstest.config.ts']); + }); + + it('normalizes reported paths before matching', () => { + const covered = computeCoveredConfigs([ + p('/repo/rstest.config.ts', '/repo', [ + // Child config file reported with a redundant segment. + file('/repo/packages/./core/rstest.config.ts'), + // Flattened inline leaf reported with a trailing separator. + inline('/repo/sub/a/'), + ]), + p('/repo/packages/core/rstest.config.ts', '/repo/packages/core'), + p('/repo/sub/rstest.config.ts', '/repo/sub', [inline('/repo/sub/a')]), + ]); + expect([...covered].sort()).toEqual([ + '/repo/packages/core/rstest.config.ts', + '/repo/sub/rstest.config.ts', + ]); + }); +}); diff --git a/packages/vscode/src/stacks/test/projectCoverage.ts b/packages/vscode/src/stacks/test/projectCoverage.ts new file mode 100644 index 0000000..308df70 --- /dev/null +++ b/packages/vscode/src/stacks/test/projectCoverage.ts @@ -0,0 +1,125 @@ +import path from 'node:path'; + +export type ChildProjectRef = { + // The child's own config file, or null for an inline project. + configFilePath: string | null; + root: string | null; +}; + +// Normalize a reported path (a root directory or a config file) to a stable +// comparison key. Core may report roots with a trailing separator (e.g. +// `packages/core/`) while the extension derives its own from a config file +// path without one; on Windows paths are case-insensitive and VS Code +// lowercases drive letters in `Uri.fsPath` while core reports paths as the +// process resolved them. +const normalizePath = (value: string): string => { + const normalized = path.normalize(value).replace(/[\\/]+$/, ''); + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +}; + +const isSubset = (a: Set, b: Set): boolean => { + if (a.size > b.size) { + return false; + } + for (const value of a) { + if (!b.has(value)) { + return false; + } + } + return true; +}; + +// Whether `ancestor` is a strict parent directory of `descendant`. Both are +// expected pre-normalized (see `normalizePath`). +const isStrictAncestor = (ancestor: string, descendant: string): boolean => { + const rel = path.relative(ancestor, descendant); + // `rel === ''` covers the equal-path case (not a strict ancestor). + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); +}; + +type Node = { + key: string; + configFilePath: string; + root: string; + footprint: Set; + include: Set; +}; + +// Whether `parent` aggregates a nested intermediate config `child`. `initCli` +// flattens such a child to its leaf projects, so the child's own config file +// never appears in `parent`'s footprint β€” but its leaves do. So the child is +// covered when its own footprint is a subset of the parent's. When the two +// footprints are equal (a parent that aggregates *only* this one nested +// child), the outer config β€” the one whose root is an ancestor β€” wins, so two +// unrelated configs with identical footprints never suppress each other +// (hiding both). +const aggregatesNestedConfig = (child: Node, parent: Node): boolean => { + if ( + child.footprint.size === 0 || + !isSubset(child.footprint, parent.footprint) + ) { + return false; + } + return ( + parent.footprint.size > child.footprint.size || + isStrictAncestor(parent.root, child.root) + ); +}; + +// Config files whose tests are already rendered by *another* config, so the +// extension should not register them as their own top-level project (otherwise +// the same files show up twice). Each project's `footprint` is the set of leaf +// projects it runs, keyed by config file when the leaf has one and by root for +// inline projects. A config is covered when another config either: +// - has this config's own file in its footprint (it directly aggregates this +// config as a leaf β€” exact identity, so a directory holding several +// configs is disambiguated for free); +// - aggregates this config's own leaves (a nested intermediate config, whose +// own file `initCli` flattens away; see `aggregatesNestedConfig`). +// In both cases the parent must also be able to *display* the child's files: +// in AST mode a project only globs its own `include`, so a child whose include +// patterns the parent does not also match is kept visible (its tests would +// otherwise vanish from the tree even though the aggregated run still executes +// them). Returns the `key`s of the covered configs; `configFilePath` is only +// match material. +export function computeCoveredConfigs( + projects: { + key: string; + configFilePath: string; + root: string; + childProjects: ChildProjectRef[]; + include: string[]; + }[], +): Set { + const nodes = projects.map((project): Node => { + const footprint = new Set(); + for (const child of project.childProjects) { + if (child.configFilePath) { + footprint.add(normalizePath(child.configFilePath)); + } else if (child.root) { + footprint.add(normalizePath(child.root)); + } + } + return { + key: project.key, + configFilePath: normalizePath(project.configFilePath), + root: normalizePath(project.root), + footprint, + include: new Set(project.include), + }; + }); + const covered = new Set(); + for (const project of nodes) { + const isCovered = nodes.some( + (other) => + other !== project && + isSubset(project.include, other.include) && + (other.footprint.has(project.configFilePath) || + aggregatesNestedConfig(project, other)), + ); + if (isCovered) { + covered.add(project.key); + } + } + return covered; +} diff --git a/packages/vscode/src/stacks/test/shared/logger.ts b/packages/vscode/src/stacks/test/shared/logger.ts new file mode 100644 index 0000000..7ecfe20 --- /dev/null +++ b/packages/vscode/src/stacks/test/shared/logger.ts @@ -0,0 +1,39 @@ +import { formatWithOptions } from 'node:util'; + +export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'; + +type Logger = { + [K in LogLevel]: (...params: unknown[]) => void; +}; + +export abstract class BaseLogger implements Logger { + constructor(private prefix?: string) {} + protected log(level: LogLevel, message: string): void { + console[level](message); + } + private logWithFormat(level: LogLevel, params: unknown[]) { + this.log( + level, + formatWithOptions( + { depth: 4 }, + ...(this.prefix ? [`[${this.prefix}]`] : []), + ...params, + ), + ); + } + trace(...params: unknown[]) { + this.logWithFormat('trace', params); + } + debug(...params: unknown[]) { + this.logWithFormat('debug', params); + } + info(...params: unknown[]) { + this.logWithFormat('info', params); + } + warn(...params: unknown[]) { + this.logWithFormat('warn', params); + } + error(...params: unknown[]) { + this.logWithFormat('error', params); + } +} diff --git a/packages/vscode/src/stacks/test/status.ts b/packages/vscode/src/stacks/test/status.ts new file mode 100644 index 0000000..53e5ded --- /dev/null +++ b/packages/vscode/src/stacks/test/status.ts @@ -0,0 +1,50 @@ +import type { StackState, StatusReporter } from '../../types'; + +/** + * The status-aggregation adaptation: the stack owns no status UI. Everything that + * used to be a one-shot `showWarningMessage` (the `@rstest/core` version check) + * or an unreported failure is reported through the shell's single aggregated + * status bar item instead. + * + * Like `logger`, this is a module singleton so the deep call sites + * (`master.ts`) can report without threading a context through every + * constructor. It no-ops while unbound, which is what the unit tests and a + * disposed stack see. + */ +class StatusHolder implements StatusReporter { + #reporter: StatusReporter | undefined; + + get stack() { + return this.#reporter?.stack ?? ('rstest' as const); + } + + public bind(reporter: StatusReporter) { + this.#reporter = reporter; + } + + public unbind() { + this.#reporter = undefined; + } + + report(state: StackState): void { + this.#reporter?.report(state); + } + + starting(detail?: string): void { + this.#reporter?.starting(detail); + } + + running(detail?: string): void { + this.#reporter?.running(detail); + } + + crashed(detail: string): void { + this.#reporter?.crashed(detail); + } + + versionMismatch(detail: string): void { + this.#reporter?.versionMismatch(detail); + } +} + +export const status = new StatusHolder(); diff --git a/packages/vscode/src/stacks/test/terminal.test.ts b/packages/vscode/src/stacks/test/terminal.test.ts new file mode 100644 index 0000000..7ac8fa8 --- /dev/null +++ b/packages/vscode/src/stacks/test/terminal.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, rs } from '@rstest/core'; + +// terminal.ts imports `vscode` (used only inside functions), so a stub is enough +// to load the module and exercise the pure `shellQuote`. +rs.mock('vscode', () => ({ default: {} })); + +import { shellQuote } from './terminal'; + +describe('shellQuote', () => { + it('leaves safe tokens unquoted', () => { + expect(shellQuote('run')).toBe('run'); + expect(shellQuote('--coverage')).toBe('--coverage'); + expect(shellQuote('src/foo.test.ts')).toBe('src/foo.test.ts'); + }); + + it('single-quotes values with shell-significant characters', () => { + // A `-t` pattern must not be expanded by the shell. + expect(shellQuote('^CLI options applies overrides$')).toBe( + "'^CLI options applies overrides$'", + ); + expect(shellQuote('a b')).toBe("'a b'"); + expect(shellQuote('$HOME')).toBe("'$HOME'"); + }); + + it('escapes embedded single quotes', () => { + expect(shellQuote("it's ok")).toBe("'it'\\''s ok'"); + }); + + it('quotes the empty string', () => { + expect(shellQuote('')).toBe("''"); + }); +}); diff --git a/packages/vscode/src/stacks/test/terminal.ts b/packages/vscode/src/stacks/test/terminal.ts new file mode 100644 index 0000000..6250bde --- /dev/null +++ b/packages/vscode/src/stacks/test/terminal.ts @@ -0,0 +1,48 @@ +import vscode from 'vscode'; + +// A single reused "Rstest" integrated terminal for the shell-terminal run mode. +// The terminal is recreated when its shell options change or the user closed +// it. Kept as a module singleton so runs from different projects share one +// terminal, mirroring how `logger` is a shared output channel. +let terminal: vscode.Terminal | undefined; +let signature = ''; + +export interface TerminalOptions { + cwd: string; + shellPath?: string; + shellArgs?: string[]; +} + +export function runInTerminal(command: string, options: TerminalOptions): void { + const sig = JSON.stringify(options); + // Dispose when the user closed it (exitStatus set) or the shell changed. + if (terminal && (terminal.exitStatus || signature !== sig)) { + terminal.dispose(); + terminal = undefined; + } + if (!terminal) { + terminal = vscode.window.createTerminal({ + name: 'Rstest', + cwd: options.cwd, + shellPath: options.shellPath || undefined, + shellArgs: options.shellArgs?.length ? options.shellArgs : undefined, + }); + signature = sig; + } + terminal.show(true); + terminal.sendText(command); +} + +export function disposeTerminal(): void { + terminal?.dispose(); + terminal = undefined; +} + +// POSIX single-quote quoting. Bare tokens (safe characters only) are left +// as-is for readability; everything else is single-quoted so the shell does +// not expand `$`, `^`, `(`, spaces, etc. in a `-t` pattern or a path. +export function shellQuote(value: string): string { + if (value === '') return "''"; + if (/^[A-Za-z0-9_./:@%+=-]+$/.test(value)) return value; + return `'${value.replaceAll("'", `'\\''`)}'`; +} diff --git a/packages/vscode/src/stacks/test/testRunReporter.ts b/packages/vscode/src/stacks/test/testRunReporter.ts new file mode 100644 index 0000000..5427f65 --- /dev/null +++ b/packages/vscode/src/stacks/test/testRunReporter.ts @@ -0,0 +1,388 @@ +import type { + Reporter, + TestCaseInfo, + TestFileInfo, + TestFileResult, + TestResult, + TestSuiteInfo, +} from '@rstest/core'; +import vscode from 'vscode'; +import type { DiagnosticEntry, RstestDiagnostics } from './diagnostics'; +import type { TestErrorStore } from './errorStore'; +import { logger } from './logger'; +import type { Project } from './project'; +import type { LogLevel } from './shared/logger'; +import { getTestItemId, TestFile, testData } from './testTree'; +import { + parseErrorStacktrace, + ROOT_SUITE_NAME, +} from './vendored/coreInternals'; + +export class TestRunReporter implements Reporter { + constructor( + private run?: vscode.TestRun, + private project?: Project, + private path: string[] = [], + private coverageEnabled?: boolean, + private onFinish?: () => void, + private createTestRun?: () => vscode.TestRun, + private projectKey = '', + private diagnostics?: RstestDiagnostics, + private errorStore?: TestErrorStore, + ) {} + + public async log(level: LogLevel, message: string) { + logger[level](message); + } + + // pipe default reporter output to vscode test results panel + onOutput(message: string) { + this.run?.appendOutput(message.replaceAll('\n', '\r\n')); + } + + // Core reports results by name path only, so duplicate sibling names are + // indistinguishable from the path alone. We reproduce the tree's id scheme + // (see getTestItemId) by counting same-named siblings under each parent, and + // pair a test's start/result events via its stable testId so a given test + // always resolves to the same occurrence. Both caches are per-run and reset + // in onTestRunStart (the reporter instance is reused across watch runs). + private resolvedItems = new Map(); + private siblingCounters = new Map>(); + + private generatePath(value: TestCaseInfo | TestSuiteInfo | TestResult) { + return value.name === ROOT_SUITE_NAME + ? [] + : [...(value.parentNames || []), value.name]; + } + private pickSibling(parent: vscode.TestItem, name: string) { + let counts = this.siblingCounters.get(parent); + if (!counts) { + counts = new Map(); + this.siblingCounters.set(parent, counts); + } + const index = counts.get(name) ?? 0; + counts.set(name, index + 1); + return parent.children.get(getTestItemId(name, index)); + } + private findTestItem(value: TestCaseInfo | TestSuiteInfo | TestResult) { + if (this.resolvedItems.has(value.testId)) { + return this.resolvedItems.get(value.testId); + } + const fileItem = this.project?.testFiles.get( + vscode.Uri.file(value.testPath).toString(), + )?.testItem; + const path = this.generatePath(value); + // Ancestors are assumed unique and resolved by their plain name; only the + // reported item's own segment needs sibling disambiguation. + const parent = path + .slice(0, -1) + .reduce( + (item, name) => item?.children.get(name), + fileItem, + ); + const item = + path.length === 0 + ? fileItem + : parent && this.pickSibling(parent, path[path.length - 1]); + this.resolvedItems.set(value.testId, item); + return item; + } + /** check whether current running suite/case contains reported suite/case */ + private contains(value: TestCaseInfo | TestSuiteInfo | TestResult) { + const path = this.generatePath(value); + if (path.length < this.path.length) return false; + return this.path.every((name, index) => path[index] === name); + } + + onTestFileStart(test: TestFileInfo) { + // only update test file result when explicit run itself or parent + if (this.path.length) return; + + const fileItem = this.project?.testFiles.get( + vscode.Uri.file(test.testPath).toString(), + )?.testItem; + if (!fileItem) return; + + this.run?.started(fileItem); + } + onTestFileReady(test: TestFileInfo) { + const fileTestItem = this.project?.testFiles.get( + vscode.Uri.file(test.testPath).toString(), + )?.testItem; + if (fileTestItem) { + const data = testData.get(fileTestItem); + if (data instanceof TestFile) { + data.updateFromList(test.tests); + } + } + } + async onTestFileResult(test: TestFileResult) { + // only update test file result when explicit run itself or parent + if (this.path.length) return; + + const fileItem = this.project?.testFiles.get( + vscode.Uri.file(test.testPath).toString(), + )?.testItem; + if (!fileItem) return; + + switch (test.status) { + case 'todo': + case 'skip': + this.run?.skipped(fileItem); + this.errorStore?.clear(fileItem); + break; + case 'pass': + this.run?.passed(fileItem, test.duration); + this.errorStore?.clear(fileItem); + break; + case 'fail': { + // When a file fails before any test case runs (a syntax/collection + // error or a worker crash during collection), the errors live only on + // the file result and no onTestCaseResult fires to surface or store + // them. Surface them on the file item so they appear in the failure + // widget and copyTestItemErrors can find them. When cases did run, + // their per-case results already carry the errors (core also + // re-aggregates suite hook errors onto file.errors, but those are + // already surfaced/stored via onTestSuiteResult on the file item), so + // we skip here to avoid double-reporting. This relies on core keeping + // file.errors as the sole carrier only when no case ran. + const fileErrors = test.results?.length + ? [] + : await this.createErrors(test.errors, test.testPath); + this.run?.failed(fileItem, fileErrors, test.duration); + if (fileErrors.length) { + this.errorStore?.set(fileItem, fileErrors); + } + break; + } + } + } + + // just reuse test case hooks + onTestSuiteStart(test: TestSuiteInfo) { + this.onTestCaseStart(test); + } + onTestSuiteResult(result: TestResult) { + this.onTestCaseResult(result); + } + + onTestCaseStart(test: TestCaseInfo | TestSuiteInfo) { + // ignore reported item not belongs current testItem + if (!this.contains(test)) return; + + const testItem = this.findTestItem(test); + if (!testItem) { + logger.error('Cannot find testItem', test); + return; + } + this.run?.started(testItem); + } + async onTestCaseResult(result: TestResult) { + // if reported result is not belongs to current testItem, only update result when there's some suite before/after hooks error + if (!this.contains(result)) { + if (!result.errors?.length) return; + } + + const testItem = this.findTestItem(result); + if (!testItem) { + logger.error('Cannot find testItem', result); + return; + } + + switch (result.status) { + case 'pass': { + this.run?.passed(testItem, result.duration); + this.diagnostics?.clearForTest(this.projectKey, testItem); + this.errorStore?.clear(testItem); + break; + } + case 'skip': + case 'todo': { + this.run?.skipped(testItem); + this.diagnostics?.clearForTest(this.projectKey, testItem); + this.errorStore?.clear(testItem); + break; + } + case 'fail': { + const errors = await this.createErrors(result.errors, result.testPath); + this.run?.failed(testItem, errors, result.duration); + this.diagnostics?.setForTest( + this.projectKey, + testItem, + this.createDiagnostics(testItem, errors), + ); + this.errorStore?.set(testItem, errors); + break; + } + } + } + + private createDiagnostics( + testItem: vscode.TestItem, + messages: vscode.TestMessage[], + ): DiagnosticEntry[] { + const diagnostics: DiagnosticEntry[] = []; + for (const message of messages) { + const location = this.getDiagnosticLocation(testItem, message); + if (!location || location.uri.scheme !== 'file') { + continue; + } + + const diagnostic = new vscode.Diagnostic( + location.range, + `[${testItem.label}] ${message.message}`, + vscode.DiagnosticSeverity.Error, + ); + diagnostic.source = 'rstest'; + diagnostics.push({ uri: location.uri, diagnostic }); + } + return diagnostics; + } + + private getDiagnosticLocation( + testItem: vscode.TestItem, + message: vscode.TestMessage, + ) { + if (message.location) { + return message.location; + } + + if (testItem.uri && testItem.range) { + return new vscode.Location(testItem.uri, testItem.range); + } + } + + private isFirstRun = true; + + async onTestRunStart() { + this.resolvedItems.clear(); + this.siblingCounters.clear(); + this.diagnostics?.clearForProject(this.projectKey); + if (!this.isFirstRun) { + this.run = this.createTestRun?.(); + } + } + + async onTestRunEnd() { + if (this.coverageEnabled) return; + + if (this.isFirstRun) { + this.onFinish?.(); + } else { + this.run?.end(); + } + this.isFirstRun = false; + } + + async onCoverageEnd() { + if (this.isFirstRun) { + this.onFinish?.(); + } else { + this.run?.end(); + } + this.isFirstRun = false; + } + + async onCoverage( + uri: string, + statementCoverage: vscode.TestCoverageCount, + branchCoverage?: vscode.TestCoverageCount, + declarationCoverage?: vscode.TestCoverageCount, + details?: vscode.FileCoverageDetail[], + ) { + this.run?.addCoverage( + new RstestFileCoverage( + vscode.Uri.file(uri), + statementCoverage, + branchCoverage, + declarationCoverage, + details?.map((detail) => { + const mapLocation = (location: vscode.Position | vscode.Range) => { + if ('start' in location) + return new vscode.Range( + location.start.line, + location.start.character, + location.end.line, + location.end.character, + ); + return new vscode.Position(location.line, location.character); + }; + return 'name' in detail + ? new vscode.DeclarationCoverage( + detail.name, + detail.executed, + mapLocation(detail.location), + ) + : new vscode.StatementCoverage( + detail.executed, + mapLocation(detail.location), + detail.branches.map( + (branch) => + new vscode.BranchCoverage( + branch.executed, + branch.location && mapLocation(branch.location), + ), + ), + ); + }), + ), + ); + } + + private createErrors(errors: TestResult['errors'], testPath: string) { + return Promise.all( + (errors || []).map((error) => this.createError(error, testPath)), + ); + } + + private async createError( + error: NonNullable[number], + testPath: string, + ) { + const message = + error.diff && error.expected !== undefined && error.actual !== undefined + ? vscode.TestMessage.diff(error.message, error.expected, error.actual) + : new vscode.TestMessage(error.message); + + if ( + error.diff && + // Snapshot `Foo > inner Foo > should return "foo" 1` mismatched + error.message.startsWith('Snapshot ') && + error.message.endsWith(' mismatched') + ) { + message.contextValue = 'canUpdateSnapshot'; + } + + if (error.stack) { + const frames = await parseErrorStacktrace({ stack: error.stack }); + + // pick last frame which file equals current test file as error location + const locationFrame = frames.findLast((frame) => frame.file === testPath); + if (locationFrame?.lineNumber && locationFrame.column) { + message.location = new vscode.Location( + vscode.Uri.file(testPath), + new vscode.Position( + locationFrame.lineNumber - 1, + locationFrame.column - 1, + ), + ); + } + // Avoid showing compiled runtime stack frames in the editor failure widget. + // They are usually not actionable for users and add noisy duplicated entries. + } + + return message; + } +} + +export class RstestFileCoverage extends vscode.FileCoverage { + constructor( + uri: vscode.Uri, + statementCoverage: vscode.TestCoverageCount, + branchCoverage?: vscode.TestCoverageCount, + declarationCoverage?: vscode.TestCoverageCount, + public readonly details: vscode.FileCoverageDetail[] = [], + ) { + super(uri, statementCoverage, branchCoverage, declarationCoverage); + } +} diff --git a/packages/vscode/src/stacks/test/testTree.test.ts b/packages/vscode/src/stacks/test/testTree.test.ts new file mode 100644 index 0000000..48816b3 --- /dev/null +++ b/packages/vscode/src/stacks/test/testTree.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, rs } from '@rstest/core'; + +rs.mock('vscode', () => { + class Range { + constructor( + public startLine: number, + public startChar: number, + public endLine: number, + public endChar: number, + ) {} + } + const channel = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + appendLine: () => {}, + dispose: () => {}, + }; + const vscode = { + Range, + Uri: { + file: (fsPath: string) => ({ + fsPath, + toString: () => `file://${fsPath}`, + }), + }, + window: { createOutputChannel: () => channel }, + workspace: { fs: {} }, + }; + return { ...vscode, default: vscode }; +}); + +const makeCollection = () => { + const map = new Map(); + return { + replace: (items: any[]) => { + map.clear(); + for (const item of items) map.set(item.id, item); + }, + forEach: (cb: (item: any) => void) => map.forEach((v) => cb(v)), + get: (id: string) => map.get(id), + get size() { + return map.size; + }, + }; +}; + +const createController = () => + ({ + createTestItem: (id: string, label: string, uri: unknown) => ({ + id, + label, + uri, + range: undefined as any, + error: undefined as any, + children: makeCollection(), + }), + }) as any; + +const location = (line: number) => ({ line, column: 3 }); + +// suite "outer" @ line 7, cases "a" @ 12 and "b" @ 16 (1-based, like core) +const withLocations = [ + { + type: 'suite', + name: 'outer', + location: location(7), + tests: [ + { type: 'case', name: 'a', location: location(12), tests: [] }, + { type: 'case', name: 'b', location: location(16), tests: [] }, + ], + }, +] as any; + +const withoutLocations = [ + { + type: 'suite', + name: 'outer', + location: undefined, + tests: [ + { type: 'case', name: 'a', location: undefined, tests: [] }, + { type: 'case', name: 'b', location: undefined, tests: [] }, + ], + }, +] as any; + +describe('TestFile.updateFromList', () => { + it('keeps existing ranges when a rebuilt test reports no location', async () => { + const { TestFile } = await import('./testTree'); + const controller = createController(); + const uri = { fsPath: '/x/outer.test.ts', toString: () => 'file:///x' }; + const file = new TestFile({} as any, uri as any, controller); + const root = controller.createTestItem('root', 'outer.test.ts', uri); + file.setTestItem(root); + + // Discovery-like pass with real source locations. + file.updateFromList(withLocations); + const suite1 = root.children.get('outer'); + expect(suite1.range.startLine).toBe(6); + expect(suite1.children.get('a').range.startLine).toBe(11); + expect(suite1.children.get('b').range.startLine).toBe(15); + + // A run reports the same tests without locations; ranges must survive + // instead of collapsing to line 1. + file.updateFromList(withoutLocations); + const suite2 = root.children.get('outer'); + expect(suite2.range.startLine).toBe(6); + expect(suite2.children.get('a').range.startLine).toBe(11); + expect(suite2.children.get('b').range.startLine).toBe(15); + }); + + it('uses the reported location when one is present', async () => { + const { TestFile } = await import('./testTree'); + const controller = createController(); + const uri = { fsPath: '/x/outer.test.ts', toString: () => 'file:///x' }; + const file = new TestFile({} as any, uri as any, controller); + file.setTestItem(controller.createTestItem('root', 'outer.test.ts', uri)); + + file.updateFromList(withLocations); + file.updateFromList([ + { + type: 'suite', + name: 'outer', + location: location(9), + tests: [{ type: 'case', name: 'a', location: location(20), tests: [] }], + }, + ] as any); + + const root = (file as any).testItem; + const suite = root.children.get('outer'); + expect(suite.range.startLine).toBe(8); + expect(suite.children.get('a').range.startLine).toBe(19); + }); + + it('preserves separate ranges for duplicate sibling names', async () => { + const { TestFile, getTestItemId } = await import('./testTree'); + const controller = createController(); + const uri = { fsPath: '/x/dup.test.ts', toString: () => 'file:///x' }; + const file = new TestFile({} as any, uri as any, controller); + const root = controller.createTestItem('root', 'dup.test.ts', uri); + file.setTestItem(root); + + const dup = [ + { type: 'case', name: 'renders', location: location(4), tests: [] }, + { type: 'case', name: 'renders', location: location(9), tests: [] }, + ] as any; + file.updateFromList(dup); + // duplicate siblings get distinct ids by occurrence index + expect(root.children.get(getTestItemId('renders', 0)).range.startLine).toBe( + 3, + ); + expect(root.children.get(getTestItemId('renders', 1)).range.startLine).toBe( + 8, + ); + + // location-less rebuild must keep each occurrence's own range, not collapse + // both onto the last one's. + file.updateFromList([ + { type: 'case', name: 'renders', location: undefined, tests: [] }, + { type: 'case', name: 'renders', location: undefined, tests: [] }, + ] as any); + expect(root.children.get(getTestItemId('renders', 0)).range.startLine).toBe( + 3, + ); + expect(root.children.get(getTestItemId('renders', 1)).range.startLine).toBe( + 8, + ); + }); +}); diff --git a/packages/vscode/src/stacks/test/testTree.ts b/packages/vscode/src/stacks/test/testTree.ts new file mode 100644 index 0000000..d65330a --- /dev/null +++ b/packages/vscode/src/stacks/test/testTree.ts @@ -0,0 +1,251 @@ +import { TextDecoder } from 'node:util'; +import type { TestInfo } from '@rstest/core'; +import vscode from 'vscode'; +import { logger } from './logger'; +import type { RstestApi } from './master'; +import type { Project, WorkspaceManager } from './project'; +import { ROOT_SUITE_NAME } from './vendored/coreInternals'; + +const textDecoder = new TextDecoder('utf-8'); + +export const testData = new WeakMap< + vscode.TestItem, + WorkspaceManager | Project | TestFolder | ProjectFolder | TestFile | TestCase +>(); + +const getContentFromFilesystem = async (uri: vscode.Uri) => { + try { + const rawContent = await vscode.workspace.fs.readFile(uri); + return textDecoder.decode(rawContent); + } catch (e) { + logger.warn(`Error providing tests for ${uri.fsPath}`, e); + return ''; + } +}; + +// Duplicate sibling test/suite names get a unique TestItem id by appending +// their 0-based occurrence index. Creation (TestFile.onTest) and result lookup +// (TestRunReporter.findTestItem) must derive ids identically, so both go +// through this helper. `siblingIndex` is the number of prior same-named +// siblings (0 for the first, which keeps its plain name as id). +export function getTestItemId(name: string, siblingIndex: number): string { + return siblingIndex ? [name, siblingIndex].join('@@@@@@') : name; +} + +// Occurrence index of `name` among the siblings already collected in `parent` +// (0 for the first). Shared by tree creation and the range snapshot so both +// derive the same duplicate-aware id via getTestItemId. +function siblingIndexOf(parent: vscode.TestItem[], name: string): number { + return parent.filter((child) => child.label === name).length; +} + +export function gatherTestItems( + collection: vscode.TestItemCollection, + recursive = true, +) { + const items: vscode.TestItem[] = []; + collection.forEach((item) => { + items.push(item); + if (recursive && item.children.size > 0) { + gatherTestItems(item.children).forEach((child) => { + items.push(child); + }); + } + }); + return items; +} + +export class TestFolder { + constructor( + public api: RstestApi, + public uri: vscode.Uri, + ) {} +} + +// Marker for a folder node that groups multiple projects by directory. Unlike +// `TestFolder`, it does not belong to a single project/api, so running it +// recurses into its children instead of invoking one api. +export class ProjectFolder {} + +export class TestFile { + public didResolve = false; + public testItem?: vscode.TestItem; + private children: vscode.TestItem[] = []; + + constructor( + public api: RstestApi, + public uri: vscode.Uri, + private controller: vscode.TestController, + ) {} + + public setTestItem(item: vscode.TestItem) { + this.testItem = item; + item.children.replace(this.children); + } + + public async updateFromDisk() { + const content = await getContentFromFilesystem(this.uri); + this.updateFromContents(content); + } + + /** + * Parses the tests from the input text, and updates the tests contained + * by this file to be those from the text, + */ + private async updateFromContents(content: string) { + // Maintain a stack of ancestors to build a hierarchical tree + const ancestors: { name: string; children: vscode.TestItem[] }[] = [ + { name: 'ROOT', children: [] }, + ]; + this.didResolve = true; + + const { parseTestFile } = await import('./parserTest'); + parseTestFile(content, { + onTest: (range, name, testType) => { + const vscodeRange = new vscode.Range( + new vscode.Position(range.startLine, range.startChar), + new vscode.Position(range.endLine, range.endChar), + ); + + const parent = ancestors[ancestors.length - 1]; + + const parentNames = ancestors.slice(1).map((item) => item.name); + + const testItem = this.onTest( + vscodeRange, + name, + testType, + parent.children, + parentNames, + ); + + const isSuite = testType === 'describe' || testType === 'suite'; + + testData.set( + testItem, + new TestCase( + this.api, + this.uri, + parentNames, + isSuite ? 'suite' : 'case', + ), + ); + + if (isSuite) { + const children: vscode.TestItem[] = []; + // This becomes the new parent for subsequently discovered children + ancestors.push({ name, children: children }); + return () => { + // Assign children to suite and pop from stack + testItem.children.replace(children); + ancestors.pop(); + }; + } + }, + }); + this.children = ancestors[0].children; + this.testItem?.children.replace(this.children); + } + + public updateFromList(tests: TestInfo[]) { + // A run's reported tests may arrive without a source location (the runtime + // only emits locations when `includeTaskLocation` resolves one, which + // depends on the project's core version and build). Snapshot the ranges we + // already have so a location-less test keeps its range instead of + // collapsing to line 1, which would move every gutter icon to the imports. + // Keys are the path of duplicate-aware item ids so that duplicate sibling + // names each keep their own range. + const previousRanges = new Map(); + const rangeKey = (idPath: string[]) => idPath.join('\x00'); + const snapshot = (item: vscode.TestItem, idPath: string[]) => { + if (item.range) previousRanges.set(rangeKey(idPath), item.range); + item.children.forEach((child) => snapshot(child, [...idPath, child.id])); + }; + this.children.forEach((item) => snapshot(item, [item.id])); + + const handleChild = ( + test: TestInfo, + parent: vscode.TestItem[], + parentNames: string[], + parentIds: string[], + ) => { + const names = [...parentNames, test.name]; + const ids = [ + ...parentIds, + getTestItemId(test.name, siblingIndexOf(parent, test.name)), + ]; + let range: vscode.Range | undefined; + if (test.location) { + // vscode location is zero based + const line = test.location.line - 1; + const column = test.location.column - 1; + range = new vscode.Range(line, column, line, column); + } else { + range = previousRanges.get(rangeKey(ids)); + } + const testItem = this.onTest( + range, + test.name, + test.type === 'suite' ? 'suite' : 'test', + parent, + parentNames, + ); + if (test.type === 'suite') { + const children: vscode.TestItem[] = []; + test.tests.forEach((child) => { + handleChild(child, children, names, ids); + }); + testItem.children.replace(children); + } + }; + const children: vscode.TestItem[] = []; + const realTests = + tests[0]?.type === 'suite' && tests[0].name === ROOT_SUITE_NAME + ? tests[0].tests + : tests; + realTests.forEach((test) => { + handleChild(test, children, [], []); + }); + this.children = children; + this.testItem?.children.replace(this.children); + } + + private onTest( + range: vscode.Range | undefined, + name: string, + testType: 'test' | 'it' | 'suite' | 'describe', + parent: vscode.TestItem[], + parentNames: string[], + ) { + const siblingsCount = siblingIndexOf(parent, name); + + const id = getTestItemId(name, siblingsCount); + + const isSuite = testType === 'describe' || testType === 'suite'; + + const testItem = this.controller.createTestItem(id, name, this.uri); + testData.set( + testItem, + new TestCase(this.api, this.uri, parentNames, isSuite ? 'suite' : 'case'), + ); + + if (range) testItem.range = range; + + // warn about duplicated name + if (siblingsCount) testItem.error = `Duplicated ${testType} name`; + + // Set TestCase data for both describe blocks and leaf tests + parent.push(testItem); + + return testItem; + } +} + +export class TestCase { + constructor( + public api: RstestApi, + public uri: vscode.Uri, + public parentNames: string[], + public type: 'suite' | 'case', + ) {} +} diff --git a/packages/vscode/src/stacks/test/types.ts b/packages/vscode/src/stacks/test/types.ts new file mode 100644 index 0000000..ddd541e --- /dev/null +++ b/packages/vscode/src/stacks/test/types.ts @@ -0,0 +1,9 @@ +import type { RstestConfig } from '@rstest/core'; + +//#region master -> worker +export type WorkerInitOptions = RstestConfig & { + configFilePath: string; + fileFilters?: string[]; + rstestPath: string; + command?: 'run' | 'list' | 'watch'; +}; diff --git a/packages/vscode/src/stacks/test/utils.test.ts b/packages/vscode/src/stacks/test/utils.test.ts new file mode 100644 index 0000000..7b3c007 --- /dev/null +++ b/packages/vscode/src/stacks/test/utils.test.ts @@ -0,0 +1,17 @@ +import { expect, it, rs } from '@rstest/core'; +import { isTestFile } from './utils'; + +rs.mock('vscode', () => { + return {}; +}); + +it('test isTestFile', () => { + expect(isTestFile('/path/to/file.test.js')).toBeTruthy(); + expect(isTestFile('/path/to/file.spec.tsx')).toBeTruthy(); + expect(isTestFile('/path/to/file.test.mjs')).toBeTruthy(); + expect(isTestFile('/path/to/file.spec.cjs')).toBeTruthy(); + // cspell:disable-next-line + expect(isTestFile('/path/to/file.testmjs')).toBeFalsy(); + expect(isTestFile('/path/to/file.js')).toBeFalsy(); + expect(isTestFile('/path/to/testfile.txt')).toBeFalsy(); +}); diff --git a/packages/vscode/src/stacks/test/utils.ts b/packages/vscode/src/stacks/test/utils.ts new file mode 100644 index 0000000..5ae18fe --- /dev/null +++ b/packages/vscode/src/stacks/test/utils.ts @@ -0,0 +1,8 @@ +export function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function isTestFile(filename: string): boolean { + const regex = /.*\.(test|spec)\.(c|m)?[jt]sx?$/; + return regex.test(filename); +} diff --git a/packages/vscode/src/stacks/test/vendored/coreInternals.ts b/packages/vscode/src/stacks/test/vendored/coreInternals.ts new file mode 100644 index 0000000..aeaadfb --- /dev/null +++ b/packages/vscode/src/stacks/test/vendored/coreInternals.ts @@ -0,0 +1,76 @@ +/** + * Vendored from `web-infra-dev/rstest` @ origin/main: + * - `packages/core/src/utils/constants.ts` (`ROOT_SUITE_NAME`) + * - `packages/core/src/utils/error.ts` (`parseErrorStacktrace`) + * + * Upstream's VS Code extension lives in the same monorepo and deep-imports both + * from `../../core/src/...`. Neither is reachable from the published package: + * `@rstest/core`'s exports map is `.`, `./api`, `./internal/adapter`, + * `./internal/browser`, `./internal/browser-runtime`, `./package.json`, + * `./globals`, `./importMeta` β€” so a standalone repo has to vendor them. + * + * TODO: ask `@rstest/core` to export these (an `./internal/host` subpath would + * delete this file). Until then this is a trimmed copy: + * - no `getSourcemap` branch. The only call site is + * `parseErrorStacktrace({ stack })` in `testRunReporter.ts`, which never + * passes one, so `@jridgewell/trace-mapping` is not pulled in. + * - no `isDebug()` default for `fullStack`. That helper lives in core's logger + * and keys off core's own `DEBUG` handling, which does not exist in the + * extension host; the caller does not pass the flag either. + * + * The stack-frame filtering β€” `stackIgnores`, the http-like-file rejection and + * the backslash normalization β€” is kept byte-identical, because it is what + * decides which frame becomes the failure location in the editor. + */ +import { parse as stackTraceParse, type StackFrame } from 'stacktrace-parser'; + +export const ROOT_SUITE_NAME = 'Rstest:_internal_root_suite'; + +const isHttpLikeFile = (file: string): boolean => /^https?:\/\//.test(file); + +const stackIgnores: (RegExp | string)[] = [ + /\/@rstest\/core/, + /rstest\/packages\/core\/dist/, + /node_modules\/chai/, + /node_modules\/@vitest\/expect/, + /node_modules\/@vitest\/snapshot/, + /node:\w+/, + /webpack\/runtime/, + /rstest runtime/, + // windows path + /webpack\\runtime/, + '', +]; + +export async function parseErrorStacktrace({ + stack, + fullStack = false, +}: { + fullStack?: boolean; + stack: string; +}): Promise { + const stackFrames = stackTraceParse(stack).filter((frame) => + fullStack + ? true + : frame.file && !stackIgnores.some((entry) => frame.file?.match(entry)), + ); + + if (fullStack) { + return stackFrames; + } + + const filteredFrames = stackFrames.filter((frame) => { + if (!frame.file) { + return false; + } + + if (isHttpLikeFile(frame.file)) { + return false; + } + + const normalizedFile = frame.file.replace(/\\/g, '/'); + return !stackIgnores.some((entry) => normalizedFile.match(entry)); + }); + + return filteredFrames; +} diff --git a/packages/vscode/src/stacks/test/worker/index.ts b/packages/vscode/src/stacks/test/worker/index.ts new file mode 100644 index 0000000..514bed7 --- /dev/null +++ b/packages/vscode/src/stacks/test/worker/index.ts @@ -0,0 +1,106 @@ +import { pathToFileURL } from 'node:url'; +import { createBirpc } from 'birpc'; +import type { TestRunReporter } from '../testRunReporter'; +import type { WorkerInitOptions } from '../types'; +import { logger } from './logger'; +import { CoverageReporter, ProgressLogger, ProgressReporter } from './reporter'; + +// fix ESM import path issue on windows +// Only URLs with a scheme in: file, data, and node are supported by the default ESM loader. +const normalizeImportPath = (path: string) => { + return pathToFileURL(path).toString(); +}; + +export class Worker { + private async init({ + configFilePath, + fileFilters, + rstestPath, + command = 'run', + ...overrideConfig + }: WorkerInitOptions) { + const rstestModule = (await import( + normalizeImportPath(rstestPath) + )) as typeof import('@rstest/core'); + logger.debug('Loaded Rstest module'); + const { createRstest, initCli } = rstestModule; + + const initializedOptions = await initCli({ + config: configFilePath, + }); + const { projects, config: initializedConfig } = initializedOptions; + logger.debug('initializedOptions', initializedOptions); + + const rstest = createRstest( + { + config: { + ...initializedConfig, + ...overrideConfig, + reporters: [ + // place default reporter first to ensure output is flushed + ['default', { logger: new ProgressLogger() }], + new ProgressReporter(), + ], + coverage: { + ...initializedConfig.coverage, + ...overrideConfig.coverage, + }, + }, + configFilePath, + projects, + }, + command, + fileFilters ?? [], + ); + + return { rstest, projects }; + } + + public async getNormalizedConfig(options: WorkerInitOptions) { + const { rstest, projects } = await this.init(options); + return { + root: rstest.context.normalizedConfig.root, + include: rstest.context.normalizedConfig.include, + exclude: rstest.context.normalizedConfig.exclude.patterns, + // Sub-projects this config aggregates via `projects`. Empty for a leaf + // config. The extension uses these to avoid registering a child config + // as its own top-level project when a parent already covers it + // (otherwise the same test files show up twice). A file-based child is + // identified by its config file; inline children only have a root. + // `null` (not `undefined`) so the fields survive the IPC JSON round-trip. + childProjects: projects.map((project) => ({ + configFilePath: project.configFilePath ?? null, + root: project.config.root ?? null, + })), + }; + } + + public async runTest(data: WorkerInitOptions) { + logger.debug('Received runTest request', JSON.stringify(data, null, 2)); + try { + const { rstest } = await this.init(data); + if (data.coverage?.enabled) { + rstest.context.normalizedConfig.coverage.reporters.push( + new CoverageReporter(), + ); + } + const res = await rstest.runTests(); + logger.debug('Test run completed', { result: res }); + } catch (error) { + logger.error('Test run failed', error); + throw error; + } + } + + public async listTests(data: WorkerInitOptions) { + const { rstest } = await this.init({ ...data, command: 'list' }); + const res = await rstest.listTests({}); + return res; + } +} + +export const masterApi = createBirpc(new Worker(), { + post: (data) => process.send?.(data), + on: (fn) => process.on('message', fn), + bind: 'functions', +}); diff --git a/packages/vscode/src/stacks/test/worker/logger.ts b/packages/vscode/src/stacks/test/worker/logger.ts new file mode 100644 index 0000000..28ff0ec --- /dev/null +++ b/packages/vscode/src/stacks/test/worker/logger.ts @@ -0,0 +1,13 @@ +import { BaseLogger, type LogLevel } from '../shared/logger'; +import { masterApi } from '.'; + +class WorkerLogger extends BaseLogger { + constructor() { + super('worker'); + } + protected override log(level: LogLevel, message: string) { + masterApi.log.asEvent(level, message); + } +} + +export const logger = new WorkerLogger(); diff --git a/packages/vscode/src/stacks/test/worker/reporter.ts b/packages/vscode/src/stacks/test/worker/reporter.ts new file mode 100644 index 0000000..2e757fc --- /dev/null +++ b/packages/vscode/src/stacks/test/worker/reporter.ts @@ -0,0 +1,105 @@ +import { Writable } from 'node:stream'; +import type { Reporter } from '@rstest/core'; +import type { + Context, + ReportBase, + ReportNode, + Visitor, +} from 'istanbul-lib-report'; +import type vscode from 'vscode'; +import { masterApi } from '.'; + +export class ProgressReporter implements Reporter { + readonly flushOutputStreams = false; + + onTestRunStart = masterApi.onTestRunStart.asEvent; + onTestRunEnd = () => masterApi.onTestRunEnd.asEvent(); + onTestFileStart = masterApi.onTestFileStart.asEvent; + onTestFileReady = masterApi.onTestFileReady.asEvent; + onTestFileResult = masterApi.onTestFileResult.asEvent; + onTestSuiteStart = masterApi.onTestSuiteStart.asEvent; + onTestSuiteResult = masterApi.onTestSuiteResult.asEvent; + onTestCaseStart = masterApi.onTestCaseStart.asEvent; + onTestCaseResult = masterApi.onTestCaseResult.asEvent; +} + +export class ProgressLogger { + outputStream = new Writable({ + decodeStrings: false, + write: (chunk, _encoding, cb) => { + masterApi.onOutput.asEvent(chunk); + cb(null); + }, + }); + errorStream = this.outputStream; + getColumns = () => Number.POSITIVE_INFINITY; +} + +export class CoverageReporter + // implements ReportBase instead of extend it, to prevent bundle istanbul-lib-report into output + implements ReportBase, Partial> +{ + // https://github.com/istanbuljs/istanbuljs/blob/28ffdbc314596bdcb3007e85d30a62372602b262/packages/istanbul-lib-report/lib/report-base.js#L11-L13 + execute(context: Context) { + context.getTree().visit(this, context); + } + + onDetail(root: ReportNode) { + const summary = root.getCoverageSummary(false); + const coverage = root.getFileCoverage(); + + const details: vscode.FileCoverageDetail[] = []; + + /** map istanbul range to vscode range */ + const mapRange = ( + range: (typeof coverage.statementMap)[string], + ): vscode.Range => + ({ + // TODO why line maybe zero? + start: { + line: (range.start.line || 1) - 1, + character: range.start.column, + }, + end: { line: (range.end.line || 1) - 1, character: range.end.column }, + }) as vscode.Range; + + for (const [key, branchMapping] of Object.entries(coverage.branchMap)) { + details.push({ + executed: coverage.b[key].some(Boolean), + location: mapRange(branchMapping.loc), + branches: branchMapping.locations.map((location, index) => ({ + executed: coverage.b[key][index], + location: mapRange(location), + })), + } satisfies vscode.StatementCoverage); + } + + for (const [key, functionMapping] of Object.entries(coverage.fnMap)) { + details.push({ + name: functionMapping.name, + executed: coverage.f[key] || 0, + location: mapRange(functionMapping.loc), + } satisfies vscode.DeclarationCoverage); + } + + for (const [key, statementRange] of Object.entries(coverage.statementMap)) { + details.push({ + branches: [], + executed: coverage.s[key] || 0, + location: mapRange(statementRange), + } satisfies vscode.StatementCoverage); + } + + masterApi.onCoverage.asEvent( + coverage.path, + summary.statements, + summary.branches, + summary.functions, + details, + ); + } + + onEnd() { + masterApi.onCoverageEnd.asEvent(); + } +} diff --git a/packages/vscode/src/statusBar.ts b/packages/vscode/src/statusBar.ts new file mode 100644 index 0000000..917692c --- /dev/null +++ b/packages/vscode/src/statusBar.ts @@ -0,0 +1,190 @@ +import vscode from 'vscode'; +import { + type StackId, + type StackState, + type StatusReporter, + STACK_IDS, + STACK_LABELS, +} from './types'; + +/** Commands the status bar hover links to, per stack. */ +const OUTPUT_COMMANDS: Readonly> = { + rslint: 'rstack.rslint.output.focus', + rstest: 'rstack.rstest.output.focus', + fmt: 'rstack.fmt.output.focus', +}; + +const RESTART_COMMANDS: Partial>> = { + rslint: 'rstack.rslint.restart', +}; + +const STATE_ICONS: Readonly> = { + 'not-detected': '$(circle-slash)', + disabled: '$(circle-slash)', + starting: '$(loading~spin)', + running: '$(check)', + crashed: '$(error)', + 'version-mismatch': '$(warning)', +}; + +const stateText = (state: StackState): string => { + switch (state.kind) { + case 'not-detected': + return 'not detected'; + case 'disabled': + return state.reason ? `disabled β€” ${state.reason}` : 'disabled'; + case 'starting': + return state.detail ? `starting β€” ${state.detail}` : 'starting'; + case 'running': + return state.detail ? `running β€” ${state.detail}` : 'running'; + case 'crashed': + return `crashed β€” ${state.detail}`; + case 'version-mismatch': + return `version mismatch β€” ${state.detail}`; + } +}; + +/** + * The single always-present status bar item. It is visible + * whenever the extension is installed and enabled, even when nothing is + * detected β€” it is the answer to "is the extension broken or just idle?". + */ +export class StatusBar implements vscode.Disposable { + readonly #item: vscode.StatusBarItem; + readonly #states = new Map( + STACK_IDS.map((stack) => [stack, { kind: 'not-detected' }]), + ); + + constructor() { + this.#item = vscode.window.createStatusBarItem( + 'rstack.status', + vscode.StatusBarAlignment.Right, + 100, + ); + this.#item.name = 'Rstack'; + this.#item.command = 'rstack.showMenu'; + this.render(); + this.#item.show(); + } + + reporterFor(stack: StackId): StatusReporter { + return { + stack, + report: (state) => this.setState(stack, state), + starting: (detail) => this.setState(stack, { kind: 'starting', detail }), + running: (detail) => this.setState(stack, { kind: 'running', detail }), + crashed: (detail) => this.setState(stack, { kind: 'crashed', detail }), + versionMismatch: (detail) => + this.setState(stack, { kind: 'version-mismatch', detail }), + }; + } + + setState(stack: StackId, state: StackState): void { + this.#states.set(stack, state); + this.render(); + } + + stateOf(stack: StackId): StackState { + return this.#states.get(stack) ?? { kind: 'not-detected' }; + } + + /** The QuickPick behind the status bar item. */ + async showMenu(): Promise { + type Item = vscode.QuickPickItem & { readonly command?: string }; + const items: Item[] = []; + for (const stack of STACK_IDS) { + const state = this.stateOf(stack); + items.push({ + label: `${STATE_ICONS[state.kind]} ${STACK_LABELS[stack]}`, + description: stateText(state), + detail: 'Show output', + command: OUTPUT_COMMANDS[stack], + }); + const restart = RESTART_COMMANDS[stack]; + if (restart && state.kind !== 'not-detected') { + items.push({ + label: `$(refresh) Restart ${STACK_LABELS[stack]}`, + command: restart, + }); + } + } + items.push( + { label: '', kind: vscode.QuickPickItemKind.Separator }, + { + label: '$(output) Show Rstack extension log', + command: 'rstack.showOutput', + }, + { + label: '$(arrow-right) Migrate Rslint/Rstest settings', + command: 'rstack.migrateSettings', + }, + ); + + const picked = await vscode.window.showQuickPick(items, { + title: 'Rstack', + placeHolder: 'Select an action', + }); + if (picked?.command) { + await vscode.commands.executeCommand(picked.command); + } + } + + private render(): void { + const states = STACK_IDS.map((stack) => this.stateOf(stack)); + const worst = states.find((state) => state.kind === 'crashed') + ? 'crashed' + : states.find((state) => state.kind === 'version-mismatch') + ? 'version-mismatch' + : states.find((state) => state.kind === 'starting') + ? 'starting' + : states.find((state) => state.kind === 'running') + ? 'running' + : 'idle'; + + switch (worst) { + case 'crashed': + this.#item.text = '$(error) Rstack'; + this.#item.backgroundColor = new vscode.ThemeColor( + 'statusBarItem.errorBackground', + ); + break; + case 'version-mismatch': + this.#item.text = '$(warning) Rstack'; + this.#item.backgroundColor = new vscode.ThemeColor( + 'statusBarItem.warningBackground', + ); + break; + case 'starting': + this.#item.text = '$(loading~spin) Rstack'; + this.#item.backgroundColor = undefined; + break; + default: + this.#item.text = '$(layers) Rstack'; + this.#item.backgroundColor = undefined; + break; + } + + const tooltip = new vscode.MarkdownString(undefined, true); + // Command links are only rendered in trusted markdown. + tooltip.isTrusted = true; + tooltip.appendMarkdown('**Rstack**\n\n'); + for (const stack of STACK_IDS) { + const state = this.stateOf(stack); + const links = [`[Output](command:${OUTPUT_COMMANDS[stack]})`]; + const restart = RESTART_COMMANDS[stack]; + if (restart && state.kind !== 'not-detected') { + links.push(`[Restart](command:${restart})`); + } + tooltip.appendMarkdown( + `${STATE_ICONS[state.kind]} **${STACK_LABELS[stack]}** β€” ${stateText( + state, + )} Β· ${links.join(' Β· ')}\n\n`, + ); + } + this.#item.tooltip = tooltip; + } + + dispose(): void { + this.#item.dispose(); + } +} diff --git a/packages/vscode/src/types.ts b/packages/vscode/src/types.ts new file mode 100644 index 0000000..5f31eb6 --- /dev/null +++ b/packages/vscode/src/types.ts @@ -0,0 +1,134 @@ +import type vscode from 'vscode'; + +/** + * The three stacks this extension integrates. The ids double as the settings + * and command namespace segments (`rstack..*`) and as the `when` context + * key segments (`rstack..detected`, `rstack..active`). + */ +export const STACK_IDS = ['rslint', 'rstest', 'fmt'] as const; + +export type StackId = (typeof STACK_IDS)[number]; + +export const STACK_LABELS: Readonly> = { + rslint: 'Rslint', + rstest: 'Rstest', + fmt: 'rs fmt', +}; + +/** + * Per-stack state machine surfaced by the status bar hover. + * + * `disabled` covers every "we deliberately did not start" case: the kill-switch + * settings, Restricted Mode, and phase-gated stacks. The reason is shown to the + * user, so it must be a complete sentence fragment. + */ +export type StackState = + | { readonly kind: 'not-detected' } + | { readonly kind: 'disabled'; readonly reason?: string } + | { readonly kind: 'starting'; readonly detail?: string } + | { readonly kind: 'running'; readonly detail?: string } + | { readonly kind: 'crashed'; readonly detail: string } + | { readonly kind: 'version-mismatch'; readonly detail: string }; + +/** + * The seam every stack reports through instead of owning a status bar item + * (the status-aggregation adaptation). The shell aggregates all three + * reporters into the single status bar item. + */ +export interface StatusReporter { + readonly stack: StackId; + report(state: StackState): void; + starting(detail?: string): void; + running(detail?: string): void; + crashed(detail: string): void; + versionMismatch(detail: string): void; +} + +/** What detection found for one stack in one workspace folder. */ +export interface StackDetection { + readonly detected: boolean; + /** Tool-native config files (`rslint.config.*` / `rstest.config.*`). */ + readonly configFiles: readonly vscode.Uri[]; + /** + * `rstack.config.*` files governing this folder. A folder can be detected + * through these alone, in which case the stack has to go through the rstack + * shim bridge. + */ + readonly rstackConfigFiles: readonly vscode.Uri[]; + /** `node_modules/.bin/rs` (or `rstack`), the `rs fmt` bin probe result. */ + readonly binPath?: string; +} + +export interface FolderDetection { + readonly folder: vscode.WorkspaceFolder; + readonly stacks: Readonly>; +} + +/** Immutable result of one detection pass over all workspace folders. */ +export interface DetectionSnapshot { + readonly folders: readonly FolderDetection[]; + /** True when at least one folder detected the stack. */ + isDetected(stack: StackId): boolean; + /** The folders in which the stack was detected, in workspace order. */ + foldersFor(stack: StackId): readonly FolderDetection[]; + /** Detection result for one folder, or `undefined` if it is not watched. */ + forFolder(folder: vscode.WorkspaceFolder): FolderDetection | undefined; +} + +/** + * Everything a stack gets from the shell. A stack must not create its own + * status bar item or output channel, and must not read `rslint.*`/`rstest.*` + * settings β€” the namespace is `rstack..*`. + */ +export interface StackContext { + readonly stack: StackId; + readonly extensionContext: vscode.ExtensionContext; + /** The stack's own output channel, one of the four the shell owns. */ + readonly output: vscode.LogOutputChannel; + readonly status: StatusReporter; + /** Detection state at registration time. */ + readonly detection: DetectionSnapshot; + /** + * Fires when detection changed while the stack stays registered (a folder + * gained or lost a config file). The shell handles the gate flipping; a stack + * only has to reconcile its own per-folder runtimes. + */ + readonly onDidChangeDetection: vscode.Event; +} + +/** + * One per stack. `register` is called when the stack passes the gate + * (`rstack.enable && rstack..enable && detected(stack)`) + * and `dispose` when it stops passing it or the extension deactivates. + * + * `register` rejecting is contained by the shell: the failure is + * reported to the status bar and the other stacks keep running. + * + * `register` may return an exports object (e.g. the Rstest stack's live + * `TestController`). The shell republishes it through the extension's public + * exports so E2E tests can reach live internals via + * `extensions.getExtension('rstack.rstack').exports` β€” the same pattern the + * upstream extensions used. Return `undefined` to publish nothing. + */ +export interface StackController { + readonly id: StackId; + register(context: StackContext): Promise | void>; + /** Teardown may be asynchronous (stopping a language server, workers). */ + dispose(): void | Promise; +} + +/** + * The extension's public exports (`activate`'s return value). Meant for E2E + * tests; not a stable API for other extensions. + */ +export interface RstackExtensionExports { + /** Live exports the stack published at registration; undefined when inactive. */ + getStackExports(stack: StackId): Record | undefined; + /** + * Resolves with the stack's exports once it registers (immediately if it + * already did). Rejects nothing: a stack that never activates never settles. + */ + whenStackActive(stack: StackId): Promise>; +} + +export type StackControllerFactory = () => StackController; diff --git a/packages/vscode/tests/e2e/fixtures/e2e.code-workspace b/packages/vscode/tests/e2e/fixtures/e2e.code-workspace new file mode 100644 index 0000000..552d09c --- /dev/null +++ b/packages/vscode/tests/e2e/fixtures/e2e.code-workspace @@ -0,0 +1,16 @@ +{ + // One multi-root window holding all three fixtures. Detection is per + // workspace folder, so a single VS Code launch can assert that + // each fixture lights exactly the stacks it should β€” and it exercises the + // multi-root path the per-folder coordinators are built for. + "folders": [ + { "path": "rslint" }, + { "path": "rstest" }, + { "path": "rstack" } + ], + "settings": { + "files.exclude": { + "**/node_modules": true + } + } +} diff --git a/packages/vscode/tests/e2e/fixtures/rslint/local-plugin.mjs b/packages/vscode/tests/e2e/fixtures/rslint/local-plugin.mjs new file mode 100644 index 0000000..69e16d3 --- /dev/null +++ b/packages/vscode/tests/e2e/fixtures/rslint/local-plugin.mjs @@ -0,0 +1,34 @@ +/** + * An object-form ESLint plugin, mounted by `rslint.config.mjs`. + * + * Object-form plugin rules run in JS, not in the Go linter: the server sends a + * reverse `rslint/pluginLint` request back to the client, which answers it from + * a worker pool owned by `@rslint/core/eslint-plugin`. That is exactly the path + * the plugin-host regression smoke test exercises, so the rule has to come from a + * plugin rather than from Rslint's native rule set. + * + * `.mjs` on purpose: a `.ts` plugin/config would drag `jiti` (or native type + * stripping) into a test that is about module resolution, not about config + * loaders. + */ +export default { + meta: { name: 'rstack-editor-e2e-local-plugin', version: '1.0.0' }, + rules: { + 'no-null': { + meta: { + type: 'suggestion', + schema: [], + messages: { unexpected: 'Unexpected `null` literal.' }, + }, + create(context) { + return { + Literal(node) { + if (node.raw === 'null') { + context.report({ node, messageId: 'unexpected' }); + } + }, + }; + }, + }, + }, +}; diff --git a/packages/vscode/tests/e2e/fixtures/rslint/package.json b/packages/vscode/tests/e2e/fixtures/rslint/package.json new file mode 100644 index 0000000..6d97a0c --- /dev/null +++ b/packages/vscode/tests/e2e/fixtures/rslint/package.json @@ -0,0 +1,10 @@ +{ + "name": "rstack-editor-fixture-rslint", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "E2E fixture: a project detected as Rslint only, installed from the npm registry.", + "dependencies": { + "@rslint/core": "^0.7.2" + } +} diff --git a/packages/vscode/tests/e2e/fixtures/rslint/rslint.config.mjs b/packages/vscode/tests/e2e/fixtures/rslint/rslint.config.mjs new file mode 100644 index 0000000..46c488e --- /dev/null +++ b/packages/vscode/tests/e2e/fixtures/rslint/rslint.config.mjs @@ -0,0 +1,16 @@ +import localPlugin from './local-plugin.mjs'; + +/** + * A real Rslint flat config: it is loaded by the language server through the + * project's own `@rslint/core` (this extension ships none), and by the + * plugin-host regression smoke test through `createPluginLintHost`. + */ +export default [ + { + files: ['src/**/*.ts'], + plugins: { local: localPlugin }, + rules: { + 'local/no-null': 'error', + }, + }, +]; diff --git a/packages/vscode/tests/e2e/fixtures/rslint/src/index.ts b/packages/vscode/tests/e2e/fixtures/rslint/src/index.ts new file mode 100644 index 0000000..9ae7c54 --- /dev/null +++ b/packages/vscode/tests/e2e/fixtures/rslint/src/index.ts @@ -0,0 +1,6 @@ +// The lintable issue this fixture exists for: the `null` literal below is +// reported by `local/no-null` (see `rslint.config.mjs`). Exactly one `null` +// literal β€” the smoke test asserts on the diagnostic count. +export function getValue() { + return null; +} diff --git a/packages/vscode/tests/e2e/fixtures/rstack/.npmrc b/packages/vscode/tests/e2e/fixtures/rstack/.npmrc new file mode 100644 index 0000000..d6cdbff --- /dev/null +++ b/packages/vscode/tests/e2e/fixtures/rstack/.npmrc @@ -0,0 +1,9 @@ +# `@rslint/core` and `@rstest/core` are transitive dependencies of `rstack`. +# npm and Yarn hoist those into the project's root `node_modules`, which is +# where this extension resolves them from; pnpm's isolated +# store does not, and a fixture that cannot resolve them would silently walk up +# into THIS REPO's own node_modules and test the extension's dev copies instead +# of the project's published ones. Hoisting them reproduces the layout the +# extension is designed against and keeps the fixture self-contained. +public-hoist-pattern[]=@rslint/core +public-hoist-pattern[]=@rstest/core diff --git a/packages/vscode/tests/e2e/fixtures/rstack/package.json b/packages/vscode/tests/e2e/fixtures/rstack/package.json new file mode 100644 index 0000000..38b8068 --- /dev/null +++ b/packages/vscode/tests/e2e/fixtures/rstack/package.json @@ -0,0 +1,13 @@ +{ + "name": "rstack-editor-fixture-rstack", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "E2E fixture: an rstack-cli project whose only config is `rstack.config.ts`, which lights the Rstest and rs fmt stacks.", + "dependencies": { + "rstack": "^0.3.2" + }, + "devDependencies": { + "jiti": "^2.0.0" + } +} diff --git a/packages/vscode/tests/e2e/fixtures/rstack/rstack.config.ts b/packages/vscode/tests/e2e/fixtures/rstack/rstack.config.ts new file mode 100644 index 0000000..9003997 --- /dev/null +++ b/packages/vscode/tests/e2e/fixtures/rstack/rstack.config.ts @@ -0,0 +1,23 @@ +// Rstack configuration guide: https://rstack.rs/config +// +// This fixture has NO tool-native config: no `rslint.config.*`, no +// `rstest.config.*`. `rstack.config.ts` is the single config source, and its +// presence alone must light the Rstest and rs fmt stacks (Rslint via +// `define.lint()` is deferred β€” TODO(rstack-bridge)) β€” `rs lint` and +// `rs test` inject rstack's own shim configs, so a tool-native file never has +// to exist. +import { define } from 'rstack'; + +define.lint([ + { + files: ['src/**/*.ts'], + rules: { + 'no-debugger': 'error', + }, + }, +]); + +define.test({ + name: 'rstack-editor-fixture-rstack', + include: ['tests/**/*.test.ts'], +}); diff --git a/packages/vscode/tests/e2e/fixtures/rstack/src/index.ts b/packages/vscode/tests/e2e/fixtures/rstack/src/index.ts new file mode 100644 index 0000000..ef833c6 --- /dev/null +++ b/packages/vscode/tests/e2e/fixtures/rstack/src/index.ts @@ -0,0 +1,6 @@ +// A lintable issue for the `no-debugger` rule configured through +// `define.lint()` in `rstack.config.ts`. +export function trace(value: unknown): unknown { + debugger; + return value; +} diff --git a/packages/vscode/tests/e2e/fixtures/rstack/tests/basic.test.ts b/packages/vscode/tests/e2e/fixtures/rstack/tests/basic.test.ts new file mode 100644 index 0000000..4db5dbb --- /dev/null +++ b/packages/vscode/tests/e2e/fixtures/rstack/tests/basic.test.ts @@ -0,0 +1,7 @@ +import { expect, test } from '@rstest/core'; + +// Discovered through `define.test()` in `rstack.config.ts`, not through an +// `rstest.config.*` file. +test('trims a string', () => { + expect(' rstack '.trim()).toBe('rstack'); +}); diff --git a/packages/vscode/tests/e2e/fixtures/rstest/package.json b/packages/vscode/tests/e2e/fixtures/rstest/package.json new file mode 100644 index 0000000..87fab39 --- /dev/null +++ b/packages/vscode/tests/e2e/fixtures/rstest/package.json @@ -0,0 +1,10 @@ +{ + "name": "rstack-editor-fixture-rstest", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "E2E fixture: a project detected as Rstest only, pinned to the launch floor `@rstest/core >= 0.6.0`.", + "dependencies": { + "@rstest/core": "^0.6.0" + } +} diff --git a/packages/vscode/tests/e2e/fixtures/rstest/rstest.config.ts b/packages/vscode/tests/e2e/fixtures/rstest/rstest.config.ts new file mode 100644 index 0000000..b73eab9 --- /dev/null +++ b/packages/vscode/tests/e2e/fixtures/rstest/rstest.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + name: 'rstack-editor-fixture-rstest', + include: ['tests/**/*.test.ts'], +}); diff --git a/packages/vscode/tests/e2e/fixtures/rstest/tests/basic.test.ts b/packages/vscode/tests/e2e/fixtures/rstest/tests/basic.test.ts new file mode 100644 index 0000000..b8a5cd6 --- /dev/null +++ b/packages/vscode/tests/e2e/fixtures/rstest/tests/basic.test.ts @@ -0,0 +1,7 @@ +import { expect, test } from '@rstest/core'; + +// Trivial on purpose: the fixture exists to be *discovered*, so the test tree +// has something to show. What it asserts is irrelevant. +test('adds two numbers', () => { + expect(1 + 1).toBe(2); +}); diff --git a/packages/vscode/tests/e2e/lint/fixtures/basic/.gitignore b/packages/vscode/tests/e2e/lint/fixtures/basic/.gitignore new file mode 100644 index 0000000..9ba389d --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/basic/.gitignore @@ -0,0 +1 @@ +src/gitignored.ts diff --git a/packages/vscode/tests/e2e/lint/fixtures/basic/rslint.config.mjs b/packages/vscode/tests/e2e/lint/fixtures/basic/rslint.config.mjs new file mode 100644 index 0000000..37d5c6e --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/basic/rslint.config.mjs @@ -0,0 +1,34 @@ +// Ported from web-infra-dev/rslint `packages/rslint/fixtures/rslint.json` +// (origin/main), converted to a JS config: `rslint.json` is deprecated +// upstream and not supported by this extension β€” it is not a detection signal, +// so the fixture must carry the equivalent `rslint.config.mjs` +// for the suite to run at all. The rule set is upstream's, verbatim. +export default [ + { + languageOptions: { + parserOptions: { + projectService: false, + project: ['./tsconfig.json'], + }, + }, + rules: { + '@typescript-eslint/no-unsafe-assignment': 'error', + '@typescript-eslint/no-unsafe-member-access': 'error', + '@typescript-eslint/no-unsafe-argument': 'error', + '@typescript-eslint/no-unnecessary-type-assertion': 'error', + '@typescript-eslint/no-unnecessary-type-constraint': 'warn', + '@typescript-eslint/adjacent-overload-signatures': 'error', + '@typescript-eslint/array-type': 'error', + '@typescript-eslint/class-literal-property-style': 'error', + '@typescript-eslint/no-empty-function': 'error', + '@typescript-eslint/no-empty-interface': 'error', + '@typescript-eslint/no-require-imports': 'error', + '@typescript-eslint/no-namespace': 'error', + '@typescript-eslint/no-wrapper-object-types': 'error', + '@typescript-eslint/no-inferrable-types': 'error', + 'prefer-const': 'off', + 'one-var': 'off', + }, + plugins: ['@typescript-eslint'], + }, +]; diff --git a/packages/vscode/tests/e2e/lint/fixtures/basic/src/autofix.ts b/packages/vscode/tests/e2e/lint/fixtures/basic/src/autofix.ts new file mode 100644 index 0000000..21b8874 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/basic/src/autofix.ts @@ -0,0 +1,10 @@ +// Test file for auto fix code actions +const someValue: string = 'hello'; + +// This should trigger no-unnecessary-type-assertion rule (has auto fix) +const result = (someValue as string).toUpperCase(); + +// Another example that should trigger a fix +function example(): number { + return 42 as number; // unnecessary type assertion +} diff --git a/packages/vscode/tests/e2e/lint/fixtures/basic/src/close-test.ts b/packages/vscode/tests/e2e/lint/fixtures/basic/src/close-test.ts new file mode 100644 index 0000000..2f4c2aa --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/basic/src/close-test.ts @@ -0,0 +1,3 @@ +// Fixture for close-tab-clears-diagnostics test +const unsafeVal: any = 42; +unsafeVal.prop; diff --git a/packages/vscode/tests/e2e/lint/fixtures/basic/src/disable-file.ts b/packages/vscode/tests/e2e/lint/fixtures/basic/src/disable-file.ts new file mode 100644 index 0000000..ae49c8c --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/basic/src/disable-file.ts @@ -0,0 +1,9 @@ +// Test file for disable rule code actions +// These should trigger unsafe rules (no auto fix available) + +const obj: any = {}; +const value = obj.someProperty.nested; // no-unsafe-member-access + +function takesString(s: string) {} +const anyValue: any = 'hello'; +takesString(anyValue); // no-unsafe-argument diff --git a/packages/vscode/tests/e2e/lint/fixtures/basic/src/disable.ts b/packages/vscode/tests/e2e/lint/fixtures/basic/src/disable.ts new file mode 100644 index 0000000..ae49c8c --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/basic/src/disable.ts @@ -0,0 +1,9 @@ +// Test file for disable rule code actions +// These should trigger unsafe rules (no auto fix available) + +const obj: any = {}; +const value = obj.someProperty.nested; // no-unsafe-member-access + +function takesString(s: string) {} +const anyValue: any = 'hello'; +takesString(anyValue); // no-unsafe-argument diff --git a/packages/vscode/tests/e2e/lint/fixtures/basic/src/error-transitions.ts b/packages/vscode/tests/e2e/lint/fixtures/basic/src/error-transitions.ts new file mode 100644 index 0000000..59adaec --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/basic/src/error-transitions.ts @@ -0,0 +1,2 @@ +// Initial clean file for error transition test +export {}; diff --git a/packages/vscode/tests/e2e/lint/fixtures/basic/src/fixall-cascade.ts b/packages/vscode/tests/e2e/lint/fixtures/basic/src/fixall-cascade.ts new file mode 100644 index 0000000..3d0e9a6 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/basic/src/fixall-cascade.ts @@ -0,0 +1,7 @@ +// Test file for multi-pass cascade fix: +// Pass 1: no-wrapper-object-types fixes String β†’ string, Number β†’ number, Boolean β†’ boolean +// Pass 2: no-inferrable-types removes now-inferrable type annotations +const cascadeA: String = 'hello'; +const cascadeB: Number = 42; +const cascadeC: Boolean = true; +export { cascadeA, cascadeB, cascadeC }; diff --git a/packages/vscode/tests/e2e/lint/fixtures/basic/src/fixall.ts b/packages/vscode/tests/e2e/lint/fixtures/basic/src/fixall.ts new file mode 100644 index 0000000..c0b0b38 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/basic/src/fixall.ts @@ -0,0 +1,7 @@ +// Test file for fixAll code action (auto-fix on save) +const value1: string = 'hello'; +const value2: number = 42; + +// Multiple auto-fixable issues: no-unnecessary-type-assertion +const result1 = (value1 as string).toUpperCase(); +const result2 = (value2 as number).toFixed(2); diff --git a/packages/vscode/tests/e2e/lint/fixtures/basic/src/index.ts b/packages/vscode/tests/e2e/lint/fixtures/basic/src/index.ts new file mode 100644 index 0000000..20fe523 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/basic/src/index.ts @@ -0,0 +1,21 @@ +let a: any = 10; +a.b = 10; + +let b: any = 200; +b.c = 200; + +// Test adjacent-overload-signatures +interface TestInterface { + foo(x: string): void; + bar(): void; + foo(x: number): void; // This should trigger adjacent-overload-signatures +} + +// Test array-type +let arr1: Array = []; // This should trigger array-type (prefer string[]) +let arr2: ReadonlyArray = []; // This should trigger array-type (prefer readonly number[]) + +// Test class-literal-property-style +class TestClass { + readonly prop1 = 'literal'; // This should trigger class-literal-property-style (prefer getter) +} diff --git a/packages/vscode/tests/e2e/lint/fixtures/basic/src/styles.css b/packages/vscode/tests/e2e/lint/fixtures/basic/src/styles.css new file mode 100644 index 0000000..b95ee96 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/basic/src/styles.css @@ -0,0 +1,5 @@ +/* This file is used to test that non-TS files don't get diagnostics */ +body { + margin: 0; + padding: 0; +} diff --git a/packages/vscode/tests/e2e/lint/fixtures/basic/tsconfig.json b/packages/vscode/tests/e2e/lint/fixtures/basic/tsconfig.json new file mode 100644 index 0000000..89e06f2 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/basic/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "strictNullChecks": true, + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/vscode/tests/e2e/lint/fixtures/eslint-plugins/local-plugin.mjs b/packages/vscode/tests/e2e/lint/fixtures/eslint-plugins/local-plugin.mjs new file mode 100644 index 0000000..98329ce --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/eslint-plugins/local-plugin.mjs @@ -0,0 +1,63 @@ +// A minimal self-contained ESLint plugin, mounted via the config's +// `plugins`. Kept dependency-free so the fixture needs no node_modules: +// - no-null reports every `null` literal (suggestion only) +// - prefer-array-some reports `.filter` member access, with an auto fix +// (filter -> some) so it participates in source.fixAll +export default { + meta: { name: 'local-plugin', version: '1.0.0' }, + rules: { + 'no-null': { + meta: { + type: 'suggestion', + hasSuggestions: true, + schema: [], + messages: { + error: 'Do not use `null`; prefer `undefined`.', + replaceWithUndefined: 'Replace `null` with `undefined`.', + }, + }, + create(context) { + return { + Literal(node) { + if (node.raw !== 'null') return; + context.report({ + node, + messageId: 'error', + suggest: [ + { + messageId: 'replaceWithUndefined', + fix: (fixer) => fixer.replaceText(node, 'undefined'), + }, + ], + }); + }, + }; + }, + }, + 'prefer-array-some': { + meta: { + type: 'suggestion', + fixable: 'code', + schema: [], + messages: { preferSome: 'Prefer `.some(…)` over `.filter(…)`.' }, + }, + create(context) { + return { + MemberExpression(node) { + if ( + node.property && + node.property.type === 'Identifier' && + node.property.name === 'filter' + ) { + context.report({ + node: node.property, + messageId: 'preferSome', + fix: (fixer) => fixer.replaceText(node.property, 'some'), + }); + } + }, + }; + }, + }, + }, +}; diff --git a/packages/vscode/tests/e2e/lint/fixtures/eslint-plugins/rslint.config.mjs b/packages/vscode/tests/e2e/lint/fixtures/eslint-plugins/rslint.config.mjs new file mode 100644 index 0000000..99dc80b --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/eslint-plugins/rslint.config.mjs @@ -0,0 +1,16 @@ +import local from './local-plugin.mjs'; + +// Mounts a community-style plugin via `plugins` (reverse-dispatched to +// the Node worker by the LSP server) alongside a native rule (`no-console`), +// so the suite can assert plugin + native diagnostics are merged + published. +export default [ + { + files: ['**/*.ts'], + plugins: { local }, + rules: { + 'local/no-null': 'error', + 'local/prefer-array-some': 'error', + 'no-console': 'error', + }, + }, +]; diff --git a/packages/vscode/tests/e2e/lint/fixtures/eslint-plugins/src/index.ts b/packages/vscode/tests/e2e/lint/fixtures/eslint-plugins/src/index.ts new file mode 100644 index 0000000..d0a9463 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/eslint-plugins/src/index.ts @@ -0,0 +1,8 @@ +// Triggers all three configured rules: +// null literal -> local/no-null (plugin) +// .filter member access -> local/prefer-array-some (plugin) +// console.* -> no-console (native) +const value = null; +const numbers = [1, 2, 3]; +const hasPositive = numbers.filter((n) => n > 0).length > 0; +console.log(value, hasPositive); diff --git a/packages/vscode/tests/e2e/lint/fixtures/eslint-plugins/tsconfig.json b/packages/vscode/tests/e2e/lint/fixtures/eslint-plugins/tsconfig.json new file mode 100644 index 0000000..48a569a --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/eslint-plugins/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/packages/vscode/tests/e2e/lint/fixtures/jsconfig/rslint.config.js b/packages/vscode/tests/e2e/lint/fixtures/jsconfig/rslint.config.js new file mode 100644 index 0000000..ef97023 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/jsconfig/rslint.config.js @@ -0,0 +1,16 @@ +export default [ + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: false, + project: ['./tsconfig.json'], + }, + }, + rules: { + '@typescript-eslint/no-unsafe-member-access': 'error', + '@typescript-eslint/no-explicit-any': 'off', + }, + plugins: ['@typescript-eslint'], + }, +]; diff --git a/packages/vscode/tests/e2e/lint/fixtures/jsconfig/rslint.json b/packages/vscode/tests/e2e/lint/fixtures/jsconfig/rslint.json new file mode 100644 index 0000000..03829f1 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/jsconfig/rslint.json @@ -0,0 +1,16 @@ +[ + { + "files": ["**/*.ts"], + "languageOptions": { + "parserOptions": { + "projectService": false, + "project": ["./tsconfig.json"] + } + }, + "rules": { + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-unsafe-member-access": "off" + }, + "plugins": ["@typescript-eslint"] + } +] diff --git a/packages/vscode/tests/e2e/lint/fixtures/jsconfig/src/index.ts b/packages/vscode/tests/e2e/lint/fixtures/jsconfig/src/index.ts new file mode 100644 index 0000000..0c633f3 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/jsconfig/src/index.ts @@ -0,0 +1,2 @@ +let a: any = 10; +a.b = 20; diff --git a/packages/vscode/tests/e2e/lint/fixtures/jsconfig/tsconfig.json b/packages/vscode/tests/e2e/lint/fixtures/jsconfig/tsconfig.json new file mode 100644 index 0000000..99878f0 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/jsconfig/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "strict": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/vscode/tests/e2e/lint/fixtures/monorepo/packages/bar/src/index.ts b/packages/vscode/tests/e2e/lint/fixtures/monorepo/packages/bar/src/index.ts new file mode 100644 index 0000000..0c633f3 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/monorepo/packages/bar/src/index.ts @@ -0,0 +1,2 @@ +let a: any = 10; +a.b = 20; diff --git a/packages/vscode/tests/e2e/lint/fixtures/monorepo/packages/broken/rslint.config.js b/packages/vscode/tests/e2e/lint/fixtures/monorepo/packages/broken/rslint.config.js new file mode 100644 index 0000000..d85a466 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/monorepo/packages/broken/rslint.config.js @@ -0,0 +1,2 @@ +// Intentionally broken config β€” syntax error for testing partial load failure +export default [INVALID SYNTAX HERE; diff --git a/packages/vscode/tests/e2e/lint/fixtures/monorepo/packages/broken/src/index.ts b/packages/vscode/tests/e2e/lint/fixtures/monorepo/packages/broken/src/index.ts new file mode 100644 index 0000000..0c633f3 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/monorepo/packages/broken/src/index.ts @@ -0,0 +1,2 @@ +let a: any = 10; +a.b = 20; diff --git a/packages/vscode/tests/e2e/lint/fixtures/monorepo/packages/foo/rslint.config.js b/packages/vscode/tests/e2e/lint/fixtures/monorepo/packages/foo/rslint.config.js new file mode 100644 index 0000000..1858225 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/monorepo/packages/foo/rslint.config.js @@ -0,0 +1,16 @@ +export default [ + { + files: ['src/**/*.ts'], + languageOptions: { + parserOptions: { + projectService: false, + project: ['../../tsconfig.json'], + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unsafe-member-access': 'error', + }, + plugins: ['@typescript-eslint'], + }, +]; diff --git a/packages/vscode/tests/e2e/lint/fixtures/monorepo/packages/foo/src/index.ts b/packages/vscode/tests/e2e/lint/fixtures/monorepo/packages/foo/src/index.ts new file mode 100644 index 0000000..0c633f3 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/monorepo/packages/foo/src/index.ts @@ -0,0 +1,2 @@ +let a: any = 10; +a.b = 20; diff --git a/packages/vscode/tests/e2e/lint/fixtures/monorepo/rslint.config.js b/packages/vscode/tests/e2e/lint/fixtures/monorepo/rslint.config.js new file mode 100644 index 0000000..119c6e9 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/monorepo/rslint.config.js @@ -0,0 +1,16 @@ +export default [ + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: false, + project: ['./tsconfig.json'], + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-unsafe-member-access': 'off', + }, + plugins: ['@typescript-eslint'], + }, +]; diff --git a/packages/vscode/tests/e2e/lint/fixtures/monorepo/src/index.ts b/packages/vscode/tests/e2e/lint/fixtures/monorepo/src/index.ts new file mode 100644 index 0000000..0c633f3 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/monorepo/src/index.ts @@ -0,0 +1,2 @@ +let a: any = 10; +a.b = 20; diff --git a/packages/vscode/tests/e2e/lint/fixtures/monorepo/tsconfig.json b/packages/vscode/tests/e2e/lint/fixtures/monorepo/tsconfig.json new file mode 100644 index 0000000..2c2cab4 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/monorepo/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "strict": true + }, + "include": ["src/**/*.ts", "packages/*/src/**/*.ts"] +} diff --git a/packages/vscode/tests/e2e/lint/fixtures/multiroot/multiroot.code-workspace b/packages/vscode/tests/e2e/lint/fixtures/multiroot/multiroot.code-workspace new file mode 100644 index 0000000..c09328a --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/multiroot/multiroot.code-workspace @@ -0,0 +1,8 @@ +{ + "folders": [ + { "path": "parent" }, + { "path": "sentinel" }, + { "name": "app", "path": "twins/left/app" }, + { "name": "app", "path": "twins/right/app" }, + ], +} diff --git a/packages/vscode/tests/e2e/lint/fixtures/multiroot/nested-initial.code-workspace b/packages/vscode/tests/e2e/lint/fixtures/multiroot/nested-initial.code-workspace new file mode 100644 index 0000000..45e9b10 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/multiroot/nested-initial.code-workspace @@ -0,0 +1,7 @@ +{ + "folders": [ + { "path": "parent" }, + { "path": "parent/nested" }, + { "path": "sentinel" }, + ], +} diff --git a/packages/vscode/tests/e2e/lint/fixtures/multiroot/parent/nested/rslint.config.js b/packages/vscode/tests/e2e/lint/fixtures/multiroot/parent/nested/rslint.config.js new file mode 100644 index 0000000..0b4f18d --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/multiroot/parent/nested/rslint.config.js @@ -0,0 +1,9 @@ +export default [ + { + files: ['**/*.ts'], + plugins: ['@typescript-eslint'], + rules: { + '@typescript-eslint/no-explicit-any': 'error', + }, + }, +]; diff --git a/packages/vscode/tests/e2e/lint/fixtures/multiroot/parent/nested/src/index.ts b/packages/vscode/tests/e2e/lint/fixtures/multiroot/parent/nested/src/index.ts new file mode 100644 index 0000000..c0ccf9d --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/multiroot/parent/nested/src/index.ts @@ -0,0 +1,2 @@ +let nestedValue: any = 1; +void nestedValue; diff --git a/packages/vscode/tests/e2e/lint/fixtures/multiroot/parent/rslint.config.js b/packages/vscode/tests/e2e/lint/fixtures/multiroot/parent/rslint.config.js new file mode 100644 index 0000000..0b4f18d --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/multiroot/parent/rslint.config.js @@ -0,0 +1,9 @@ +export default [ + { + files: ['**/*.ts'], + plugins: ['@typescript-eslint'], + rules: { + '@typescript-eslint/no-explicit-any': 'error', + }, + }, +]; diff --git a/packages/vscode/tests/e2e/lint/fixtures/multiroot/parent/src/index.ts b/packages/vscode/tests/e2e/lint/fixtures/multiroot/parent/src/index.ts new file mode 100644 index 0000000..d50435a --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/multiroot/parent/src/index.ts @@ -0,0 +1,2 @@ +let parentValue: any = 1; +void parentValue; diff --git a/packages/vscode/tests/e2e/lint/fixtures/multiroot/sentinel/rslint.config.js b/packages/vscode/tests/e2e/lint/fixtures/multiroot/sentinel/rslint.config.js new file mode 100644 index 0000000..0b4f18d --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/multiroot/sentinel/rslint.config.js @@ -0,0 +1,9 @@ +export default [ + { + files: ['**/*.ts'], + plugins: ['@typescript-eslint'], + rules: { + '@typescript-eslint/no-explicit-any': 'error', + }, + }, +]; diff --git a/packages/vscode/tests/e2e/lint/fixtures/multiroot/sentinel/src/index.ts b/packages/vscode/tests/e2e/lint/fixtures/multiroot/sentinel/src/index.ts new file mode 100644 index 0000000..44248fc --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/multiroot/sentinel/src/index.ts @@ -0,0 +1,2 @@ +let sentinelValue: any = 1; +void sentinelValue; diff --git a/packages/vscode/tests/e2e/lint/fixtures/multiroot/twins/left/app/rslint.config.js b/packages/vscode/tests/e2e/lint/fixtures/multiroot/twins/left/app/rslint.config.js new file mode 100644 index 0000000..0b4f18d --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/multiroot/twins/left/app/rslint.config.js @@ -0,0 +1,9 @@ +export default [ + { + files: ['**/*.ts'], + plugins: ['@typescript-eslint'], + rules: { + '@typescript-eslint/no-explicit-any': 'error', + }, + }, +]; diff --git a/packages/vscode/tests/e2e/lint/fixtures/multiroot/twins/left/app/src/index.ts b/packages/vscode/tests/e2e/lint/fixtures/multiroot/twins/left/app/src/index.ts new file mode 100644 index 0000000..44968b8 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/multiroot/twins/left/app/src/index.ts @@ -0,0 +1,2 @@ +let leftValue: any = 1; +void leftValue; diff --git a/packages/vscode/tests/e2e/lint/fixtures/multiroot/twins/right/app/rslint.config.js b/packages/vscode/tests/e2e/lint/fixtures/multiroot/twins/right/app/rslint.config.js new file mode 100644 index 0000000..0b4f18d --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/multiroot/twins/right/app/rslint.config.js @@ -0,0 +1,9 @@ +export default [ + { + files: ['**/*.ts'], + plugins: ['@typescript-eslint'], + rules: { + '@typescript-eslint/no-explicit-any': 'error', + }, + }, +]; diff --git a/packages/vscode/tests/e2e/lint/fixtures/multiroot/twins/right/app/src/index.ts b/packages/vscode/tests/e2e/lint/fixtures/multiroot/twins/right/app/src/index.ts new file mode 100644 index 0000000..ecab933 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/multiroot/twins/right/app/src/index.ts @@ -0,0 +1,2 @@ +let rightValue: any = 1; +void rightValue; diff --git a/packages/vscode/tests/e2e/lint/fixtures/noconfig/src/index.ts b/packages/vscode/tests/e2e/lint/fixtures/noconfig/src/index.ts new file mode 100644 index 0000000..0c633f3 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/noconfig/src/index.ts @@ -0,0 +1,2 @@ +let a: any = 10; +a.b = 20; diff --git a/packages/vscode/tests/e2e/lint/fixtures/noconfig/tsconfig.json b/packages/vscode/tests/e2e/lint/fixtures/noconfig/tsconfig.json new file mode 100644 index 0000000..99878f0 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/noconfig/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "strict": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/vscode/tests/e2e/lint/fixtures/package.json b/packages/vscode/tests/e2e/lint/fixtures/package.json new file mode 100644 index 0000000..909abcc --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/package.json @@ -0,0 +1,10 @@ +{ + "name": "rstack-lint-e2e-fixtures", + "version": "0.0.0", + "private": true, + "description": "Shared install root for the Rslint E2E fixture workspaces. The extension ships no binary: every fixture resolves @rslint/core - including its native Go binary, config-loader and eslint-plugin host - from this one published-npm install. jiti backs the config-file-loader's TypeScript-config fallback.", + "dependencies": { + "@rslint/core": "^0.7.2", + "jiti": "^2.7.0" + } +} diff --git a/packages/vscode/tests/e2e/lint/fixtures/project-service-scope/rslint.config.js b/packages/vscode/tests/e2e/lint/fixtures/project-service-scope/rslint.config.js new file mode 100644 index 0000000..4f229f2 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/project-service-scope/rslint.config.js @@ -0,0 +1,22 @@ +// Matches the shape of `ts.configs.recommended`: parserOptions only sets +// `projectService: true`, with no explicit `project`. tsconfig.json's +// `include` covers `src` only. +export default [ + { ignores: ['**/dist/**'] }, + { + files: ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts'], + plugins: ['@typescript-eslint'], + languageOptions: { + parserOptions: { + projectService: true, + }, + }, + rules: { + '@typescript-eslint/no-unused-vars': 'error', + // Non-type-aware marker rule. The suite relies on its diagnostic as a + // "rslint has finished linting this file" signal, so the negative + // assertion does not need a fixed-duration sleep. + 'no-console': 'error', + }, + }, +]; diff --git a/packages/vscode/tests/e2e/lint/fixtures/project-service-scope/src/covered.ts b/packages/vscode/tests/e2e/lint/fixtures/project-service-scope/src/covered.ts new file mode 100644 index 0000000..6187b57 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/project-service-scope/src/covered.ts @@ -0,0 +1,4 @@ +// File IN tsconfig.include (`src`). Type-aware rules should fire. +export const covered = ((command: string, args: string[], options: unknown) => { + return { stdout: '', stderr: '', exitCode: 0 }; +}) as unknown; diff --git a/packages/vscode/tests/e2e/lint/fixtures/project-service-scope/template-nested/orphan.ts b/packages/vscode/tests/e2e/lint/fixtures/project-service-scope/template-nested/orphan.ts new file mode 100644 index 0000000..c524fe4 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/project-service-scope/template-nested/orphan.ts @@ -0,0 +1,10 @@ +// This file lives under a config directory that has NO tsconfig.json. +// The nearest rslint config says `projectService: true` + no explicit +// `project`, so rslint can't resolve a tsconfig for it. +// +// The LSP must not run `@typescript-eslint/no-unused-vars` (a type-aware rule) +// here, but non-type-aware native rules and their fixes must remain active. +export const orphan = ((command: string, args: string[], options: unknown) => { + var output = command; + return { stdout: output, stderr: '', exitCode: 0 }; +}) as unknown; diff --git a/packages/vscode/tests/e2e/lint/fixtures/project-service-scope/template-nested/rslint.config.js b/packages/vscode/tests/e2e/lint/fixtures/project-service-scope/template-nested/rslint.config.js new file mode 100644 index 0000000..5bafb91 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/project-service-scope/template-nested/rslint.config.js @@ -0,0 +1,21 @@ +// Nested rslint config inside a template-style subdirectory that does NOT +// ship a tsconfig.json. Mirrors the create-rstack layout where the +// `template-rslint/` starter directory carries a rslint.config.ts but no +// tsconfig. Previously this caused the LSP to fall back to a global +// "allow-all" mode, incorrectly enabling type-aware rules. A config without +// any resolved tsconfig now disables type-aware rules for its own files. +export default [ + { + files: ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts'], + plugins: ['@typescript-eslint'], + languageOptions: { + parserOptions: { + projectService: true, + }, + }, + rules: { + '@typescript-eslint/no-unused-vars': 'error', + 'no-var': 'error', + }, + }, +]; diff --git a/packages/vscode/tests/e2e/lint/fixtures/project-service-scope/test/skills.test.ts b/packages/vscode/tests/e2e/lint/fixtures/project-service-scope/test/skills.test.ts new file mode 100644 index 0000000..5f1ff73 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/project-service-scope/test/skills.test.ts @@ -0,0 +1,13 @@ +// File NOT in tsconfig.include (test/ outside src/). +// The `console.log` below triggers the non-type-aware `no-console` rule, +// acting as a marker so tests can wait for rslint to finalize diagnostics +// on this file without resorting to a fixed-duration sleep. +console.log('skills.test fixture loaded'); + +export const uncovered = (( + command: string, + args: string[], + options: unknown, +) => { + return { stdout: '', stderr: '', exitCode: 0 }; +}) as unknown; diff --git a/packages/vscode/tests/e2e/lint/fixtures/project-service-scope/tsconfig.json b/packages/vscode/tests/e2e/lint/fixtures/project-service-scope/tsconfig.json new file mode 100644 index 0000000..37ebbaf --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/project-service-scope/tsconfig.json @@ -0,0 +1,4 @@ +{ + "compilerOptions": { "strict": true, "noEmit": true }, + "include": ["src"] +} diff --git a/packages/vscode/tests/e2e/lint/fixtures/type-aware-scope/packages/cli/src/preview.ts b/packages/vscode/tests/e2e/lint/fixtures/type-aware-scope/packages/cli/src/preview.ts new file mode 100644 index 0000000..c008764 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/type-aware-scope/packages/cli/src/preview.ts @@ -0,0 +1,3 @@ +export async function previewCommand(): Promise { + console.log('preview'); +} diff --git a/packages/vscode/tests/e2e/lint/fixtures/type-aware-scope/packages/core/src/dependency.ts b/packages/vscode/tests/e2e/lint/fixtures/type-aware-scope/packages/core/src/dependency.ts new file mode 100644 index 0000000..cbf7640 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/type-aware-scope/packages/core/src/dependency.ts @@ -0,0 +1 @@ +export const dependency: any = {}; diff --git a/packages/vscode/tests/e2e/lint/fixtures/type-aware-scope/packages/core/src/index.ts b/packages/vscode/tests/e2e/lint/fixtures/type-aware-scope/packages/core/src/index.ts new file mode 100644 index 0000000..6570668 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/type-aware-scope/packages/core/src/index.ts @@ -0,0 +1,6 @@ +import { dependency } from './dependency'; + +export async function coreFunction(): Promise { + console.log('hello'); + dependency.value; +} diff --git a/packages/vscode/tests/e2e/lint/fixtures/type-aware-scope/packages/core/src/session.ts b/packages/vscode/tests/e2e/lint/fixtures/type-aware-scope/packages/core/src/session.ts new file mode 100644 index 0000000..bd5e3aa --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/type-aware-scope/packages/core/src/session.ts @@ -0,0 +1 @@ +export const sessionProjectMarker = true; diff --git a/packages/vscode/tests/e2e/lint/fixtures/type-aware-scope/packages/core/tsconfig.json b/packages/vscode/tests/e2e/lint/fixtures/type-aware-scope/packages/core/tsconfig.json new file mode 100644 index 0000000..5bc8b6e --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/type-aware-scope/packages/core/tsconfig.json @@ -0,0 +1,4 @@ +{ + "compilerOptions": { "strict": true, "noEmit": true }, + "include": ["src/session.ts"] +} diff --git a/packages/vscode/tests/e2e/lint/fixtures/type-aware-scope/packages/core/tsconfig.lint.json b/packages/vscode/tests/e2e/lint/fixtures/type-aware-scope/packages/core/tsconfig.lint.json new file mode 100644 index 0000000..e0ba56a --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/type-aware-scope/packages/core/tsconfig.lint.json @@ -0,0 +1,4 @@ +{ + "compilerOptions": { "strict": true, "noEmit": true }, + "include": ["src/**/*.ts"] +} diff --git a/packages/vscode/tests/e2e/lint/fixtures/type-aware-scope/rslint.config.js b/packages/vscode/tests/e2e/lint/fixtures/type-aware-scope/rslint.config.js new file mode 100644 index 0000000..c1c4370 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/type-aware-scope/rslint.config.js @@ -0,0 +1,17 @@ +export default [ + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: false, + project: ['./packages/core/tsconfig.lint.json'], + }, + }, + rules: { + '@typescript-eslint/no-unsafe-member-access': 'error', + '@typescript-eslint/require-await': 'error', + 'no-console': 'error', + }, + plugins: ['@typescript-eslint'], + }, +]; diff --git a/packages/vscode/tests/e2e/lint/runSuite.ts b/packages/vscode/tests/e2e/lint/runSuite.ts new file mode 100644 index 0000000..87ce2e8 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/runSuite.ts @@ -0,0 +1,382 @@ +/** + * The in-Extension-Host runner shared by every lint suite (upstream + * `rslint/packages/vscode-extension/__tests__/runSuite.ts`). + * + * Adaptations from upstream: + * - Extension ID is `rstack.rstack` (the unified shell). + * - Upstream's `extension.activate()` resolved only once a language-server + * root was ready. Our shell activates without blocking on a server start + * (the shell-activation adaptation), so suites that expect a lint runtime wait + * for the lint stack to register through the extension's public exports + * channel (`whenStackActive('rslint')`) instead. The no-config suite asserts + * the opposite state and opts out via `createRun({ expectLintStack: false })`. + * - `fast-glob` is replaced by a small recursive walk (one dependency less). + */ +import fs from 'node:fs'; +import path from 'node:path'; +import Mocha from 'mocha'; +import * as vscode from 'vscode'; +import { isCodeActionCancellation } from './utils/codeActionRegistry'; +import { runBeforeDeadline } from './utils/deadline'; +import { EXTENSION_ID, workspaceMarkerFile } from './utils/extension'; +import type { RstackExtensionExports } from '../../../src/types'; + +const startupTimeoutMs = 120_000; +const typescriptCodeActionProbeSource = `interface RslintCodeActionProbe { + value: number; +} +class RslintCodeActionProbeImpl implements RslintCodeActionProbe {} +function rslintCodeActionProbeFunction() { + return 1 + 2; +} +`; + +export interface RunSuiteOptions { + /** + * Whether the fixture workspace is expected to light the Rslint stack. + * Defaults to true: the harness then blocks until the shell registered the + * lint controller, which mirrors upstream's activation contract. The + * no-config suite passes false β€” its whole point is that the stack never + * registers (rslint is not zero-config). + */ + readonly expectLintStack?: boolean; +} + +type RunCallback = (error: unknown, failures?: number) => void; + +export function createRun( + options: RunSuiteOptions = {}, +): (testPath: string, callback: RunCallback) => void { + return (testPath, callback) => { + void activateAndRun(testPath, options, callback); + }; +} + +const collectTests = (dir: string): string[] => { + const files: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...collectTests(full)); + } else if (entry.name.endsWith('.test.js')) { + files.push(full); + } + } + return files.sort(); +}; + +async function activateAndRun( + testPath: string, + options: RunSuiteOptions, + callback: RunCallback, +): Promise { + try { + // This deadline starts before any extension activation. Mocha has not been + // created yet, so every asynchronous startup step must be independently + // bounded instead of relying on a per-test timeout that does not exist yet. + const startupDeadline = Date.now() + startupTimeoutMs; + verifyIsolatedWorkspace(); + const extension = vscode.extensions.getExtension(EXTENSION_ID); + if (!extension) { + throw new Error(`Extension ${EXTENSION_ID} is unavailable`); + } + + // Extension.activate() is the shell's readiness contract: detection ran + // and the initial reconcile pass registered every gated stack. + const extensionExports = await runBeforeDeadline( + () => extension.activate() as Promise, + startupDeadline, + `activation of ${EXTENSION_ID}`, + ); + if (options.expectLintStack !== false) { + // The upstream readiness contract, restated on the shell architecture: + // do not load a single test while the lint controller is unregistered. + await runBeforeDeadline( + () => extensionExports.whenStackActive('rslint'), + startupDeadline, + 'the Rslint stack to register (is the fixture config detected?)', + ); + } + await activateBuiltInCodeActionExtensions(startupDeadline); + await waitForTypeScriptCodeActionProviders(startupDeadline); + + const files = collectTests(testPath); + if (files.length === 0) { + throw new Error(`No compiled test files found in ${testPath}`); + } + const mocha = new Mocha({ ui: 'tdd' }); + files.forEach((file) => mocha.addFile(file)); + mocha.run((failures) => callback(null, failures)); + } catch (error) { + callback(error); + } +} + +function canonicalPath(filePath: string): string { + const realPath = fs.realpathSync.native(filePath); + return process.platform === 'win32' ? realPath.toLowerCase() : realPath; +} + +function isPathWithin(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return ( + relative === '' || + (relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative)) + ); +} + +function findWorkspaceMarker(startPath: string): string { + let current = startPath; + for (;;) { + const candidate = path.join(current, workspaceMarkerFile); + if (fs.existsSync(candidate)) return candidate; + const parent = path.dirname(current); + if (parent === current) { + throw new Error( + `Could not find isolated workspace marker above ${startPath}`, + ); + } + current = parent; + } +} + +function verifyIsolatedWorkspace(): void { + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) { + throw new Error('Expected at least one isolated workspace folder, got 0'); + } + + const actualPaths = folders + .map((folder) => canonicalPath(folder.uri.fsPath)) + .sort(); + const markerPaths = new Set( + actualPaths.map((actualPath) => + canonicalPath(findWorkspaceMarker(actualPath)), + ), + ); + if (markerPaths.size !== 1) { + throw new Error( + `Workspace folders do not share one isolated sandbox marker: ${[ + ...markerPaths, + ].join(', ')}`, + ); + } + const markerPath = [...markerPaths][0]; + let marker: unknown; + try { + marker = JSON.parse(fs.readFileSync(markerPath, 'utf8')); + } catch (error) { + throw new Error( + `Could not read isolated workspace marker ${markerPath}: ${String(error)}`, + ); + } + if ( + typeof marker !== 'object' || + marker === null || + !('version' in marker) || + marker.version !== 2 || + !('nonce' in marker) || + typeof marker.nonce !== 'string' || + !/^[0-9a-f-]{36}$/i.test(marker.nonce) || + !('expectedWorkspace' in marker) || + typeof marker.expectedWorkspace !== 'string' || + !('expectedWorkspaceFolders' in marker) || + !Array.isArray(marker.expectedWorkspaceFolders) || + marker.expectedWorkspaceFolders.length === 0 || + !marker.expectedWorkspaceFolders.every( + (folder): folder is string => typeof folder === 'string', + ) || + !('sourceWorkspace' in marker) || + typeof marker.sourceWorkspace !== 'string' + ) { + throw new Error(`Invalid isolated workspace marker ${markerPath}`); + } + + const expectedPath = canonicalPath(marker.expectedWorkspace); + const sourcePath = canonicalPath(marker.sourceWorkspace); + const expectedPaths = marker.expectedWorkspaceFolders + .map((folder) => canonicalPath(folder)) + .sort(); + if (canonicalPath(path.dirname(markerPath)) !== expectedPath) { + throw new Error( + `Isolated workspace marker is outside its declared sandbox ${expectedPath}`, + ); + } + if ( + expectedPaths.some( + (expectedFolder) => !isPathWithin(expectedPath, expectedFolder), + ) + ) { + throw new Error( + 'An expected workspace folder escapes its isolated sandbox', + ); + } + if ( + actualPaths.length !== expectedPaths.length || + actualPaths.some((actualPath, index) => actualPath !== expectedPaths[index]) + ) { + throw new Error( + `VS Code opened workspace folders ${JSON.stringify(actualPaths)} instead of ${JSON.stringify(expectedPaths)}`, + ); + } + if ( + expectedPath === sourcePath || + actualPaths.some((actualPath) => isPathWithin(sourcePath, actualPath)) + ) { + throw new Error( + 'VS Code tests must not run against tracked source fixtures', + ); + } +} + +async function activateBuiltInCodeActionExtensions( + startupDeadline: number, +): Promise { + for (const id of ['vscode.typescript-language-features', 'vscode.git']) { + const extension = vscode.extensions.getExtension(id); + if (!extension) { + throw new Error(`Built-in extension ${id} is unavailable`); + } + await runBeforeDeadline( + () => extension.activate(), + startupDeadline, + `activation of built-in extension ${id}`, + ); + } +} + +/** + * VS Code's built-in TypeScript extension activates lazily. Its activation + * function returns before tsserver is ready, then four modules asynchronously + * register the TypeScript code-action providers. A provider registration while + * code actions on save are running cancels that save in VS Code 1.128. + * + * Probe the public behavior of the quick-fix, refactor, organize-imports, and + * fix-all providers on a controlled untitled document. This eagerly resolves + * known lazy registrations; the generic registry-quiescence sentinel still + * runs immediately before every save. Save assertions remain single-shot. + */ +async function waitForTypeScriptCodeActionProviders( + startupDeadline: number, +): Promise { + const document = await runBeforeDeadline( + () => + vscode.workspace.openTextDocument({ + content: typescriptCodeActionProbeSource, + language: 'typescript', + }), + startupDeadline, + 'the TypeScript code-action probe document to open', + ); + + const quickFixRange = document.lineAt(3).range; + const refactorLine = document.lineAt(5); + const refactorExpression = '1 + 2'; + const refactorStart = refactorLine.text.indexOf(refactorExpression); + if (refactorStart < 0) { + throw new Error('TypeScript code-action probe expression is unavailable'); + } + + const emptyRange = new vscode.Range(0, 0, 0, 0); + const probes = new Map< + string, + { + kind: vscode.CodeActionKind; + range: vscode.Range | vscode.Selection; + } + >([ + [ + 'quick fix', + { kind: vscode.CodeActionKind.QuickFix, range: quickFixRange }, + ], + [ + 'refactor', + { + kind: vscode.CodeActionKind.Refactor, + range: new vscode.Selection( + refactorLine.lineNumber, + refactorStart, + refactorLine.lineNumber, + refactorStart + refactorExpression.length, + ), + }, + ], + [ + 'organize imports', + { kind: vscode.CodeActionKind.SourceOrganizeImports, range: emptyRange }, + ], + [ + 'fix all', + { kind: vscode.CodeActionKind.SourceFixAll, range: emptyRange }, + ], + ]); + + let lastProbeError: unknown; + + while (Date.now() < startupDeadline) { + for (const [name, probe] of probes) { + try { + const actions = await executeCodeActionProbeBeforeDeadline( + document, + probe, + name, + startupDeadline, + ); + if ( + actions?.some( + (action) => action.kind && probe.kind.contains(action.kind), + ) + ) { + probes.delete(name); + } + } catch (error) { + // Only the known cancellation caused by an in-flight provider + // registration is retryable. Unexpected command failures fail fast. + if (!isCodeActionCancellation(error)) throw error; + lastProbeError = error; + } + } + + if (probes.size === 0) { + return; + } + await new Promise((resolve) => + setTimeout( + resolve, + Math.min(100, Math.max(0, startupDeadline - Date.now())), + ), + ); + } + + throw new Error( + `Timed out after ${startupTimeoutMs}ms during VS Code test startup while waiting for TypeScript code-action providers` + + (probes.size > 0 + ? `; missing actions: ${[...probes.keys()].join(', ')}` + : '') + + (lastProbeError ? `; last probe error: ${String(lastProbeError)}` : ''), + ); +} + +async function executeCodeActionProbeBeforeDeadline( + document: vscode.TextDocument, + probe: { + kind: vscode.CodeActionKind; + range: vscode.Range | vscode.Selection; + }, + name: string, + deadline: number, +): Promise { + return runBeforeDeadline( + () => + vscode.commands.executeCommand( + 'vscode.executeCodeActionProvider', + document.uri, + probe.range, + probe.kind.value, + ), + deadline, + `the TypeScript ${name} code-action probe to settle`, + ); +} diff --git a/packages/vscode/tests/e2e/lint/runTest.ts b/packages/vscode/tests/e2e/lint/runTest.ts new file mode 100644 index 0000000..f2ce20b --- /dev/null +++ b/packages/vscode/tests/e2e/lint/runTest.ts @@ -0,0 +1,321 @@ +/** + * The `@vscode/test-electron` orchestrator for the ported Rslint E2E suites + * (upstream `rslint/packages/vscode-extension/__tests__/runTest.ts`). + * + * Isolated-sandbox harness: suites intentionally create, rewrite and delete + * config files, so every suite runs against a private temp copy of its fixture + * workspace β€” even a crashing Extension Host cannot corrupt a tracked fixture. + * A signed marker file lets the in-host runner (`runSuite.ts`) verify it is + * inside a sandbox before a single test executes. + * + * Adaptations from upstream: + * - The extension under test is `rstack.rstack` (the unified shell) built to + * `dist/extension.js`. + * - There is no built-in binary: every fixture resolves + * `@rslint/core` β€” including its native Go binary β€” from the shared fixture + * install root (`tests/e2e/lint/fixtures/package.json`, published npm + * versions). The sandbox preserves that resolution the way + * upstream preserved its monorepo package boundary: the install root's + * `package.json` is copied next to the workspace copy and its `node_modules` + * is symlinked there, so Node's walk-up resolution finds the real install + * without placing a writable `node_modules` inside the test workspace. + * - Upstream's first suite ("JSON config tests") ran against the fixture + * shipped inside `@rslint/core` which was configured via `rslint.json`. + * `rslint.json` is not supported by this extension (JSON configs are + * deprecated upstream), so that + * fixture is ported as `fixtures/basic` with an equivalent + * `rslint.config.mjs`. + */ +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { downloadAndUnzipVSCode, runTests } from '@vscode/test-electron'; + +interface TestSuite { + name: string; + workspace: string; + tests: string; + workspaceEntry?: string; + workspaceFolders?: string[]; +} + +const workspaceMarkerFile = '.rstack-vscode-test-sandbox.json'; + +function resolveSandboxEntry(workspaceRoot: string, entry: string): string { + const resolved = path.resolve(workspaceRoot, entry); + const relative = path.relative(workspaceRoot, resolved); + if ( + relative === '..' || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + throw new Error(`Workspace entry escapes its isolated sandbox: ${entry}`); + } + return resolved; +} + +async function findPackageRoot(startPath: string): Promise { + let current = path.resolve(startPath); + for (;;) { + try { + await fs.promises.access(path.join(current, 'package.json')); + return current; + } catch { + const parent = path.dirname(current); + if (parent === current) { + throw new Error(`Could not find a package boundary above ${startPath}`); + } + current = parent; + } + } +} + +async function runIsolatedSuite( + extensionDevelopmentPath: string, + vscodeExecutablePath: string, + suite: TestSuite, +): Promise { + // Go discovery intentionally searches strict cwd ancestors, so keep each + // fixture in a clean physical workspace outside this repository. Use short + // paths on Unix as VS Code and the language client append socket names that + // can otherwise exceed macOS's Unix-domain socket path limit. + const tempRoot = process.platform === 'win32' ? os.tmpdir() : '/tmp'; + const profileRoot = await fs.promises.mkdtemp(path.join(tempRoot, 'rsv-')); + const userDataDir = path.join(profileRoot, 'u'); + const extensionsDir = path.join(profileRoot, 'e'); + const workspaceCopy = path.join(profileRoot, 'w'); + + let testError: unknown; + try { + // Suites intentionally create, rewrite, and delete config files. Run them + // against a private copy so even an Extension Host crash cannot mutate a + // tracked fixture in the checkout. + await fs.promises.cp(suite.workspace, workspaceCopy, { + recursive: true, + force: false, + errorOnExist: true, + }); + const expectedWorkspaceFolders = await Promise.all( + (suite.workspaceFolders ?? ['.']).map((folder) => + fs.promises.realpath(resolveSandboxEntry(workspaceCopy, folder)), + ), + ); + await fs.promises.writeFile( + path.join(workspaceCopy, workspaceMarkerFile), + JSON.stringify({ + version: 2, + nonce: randomUUID(), + sourceWorkspace: await fs.promises.realpath(suite.workspace), + expectedWorkspace: await fs.promises.realpath(workspaceCopy), + expectedWorkspaceFolders, + }), + { encoding: 'utf8', flag: 'wx', mode: 0o600 }, + ); + + // Preserve the fixture install root's package boundary and dependency + // lookup (the project-resolved `@rslint/core`) without + // placing a writable node_modules link inside the test workspace. + const packageRoot = await findPackageRoot(suite.workspace); + await fs.promises.copyFile( + path.join(packageRoot, 'package.json'), + path.join(profileRoot, 'package.json'), + ); + await fs.promises.symlink( + path.join(packageRoot, 'node_modules'), + path.join(profileRoot, 'node_modules'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + + await runTests({ + extensionDevelopmentPath, + extensionTestsPath: suite.tests, + vscodeExecutablePath, + launchArgs: [ + suite.workspaceEntry + ? resolveSandboxEntry(workspaceCopy, suite.workspaceEntry) + : workspaceCopy, + '--disable-extensions', + '--disable-updates', + // The fixtures spawn project-local binaries, which Restricted Mode + // forbids by design. + '--disable-workspace-trust', + '--force-disable-user-env', + '--skip-release-notes', + '--skip-welcome', + `--user-data-dir=${userDataDir}`, + `--extensions-dir=${extensionsDir}`, + ], + }); + } catch (error) { + testError = error; + } + + let cleanupError: unknown; + try { + await fs.promises.rm(profileRoot, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 200, + }); + } catch (error) { + cleanupError = error; + } + + if (testError && cleanupError) { + throw new AggregateError( + [testError, cleanupError], + `${suite.name} failed and its isolated sandbox could not be removed`, + ); + } + if (testError) throw testError; + if (cleanupError) throw cleanupError; +} + +async function main(): Promise { + // `__dirname` is `/tests-dist/tests/e2e/lint` (see tsconfig.e2e.json). + const extensionDevelopmentPath = path.resolve(__dirname, '../../../..'); + const fixturesRoot = path.join( + extensionDevelopmentPath, + 'tests/e2e/lint/fixtures', + ); + const fixture = (name: string): string => path.join(fixturesRoot, name); + const suiteDir = (name: string): string => path.resolve(__dirname, name); + + // The extension host loads `main` from `package.json`; an unbuilt repo would + // otherwise fail deep inside VS Code with an unhelpful activation error. + if ( + !fs.existsSync(path.join(extensionDevelopmentPath, 'dist/extension.js')) + ) { + throw new Error( + 'dist/extension.js is missing β€” run `pnpm build` before `pnpm test:e2e:lint`.', + ); + } + // One shared install root serves every lint fixture workspace (published + // @rslint/core) β€” that is what the guard probes. + if (!fs.existsSync(path.join(fixturesRoot, 'node_modules'))) { + throw new Error( + 'the lint E2E fixtures are not installed β€” run `pnpm test:e2e:fixtures lint`.', + ); + } + + // Resolve the executable once so every suite explicitly uses the same one. + // `VSCODE_TEST_EXECUTABLE`/`VSCODE_TEST_VERSION` mirror the other runners in + // this repo; a cached download under `.vscode-test/` is reused. + const vscodeExecutablePath = + process.env.VSCODE_TEST_EXECUTABLE || + (await downloadAndUnzipVSCode({ + version: process.env.VSCODE_TEST_VERSION ?? 'stable', + timeout: 60_000, + extensionDevelopmentPath, + })); + + const suites: TestSuite[] = [ + { + // Upstream "JSON config tests": same suite, JS-config fixture (JSON + // configs are deprecated upstream and unsupported here). + name: 'Basic config tests', + workspace: fixture('basic'), + tests: suiteDir('suite'), + }, + { + name: 'JS config tests', + workspace: fixture('jsconfig'), + tests: suiteDir('suite-jsconfig'), + }, + { + name: 'Monorepo config tests', + workspace: fixture('monorepo'), + tests: suiteDir('suite-monorepo'), + }, + { + name: 'Multi-root dynamic ownership tests', + workspace: fixture('multiroot'), + tests: suiteDir('suite-multiroot'), + workspaceEntry: 'multiroot.code-workspace', + workspaceFolders: [ + 'parent', + 'sentinel', + 'twins/left/app', + 'twins/right/app', + ], + }, + { + name: 'Multi-root initial parent-child tests', + workspace: fixture('multiroot'), + tests: suiteDir('suite-multiroot'), + workspaceEntry: 'nested-initial.code-workspace', + workspaceFolders: ['parent', 'parent/nested', 'sentinel'], + }, + { + name: 'No config tests', + workspace: fixture('noconfig'), + tests: suiteDir('suite-noconfig'), + }, + { + name: 'Type-aware scope tests', + workspace: fixture('type-aware-scope'), + tests: suiteDir('suite-type-aware-scope'), + }, + { + name: 'projectService scope tests', + workspace: fixture('project-service-scope'), + tests: suiteDir('suite-project-service-scope'), + }, + { + name: 'eslintPlugins tests', + workspace: fixture('eslint-plugins'), + tests: suiteDir('suite-eslint-plugins'), + }, + ]; + + // Optional development filter: `RSTACK_LINT_E2E_SUITES="No config,Monorepo"` + // runs only the suites whose name contains one of the comma-separated + // fragments (case-insensitive). CI leaves it unset and runs everything. + const suiteFilter = (process.env.RSTACK_LINT_E2E_SUITES ?? '') + .split(',') + .map((fragment) => fragment.trim().toLowerCase()) + .filter((fragment) => fragment.length > 0); + const selectedSuites = + suiteFilter.length === 0 + ? suites + : suites.filter((suite) => + suiteFilter.some((fragment) => + suite.name.toLowerCase().includes(fragment), + ), + ); + if (selectedSuites.length === 0) { + throw new Error( + `RSTACK_LINT_E2E_SUITES matched no suites: ${process.env.RSTACK_LINT_E2E_SUITES}`, + ); + } + + const failures: unknown[] = []; + for (const suite of selectedSuites) { + console.log(`\n=== ${suite.name} ===`); + try { + await runIsolatedSuite( + extensionDevelopmentPath, + vscodeExecutablePath, + suite, + ); + } catch (error) { + console.error(`${suite.name} failed:`, error); + failures.push(error); + } + } + + if (failures.length > 0) { + throw new AggregateError( + failures, + `${failures.length} VS Code suite(s) failed`, + ); + } +} + +void main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/packages/vscode/tests/e2e/lint/suite-eslint-plugins/eslint-plugins.test.ts b/packages/vscode/tests/e2e/lint/suite-eslint-plugins/eslint-plugins.test.ts new file mode 100644 index 0000000..62138cf --- /dev/null +++ b/packages/vscode/tests/e2e/lint/suite-eslint-plugins/eslint-plugins.test.ts @@ -0,0 +1,146 @@ +// Ported verbatim from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/suite-eslint-plugins/eslint-plugins.test.ts` (origin/main). +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import fs from 'node:fs'; +import path from 'node:path'; +import { waitForContentChange } from '../suite/fixall-helpers'; +import { saveDocumentOnce } from '../utils/codeActionRegistry'; +import { withCodeActionsOnSave } from '../utils/configuration'; +import { waitForRslintDiagnostics } from '../utils/diagnostics'; +import { + closeAndDeleteTemporaryDocument, + temporaryFilePath, +} from '../utils/documents'; + +// End-to-end VS Code coverage for the object-form `plugins` reverse-dispatch +// path: the LSP server lints natively but dispatches rules mounted via a +// config's object-form `plugins` to the extension-side worker pool +// (PluginLintPool), then merges + publishes. The Go merge/dispatch units are +// covered in internal/lsp/eslint_plugin_test.go; this exercises the full loop. +// +// Fixture: rslint.config.mjs mounts ./local-plugin.mjs under object-form +// `plugins` with rules local/no-null + local/prefer-array-some, plus native +// no-console. +suite('rslint object-form plugins integration', function () { + this.timeout(120000); + + function workspaceRoot(): string { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) throw new Error('Test workspace is unavailable'); + return workspaceFolder.uri.fsPath; + } + + // LSP diagnostic messages are formatted as `[] ` + // (see internal/lsp/service.go), so ruleName is matchable on `.message`. + function messages(diags: vscode.Diagnostic[]): string { + return diags.map((d) => d.message).join(' | '); + } + + // Keep the original flaky scenario first: no preceding test may warm the + // diagnostics or code-action-on-save path in this fresh extension host. + test('plugin auto-fix participates in source.fixAll on save', async () => { + const tmpFile = temporaryFilePath( + path.join(workspaceRoot(), 'src'), + '_fixall_plugin_', + ); + fs.writeFileSync(tmpFile, '// placeholder\n', 'utf-8'); + + let doc: vscode.TextDocument | undefined; + let testError: unknown; + try { + const openedDocument = await vscode.workspace.openTextDocument(tmpFile); + doc = openedDocument; + const editor = await vscode.window.showTextDocument(openedDocument); + await withCodeActionsOnSave( + openedDocument, + { 'source.fixAll': 'explicit' }, + async () => { + await editor.edit((b) => + b.replace( + new vscode.Range( + openedDocument.positionAt(0), + openedDocument.positionAt(openedDocument.getText().length), + ), + 'const numbers = [1, 2, 3];\nconst ok = numbers.filter((n) => n > 0).length > 0;\nexport { ok };\n', + ), + ); + + const diagnostics = await waitForRslintDiagnostics( + openedDocument, + (diags) => + diags.some((d) => d.message.includes('local/prefer-array-some')), + ); + assert.ok( + diagnostics.some((d) => + d.message.includes('local/prefer-array-some'), + ), + `prefer-array-some did not appear; cannot exercise fixAll. Got: ${messages(diagnostics)}`, + ); + + await saveDocumentOnce( + openedDocument, + 'Document should complete the plugin code-action-on-save pipeline', + ); + await waitForContentChange( + openedDocument, + (content) => !content.includes('.filter('), + 60000, + ); + + assert.ok( + !openedDocument.getText().includes('.filter('), + `Plugin fix (filter -> some) should apply via source.fixAll on save.\nContent: ${openedDocument.getText()}`, + ); + assert.ok( + openedDocument.getText().includes('.some('), + `Expected '.some(' after fixAll.\nContent: ${openedDocument.getText()}`, + ); + }, + ); + } catch (error) { + testError = error; + } + + const errors: unknown[] = []; + if (testError) errors.push(testError); + try { + await closeAndDeleteTemporaryDocument(doc, tmpFile); + } catch (error) { + errors.push(error); + } + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) { + throw new AggregateError( + errors, + 'Plugin on-save test and temporary-file cleanup failed', + ); + } + }); + + test('mounted plugin rules report, merged with native rules', async () => { + const filePath = path.join(workspaceRoot(), 'src', 'index.ts'); + const doc = await vscode.workspace.openTextDocument(filePath); + await vscode.window.showTextDocument(doc); + + const diagnostics = await waitForRslintDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('local/no-null')), + ); + const msgs = messages(diagnostics); + + // Both plugin rules must come back from the worker... + assert.ok( + diagnostics.some((d) => d.message.includes('local/no-null')), + `Expected local/no-null. Got: ${msgs}`, + ); + assert.ok( + diagnostics.some((d) => d.message.includes('local/prefer-array-some')), + `Expected local/prefer-array-some. Got: ${msgs}`, + ); + // ...alongside the natively-linted rule, proving the merge. + assert.ok( + diagnostics.some((d) => d.message.includes('no-console')), + `Expected native no-console merged with plugin diagnostics. Got: ${msgs}`, + ); + }); +}); diff --git a/packages/vscode/tests/e2e/lint/suite-eslint-plugins/index.ts b/packages/vscode/tests/e2e/lint/suite-eslint-plugins/index.ts new file mode 100644 index 0000000..e8a41f6 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/suite-eslint-plugins/index.ts @@ -0,0 +1,3 @@ +import { createRun } from '../runSuite'; + +export const run = createRun(); diff --git a/packages/vscode/tests/e2e/lint/suite-eslint-plugins/plugin-pool.test.ts b/packages/vscode/tests/e2e/lint/suite-eslint-plugins/plugin-pool.test.ts new file mode 100644 index 0000000..f067f44 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/suite-eslint-plugins/plugin-pool.test.ts @@ -0,0 +1,429 @@ +// Ported from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/suite-eslint-plugins/plugin-pool.test.ts` +// (origin/main). Only the import paths changed: the copied extension sources +// live under `src/stacks/lint/` in this repo. The type-only +// `@rslint/core/eslint-plugin` import stays a devDependency; the runtime pool +// under test loads nothing from it (the runtime module is always resolved from +// the user's project, never bundled). +import * as assert from 'node:assert'; + +import type { + ConfigDescriptor, + EslintPluginLintRequest, + EslintPluginLintResult, + PluginLintHost, +} from '@rslint/core/eslint-plugin'; +import { CancellationTokenSource } from 'vscode'; +import { PluginLintPool } from '../../../../src/stacks/lint/PluginLintPool'; +import type { Logger } from '../../../../src/stacks/lint/logger'; + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +class TestHost implements PluginLintHost { + readonly started = deferred(); + lintCalls = 0; + shutdownCalls = 0; + lintResult: Promise = Promise.resolve({ + results: [], + }); + + async lint(): Promise { + this.lintCalls++; + this.started.resolve(); + return this.lintResult; + } + + async shutdown(): Promise { + this.shutdownCalls++; + } +} + +function descriptor(name: string): ConfigDescriptor[] { + return [{ configPath: `/${name}.mjs`, configDirectory: `file:///${name}` }]; +} + +function request(generation: string): EslintPluginLintRequest { + return { + generation, + files: [], + rules: {}, + fix: false, + suggestionsMode: 'off', + }; +} + +function testLogger(): Logger { + return { + error() {}, + debug() {}, + } as unknown as Logger; +} + +suite('PluginLintPool generations', () => { + test('an installed generation does not wait for a slow prepare', async () => { + const hostA = new TestHost(); + const hostB = new TestHost(); + const hostBReady = deferred(); + const hostBCreateStarted = deferred(); + const pool = new PluginLintPool(testLogger(), async (configs) => { + if (configs[0].configPath === '/a.mjs') return hostA; + hostBCreateStarted.resolve(); + return hostBReady.promise; + }); + + assert.strictEqual( + await pool.prepare(descriptor('a'), 'fingerprint-a', 'a'), + true, + ); + assert.strictEqual(await pool.commit('a'), true); + + const prepareB = pool.prepare(descriptor('b'), 'fingerprint-b', 'b'); + await hostBCreateStarted.promise; + const lintA = pool.lint(request('a')); + await Promise.resolve(); + const callsBeforeBWasReady = hostA.lintCalls; + + hostBReady.resolve(hostB); + await prepareB; + await lintA; + await pool.abort('b'); + await pool.dispose(); + + assert.strictEqual( + callsBeforeBWasReady, + 1, + 'an unrelated host build must not block an installed generation', + ); + }); + + test('a generation still being installed waits and is rechecked', async () => { + const host = new TestHost(); + const hostReady = deferred(); + const hostCreateStarted = deferred(); + const pool = new PluginLintPool(testLogger(), async () => { + hostCreateStarted.resolve(); + return hostReady.promise; + }); + + const prepare = pool.prepare(descriptor('a'), 'fingerprint-a', 'a'); + await hostCreateStarted.promise; + const lint = pool.lint(request('a')); + await Promise.resolve(); + assert.strictEqual(host.lintCalls, 0); + + hostReady.resolve(host); + assert.strictEqual(await prepare, true); + await lint; + assert.strictEqual(host.lintCalls, 1); + + await pool.abort('a'); + await pool.dispose(); + }); + + test('cancellation interrupts the wait for an uninstalled generation', async () => { + const host = new TestHost(); + const hostReady = deferred(); + const hostCreateStarted = deferred(); + const pool = new PluginLintPool(testLogger(), async () => { + hostCreateStarted.resolve(); + return hostReady.promise; + }); + const cancellation = new CancellationTokenSource(); + + const prepare = pool.prepare(descriptor('a'), 'fingerprint-a', 'a'); + await hostCreateStarted.promise; + let lintSettled = false; + const lint = pool.lint(request('a'), cancellation.token).then((result) => { + lintSettled = true; + return result; + }); + cancellation.cancel(); + await new Promise((resolve) => setImmediate(resolve)); + const settledBeforeHostWasReady = lintSettled; + + hostReady.resolve(host); + await prepare; + const result = await lint; + await pool.abort('a'); + await pool.dispose(); + cancellation.dispose(); + + assert.strictEqual(settledBeforeHostWasReady, true); + assert.deepStrictEqual(result, { results: [] }); + assert.strictEqual(host.lintCalls, 0); + }); + + test('staged generations route safely and old hosts drain before shutdown', async () => { + const hosts = new Map(); + const pool = new PluginLintPool( + testLogger(), + async (configs) => { + const host = new TestHost(); + hosts.set(configs[0].configPath, host); + return host; + }, + 0, + ); + + assert.strictEqual( + await pool.prepare(descriptor('a'), 'fingerprint-a', 'a'), + true, + ); + assert.strictEqual(await pool.commit('a'), true); + const hostA = hosts.get('/a.mjs')!; + const lintAResult = deferred(); + hostA.lintResult = lintAResult.promise; + const lintA = pool.lint(request('a')); + await hostA.started.promise; + + assert.strictEqual( + await pool.prepare(descriptor('b'), 'fingerprint-b', 'b'), + true, + ); + const hostB = hosts.get('/b.mjs')!; + await pool.lint(request('b')); + assert.strictEqual( + hostB.lintCalls, + 1, + 'Go can route an accepted generation before Node observes the ack', + ); + + assert.strictEqual(await pool.commit('b'), true); + assert.strictEqual( + hostA.shutdownCalls, + 0, + 'the old host still has an active lint lease', + ); + await pool.lint(request('b')); + assert.strictEqual(hostB.lintCalls, 2); + + lintAResult.resolve({ results: [] }); + await lintA; + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.strictEqual( + hostA.shutdownCalls, + 0, + 'the predecessor remains rollback-capable until a later commit proves Go accepted b', + ); + + assert.strictEqual( + await pool.prepare(descriptor('c'), 'fingerprint-c', 'c'), + true, + ); + const hostC = hosts.get('/c.mjs')!; + assert.strictEqual(await pool.commit('c'), true); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.strictEqual(hostA.shutdownCalls, 1); + + await pool.dispose(); + assert.strictEqual(hostB.shutdownCalls, 1); + assert.strictEqual(hostC.shutdownCalls, 1); + }); + + test('abort rolls back an active commit whose response was lost', async () => { + const hosts = new Map(); + const pool = new PluginLintPool(testLogger(), async (configs) => { + const host = new TestHost(); + hosts.set(configs[0].configPath, host); + return host; + }); + + await pool.prepare(descriptor('a'), 'fingerprint-a', 'a'); + await pool.commit('a'); + await pool.prepare(descriptor('b'), 'fingerprint-b', 'b'); + await pool.commit('b'); + + // Node already switched to b, but Go did not receive/accept the commit + // response and compensates with abort. The pool must return to a rather + // than leaving both processes on different generations. + await pool.abort('b'); + await pool.lint(request('a')); + assert.strictEqual(hosts.get('/a.mjs')!.lintCalls, 1); + await assert.rejects(pool.lint(request('b')), /unknown.*generation/); + assert.strictEqual(hosts.get('/b.mjs')!.shutdownCalls, 1); + + await pool.dispose(); + assert.strictEqual(hosts.get('/a.mjs')!.shutdownCalls, 1); + }); + + test('default grace retains at most two old WorkerPools', async () => { + const hosts = new Map(); + const pool = new PluginLintPool(testLogger(), async (configs) => { + const host = new TestHost(); + hosts.set(configs[0].configPath, host); + return host; + }); + + for (const name of ['a', 'b', 'c', 'd']) { + assert.strictEqual( + await pool.prepare(descriptor(name), `fingerprint-${name}`, name), + true, + ); + assert.strictEqual(await pool.commit(name), true); + } + + const shutdownsDuringGrace = ['a', 'b', 'c', 'd'].map( + (name) => hosts.get(`/${name}.mjs`)!.shutdownCalls, + ); + await pool.dispose(); + + assert.deepStrictEqual( + shutdownsDuringGrace, + [1, 0, 0, 0], + 'the oldest pool is retired without waiting for the default 30s delay', + ); + }); + + test('the grace cap drains an acquired lease before shutdown', async () => { + const hosts = new Map(); + const pool = new PluginLintPool(testLogger(), async (configs) => { + const host = new TestHost(); + hosts.set(configs[0].configPath, host); + return host; + }); + + assert.strictEqual( + await pool.prepare(descriptor('a'), 'fingerprint-a', 'a'), + true, + ); + assert.strictEqual(await pool.commit('a'), true); + const hostA = hosts.get('/a.mjs')!; + const lintAResult = deferred(); + hostA.lintResult = lintAResult.promise; + const lintA = pool.lint(request('a')); + await hostA.started.promise; + + for (const name of ['b', 'c', 'd']) { + assert.strictEqual( + await pool.prepare(descriptor(name), `fingerprint-${name}`, name), + true, + ); + assert.strictEqual(await pool.commit(name), true); + } + assert.strictEqual( + hostA.shutdownCalls, + 0, + 'capacity eviction must not shut down an acquired lease', + ); + + lintAResult.resolve({ results: [] }); + await lintA; + assert.strictEqual(hostA.shutdownCalls, 1); + + await pool.dispose(); + }); + + test('dispose shuts down a retired host that still has an active lease', async () => { + const hosts = new Map(); + const pool = new PluginLintPool( + testLogger(), + async (configs) => { + const host = new TestHost(); + hosts.set(configs[0].configPath, host); + return host; + }, + 0, + ); + + await pool.prepare(descriptor('a'), 'fingerprint-a', 'a'); + await pool.commit('a'); + const hostA = hosts.get('/a.mjs')!; + const lintResult = deferred(); + hostA.lintResult = lintResult.promise; + const lint = pool.lint(request('a')); + await hostA.started.promise; + + await pool.prepare(descriptor('b'), 'fingerprint-b', 'b'); + await pool.commit('b'); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.strictEqual(hostA.shutdownCalls, 0); + + await pool.dispose(); + assert.strictEqual( + hostA.shutdownCalls, + 1, + 'dispose must include retired states that are no longer routable', + ); + assert.strictEqual(hosts.get('/b.mjs')!.shutdownCalls, 1); + + lintResult.resolve({ results: [] }); + await lint; + }); + + test('a failed replacement can be aborted without changing the active host', async () => { + const hostA = new TestHost(); + const pool = new PluginLintPool( + testLogger(), + async (configs) => { + if (configs[0].configPath === '/b.mjs') + throw new Error('broken plugin'); + return hostA; + }, + 0, + ); + + assert.strictEqual( + await pool.prepare(descriptor('a'), 'fingerprint-a', 'a'), + true, + ); + assert.strictEqual(await pool.commit('a'), true); + assert.strictEqual( + await pool.prepare(descriptor('b'), 'fingerprint-b', 'b'), + false, + ); + await pool.abort('b'); + + await pool.lint(request('a')); + assert.strictEqual(hostA.lintCalls, 1); + assert.strictEqual(hostA.shutdownCalls, 0); + + await pool.dispose(); + assert.strictEqual(hostA.shutdownCalls, 1); + }); + + test('a degraded generation without a host rejects plugin lint instead of returning a false green', async () => { + const pool = new PluginLintPool(testLogger(), async () => { + throw new Error('broken first plugin'); + }); + + assert.strictEqual( + await pool.prepare(descriptor('a'), 'fingerprint-a', 'a'), + false, + ); + assert.strictEqual(await pool.commit('a'), true); + await assert.rejects( + pool.lint(request('a')), + /pluginLint requested.*generation "a".*without an activated plugin host/, + ); + + await pool.dispose(); + }); + + test('an empty native-only generation has no host and disposed lint stays benign', async () => { + let createCalls = 0; + const pool = new PluginLintPool(testLogger(), async () => { + createCalls++; + return new TestHost(); + }); + + assert.strictEqual(await pool.prepare([], 'fingerprint-a', 'a'), true); + assert.strictEqual(await pool.commit('a'), true); + assert.strictEqual(createCalls, 0); + await assert.rejects( + pool.lint(request('a')), + /pluginLint requested.*generation "a".*without an activated plugin host/, + ); + + await pool.dispose(); + assert.deepStrictEqual(await pool.lint(request('a')), { results: [] }); + }); +}); diff --git a/packages/vscode/tests/e2e/lint/suite-jsconfig/config-transaction.test.ts b/packages/vscode/tests/e2e/lint/suite-jsconfig/config-transaction.test.ts new file mode 100644 index 0000000..e28145f --- /dev/null +++ b/packages/vscode/tests/e2e/lint/suite-jsconfig/config-transaction.test.ts @@ -0,0 +1,514 @@ +// Ported from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/suite-jsconfig/config-transaction.test.ts` +// (origin/main). Adaptations: +// - Import paths: the copied extension sources live under `src/stacks/lint/`. +// - `LspConfigTransactionAdapter` no longer bakes in a compile-time protocol +// constant: `@rslint/core/config-loader` is resolved from the project at +// runtime, so the adapter takes the loader's +// `CONFIG_DISCOVERY_PROTOCOL_VERSION` as a constructor argument. The tests +// inject the devDependency's constant, which tracks the same `^0.7.2` floor +// as the fixtures. +// - The watch-glob test asserts upstream's glob is kept verbatim, lockfiles +// included. +import * as assert from 'node:assert'; + +import { + CONFIG_DISCOVERY_PROTOCOL_VERSION, + type ActivateConfigsRequest, + type ActivateConfigsResponse, + type ConfigModuleActivationPlan, + type LoadConfigsRequest, + type LoadConfigsResponse, +} from '@rslint/core/config-loader'; +import { + CONFIG_REFRESH_WATCH_GLOB, + configRefreshReasonForPath, + createLanguageClientOptions, + isConfigSourceChangeDuringTransaction, + recoverConfigDiscoveryOnServerState, + retryConfigRefreshOnSourceChange, +} from '../../../../src/stacks/lint/Rslint'; +import { LspConfigTransactionAdapter } from '../../../../src/stacks/lint/ConfigTransactionAdapter'; +import { State } from 'vscode-languageclient/node'; +import { + RelativePattern, + Uri, + type DocumentFilter, + type WorkspaceFolder, +} from 'vscode'; + +suite('initial config refresh retry classification', () => { + test('isolates each language client and its documents to one workspace', () => { + const firstFolder: WorkspaceFolder = { + index: 0, + name: 'first-root', + uri: Uri.file('/workspace/first-root'), + }; + const secondFolder: WorkspaceFolder = { + index: 1, + name: 'second-root', + uri: Uri.file('/workspace/second-root'), + }; + const firstOptions = createLanguageClientOptions(firstFolder, undefined); + const secondOptions = createLanguageClientOptions(secondFolder, undefined); + + assert.strictEqual(firstOptions.workspaceFolder, firstFolder); + assert.strictEqual(secondOptions.workspaceFolder, secondFolder); + for (const [options, folder] of [ + [firstOptions, firstFolder], + [secondOptions, secondFolder], + ] as const) { + assert.ok(Array.isArray(options.documentSelector)); + assert.strictEqual(options.documentSelector.length, 4); + for (const selector of options.documentSelector as DocumentFilter[]) { + assert.ok(selector.pattern instanceof RelativePattern); + assert.strictEqual( + selector.pattern.baseUri.toString(), + folder.uri.toString(), + ); + assert.strictEqual(selector.pattern.pattern, '**/*'); + } + } + }); + + test('recognizes source-change failures without hiding unrelated startup failures', () => { + assert.strictEqual( + isConfigSourceChangeDuringTransaction({ + code: 'CONFIG_CHANGED_DURING_LOAD', + message: 'wrapped transport error', + }), + true, + ); + assert.strictEqual( + isConfigSourceChangeDuringTransaction( + new Error( + 'activate configs: config changed while its plugin host was being prepared', + ), + ), + true, + ); + assert.strictEqual( + isConfigSourceChangeDuringTransaction(new Error('plugin host failed')), + false, + ); + }); + + test('retries one transient initial source-change failure', async () => { + let initialCalls = 0; + let retryCalls = 0; + const retried = await retryConfigRefreshOnSourceChange( + async () => { + initialCalls++; + throw new Error( + 'config changed while its plugin host was being prepared', + ); + }, + async () => { + retryCalls++; + }, + ); + assert.strictEqual(retried, true); + assert.strictEqual(initialCalls, 1); + assert.strictEqual(retryCalls, 1); + }); + + test('does not retry unrelated initial failures', async () => { + let retryCalls = 0; + await assert.rejects( + retryConfigRefreshOnSourceChange( + async () => { + throw new Error('protocol failure'); + }, + async () => { + retryCalls++; + }, + ), + /protocol failure/, + ); + assert.strictEqual(retryCalls, 0); + }); +}); + +class TestConfigHost { + readonly loadRequests: LoadConfigsRequest[] = []; + readonly activationRequests: ActivateConfigsRequest[] = []; + readonly deletedTransactions: string[] = []; + loadError: Error | undefined; + changedDuringPrepare = false; + activation: ConfigModuleActivationPlan = { + transactionId: 'tx-1', + configs: [ + { + id: 'root', + configPath: '/workspace/rslint.config.mjs', + configDirectory: '/workspace', + entries: [], + sourceFingerprint: '10:abc', + }, + ], + eslintPluginEntries: [{ prefix: 'local', ruleNames: ['no-foo'] }], + pluginConfigs: [ + { + configPath: '/workspace/rslint.config.mjs', + configDirectory: '/workspace', + }, + ], + }; + + async loadConfigs(request: LoadConfigsRequest): Promise { + this.loadRequests.push(request); + if (this.loadError) throw this.loadError; + return { transactionId: request.transactionId, results: [] }; + } + + async activateConfigs( + request: ActivateConfigsRequest, + _signal?: AbortSignal, + prepare?: (activation: ConfigModuleActivationPlan) => Promise, + ): Promise { + this.activationRequests.push(request); + const activation = { + ...this.activation, + transactionId: request.transactionId, + }; + await prepare?.(activation); + if (this.changedDuringPrepare) { + throw new Error( + 'config changed while its plugin host was being prepared', + ); + } + return { + transactionId: activation.transactionId, + eslintPluginEntries: activation.eslintPluginEntries, + }; + } + + deleteSession(transactionId: string): boolean { + this.deletedTransactions.push(transactionId); + return true; + } +} + +class TestPluginPool { + readonly prepareCalls: Array<{ + descriptors: unknown[]; + fingerprint: string; + generation: string; + }> = []; + readonly commitCalls: string[] = []; + readonly abortCalls: string[] = []; + ready = true; + commitResult = true; + onPrepare: (() => void | Promise) | undefined; + + async prepare( + descriptors: Array<{ + configPath: string; + configDirectory: string; + }>, + fingerprint: string, + generation: string, + ): Promise { + this.prepareCalls.push({ descriptors, fingerprint, generation }); + await this.onPrepare?.(); + return this.ready; + } + + async commit(generation: string): Promise { + this.commitCalls.push(generation); + return this.commitResult; + } + + async abort(generation: string): Promise { + this.abortCalls.push(generation); + } +} + +function loadRequest(transactionId = 'tx-1'): LoadConfigsRequest { + return { + protocolVersion: CONFIG_DISCOVERY_PROTOCOL_VERSION, + transactionId, + loadMode: 'cached', + candidates: [], + }; +} + +suite('LSP config discovery transactions', () => { + test('the extension watcher leaves gitignore ownership to Go', () => { + assert.match(CONFIG_REFRESH_WATCH_GLOB, /rslint\.config\.js/); + assert.match(CONFIG_REFRESH_WATCH_GLOB, /rslint\.config\.mjs/); + assert.match(CONFIG_REFRESH_WATCH_GLOB, /rslint\.config\.ts/); + assert.match(CONFIG_REFRESH_WATCH_GLOB, /rslint\.config\.mts/); + assert.doesNotMatch(CONFIG_REFRESH_WATCH_GLOB, /rslint\.config\.\*/); + assert.match(CONFIG_REFRESH_WATCH_GLOB, /rslint\.jsonc/); + assert.match(CONFIG_REFRESH_WATCH_GLOB, /pnpm-lock\.yaml/); + assert.doesNotMatch(CONFIG_REFRESH_WATCH_GLOB, /\.gitignore/); + assert.strictEqual( + configRefreshReasonForPath('/workspace/packages/app/pnpm-lock.yaml'), + 'dependency-change', + ); + assert.strictEqual( + configRefreshReasonForPath('/workspace/rslint.config.mjs'), + 'config-change', + ); + }); + + test('loads fresh, stages only effective plugins, then commits atomically', async () => { + const host = new TestConfigHost(); + const pool = new TestPluginPool(); + const adapter = new LspConfigTransactionAdapter( + host, + pool, + () => 'fingerprint-1', + CONFIG_DISCOVERY_PROTOCOL_VERSION, + ); + + const loaded = await adapter.loadConfigs(loadRequest()); + assert.strictEqual(loaded.transactionId, 'tx-1'); + assert.strictEqual(host.loadRequests[0].loadMode, 'fresh'); + + const activated = await adapter.activateConfigs({ + protocolVersion: CONFIG_DISCOVERY_PROTOCOL_VERSION, + transactionId: 'tx-1', + effectiveConfigIds: ['root'], + }); + assert.deepStrictEqual(activated, { + transactionId: 'tx-1', + eslintPluginEntries: [{ prefix: 'local', ruleNames: ['no-foo'] }], + pluginHostReady: true, + }); + assert.deepStrictEqual(pool.prepareCalls, [ + { + descriptors: host.activation.pluginConfigs, + fingerprint: 'fingerprint-1', + generation: 'tx-1', + }, + ]); + + const committed = await adapter.commitConfigs({ + protocolVersion: CONFIG_DISCOVERY_PROTOCOL_VERSION, + transactionId: 'tx-1', + }); + assert.deepStrictEqual(committed, { + transactionId: 'tx-1', + committed: true, + }); + assert.deepStrictEqual(pool.commitCalls, ['tx-1']); + assert.deepStrictEqual(host.deletedTransactions, ['tx-1']); + }); + + test('a rejected commit retains the session until Go aborts it', async () => { + const host = new TestConfigHost(); + const pool = new TestPluginPool(); + pool.commitResult = false; + const adapter = new LspConfigTransactionAdapter( + host, + pool, + () => 'fingerprint-1', + CONFIG_DISCOVERY_PROTOCOL_VERSION, + ); + + await adapter.loadConfigs(loadRequest('tx-abort')); + await adapter.activateConfigs({ + protocolVersion: CONFIG_DISCOVERY_PROTOCOL_VERSION, + transactionId: 'tx-abort', + effectiveConfigIds: ['root'], + }); + await assert.rejects( + adapter.commitConfigs({ + protocolVersion: CONFIG_DISCOVERY_PROTOCOL_VERSION, + transactionId: 'tx-abort', + }), + /failed to commit plugin-host generation/, + ); + assert.deepStrictEqual(host.deletedTransactions, []); + + const aborted = await adapter.abortConfigs({ + protocolVersion: CONFIG_DISCOVERY_PROTOCOL_VERSION, + transactionId: 'tx-abort', + }); + assert.deepStrictEqual(aborted, { + transactionId: 'tx-abort', + aborted: true, + }); + assert.deepStrictEqual(pool.abortCalls, ['tx-abort']); + assert.deepStrictEqual(host.deletedTransactions, ['tx-abort']); + }); + + test('native-server restart aborts orphaned state and accepts a new transaction', async () => { + const host = new TestConfigHost(); + const pool = new TestPluginPool(); + const adapter = new LspConfigTransactionAdapter( + host, + pool, + () => 'fingerprint-restart', + CONFIG_DISCOVERY_PROTOCOL_VERSION, + ); + + await adapter.loadConfigs(loadRequest('old-process-tx')); + await adapter.activateConfigs({ + protocolVersion: CONFIG_DISCOVERY_PROTOCOL_VERSION, + transactionId: 'old-process-tx', + effectiveConfigIds: ['root'], + }); + await adapter.resetForServerRestart(); + assert.deepStrictEqual(pool.abortCalls, ['old-process-tx']); + assert.deepStrictEqual(host.deletedTransactions, ['old-process-tx']); + + await adapter.loadConfigs(loadRequest('new-process-tx')); + const activation = await adapter.activateConfigs({ + protocolVersion: CONFIG_DISCOVERY_PROTOCOL_VERSION, + transactionId: 'new-process-tx', + effectiveConfigIds: ['root'], + }); + assert.strictEqual(activation.transactionId, 'new-process-tx'); + }); + + test('a later Running state resets orphaned state before requesting an initial catalog', async () => { + const host = new TestConfigHost(); + const pool = new TestPluginPool(); + const adapter = new LspConfigTransactionAdapter( + host, + pool, + () => 'fingerprint-restart', + CONFIG_DISCOVERY_PROTOCOL_VERSION, + ); + await adapter.loadConfigs(loadRequest('orphaned-tx')); + + const events: string[] = []; + const recovery = recoverConfigDiscoveryOnServerState( + State.Running, + async (reason, beforeRequest) => { + events.push(`request:${reason}`); + await beforeRequest?.(adapter); + events.push('send'); + }, + ); + await recovery; + + assert.deepStrictEqual(events, ['request:initial', 'send']); + assert.deepStrictEqual(pool.abortCalls, ['orphaned-tx']); + assert.deepStrictEqual(host.deletedTransactions, ['orphaned-tx']); + + let stoppedRefresh = false; + const ignored = recoverConfigDiscoveryOnServerState( + State.Stopped, + async () => { + stoppedRefresh = true; + }, + ); + assert.strictEqual(ignored, undefined); + assert.strictEqual(stoppedRefresh, false); + }); + + test('a failed first plugin prepare can commit a degraded no-host generation', async () => { + const host = new TestConfigHost(); + const pool = new TestPluginPool(); + pool.ready = false; + const adapter = new LspConfigTransactionAdapter( + host, + pool, + () => 'fingerprint-degraded', + CONFIG_DISCOVERY_PROTOCOL_VERSION, + ); + + await adapter.loadConfigs(loadRequest('tx-degraded')); + const activated = await adapter.activateConfigs({ + protocolVersion: CONFIG_DISCOVERY_PROTOCOL_VERSION, + transactionId: 'tx-degraded', + effectiveConfigIds: ['root'], + }); + assert.deepStrictEqual(activated, { + transactionId: 'tx-degraded', + eslintPluginEntries: [], + pluginHostReady: false, + }); + + const committed = await adapter.commitConfigs({ + protocolVersion: CONFIG_DISCOVERY_PROTOCOL_VERSION, + transactionId: 'tx-degraded', + }); + assert.strictEqual(committed.committed, true); + assert.deepStrictEqual(pool.commitCalls, ['tx-degraded']); + assert.deepStrictEqual(pool.abortCalls, []); + assert.deepStrictEqual(host.deletedTransactions, ['tx-degraded']); + }); + + test('a config rewrite during plugin prepare aborts without commit or leaked session', async () => { + const host = new TestConfigHost(); + const pool = new TestPluginPool(); + pool.onPrepare = () => { + host.changedDuringPrepare = true; + }; + const adapter = new LspConfigTransactionAdapter( + host, + pool, + () => 'fingerprint-before-prepare', + CONFIG_DISCOVERY_PROTOCOL_VERSION, + ); + + await adapter.loadConfigs(loadRequest('tx-prepare-race')); + await assert.rejects( + adapter.activateConfigs({ + protocolVersion: CONFIG_DISCOVERY_PROTOCOL_VERSION, + transactionId: 'tx-prepare-race', + effectiveConfigIds: ['root'], + }), + /plugin host was being prepared/, + ); + assert.deepStrictEqual(pool.abortCalls, ['tx-prepare-race']); + assert.deepStrictEqual(pool.commitCalls, []); + assert.deepStrictEqual(host.deletedTransactions, ['tx-prepare-race']); + }); + + test('an abort after a successful local commit is still compensating', async () => { + const host = new TestConfigHost(); + const pool = new TestPluginPool(); + const adapter = new LspConfigTransactionAdapter( + host, + pool, + () => 'fingerprint-1', + CONFIG_DISCOVERY_PROTOCOL_VERSION, + ); + + await adapter.loadConfigs(loadRequest('tx-response-lost')); + await adapter.activateConfigs({ + protocolVersion: CONFIG_DISCOVERY_PROTOCOL_VERSION, + transactionId: 'tx-response-lost', + effectiveConfigIds: ['root'], + }); + await adapter.commitConfigs({ + protocolVersion: CONFIG_DISCOVERY_PROTOCOL_VERSION, + transactionId: 'tx-response-lost', + }); + + const aborted = await adapter.abortConfigs({ + protocolVersion: CONFIG_DISCOVERY_PROTOCOL_VERSION, + transactionId: 'tx-response-lost', + }); + assert.deepStrictEqual(aborted, { + transactionId: 'tx-response-lost', + aborted: true, + }); + assert.deepStrictEqual(pool.commitCalls, ['tx-response-lost']); + assert.deepStrictEqual(pool.abortCalls, ['tx-response-lost']); + assert.deepStrictEqual(host.deletedTransactions, [ + 'tx-response-lost', + 'tx-response-lost', + ]); + }); + + test('a failed module-host request cannot leak transaction state', async () => { + const host = new TestConfigHost(); + host.loadError = new Error('load failed'); + const adapter = new LspConfigTransactionAdapter( + host, + new TestPluginPool(), + () => 'fingerprint-1', + CONFIG_DISCOVERY_PROTOCOL_VERSION, + ); + + await assert.rejects(adapter.loadConfigs(loadRequest()), /load failed/); + assert.deepStrictEqual(host.deletedTransactions, ['tx-1']); + }); +}); diff --git a/packages/vscode/tests/e2e/lint/suite-jsconfig/index.ts b/packages/vscode/tests/e2e/lint/suite-jsconfig/index.ts new file mode 100644 index 0000000..e8a41f6 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/suite-jsconfig/index.ts @@ -0,0 +1,3 @@ +import { createRun } from '../runSuite'; + +export const run = createRun(); diff --git a/packages/vscode/tests/e2e/lint/suite-jsconfig/jsconfig.test.ts b/packages/vscode/tests/e2e/lint/suite-jsconfig/jsconfig.test.ts new file mode 100644 index 0000000..48fba2e --- /dev/null +++ b/packages/vscode/tests/e2e/lint/suite-jsconfig/jsconfig.test.ts @@ -0,0 +1,835 @@ +// Ported from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/suite-jsconfig/jsconfig.test.ts` +// (origin/main). Adaptations: +// - The fixture still ships `rslint.json` so the JS-over-JSON priority test +// keeps its upstream server-side meaning, but `rslint.json` is NOT a +// detection signal in this extension. +// - "deleting JS config should clear diagnostics" is adapted: deleting the +// last `rslint.config.*` now un-detects the folder, the shell deregisters +// the whole lint stack and every rslint diagnostic is dropped β€” the server +// never gets a chance to fall back to `rslint.json` (JSON configs are +// deprecated upstream and unsupported here). +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import path from 'node:path'; +import fs from 'node:fs'; +import { waitForRslintDiagnostics as waitForDiagnostics } from '../utils/diagnostics'; +import { closeTextEditor, revertTextDocument } from '../utils/documents'; +import { waitForLintStackRegistration } from '../utils/extension'; + +suite('rslint JS config support', function () { + this.timeout(120_000); + + function getWorkspaceRoot(): string { + return vscode.workspace.workspaceFolders![0].uri.fsPath; + } + + async function openFixture(filename: string): Promise { + const filePath = path.join(getWorkspaceRoot(), 'src', filename); + return vscode.workspace.openTextDocument(filePath); + } + + async function waitForFile(filePath: string, timeoutMs = 10_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (fs.existsSync(filePath)) return; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`Timed out waiting for file: ${filePath}`); + } + + async function withFailClosedCleanup( + testFn: () => Promise, + cleanupFn: () => Promise, + description: string, + ): Promise { + let testError: unknown; + try { + await testFn(); + } catch (error) { + testError = error; + } + + let cleanupError: unknown; + try { + await cleanupFn(); + } catch (error) { + cleanupError = error; + } + + if (testError && cleanupError) { + throw new AggregateError( + [testError, cleanupError], + `${description}: test and cleanup both failed`, + ); + } + if (testError) throw testError; + if (cleanupError) throw cleanupError; + } + + test('JS config should produce diagnostics', async () => { + const doc = await openFixture('index.ts'); + await vscode.window.showTextDocument(doc); + + // Wait specifically for JS config diagnostics. The startup snapshot may + // publish JSON fallback results before JS config activation commits. + const diagnostics = await waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('no-unsafe-member-access')), + ); + assert.ok( + diagnostics.length > 0, + `Expected diagnostics but got ${diagnostics.length}`, + ); + assert.ok( + diagnostics.some((d) => d.message.includes('no-unsafe-member-access')), + 'Expected no-unsafe-member-access diagnostic from JS config', + ); + }); + + test('JS config should take priority over JSON config', async () => { + const doc = await openFixture('index.ts'); + await vscode.window.showTextDocument(doc); + + // Wait specifically for JS config diagnostics (no-unsafe-member-access). + // JSON config may load first with no-explicit-any, but the committed JS + // config catalog should override it. + const diagnostics = await waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('no-unsafe-member-access')), + ); + + assert.ok( + diagnostics.some((d) => d.message.includes('no-unsafe-member-access')), + 'Expected no-unsafe-member-access from JS config', + ); + assert.ok( + !diagnostics.some((d) => d.message.includes('no-explicit-any')), + 'Should NOT see no-explicit-any because JS config takes priority over JSON', + ); + }); + + test('config hot reload should update diagnostics', async () => { + const doc = await openFixture('index.ts'); + await vscode.window.showTextDocument(doc); + + // 1. Verify initial diagnostics have no-unsafe-member-access. + await waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('no-unsafe-member-access')), + ); + + // 2. Subscribe BEFORE writing the new config β€” eliminates the + // "publish fires between write and subscribe" race window. The + // waiter listens; the server pushes after committing the refresh. + const configPath = path.join(getWorkspaceRoot(), 'rslint.config.js'); + const originalConfig = fs.readFileSync(configPath, 'utf8'); + const newConfig = `export default [ + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: false, + project: ['./tsconfig.json'], + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-unsafe-member-access': 'off', + }, + plugins: ['@typescript-eslint'], + }, +]; +`; + await withFailClosedCleanup( + async () => { + const reloaded = waitForDiagnostics( + doc, + (diags) => + diags.some((d) => d.message.includes('no-explicit-any')) && + !diags.some((d) => d.message.includes('no-unsafe-member-access')), + ); + fs.writeFileSync(configPath, newConfig, 'utf8'); + const updatedDiags = await reloaded; + assert.ok( + updatedDiags.some((d) => d.message.includes('no-explicit-any')), + 'After hot reload, diagnostics should include no-explicit-any', + ); + assert.ok( + !updatedDiags.some((d) => + d.message.includes('no-unsafe-member-access'), + ), + 'After hot reload, no-unsafe-member-access should be gone', + ); + }, + async () => { + const restored = waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('no-unsafe-member-access')), + ); + fs.writeFileSync(configPath, originalConfig, 'utf8'); + await restored; + }, + 'Config hot-reload test', + ); + }); + + test('one workspace config edit evaluates exactly one transaction', async () => { + const root = getWorkspaceRoot(); + const configPath = path.join(root, 'rslint.config.js'); + const markerPath = path.join(root, '.rslint-config-refresh-count'); + const originalConfig = fs.readFileSync(configPath, 'utf8'); + const doc = await openFixture('index.ts'); + await vscode.window.showTextDocument(doc); + await waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('no-unsafe-member-access')), + ); + + const countedConfig = `import fs from 'node:fs'; +fs.appendFileSync(${JSON.stringify(markerPath)}, 'x'); +export default [{ + files: ['**/*.ts'], + languageOptions: { + parserOptions: { projectService: false, project: ['./tsconfig.json'] }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-unsafe-member-access': 'off', + }, + plugins: ['@typescript-eslint'], +}]; +`; + + await withFailClosedCleanup( + async () => { + const reloaded = waitForDiagnostics( + doc, + (diags) => + diags.some((d) => d.message.includes('no-explicit-any')) && + !diags.some((d) => d.message.includes('no-unsafe-member-access')), + ); + fs.rmSync(markerPath, { force: true }); + fs.writeFileSync(configPath, countedConfig, 'utf8'); + await reloaded; + + // A duplicate didChangeWatchedFiles transaction used to race the + // direct watcher. Let any queued 300ms debounce finish before reading + // the config module's observable evaluation count. + await new Promise((resolve) => setTimeout(resolve, 1500)); + assert.strictEqual( + fs.readFileSync(markerPath, 'utf8'), + 'x', + 'one edit must evaluate one config-discovery transaction', + ); + }, + async () => { + await withFailClosedCleanup( + async () => { + const restored = waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('no-unsafe-member-access')), + ); + fs.writeFileSync(configPath, originalConfig, 'utf8'); + await restored; + }, + async () => fs.rmSync(markerPath, { force: true }), + 'Config-transaction cleanup', + ); + }, + 'Config-transaction test', + ); + }); + + test('deleting JS config should clear diagnostics', async () => { + const doc = await openFixture('index.ts'); + await vscode.window.showTextDocument(doc); + + await waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('no-unsafe-member-access')), + ); + + const configPath = path.join(getWorkspaceRoot(), 'rslint.config.js'); + const originalConfig = fs.readFileSync(configPath, 'utf8'); + + await withFailClosedCleanup( + async () => { + // Adapted expectation: the deleted file was the only + // detection signal, so the shell must deregister the lint stack β€” + // `rslint.json` on disk is not a config and cannot keep it alive. + fs.unlinkSync(configPath); + await waitForLintStackRegistration(false); + // Deregistration disposes the language client, which drops its + // diagnostics collection: the JSON fallback rule must not survive. + const afterDeleteDiags = await waitForDiagnostics( + doc, + (diags) => diags.length === 0, + ); + assert.strictEqual( + afterDeleteDiags.length, + 0, + 'After deleting the last JS config, all rslint diagnostics must be gone', + ); + }, + async () => { + // Restoring the config re-detects the folder and re-registers the + // stack without a window reload. + fs.writeFileSync(configPath, originalConfig, 'utf8'); + await waitForLintStackRegistration(true); + await waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('no-unsafe-member-access')), + ); + }, + 'JS-config deletion test', + ); + }); + + test('creating a new JS config should load it and produce diagnostics', async () => { + const configPath = path.join(getWorkspaceRoot(), 'rslint.config.js'); + const originalConfig = fs.readFileSync(configPath, 'utf8'); + const doc = await openFixture('index.ts'); + await vscode.window.showTextDocument(doc); + + // Establish a positive publication first, so clearing cannot pass on the + // document's not-yet-linted initial empty snapshot. + await waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('no-unsafe-member-access')), + ); + + await withFailClosedCleanup( + async () => { + // Step 1: delete existing config and observe its diagnostics drop. + const cleared = waitForDiagnostics(doc, (diags) => + diags.every((d) => !d.message.includes('no-unsafe-member-access')), + ); + fs.unlinkSync(configPath); + await cleared; + + // Step 2: subscribe for the new rule, then create the file. + const newConfig = `export default [ + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: false, + project: ['./tsconfig.json'], + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'error', + }, + plugins: ['@typescript-eslint'], + }, +]; +`; + const created = waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('no-explicit-any')), + ); + fs.writeFileSync(configPath, newConfig, 'utf8'); + const afterCreateDiags = await created; + assert.ok( + afterCreateDiags.some((d) => d.message.includes('no-explicit-any')), + 'After creating new JS config, should see no-explicit-any diagnostic', + ); + }, + async () => { + const restored = waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('no-unsafe-member-access')), + ); + fs.writeFileSync(configPath, originalConfig, 'utf8'); + await restored; + }, + 'JS-config creation test', + ); + }); + + test('a newly discovered broken nested config keeps valid ancestor config active', async () => { + const root = getWorkspaceRoot(); + const rootConfigPath = path.join(root, 'rslint.config.js'); + const originalRootConfig = fs.readFileSync(rootConfigPath, 'utf8'); + const nestedDir = path.join(root, 'broken-nested-config'); + const nestedFilePath = path.join(nestedDir, 'index.ts'); + const nestedConfigPath = path.join(nestedDir, 'rslint.config.js'); + const attemptedLoadPath = path.join(nestedDir, 'config-load-attempted'); + const postFailureFilePath = path.join(nestedDir, 'post-failure.ts'); + const rootDoc = await openFixture('index.ts'); + let nestedDoc: vscode.TextDocument | undefined; + let postFailureDoc: vscode.TextDocument | undefined; + + const rootConfigWithMarker = `export default [{ + files: ['**/*.ts'], + languageOptions: { + parserOptions: { projectService: false, project: ['./tsconfig.json'] }, + }, + rules: { + '@typescript-eslint/no-unsafe-member-access': 'warn', + 'no-debugger': 'error', + }, + plugins: ['@typescript-eslint'], +}]; +`; + + await withFailClosedCleanup( + async () => { + fs.mkdirSync(nestedDir, { recursive: true }); + fs.writeFileSync(nestedFilePath, 'debugger;\n', 'utf8'); + fs.writeFileSync(rootConfigPath, rootConfigWithMarker, 'utf8'); + + await vscode.window.showTextDocument(rootDoc); + await waitForDiagnostics(rootDoc, (diags) => + diags.some( + (diagnostic) => + diagnostic.message.includes('no-unsafe-member-access') && + diagnostic.severity === vscode.DiagnosticSeverity.Warning, + ), + ); + nestedDoc = await vscode.workspace.openTextDocument(nestedFilePath); + await vscode.window.showTextDocument(nestedDoc); + await waitForDiagnostics(nestedDoc, (diags) => + diags.some( + (diagnostic) => + diagnostic.message.includes('no-debugger') && + diagnostic.severity === vscode.DiagnosticSeverity.Error, + ), + ); + await closeTextEditor(nestedDoc); + nestedDoc = undefined; + + fs.writeFileSync( + nestedConfigPath, + `import fs from 'node:fs'; +fs.writeFileSync(${JSON.stringify(attemptedLoadPath)}, 'attempted', 'utf8'); +throw new Error('intentional broken nested config'); +export default []; +`, + 'utf8', + ); + await waitForFile(attemptedLoadPath); + assert.strictEqual( + fs.readFileSync(attemptedLoadPath, 'utf8'), + 'attempted', + 'The broken nested config must be evaluated before fallback is asserted', + ); + + // This URI does not exist until after the broken module has executed, + // so its diagnostics cannot be a stale snapshot from before the failed + // refresh. Its first lint must run after the blocking config transaction + // and resolve through the still-valid ancestor config. + fs.writeFileSync(postFailureFilePath, 'debugger;\n', 'utf8'); + postFailureDoc = + await vscode.workspace.openTextDocument(postFailureFilePath); + await vscode.window.showTextDocument(postFailureDoc); + const postFailureDiagnostics = await waitForDiagnostics( + postFailureDoc, + (diags) => + diags.some( + (diagnostic) => + diagnostic.message.includes('no-debugger') && + diagnostic.severity === vscode.DiagnosticSeverity.Error, + ), + ); + assert.deepStrictEqual( + postFailureDiagnostics + .filter((diagnostic) => diagnostic.message.includes('no-debugger')) + .map((diagnostic) => diagnostic.severity), + [vscode.DiagnosticSeverity.Error], + 'The valid ancestor must lint a file opened after the broken child was evaluated', + ); + }, + async () => { + const temporaryDocuments = [nestedDoc, postFailureDoc].filter( + (document): document is vscode.TextDocument => document !== undefined, + ); + await withFailClosedCleanup( + async () => { + await Promise.all( + temporaryDocuments.map((document) => closeTextEditor(document)), + ); + }, + async () => { + // The temporary root config deliberately lowers this rule to a + // warning. Waiting for Error makes restoration an observable + // config-transaction barrier before the next test starts. + const rootRestored = waitForDiagnostics(rootDoc, (diags) => + diags.some( + (diagnostic) => + diagnostic.message.includes('no-unsafe-member-access') && + diagnostic.severity === vscode.DiagnosticSeverity.Error, + ), + ); + fs.writeFileSync(rootConfigPath, originalRootConfig, 'utf8'); + fs.rmSync(nestedDir, { recursive: true, force: true }); + assert.ok( + !fs.existsSync(nestedDir), + 'Broken nested-config fixtures must be deleted during cleanup', + ); + await rootRestored; + }, + 'Broken nested-config resource cleanup', + ); + }, + 'Broken nested-config test', + ); + }); + + test('parent global ignores remove nested configs from the effective catalog', async () => { + const root = getWorkspaceRoot(); + const rootConfigPath = path.join(root, 'rslint.config.js'); + const originalRootConfig = fs.readFileSync(rootConfigPath, 'utf8'); + const nestedDir = path.join(root, 'parent-ignore-catalog-probe'); + const nestedConfigPath = path.join(nestedDir, 'rslint.config.mjs'); + const nestedFilePath = path.join(nestedDir, 'index.ts'); + const loadMarkerPath = path.join(nestedDir, 'config-loads.txt'); + const rootDoc = await openFixture('index.ts'); + + const ignoredRootConfig = originalRootConfig + .replace( + 'export default [', + "export default [{ ignores: ['parent-ignore-catalog-probe/**'] },", + ) + .replace( + "'@typescript-eslint/no-explicit-any': 'off'", + "'@typescript-eslint/no-explicit-any': 'warn'", + ); + + await withFailClosedCleanup( + async () => { + fs.mkdirSync(nestedDir, { recursive: true }); + fs.writeFileSync(nestedFilePath, 'console.log("nested");\n', 'utf8'); + fs.writeFileSync( + nestedConfigPath, + `import fs from 'node:fs'; +fs.appendFileSync(${JSON.stringify(loadMarkerPath)}, 'x'); +export default [{ files: ['**/*.ts'], rules: { 'no-console': 'error' } }]; +`, + 'utf8', + ); + + const nestedDoc = + await vscode.workspace.openTextDocument(nestedFilePath); + await vscode.window.showTextDocument(nestedDoc); + await waitForDiagnostics(nestedDoc, (diagnostics) => + diagnostics.some((diagnostic) => + diagnostic.message.includes('Unexpected console statement'), + ), + ); + + await vscode.window.showTextDocument(rootDoc); + const parentApplied = waitForDiagnostics(rootDoc, (diagnostics) => + diagnostics.some((diagnostic) => + diagnostic.message.includes('no-explicit-any'), + ), + ); + const nestedCleared = waitForDiagnostics( + nestedDoc, + (diagnostics) => + !diagnostics.some((diagnostic) => + diagnostic.message.includes('Unexpected console statement'), + ), + ); + + fs.writeFileSync(rootConfigPath, ignoredRootConfig, 'utf8'); + await Promise.all([parentApplied, nestedCleared]); + + assert.strictEqual( + fs.readFileSync(loadMarkerPath, 'utf8'), + 'x', + 'The ignored nested candidate must not be evaluated again', + ); + assert.ok( + !vscode.languages + .getDiagnostics(nestedDoc.uri) + .some((diagnostic) => + diagnostic.message.includes('Unexpected console statement'), + ), + 'The ignored nested config must not be sent in the effective catalog', + ); + }, + async () => { + const restored = waitForDiagnostics( + rootDoc, + (diagnostics) => + diagnostics.some((diagnostic) => + diagnostic.message.includes('no-unsafe-member-access'), + ) && + !diagnostics.some((diagnostic) => + diagnostic.message.includes('no-explicit-any'), + ), + ); + fs.rmSync(nestedDir, { recursive: true, force: true }); + fs.writeFileSync(rootConfigPath, originalRootConfig, 'utf8'); + await restored; + }, + 'Parent-ignore catalog test', + ); + }); + + test('config search excludes node_modules and .git', async () => { + const root = getWorkspaceRoot(); + const rootConfigPath = path.join(root, 'rslint.config.js'); + const originalRootConfig = fs.readFileSync(rootConfigPath, 'utf8'); + const rootDoc = await openFixture('index.ts'); + const gitDirectory = path.join(root, '.git'); + const gitDirectoryExisted = fs.existsSync(gitDirectory); + const probes = [ + path.join(root, 'node_modules', 'rslint-config-search-probe'), + path.join(gitDirectory, 'rslint-config-search-probe'), + ]; + const markerPaths = probes.map((probe) => path.join(probe, 'loaded.txt')); + const changedRootConfig = originalRootConfig.replace( + "'@typescript-eslint/no-explicit-any': 'off'", + "'@typescript-eslint/no-explicit-any': 'warn'", + ); + + await withFailClosedCleanup( + async () => { + for (let index = 0; index < probes.length; index++) { + fs.mkdirSync(probes[index], { recursive: true }); + fs.writeFileSync( + path.join(probes[index], 'rslint.config.mjs'), + `import fs from 'node:fs'; fs.writeFileSync(${JSON.stringify(markerPaths[index])}, 'loaded'); export default [];`, + 'utf8', + ); + } + + await vscode.window.showTextDocument(rootDoc); + const reloaded = waitForDiagnostics(rootDoc, (diagnostics) => + diagnostics.some((diagnostic) => + diagnostic.message.includes('no-explicit-any'), + ), + ); + fs.writeFileSync(rootConfigPath, changedRootConfig, 'utf8'); + await reloaded; + + for (const markerPath of markerPaths) { + assert.ok( + !fs.existsSync(markerPath), + `Excluded config was unexpectedly loaded: ${markerPath}`, + ); + } + }, + async () => { + const restored = waitForDiagnostics( + rootDoc, + (diagnostics) => + diagnostics.some((diagnostic) => + diagnostic.message.includes('no-unsafe-member-access'), + ) && + !diagnostics.some((diagnostic) => + diagnostic.message.includes('no-explicit-any'), + ), + ); + for (const probe of probes) { + fs.rmSync(probe, { recursive: true, force: true }); + } + if (!gitDirectoryExisted) { + try { + fs.rmdirSync(gitDirectory); + } catch { + // Leave a concurrently populated directory intact. + } + } + fs.writeFileSync(rootConfigPath, originalRootConfig, 'utf8'); + await restored; + }, + 'Excluded-config search test', + ); + }); + + test('same-directory configs use .js > .mjs > .ts > .mts priority', async () => { + const root = getWorkspaceRoot(); + const jsPath = path.join(root, 'rslint.config.js'); + const mjsPath = path.join(root, 'rslint.config.mjs'); + const tsPath = path.join(root, 'rslint.config.ts'); + const mtsPath = path.join(root, 'rslint.config.mts'); + const originalJS = fs.readFileSync(jsPath, 'utf8'); + const doc = await openFixture('index.ts'); + await vscode.window.showTextDocument(doc); + + const configFor = ( + enabledRule: + | '@typescript-eslint/no-explicit-any' + | '@typescript-eslint/no-unsafe-member-access', + ): string => `export default [ + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: false, + project: ['./tsconfig.json'], + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': '${enabledRule.endsWith('no-explicit-any') ? 'warn' : 'off'}', + '@typescript-eslint/no-unsafe-member-access': '${enabledRule.endsWith('no-unsafe-member-access') ? 'warn' : 'off'}', + }, + plugins: ['@typescript-eslint'], + }, +]; +`; + + const mutateAndExpectWarning = async ( + ruleName: string, + mutate: () => void, + ): Promise => { + const result = waitForDiagnostics(doc, (diags) => + diags.some( + (d) => + d.message.includes(ruleName) && + d.severity === vscode.DiagnosticSeverity.Warning, + ), + ); + mutate(); + const diagnostics = await result; + const diagnostic = diagnostics.find((d) => d.message.includes(ruleName)); + assert.strictEqual( + diagnostic?.severity, + vscode.DiagnosticSeverity.Warning, + `Expected ${ruleName} from the selected config`, + ); + }; + + await waitForDiagnostics(doc, (diags) => + diags.some( + (diagnostic) => + diagnostic.message.includes('no-unsafe-member-access') && + diagnostic.severity === vscode.DiagnosticSeverity.Error, + ), + ); + + await withFailClosedCleanup( + async () => { + fs.writeFileSync( + mjsPath, + configFor('@typescript-eslint/no-explicit-any'), + 'utf8', + ); + fs.writeFileSync( + tsPath, + configFor('@typescript-eslint/no-unsafe-member-access'), + 'utf8', + ); + fs.writeFileSync( + mtsPath, + configFor('@typescript-eslint/no-explicit-any'), + 'utf8', + ); + + await mutateAndExpectWarning('no-explicit-any', () => + fs.unlinkSync(jsPath), + ); + await mutateAndExpectWarning('no-unsafe-member-access', () => + fs.unlinkSync(mjsPath), + ); + await mutateAndExpectWarning('no-explicit-any', () => + fs.unlinkSync(tsPath), + ); + }, + async () => { + const restored = waitForDiagnostics(doc, (diags) => + diags.some( + (d) => + d.message.includes('no-unsafe-member-access') && + d.severity === vscode.DiagnosticSeverity.Error, + ), + ); + fs.writeFileSync(jsPath, originalJS, 'utf8'); + fs.rmSync(mjsPath, { force: true }); + fs.rmSync(tsPath, { force: true }); + fs.rmSync(mtsPath, { force: true }); + await restored; + }, + 'Same-directory config-priority test', + ); + }); + + test('broken higher-priority config preserves last-good and does not load a lower variant', async () => { + const root = getWorkspaceRoot(); + const jsPath = path.join(root, 'rslint.config.js'); + const mjsPath = path.join(root, 'rslint.config.mjs'); + const attemptedLoadPath = path.join(root, 'broken-config-attempted.txt'); + const originalJS = fs.readFileSync(jsPath, 'utf8'); + const doc = await openFixture('index.ts'); + await vscode.window.showTextDocument(doc); + await waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('no-unsafe-member-access')), + ); + + const lowerPriorityConfig = `export default [{ + languageOptions: { parserOptions: { project: ['./tsconfig.json'] } }, + rules: { + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unsafe-member-access': 'off', + }, + plugins: ['@typescript-eslint'], +}]; +`; + + await withFailClosedCleanup( + async () => { + fs.writeFileSync(mjsPath, lowerPriorityConfig, 'utf8'); + fs.writeFileSync( + jsPath, + `import fs from 'node:fs'; +fs.writeFileSync(${JSON.stringify(attemptedLoadPath)}, 'attempted'); +throw new Error('intentional broken higher-priority config'); +export default []; +`, + 'utf8', + ); + await waitForFile(attemptedLoadPath); + + // Request fresh diagnostics only after the failing module was actually + // evaluated. A stale pre-reload snapshot cannot satisfy both content + // transitions, and falling through to .mjs would report another rule. + const originalContent = doc.getText(); + const editor = await vscode.window.showTextDocument(doc); + const cleared = waitForDiagnostics( + doc, + (diagnostics) => diagnostics.length === 0, + ); + assert.ok( + await editor.edit((edit) => { + edit.replace( + new vscode.Range( + doc.positionAt(0), + doc.positionAt(doc.getText().length), + ), + 'const safe = 1;\n', + ); + }), + 'Editing the last-good config probe to clean content should succeed', + ); + await cleared; + + const lastGoodApplied = waitForDiagnostics(doc, (diagnostics) => + diagnostics.some((diagnostic) => + diagnostic.message.includes('no-unsafe-member-access'), + ), + ); + assert.ok( + await editor.edit((edit) => { + edit.replace( + new vscode.Range( + doc.positionAt(0), + doc.positionAt(doc.getText().length), + ), + originalContent, + ); + }), + 'Restoring the last-good config probe content should succeed', + ); + const diagnostics = await lastGoodApplied; + assert.ok( + !diagnostics.some((d) => d.message.includes('no-explicit-any')), + 'A broken .js must not fall through to .mjs or JSON', + ); + }, + async () => { + await revertTextDocument(doc); + const restored = waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('no-unsafe-member-access')), + ); + fs.writeFileSync(jsPath, originalJS, 'utf8'); + fs.rmSync(mjsPath, { force: true }); + fs.rmSync(attemptedLoadPath, { force: true }); + await restored; + }, + 'Broken higher-priority config test', + ); + }); +}); diff --git a/packages/vscode/tests/e2e/lint/suite-jsconfig/rslint-lifecycle.test.ts b/packages/vscode/tests/e2e/lint/suite-jsconfig/rslint-lifecycle.test.ts new file mode 100644 index 0000000..7cd3a4e --- /dev/null +++ b/packages/vscode/tests/e2e/lint/suite-jsconfig/rslint-lifecycle.test.ts @@ -0,0 +1,196 @@ +// Ported from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/suite-jsconfig/rslint-lifecycle.test.ts` +// (origin/main). Only the import paths changed: the copied extension sources +// live under `src/stacks/lint/` in this repo. +import * as assert from 'node:assert'; +import { window } from 'vscode'; +import { CloseAction, ErrorAction, State } from 'vscode-languageclient/node'; +import { LanguageServerProcessOwner } from '../../../../src/stacks/lint/LanguageServerProcessOwner'; +import { + disposeLanguageClient, + ManagedLanguageClient, + shouldResetDocumentSessionOnServerState, + waitForPromiseSettlement, +} from '../../../../src/stacks/lint/Rslint'; + +const FORCE_KILL_CHILD = + "process.on('SIGTERM', () => undefined); setTimeout(() => process.stdout.write('ready\\n'), 20); setInterval(() => undefined, 1_000)"; + +function processOwner(source = FORCE_KILL_CHILD): LanguageServerProcessOwner { + return new LanguageServerProcessOwner( + process.execPath, + ['-e', source], + process.cwd(), + { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + ); +} + +async function waitForOutput( + stream: NodeJS.ReadableStream, + expected: string, +): Promise { + await new Promise((resolve) => { + const onData = (chunk: Buffer): void => { + if (!chunk.toString().includes(expected)) return; + stream.removeListener('data', onData); + resolve(); + }; + stream.on('data', onData); + }); +} + +suite('Rslint lifecycle', () => { + test('disposes diagnostics without waiting for a Starting client handshake', async () => { + let clientDisposeCalls = 0; + let diagnosticDisposeCalls = 0; + + await disposeLanguageClient({ + state: State.Starting, + diagnostics: { + dispose() { + diagnosticDisposeCalls++; + }, + }, + async dispose() { + clientDisposeCalls++; + throw new Error('client is still starting'); + }, + }); + + assert.strictEqual(clientDisposeCalls, 1); + assert.strictEqual(diagnosticDisposeCalls, 1); + }); + + test('reports a Running client disposal failure after disposing diagnostics', async () => { + let diagnosticDisposeCalls = 0; + + await assert.rejects( + disposeLanguageClient({ + state: State.Running, + diagnostics: { + dispose() { + diagnosticDisposeCalls++; + }, + }, + async dispose() { + throw new Error('shutdown failed'); + }, + }), + /shutdown failed/, + ); + + assert.strictEqual(diagnosticDisposeCalls, 1); + }); + + test('awaits native child termination and rejects later restarts', async () => { + const owner = processOwner(); + const child = await owner.start(); + await waitForOutput(child.stdout, 'ready'); + + await owner.close(); + + assert.ok( + child.exitCode !== null || child.signalCode !== null, + 'close should resolve only after the child exits', + ); + if (process.platform !== 'win32') { + assert.strictEqual(child.signalCode, 'SIGKILL'); + } + await assert.rejects(owner.start(), /process owner is closing/); + }); + + test('terminates the prior child before an automatic restart spawn', async () => { + const owner = processOwner(); + const first = await owner.start(); + await waitForOutput(first.stdout, 'ready'); + + const second = await owner.start(); + + assert.notStrictEqual(first.pid, second.pid); + assert.ok( + first.exitCode !== null || first.signalCode !== null, + 'the old child must close before start returns its replacement', + ); + await waitForOutput(second.stdout, 'ready'); + await owner.close(); + }); + + test('settles a hung initialize tail after forced transport close', async () => { + const source = + "process.on('SIGTERM', () => undefined); process.stdin.on('data', () => process.stderr.write('initialize-request\\n')); setTimeout(() => process.stderr.write('ready\\n'), 20); setInterval(() => undefined, 1_000)"; + const owner = processOwner(source); + const outputChannel = window.createOutputChannel( + `Rslint lifecycle hang ${Date.now()}`, + ); + let closing = false; + let spawnCount = 0; + let initializeReceived!: () => void; + const receivedInitialize = new Promise((resolve) => { + initializeReceived = resolve; + }); + const client = new ManagedLanguageClient( + `rslint-lifecycle-hang-${Date.now()}`, + 'Rslint lifecycle hang probe', + async () => { + const child = await owner.start(); + spawnCount++; + child.stderr.on('data', (chunk: Buffer) => { + if (chunk.toString().includes('initialize-request')) { + initializeReceived(); + } + }); + await waitForOutput(child.stderr, 'ready'); + return child; + }, + { + documentSelector: [], + outputChannel, + errorHandler: { + error: () => ({ action: ErrorAction.Shutdown }), + closed: () => ({ + action: closing ? CloseAction.DoNotRestart : CloseAction.Restart, + handled: closing, + }), + }, + }, + ); + + const startPromise = client.start(); + void startPromise.catch(() => undefined); + await waitForPromiseSettlement( + receivedInitialize, + 2_000, + 'initialize request probe', + ); + assert.strictEqual(client.state, State.Starting); + + closing = true; + owner.beginClose(); + await disposeLanguageClient(client); + await owner.close(); + await waitForPromiseSettlement( + startPromise, + 2_000, + 'hung language client start', + ); + + assert.strictEqual(client.state, State.Stopped); + assert.strictEqual(spawnCount, 1, 'closing must suppress restart'); + outputChannel.dispose(); + }); + + test('resets document sessions as soon as a Running server exits', () => { + assert.strictEqual( + shouldResetDocumentSessionOnServerState(State.Running, State.Stopped), + true, + ); + assert.strictEqual( + shouldResetDocumentSessionOnServerState(State.Stopped, State.Starting), + false, + ); + assert.strictEqual( + shouldResetDocumentSessionOnServerState(State.Starting, State.Running), + false, + ); + }); +}); diff --git a/packages/vscode/tests/e2e/lint/suite-jsconfig/workspace-coordinator.test.ts b/packages/vscode/tests/e2e/lint/suite-jsconfig/workspace-coordinator.test.ts new file mode 100644 index 0000000..757cf55 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/suite-jsconfig/workspace-coordinator.test.ts @@ -0,0 +1,340 @@ +// Ported from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/suite-jsconfig/workspace-coordinator.test.ts` +// (origin/main). Only the import path changed: the copied extension sources +// live under `src/stacks/lint/` in this repo. +import * as assert from 'node:assert'; +import { + Uri, + type TextDocument, + type WorkspaceFolder, + type WorkspaceFoldersChangeEvent, +} from 'vscode'; +import { + WorkspaceRslintCoordinator, + workspaceRootKey, + type WorkspaceCoordinatorLogger, + type WorkspaceRootRouter, + type WorkspaceRuntime, +} from '../../../../src/stacks/lint/WorkspaceRslintCoordinator'; + +type StartMode = 'ready' | 'fail' | 'pending' | 'factory-fail'; + +class FakeRuntime implements WorkspaceRuntime { + readonly opened: string[] = []; + readonly closedDocuments: string[] = []; + closeCalls = 0; + failClose = false; + + constructor( + readonly workspaceFolder: WorkspaceFolder, + readonly rootKey: string, + private readonly startMode: StartMode, + ) {} + + async start(signal: AbortSignal): Promise { + if (this.startMode === 'ready') return; + if (this.startMode === 'fail') throw new Error(`failed ${this.rootKey}`); + await new Promise((resolve, reject) => { + const onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + void resolve; + }); + } + + async close(): Promise { + this.closeCalls++; + if (this.failClose) throw new Error(`close failed ${this.rootKey}`); + } + + async sendDocumentOpen(document: TextDocument): Promise { + this.opened.push(document.uri.toString()); + } + + async sendDocumentClose(document: TextDocument): Promise { + this.closedDocuments.push(document.uri.toString()); + } + + clearDocumentDiagnostics(): void {} +} + +class FakeRouter implements WorkspaceRootRouter { + readonly active = new Map(); + + async activate(runtime: WorkspaceRuntime): Promise { + this.active.set(runtime.rootKey, runtime); + } + + async deactivate(rootKey: string): Promise { + this.active.delete(rootKey); + } + + async closeAll(): Promise { + this.active.clear(); + } +} + +const silentLogger: WorkspaceCoordinatorLogger = { + debug() {}, + info() {}, + warn() {}, + error() {}, +}; + +function folder(fsPath: string, name = 'app', index = 0): WorkspaceFolder { + return { uri: Uri.file(fsPath), name, index }; +} + +function changeEvent( + added: readonly WorkspaceFolder[], + removed: readonly WorkspaceFolder[], +): WorkspaceFoldersChangeEvent { + return { added, removed }; +} + +async function eventually( + predicate: () => boolean, + message: string, +): Promise { + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + assert.fail(message); +} + +function coordinatorHarness( + modeFor: (rootKey: string) => StartMode = () => 'ready', +) { + const router = new FakeRouter(); + const runtimes: FakeRuntime[] = []; + const coordinator = new WorkspaceRslintCoordinator( + router, + (workspaceFolder, rootKey) => { + const mode = modeFor(rootKey); + if (mode === 'factory-fail') { + throw new Error(`factory failed ${rootKey}`); + } + const runtime = new FakeRuntime(workspaceFolder, rootKey, mode); + runtimes.push(runtime); + return runtime; + }, + silentLogger, + ); + return { coordinator, router, runtimes }; +} + +suite('workspace runtime coordinator', () => { + test('uses URI identity for same-name roots', async () => { + const first = folder('/workspace/first/app', 'app', 0); + const second = folder('/workspace/second/app', 'app', 1); + const { coordinator, router, runtimes } = coordinatorHarness(); + + await coordinator.initialize([first, second]); + await eventually( + () => router.active.size === 2, + 'both same-name roots should become active', + ); + + assert.notStrictEqual(workspaceRootKey(first), workspaceRootKey(second)); + assert.strictEqual(runtimes.length, 2); + await coordinator.close(); + }); + + test('isolates initial root failures', async () => { + const broken = folder('/workspace/broken', 'broken', 0); + const healthy = folder('/workspace/healthy', 'healthy', 1); + const { coordinator, router } = coordinatorHarness((key) => + key === workspaceRootKey(broken) ? 'fail' : 'ready', + ); + + await coordinator.initialize([broken, healthy]); + await eventually( + () => router.active.has(workspaceRootKey(healthy)), + 'healthy root should remain active', + ); + assert.strictEqual(router.active.has(workspaceRootKey(broken)), false); + await coordinator.close(); + }); + + test('isolates runtime factory failures', async () => { + const broken = folder('/workspace/broken', 'broken', 0); + const healthy = folder('/workspace/healthy', 'healthy', 1); + const { coordinator, router } = coordinatorHarness((key) => + key === workspaceRootKey(broken) ? 'factory-fail' : 'ready', + ); + + await coordinator.initialize([broken, healthy]); + await eventually( + () => router.active.has(workspaceRootKey(healthy)), + 'healthy root should survive a sibling factory failure', + ); + await coordinator.close(); + }); + + test('does not let a pending root block another root or removal', async () => { + const pending = folder('/workspace/pending', 'pending', 0); + const healthy = folder('/workspace/healthy', 'healthy', 1); + const { coordinator, router, runtimes } = coordinatorHarness((key) => + key === workspaceRootKey(pending) ? 'pending' : 'ready', + ); + + await coordinator.initialize([pending, healthy]); + coordinator.handleWorkspaceFoldersChanged(changeEvent([], [pending]), [ + healthy, + ]); + await eventually( + () => + runtimes.find( + (runtime) => runtime.rootKey === workspaceRootKey(pending), + )?.closeCalls === 1, + 'removed pending root should close', + ); + assert.strictEqual(router.active.has(workspaceRootKey(healthy)), true); + await coordinator.close(); + }); + + test('follows a topology replacement while activation is still pending', async () => { + const pending = folder('/workspace/pending', 'pending', 0); + const replacement = folder('/workspace/replacement', 'replacement', 0); + const { coordinator, router } = coordinatorHarness((key) => + key === workspaceRootKey(pending) ? 'pending' : 'ready', + ); + + const initializing = coordinator.initialize([pending]); + coordinator.handleWorkspaceFoldersChanged( + changeEvent([replacement], [pending]), + [replacement], + ); + await initializing; + + assert.strictEqual(router.active.has(workspaceRootKey(pending)), false); + assert.strictEqual(router.active.has(workspaceRootKey(replacement)), true); + await coordinator.close(); + }); + + test('lets an added healthy root unblock a pending initial root', async () => { + const pending = folder('/workspace/pending', 'pending', 0); + const healthy = folder('/workspace/healthy', 'healthy', 1); + const { coordinator, router } = coordinatorHarness((key) => + key === workspaceRootKey(pending) ? 'pending' : 'ready', + ); + + const initializing = coordinator.initialize([pending]); + coordinator.handleWorkspaceFoldersChanged(changeEvent([healthy], []), [ + pending, + healthy, + ]); + await initializing; + + assert.strictEqual(router.active.has(workspaceRootKey(healthy)), true); + await coordinator.close(); + }); + + test('replaces a renamed root even when its URI is unchanged', async () => { + const original = folder('/workspace/app', 'old-name', 0); + const renamed = folder('/workspace/app', 'new-name', 0); + const { coordinator, router, runtimes } = coordinatorHarness(); + await coordinator.initialize([original]); + + coordinator.handleWorkspaceFoldersChanged( + changeEvent([renamed], [original]), + [renamed], + ); + await eventually( + () => runtimes.length === 2 && runtimes[0].closeCalls === 1, + 'rename should replace and close the old runtime', + ); + assert.strictEqual( + router.active.get(workspaceRootKey(renamed))?.workspaceFolder.name, + 'new-name', + ); + await coordinator.close(); + }); + + test('quarantines a close-failed runtime instead of overlapping its replacement', async () => { + const original = folder('/workspace/app', 'old-name', 0); + const renamed = folder('/workspace/app', 'new-name', 0); + const { coordinator, router, runtimes } = coordinatorHarness(); + await coordinator.initialize([original]); + runtimes[0].failClose = true; + + coordinator.handleWorkspaceFoldersChanged( + changeEvent([renamed], [original]), + [renamed], + ); + await eventually( + () => runtimes[0].closeCalls === 1, + 'replacement should attempt to close the old runtime', + ); + + assert.strictEqual(runtimes.length, 1, 'replacement must not overlap'); + assert.strictEqual(router.active.has(workspaceRootKey(original)), false); + await assert.rejects( + coordinator.close(), + /failed to close workspace coordinator/, + ); + assert.strictEqual( + runtimes[0].closeCalls, + 2, + 'terminal close should retry', + ); + }); + + test('does not restart a root when only its positional index changes', async () => { + const original = folder('/workspace/app', 'app', 0); + const shifted = folder('/workspace/app', 'app', 1); + const inserted = folder('/workspace/inserted', 'inserted', 0); + const { coordinator, router, runtimes } = coordinatorHarness(); + await coordinator.initialize([original]); + + coordinator.handleWorkspaceFoldersChanged(changeEvent([inserted], []), [ + inserted, + shifted, + ]); + await eventually( + () => router.active.size === 2, + 'new root should become active', + ); + assert.strictEqual( + runtimes.filter( + (runtime) => runtime.rootKey === workspaceRootKey(original), + ).length, + 1, + ); + await coordinator.close(); + }); + + test('rejects activation when every root fails', async () => { + const first = folder('/workspace/first', 'first', 0); + const second = folder('/workspace/second', 'second', 1); + const { coordinator } = coordinatorHarness(() => 'fail'); + + await assert.rejects( + coordinator.initialize([first, second]), + /All Rslint workspace roots failed/, + ); + await coordinator.close(); + }); + + test('closes every root and reports terminal close failures', async () => { + const first = folder('/workspace/first', 'first', 0); + const second = folder('/workspace/second', 'second', 1); + const { coordinator, router, runtimes } = coordinatorHarness(); + await coordinator.initialize([first, second]); + await eventually( + () => router.active.size === 2, + 'both roots should become active', + ); + runtimes[0].failClose = true; + + await assert.rejects( + coordinator.close(), + /failed to close workspace coordinator/, + ); + assert.deepStrictEqual( + runtimes.map((runtime) => runtime.closeCalls), + [1, 1], + ); + }); +}); diff --git a/packages/vscode/tests/e2e/lint/suite-jsconfig/workspace-router.test.ts b/packages/vscode/tests/e2e/lint/suite-jsconfig/workspace-router.test.ts new file mode 100644 index 0000000..941c3b3 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/suite-jsconfig/workspace-router.test.ts @@ -0,0 +1,445 @@ +// Ported from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/suite-jsconfig/workspace-router.test.ts` +// (origin/main). Only the import path changed: the copied extension sources +// live under `src/stacks/lint/` in this repo. +import * as assert from 'node:assert'; +import { + commands, + Range, + Uri, + window, + workspace, + type TextDocument, + type TextDocumentChangeEvent, + type WorkspaceFolder, +} from 'vscode'; +import { + WorkspaceDocumentRouter, + type DocumentRoutingRuntime, +} from '../../../../src/stacks/lint/WorkspaceDocumentRouter'; + +class FakeRoutingRuntime implements DocumentRoutingRuntime { + readonly events: string[] = []; + failOpen = false; + + constructor( + readonly rootKey: string, + readonly workspaceFolder: WorkspaceFolder, + ) {} + + async sendDocumentOpen(document: TextDocument): Promise { + this.events.push(`open:${document.uri}:${document.getText()}`); + if (this.failOpen) throw new Error(`open failed for ${this.rootKey}`); + } + + async sendDocumentClose(document: TextDocument): Promise { + this.events.push(`close:${document.uri}`); + } + + clearDocumentDiagnostics(uri: Uri): void { + this.events.push(`clear:${uri}`); + } +} + +function detachedDocument(uri: Uri): TextDocument { + return { + uri, + languageId: 'typescript', + getText: () => 'const stale = 1;\n', + } as TextDocument; +} + +suite('workspace document router', () => { + let document: TextDocument; + let parentFolder: WorkspaceFolder; + let childFolder: WorkspaceFolder; + let testDirectory: Uri; + + suiteSetup(async () => { + const workspaceFolder = workspace.workspaceFolders?.[0]; + assert.ok(workspaceFolder, 'test requires a workspace folder'); + parentFolder = workspaceFolder; + testDirectory = Uri.joinPath( + workspaceFolder.uri, + `.router-test-${Date.now()}`, + ); + await workspace.fs.createDirectory(testDirectory); + const file = Uri.joinPath(testDirectory, 'nested.ts'); + await workspace.fs.writeFile(file, Buffer.from('const value = 1;\n')); + document = await workspace.openTextDocument(file); + childFolder = { + uri: testDirectory, + name: 'nested', + index: workspaceFolder.index + 1, + }; + }); + + suiteTeardown(async () => { + await window.showTextDocument(document, { preview: false }); + await commands.executeCommand('workbench.action.closeActiveEditor'); + await workspace.fs.delete(testDirectory, { recursive: true }); + }); + + test('hands an open document to the longest active root', async () => { + const router = new WorkspaceDocumentRouter(); + const parent = new FakeRoutingRuntime( + parentFolder.uri.toString(), + parentFolder, + ); + const child = new FakeRoutingRuntime( + childFolder.uri.toString(), + childFolder, + ); + + await router.activate(parent); + assert.strictEqual(router.getServerOpenOwner(document), parent.rootKey); + parent.events.length = 0; + + await router.activate(child); + assert.strictEqual(router.getServerOpenOwner(document), child.rootKey); + assert.deepStrictEqual( + parent.events.filter((event) => event.includes(document.uri.toString())), + [`close:${document.uri}`, `clear:${document.uri}`], + ); + assert.ok( + child.events.some((event) => + event.startsWith(`open:${document.uri}:const value = 1;`), + ), + ); + + child.events.length = 0; + parent.events.length = 0; + await router.deactivate(child.rootKey); + assert.strictEqual(router.getServerOpenOwner(document), parent.rootKey); + assert.deepStrictEqual( + child.events.filter((event) => event.includes(document.uri.toString())), + [`close:${document.uri}`, `clear:${document.uri}`], + ); + assert.ok( + parent.events.some((event) => + event.startsWith(`open:${document.uri}:const value = 1;`), + ), + ); + await router.closeAll(); + }); + + test('forwards text changes only through the server-open owner', async () => { + const router = new WorkspaceDocumentRouter(); + const parent = new FakeRoutingRuntime( + parentFolder.uri.toString(), + parentFolder, + ); + const child = new FakeRoutingRuntime( + childFolder.uri.toString(), + childFolder, + ); + await router.activate(parent); + await router.activate(child); + + const event: TextDocumentChangeEvent = { + document, + reason: undefined, + contentChanges: [ + { + range: new Range(0, 0, 0, 0), + rangeOffset: 0, + rangeLength: 0, + text: 'x', + }, + ], + }; + let parentChanges = 0; + let childChanges = 0; + await Promise.all([ + router.createMiddleware(parent).didChange?.(event, async () => { + parentChanges++; + }), + router.createMiddleware(child).didChange?.(event, async () => { + childChanges++; + }), + ]); + assert.strictEqual(parentChanges, 0); + assert.strictEqual(childChanges, 1); + await router.closeAll(); + }); + + test('does not duplicate didOpen after a topology handoff opened the document', async () => { + const router = new WorkspaceDocumentRouter(); + const parent = new FakeRoutingRuntime( + parentFolder.uri.toString(), + parentFolder, + ); + await router.activate(parent); + + let forwarded = 0; + await router.createMiddleware(parent).didOpen?.(document, async () => { + forwarded++; + }); + + assert.strictEqual(forwarded, 0); + assert.strictEqual( + parent.events.filter((event) => event.startsWith(`open:${document.uri}:`)) + .length, + 1, + ); + await router.closeAll(); + }); + + test('clears ownership and diagnostics when a normal didClose fails', async () => { + const router = new WorkspaceDocumentRouter(); + const parent = new FakeRoutingRuntime( + parentFolder.uri.toString(), + parentFolder, + ); + await router.activate(parent); + parent.events.length = 0; + + const didClose = router.createMiddleware(parent).didClose; + assert.ok(didClose); + await assert.rejects( + didClose(document, async () => { + throw new Error('server close failed'); + }), + /server close failed/, + ); + + assert.deepStrictEqual(parent.events, [`clear:${document.uri}`]); + assert.strictEqual(router.getServerOpenOwner(document), undefined); + await router.closeAll(); + }); + + test('resets a restarted server session before its didOpen replay', async () => { + const router = new WorkspaceDocumentRouter(); + const parent = new FakeRoutingRuntime( + parentFolder.uri.toString(), + parentFolder, + ); + await router.activate(parent); + parent.events.length = 0; + + const didOpen = router.createMiddleware(parent).didOpen; + assert.ok(didOpen); + const reset = router.resetServerSession(parent); + const replay = didOpen(document, async () => { + parent.events.push(`replay-open:${document.uri}`); + }); + await Promise.all([reset, replay]); + + assert.deepStrictEqual( + parent.events.filter((event) => event.includes(document.uri.toString())), + [`clear:${document.uri}`, `replay-open:${document.uri}`], + ); + assert.strictEqual(router.getServerOpenOwner(document), parent.rootKey); + await router.closeAll(); + }); + + test('lets a nested root activate during the restart gap after early reset', async () => { + const router = new WorkspaceDocumentRouter(); + const parent = new FakeRoutingRuntime( + parentFolder.uri.toString(), + parentFolder, + ); + const child = new FakeRoutingRuntime( + childFolder.uri.toString(), + childFolder, + ); + await router.activate(parent); + await router.resetServerSession(parent); + parent.events.length = 0; + + await router.activate(child); + + assert.strictEqual(router.getServerOpenOwner(document), child.rootKey); + assert.strictEqual( + parent.events.some((event) => event === `close:${document.uri}`), + false, + 'a cleared old transport must not receive didClose', + ); + assert.ok( + child.events.some((event) => + event.startsWith(`open:${document.uri}:const value = 1;`), + ), + ); + await router.closeAll(); + }); + + test('forgets a document closed during restart before the same URI reopens', async () => { + const file = Uri.joinPath(testDirectory, 'restart-closed.ts'); + const staleDocument = detachedDocument(file); + const router = new WorkspaceDocumentRouter(); + const parent = new FakeRoutingRuntime( + parentFolder.uri.toString(), + parentFolder, + ); + await router.activate(parent); + const didOpen = router.createMiddleware(parent).didOpen; + assert.ok(didOpen); + await didOpen(staleDocument, async () => undefined); + assert.strictEqual( + router.getServerOpenOwner(staleDocument), + parent.rootKey, + ); + + // Simulate the LanguageClient feature-listener gap: VS Code closes the + // document, but this router never receives that generation's didClose. A + // detached TextDocument models the retained old session while the URI is + // absent from workspace.textDocuments. + assert.strictEqual( + workspace.textDocuments.some( + (candidate) => candidate.uri.toString() === file.toString(), + ), + false, + ); + + parent.events.length = 0; + await router.resetServerSession(parent); + assert.deepStrictEqual( + parent.events.filter((event) => event.includes(file.toString())), + [`clear:${file}`], + ); + + const reopenedDocument = detachedDocument(file); + let forwarded = 0; + await didOpen(reopenedDocument, async () => { + forwarded++; + }); + assert.strictEqual(forwarded, 1); + assert.strictEqual( + router.getServerOpenOwner(reopenedDocument), + parent.rootKey, + ); + + await router.closeAll(); + }); + + test('drains a closed restart-gap session when its root is removed', async () => { + const file = Uri.joinPath(testDirectory, 'removed-root-stale.ts'); + const staleDocument = detachedDocument(file); + const router = new WorkspaceDocumentRouter(); + const parent = new FakeRoutingRuntime( + parentFolder.uri.toString(), + parentFolder, + ); + await router.activate(parent); + const firstDidOpen = router.createMiddleware(parent).didOpen; + assert.ok(firstDidOpen); + await firstDidOpen(staleDocument, async () => undefined); + assert.strictEqual( + workspace.textDocuments.some( + (candidate) => candidate.uri.toString() === file.toString(), + ), + false, + ); + + parent.events.length = 0; + await router.deactivate(parent.rootKey); + assert.deepStrictEqual( + parent.events.filter((event) => event.includes(file.toString())), + [`clear:${file}`], + ); + + await router.activate(parent); + const reopenedDocument = detachedDocument(file); + const didOpen = router.createMiddleware(parent).didOpen; + assert.ok(didOpen); + let forwarded = 0; + await didOpen(reopenedDocument, async () => { + forwarded++; + }); + assert.strictEqual(forwarded, 1); + assert.strictEqual( + router.getServerOpenOwner(reopenedDocument), + parent.rootKey, + ); + + await router.closeAll(); + }); + + test('rejects diagnostics from a replaced runtime with the same root URI', async () => { + const router = new WorkspaceDocumentRouter(); + const oldRuntime = new FakeRoutingRuntime( + parentFolder.uri.toString(), + parentFolder, + ); + const replacement = new FakeRoutingRuntime( + parentFolder.uri.toString(), + parentFolder, + ); + const oldMiddleware = router.createMiddleware(oldRuntime); + const replacementMiddleware = router.createMiddleware(replacement); + await router.activate(oldRuntime); + await router.deactivate(oldRuntime.rootKey); + await router.activate(replacement); + + let oldDiagnostics = 0; + let replacementDiagnostics = 0; + oldMiddleware.handleDiagnostics?.(document.uri, [], () => { + oldDiagnostics++; + }); + replacementMiddleware.handleDiagnostics?.(document.uri, [], () => { + replacementDiagnostics++; + }); + + assert.strictEqual(oldDiagnostics, 0); + assert.strictEqual(replacementDiagnostics, 1); + await router.closeAll(); + }); + + test('drops a code action whose ownership changes while awaiting', async () => { + const router = new WorkspaceDocumentRouter(); + const parent = new FakeRoutingRuntime( + parentFolder.uri.toString(), + parentFolder, + ); + const child = new FakeRoutingRuntime( + childFolder.uri.toString(), + childFolder, + ); + await router.activate(parent); + + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + const action = router.createMiddleware(parent).provideCodeActions?.( + document, + new Range(0, 0, 0, 0), + { diagnostics: [], only: undefined, triggerKind: 1 }, + { + isCancellationRequested: false, + onCancellationRequested: () => ({ dispose() {} }), + }, + async () => { + await pending; + return []; + }, + ); + + await router.activate(child); + release(); + assert.strictEqual(await Promise.resolve(action), undefined); + await router.closeAll(); + }); + + test('rolls back to the prior owner when a new owner cannot open', async () => { + const router = new WorkspaceDocumentRouter(); + const parent = new FakeRoutingRuntime( + parentFolder.uri.toString(), + parentFolder, + ); + const child = new FakeRoutingRuntime( + childFolder.uri.toString(), + childFolder, + ); + child.failOpen = true; + await router.activate(parent); + + await assert.rejects( + router.activate(child), + /failed to activate document owner/, + ); + assert.strictEqual(router.getServerOpenOwner(document), parent.rootKey); + assert.strictEqual(router.ownerKeyForDocument(document), parent.rootKey); + await router.closeAll(); + }); +}); diff --git a/packages/vscode/tests/e2e/lint/suite-monorepo/index.ts b/packages/vscode/tests/e2e/lint/suite-monorepo/index.ts new file mode 100644 index 0000000..e8a41f6 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/suite-monorepo/index.ts @@ -0,0 +1,3 @@ +import { createRun } from '../runSuite'; + +export const run = createRun(); diff --git a/packages/vscode/tests/e2e/lint/suite-monorepo/monorepo.test.ts b/packages/vscode/tests/e2e/lint/suite-monorepo/monorepo.test.ts new file mode 100644 index 0000000..8b7bca7 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/suite-monorepo/monorepo.test.ts @@ -0,0 +1,527 @@ +// Ported verbatim from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/suite-monorepo/monorepo.test.ts` (origin/main). +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import path from 'node:path'; +import fs from 'node:fs'; +import { waitForRslintDiagnostics as waitForDiagnostics } from '../utils/diagnostics'; +import { revertTextDocument } from '../utils/documents'; + +suite('rslint monorepo multi-config support', function () { + this.timeout(120000); + + function getWorkspaceRoot(): string { + return vscode.workspace.workspaceFolders![0].uri.fsPath; + } + + async function openFile(relativePath: string): Promise { + const filePath = path.join(getWorkspaceRoot(), relativePath); + return vscode.workspace.openTextDocument(filePath); + } + + async function triggerRelint(editor: vscode.TextEditor): Promise { + await editor.edit((eb) => { + eb.insert(new vscode.Position(0, 0), ' '); + }); + await editor.edit((eb) => { + eb.delete(new vscode.Range(0, 0, 0, 1)); + }); + } + + // ======== Basic multi-config resolution ======== + + test('root file should use root config (no-explicit-any: error)', async () => { + const doc = await openFile('src/index.ts'); + await vscode.window.showTextDocument(doc); + + const diagnostics = await waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('no-explicit-any')), + ); + + assert.ok( + diagnostics.some((d) => d.message.includes('no-explicit-any')), + 'Root file should see no-explicit-any from root config', + ); + assert.ok( + !diagnostics.some((d) => d.message.includes('no-unsafe-member-access')), + 'Root file should NOT see no-unsafe-member-access (off in root config)', + ); + }); + + test('foo sub-package file should use foo config (no-unsafe-member-access: error)', async () => { + const doc = await openFile('packages/foo/src/index.ts'); + await vscode.window.showTextDocument(doc); + + const diagnostics = await waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('no-unsafe-member-access')), + ); + + assert.ok( + diagnostics.some((d) => d.message.includes('no-unsafe-member-access')), + 'Foo file should see no-unsafe-member-access from foo config', + ); + assert.ok( + !diagnostics.some((d) => d.message.includes('no-explicit-any')), + 'Foo file should NOT see no-explicit-any (off in foo config)', + ); + }); + + test('bar sub-package file should fall back to root config (no sub-config)', async () => { + const doc = await openFile('packages/bar/src/index.ts'); + await vscode.window.showTextDocument(doc); + + const diagnostics = await waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('no-explicit-any')), + ); + + assert.ok( + diagnostics.some((d) => d.message.includes('no-explicit-any')), + 'Bar file should see no-explicit-any from root config (fallback)', + ); + assert.ok( + !diagnostics.some((d) => d.message.includes('no-unsafe-member-access')), + 'Bar file should NOT see no-unsafe-member-access (off in root config)', + ); + }); + + // ======== Broken config resilience ======== + + test('broken sub-package config should not prevent other configs from loading', async () => { + // The "broken" package has a syntactically invalid rslint.config.js. + // Despite this, foo's valid config should still work correctly. + const doc = await openFile('packages/foo/src/index.ts'); + await vscode.window.showTextDocument(doc); + + const diagnostics = await waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('no-unsafe-member-access')), + ); + + assert.ok( + diagnostics.some((d) => d.message.includes('no-unsafe-member-access')), + 'Foo file should still use foo config despite broken sibling config', + ); + }); + + test('broken sub-package file should fall back to root config', async () => { + // Partial config failures retain the established behavior: the failed + // config is skipped while the valid ancestor config remains active. + const doc = await openFile('packages/broken/src/index.ts'); + await vscode.window.showTextDocument(doc); + + const diagnostics = await waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('no-explicit-any')), + ); + + assert.ok( + diagnostics.some((d) => d.message.includes('no-explicit-any')), + 'Broken package file should fall back to the root config', + ); + }); + + // ======== Config hot reload in monorepo ======== + + test('changing foo sub-package config should update foo file diagnostics', async () => { + const doc = await openFile('packages/foo/src/index.ts'); + const editor = await vscode.window.showTextDocument(doc); + + // 1. Verify initial: foo config has no-unsafe-member-access: error + const initialDiags = await waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('no-unsafe-member-access')), + ); + assert.ok( + initialDiags.some((d) => d.message.includes('no-unsafe-member-access')), + 'Initial: foo file should have no-unsafe-member-access', + ); + + // 2. Change foo config to enable no-explicit-any instead + const fooConfigPath = path.join( + getWorkspaceRoot(), + 'packages/foo/rslint.config.js', + ); + const originalConfig = fs.readFileSync(fooConfigPath, 'utf8'); + + const newConfig = `export default [ + { + files: ['src/**/*.ts'], + languageOptions: { + parserOptions: { + projectService: false, + project: ['../../tsconfig.json'], + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-unsafe-member-access': 'off', + }, + plugins: ['@typescript-eslint'], + }, +]; +`; + + try { + fs.writeFileSync(fooConfigPath, newConfig, 'utf8'); + await new Promise((resolve) => setTimeout(resolve, 2000)); + await triggerRelint(editor); + + const updatedDiags = await waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('no-explicit-any')), + ); + + assert.ok( + updatedDiags.some((d) => d.message.includes('no-explicit-any')), + 'After change: foo file should see no-explicit-any', + ); + assert.ok( + !updatedDiags.some((d) => + d.message.includes('no-unsafe-member-access'), + ), + 'After change: foo file should NOT see no-unsafe-member-access', + ); + } finally { + fs.writeFileSync(fooConfigPath, originalConfig, 'utf8'); + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + }); + + test('deleting foo sub-package config should make foo file fall back to root config', async () => { + const doc = await openFile('packages/foo/src/index.ts'); + const editor = await vscode.window.showTextDocument(doc); + + // 1. Verify initial: foo config has no-unsafe-member-access: error + const initialDiags = await waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('no-unsafe-member-access')), + ); + assert.ok( + initialDiags.some((d) => d.message.includes('no-unsafe-member-access')), + 'Initial: foo file should have no-unsafe-member-access', + ); + + // 2. Delete foo's config + const fooConfigPath = path.join( + getWorkspaceRoot(), + 'packages/foo/rslint.config.js', + ); + const originalConfig = fs.readFileSync(fooConfigPath, 'utf8'); + + try { + fs.unlinkSync(fooConfigPath); + await new Promise((resolve) => setTimeout(resolve, 3000)); + await triggerRelint(editor); + + // 3. Foo file should now fall back to root config (no-explicit-any: error) + const afterDeleteDiags = await waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('no-explicit-any')), + ); + + assert.ok( + afterDeleteDiags.some((d) => d.message.includes('no-explicit-any')), + 'After delete: foo file should fall back to root config (no-explicit-any)', + ); + assert.ok( + !afterDeleteDiags.some((d) => + d.message.includes('no-unsafe-member-access'), + ), + 'After delete: foo file should NOT see no-unsafe-member-access (off in root)', + ); + } finally { + fs.writeFileSync(fooConfigPath, originalConfig, 'utf8'); + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + }); + + test('corrupting foo config should not break other configs', async () => { + const fooDoc = await openFile('packages/foo/src/index.ts'); + await vscode.window.showTextDocument(fooDoc); + + // 1. Verify initial: foo config works + const initialDiags = await waitForDiagnostics(fooDoc, (diags) => + diags.some((d) => d.message.includes('no-unsafe-member-access')), + ); + assert.ok( + initialDiags.some((d) => d.message.includes('no-unsafe-member-access')), + 'Initial: foo file should have no-unsafe-member-access', + ); + + // 2. Corrupt foo's config with syntax error + const fooConfigPath = path.join( + getWorkspaceRoot(), + 'packages/foo/rslint.config.js', + ); + const originalConfig = fs.readFileSync(fooConfigPath, 'utf8'); + + try { + fs.writeFileSync(fooConfigPath, 'export default [BROKEN SYNTAX;', 'utf8'); + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // 3. Root config should still work for bar + const barDoc = await openFile('packages/bar/src/index.ts'); + await vscode.window.showTextDocument(barDoc); + + const barDiags = await waitForDiagnostics(barDoc, (diags) => + diags.some((d) => d.message.includes('no-explicit-any')), + ); + assert.ok( + barDiags.some((d) => d.message.includes('no-explicit-any')), + 'Bar file should still use root config after foo config is corrupted', + ); + } finally { + fs.writeFileSync(fooConfigPath, originalConfig, 'utf8'); + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + }); + + test('creating new sub-package config for bar should override root config', async () => { + const doc = await openFile('packages/bar/src/index.ts'); + const editor = await vscode.window.showTextDocument(doc); + + // 1. Verify initial: bar uses root config (no-explicit-any: error) + const initialDiags = await waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('no-explicit-any')), + ); + assert.ok( + initialDiags.some((d) => d.message.includes('no-explicit-any')), + 'Initial: bar file should have no-explicit-any from root config', + ); + + // 2. Create a new config for bar with opposite rules + const barConfigPath = path.join( + getWorkspaceRoot(), + 'packages/bar/rslint.config.js', + ); + + const barConfig = `export default [ + { + languageOptions: { + parserOptions: { + projectService: false, + project: ['../../tsconfig.json'], + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unsafe-member-access': 'error', + }, + plugins: ['@typescript-eslint'], + }, +]; +`; + + try { + fs.writeFileSync(barConfigPath, barConfig, 'utf8'); + await new Promise((resolve) => setTimeout(resolve, 3000)); + await triggerRelint(editor); + + // 3. Bar should now use its own config + const afterCreateDiags = await waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('no-unsafe-member-access')), + ); + assert.ok( + afterCreateDiags.some((d) => + d.message.includes('no-unsafe-member-access'), + ), + 'After create: bar file should see no-unsafe-member-access from new bar config', + ); + assert.ok( + !afterCreateDiags.some((d) => d.message.includes('no-explicit-any')), + 'After create: bar file should NOT see no-explicit-any (off in bar config)', + ); + + // 4. Delete bar config β†’ should fall back to root config + fs.unlinkSync(barConfigPath); + await new Promise((resolve) => setTimeout(resolve, 3000)); + await triggerRelint(editor); + + const afterDeleteDiags = await waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('no-explicit-any')), + ); + assert.ok( + afterDeleteDiags.some((d) => d.message.includes('no-explicit-any')), + 'After delete: bar file should fall back to root config (no-explicit-any)', + ); + assert.ok( + !afterDeleteDiags.some((d) => + d.message.includes('no-unsafe-member-access'), + ), + 'After delete: bar file should NOT see no-unsafe-member-access (off in root)', + ); + } finally { + try { + fs.unlinkSync(barConfigPath); + } catch { + /* ignore */ + } + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + }); + + test('deleting root config should not affect sub-package with its own config', async () => { + const rootConfigPath = path.join(getWorkspaceRoot(), 'rslint.config.js'); + const originalRootConfig = fs.readFileSync(rootConfigPath, 'utf8'); + + const fooDoc = await openFile('packages/foo/src/index.ts'); + await vscode.window.showTextDocument(fooDoc); + const barDoc = await openFile('packages/bar/src/index.ts'); + await vscode.window.showTextDocument(barDoc); + + // 1. Establish positive publications for both configs. Any later empty bar + // snapshot is therefore a real transition, not a not-yet-linted default. + const initialFooDiags = await waitForDiagnostics(fooDoc, (diags) => + diags.some((d) => d.message.includes('no-unsafe-member-access')), + ); + assert.ok( + initialFooDiags.some((d) => + d.message.includes('no-unsafe-member-access'), + ), + 'Initial: foo file should have no-unsafe-member-access from foo config', + ); + await waitForDiagnostics(barDoc, (diags) => + diags.some((d) => d.message.includes('no-explicit-any')), + ); + + let testError: unknown; + try { + // 2. Delete root config + const barCleared = waitForDiagnostics( + barDoc, + (diagnostics) => diagnostics.length === 0, + ); + fs.unlinkSync(rootConfigPath); + await triggerRelint(await vscode.window.showTextDocument(fooDoc)); + await triggerRelint(await vscode.window.showTextDocument(barDoc)); + + // 3. Bar has no sub-config, so the observed root-config diagnostic must + // transition to an explicit empty publication. + const barDiags = await barCleared; + assert.strictEqual( + barDiags.length, + 0, + 'After root delete: bar file should have no rslint diagnostics (no config)', + ); + + // 4. Prove foo's surviving sub-config still evaluates new content after + // the root deletion transaction, rather than accepting a stale snapshot. + const originalFooContent = fooDoc.getText(); + const fooEditor = await vscode.window.showTextDocument(fooDoc); + const fooCleared = waitForDiagnostics( + fooDoc, + (diagnostics) => diagnostics.length === 0, + ); + assert.ok( + await fooEditor.edit((edit) => { + edit.replace( + new vscode.Range( + fooDoc.positionAt(0), + fooDoc.positionAt(fooDoc.getText().length), + ), + 'const safe = 1;\n', + ); + }), + 'Editing foo to a clean state should succeed', + ); + await fooCleared; + + const fooRestored = waitForDiagnostics(fooDoc, (diagnostics) => + diagnostics.some((diagnostic) => + diagnostic.message.includes('no-unsafe-member-access'), + ), + ); + assert.ok( + await fooEditor.edit((edit) => { + edit.replace( + new vscode.Range( + fooDoc.positionAt(0), + fooDoc.positionAt(fooDoc.getText().length), + ), + originalFooContent, + ); + }), + 'Restoring foo content should succeed', + ); + await fooRestored; + } catch (error) { + testError = error; + } + + let restoreError: unknown; + try { + await revertTextDocument(fooDoc); + const rootRestored = waitForDiagnostics(barDoc, (diagnostics) => + diagnostics.some((diagnostic) => + diagnostic.message.includes('no-explicit-any'), + ), + ); + fs.writeFileSync(rootConfigPath, originalRootConfig, 'utf8'); + await triggerRelint(await vscode.window.showTextDocument(barDoc)); + await rootRestored; + } catch (error) { + restoreError = error; + } + + if (testError && restoreError) { + throw new AggregateError( + [testError, restoreError], + 'Root-config deletion test and restoration both failed', + ); + } + if (testError) throw testError; + if (restoreError) throw restoreError; + }); + + test('changing root config should update bar file diagnostics (no sub-config)', async () => { + const doc = await openFile('packages/bar/src/index.ts'); + const editor = await vscode.window.showTextDocument(doc); + + // 1. Verify initial: bar uses root config (no-explicit-any: error) + const initialDiags = await waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('no-explicit-any')), + ); + assert.ok( + initialDiags.some((d) => d.message.includes('no-explicit-any')), + 'Initial: bar file should have no-explicit-any from root config', + ); + + // 2. Change root config to enable no-unsafe-member-access instead + const rootConfigPath = path.join(getWorkspaceRoot(), 'rslint.config.js'); + const originalConfig = fs.readFileSync(rootConfigPath, 'utf8'); + + const newConfig = `export default [ + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: false, + project: ['./tsconfig.json'], + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unsafe-member-access': 'error', + }, + plugins: ['@typescript-eslint'], + }, +]; +`; + + try { + fs.writeFileSync(rootConfigPath, newConfig, 'utf8'); + await new Promise((resolve) => setTimeout(resolve, 2000)); + await triggerRelint(editor); + + const updatedDiags = await waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('no-unsafe-member-access')), + ); + + assert.ok( + updatedDiags.some((d) => d.message.includes('no-unsafe-member-access')), + 'After change: bar file should see no-unsafe-member-access from updated root', + ); + assert.ok( + !updatedDiags.some((d) => d.message.includes('no-explicit-any')), + 'After change: bar file should NOT see no-explicit-any (off in updated root)', + ); + } finally { + fs.writeFileSync(rootConfigPath, originalConfig, 'utf8'); + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + }); +}); diff --git a/packages/vscode/tests/e2e/lint/suite-multiroot/index.ts b/packages/vscode/tests/e2e/lint/suite-multiroot/index.ts new file mode 100644 index 0000000..e8a41f6 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/suite-multiroot/index.ts @@ -0,0 +1,3 @@ +import { createRun } from '../runSuite'; + +export const run = createRun(); diff --git a/packages/vscode/tests/e2e/lint/suite-multiroot/multiroot.test.ts b/packages/vscode/tests/e2e/lint/suite-multiroot/multiroot.test.ts new file mode 100644 index 0000000..b13b01c --- /dev/null +++ b/packages/vscode/tests/e2e/lint/suite-multiroot/multiroot.test.ts @@ -0,0 +1,203 @@ +// Ported verbatim from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/suite-multiroot/multiroot.test.ts` (origin/main). +import * as assert from 'node:assert'; +import path from 'node:path'; +import * as vscode from 'vscode'; + +function workspaceFolder(name: string): vscode.WorkspaceFolder { + const folder = vscode.workspace.workspaceFolders?.find( + (candidate) => candidate.name === name, + ); + assert.ok(folder, `workspace folder ${name} is unavailable`); + return folder; +} + +async function openWorkspaceFile( + folder: vscode.WorkspaceFolder, + relativePath: string, +): Promise { + return vscode.workspace.openTextDocument( + path.join(folder.uri.fsPath, relativePath), + ); +} + +function rslintDiagnostics(document: vscode.TextDocument): vscode.Diagnostic[] { + return vscode.languages + .getDiagnostics(document.uri) + .filter((diagnostic) => diagnostic.source === 'rslint'); +} + +async function waitForSingleRslintDiagnostic( + document: vscode.TextDocument, +): Promise { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const diagnostics = rslintDiagnostics(document); + if ( + diagnostics.length === 1 && + diagnostics[0].message.includes('no-explicit-any') + ) { + // Do not accept a transient single result while a duplicate owner is + // still publishing its first diagnostics. + await new Promise((resolve) => setTimeout(resolve, 300)); + const stable = rslintDiagnostics(document); + if (stable.length === 1) return stable; + } + await new Promise((resolve) => { + const listener = vscode.languages.onDidChangeDiagnostics((event) => { + if ( + event.uris.some((uri) => uri.toString() === document.uri.toString()) + ) { + listener.dispose(); + resolve(); + } + }); + setTimeout(() => { + listener.dispose(); + resolve(); + }, 500); + }); + } + return rslintDiagnostics(document); +} + +suite('VS Code multi-root ownership', function () { + this.timeout(60_000); + + test('keeps same-name roots independent', async function () { + const appFolders = (vscode.workspace.workspaceFolders ?? []).filter( + (folder) => folder.name === 'app', + ); + if (appFolders.length === 0) this.skip(); + assert.strictEqual(appFolders.length, 2); + assert.notStrictEqual( + appFolders[0].uri.toString(), + appFolders[1].uri.toString(), + ); + + for (const folder of appFolders) { + const document = await openWorkspaceFile(folder, 'src/index.ts'); + const diagnostics = await waitForSingleRslintDiagnostic(document); + assert.strictEqual( + diagnostics.length, + 1, + `${folder.uri} should have exactly one Rslint owner`, + ); + } + }); + + test('routes an initial parent-child overlap to only the child', async function () { + const folders = vscode.workspace.workspaceFolders ?? []; + const parent = folders.find((folder) => folder.name === 'parent'); + const nested = folders.find((folder) => folder.name === 'nested'); + if (!parent || !nested) this.skip(); + + const document = await openWorkspaceFile(nested, 'src/index.ts'); + const diagnostics = await waitForSingleRslintDiagnostic(document); + assert.strictEqual(diagnostics.length, 1); + }); + + test('hands a document parent β†’ child β†’ parent without reopening it', async function () { + const folders = vscode.workspace.workspaceFolders ?? []; + if (folders.some((folder) => folder.name === 'nested')) this.skip(); + // Keep the first root and a sentinel throughout the test. VS Code may + // restart the extension host for first-root or single↔multi transitions. + assert.ok(folders.length >= 2); + const parent = workspaceFolder('parent'); + workspaceFolder('sentinel'); + const nestedUri = vscode.Uri.joinPath(parent.uri, 'nested'); + const document = await vscode.workspace.openTextDocument( + vscode.Uri.joinPath(nestedUri, 'src/index.ts'), + ); + assert.strictEqual( + (await waitForSingleRslintDiagnostic(document)).length, + 1, + ); + + await expectDiagnosticHandoff(document, async () => { + const added = vscode.workspace.updateWorkspaceFolders( + vscode.workspace.workspaceFolders?.length ?? 0, + 0, + { uri: nestedUri, name: 'nested' }, + ); + assert.strictEqual(added, true); + await waitForWorkspaceFolder(nestedUri, true); + }); + assert.strictEqual( + (await waitForSingleRslintDiagnostic(document)).length, + 1, + ); + + const nestedIndex = vscode.workspace.workspaceFolders?.findIndex( + (folder) => folder.uri.toString() === nestedUri.toString(), + ); + assert.notStrictEqual(nestedIndex, undefined); + assert.ok(nestedIndex !== undefined && nestedIndex >= 0); + await expectDiagnosticHandoff(document, async () => { + const removed = vscode.workspace.updateWorkspaceFolders(nestedIndex, 1); + assert.strictEqual(removed, true); + await waitForWorkspaceFolder(nestedUri, false); + }); + assert.strictEqual( + (await waitForSingleRslintDiagnostic(document)).length, + 1, + ); + }); +}); + +async function expectDiagnosticHandoff( + document: vscode.TextDocument, + changeTopology: () => Promise, +): Promise { + let events = 0; + let maximumDiagnosticCount = rslintDiagnostics(document).length; + const listener = vscode.languages.onDidChangeDiagnostics((event) => { + if (event.uris.some((uri) => uri.toString() === document.uri.toString())) { + events++; + maximumDiagnosticCount = Math.max( + maximumDiagnosticCount, + rslintDiagnostics(document).length, + ); + } + }); + try { + await changeTopology(); + const deadline = Date.now() + 30_000; + // VS Code may coalesce the old collection's delete with the new + // collection's publication into one aggregate diagnostic event. + while (events < 1 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + assert.ok( + events >= 1, + 'expected diagnostics to change during ownership handoff', + ); + assert.strictEqual( + (await waitForSingleRslintDiagnostic(document)).length, + 1, + ); + assert.ok( + maximumDiagnosticCount <= 1, + `ownership handoff published ${maximumDiagnosticCount} diagnostics`, + ); + } finally { + listener.dispose(); + } +} + +async function waitForWorkspaceFolder( + uri: vscode.Uri, + present: boolean, +): Promise { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + const found = vscode.workspace.workspaceFolders?.some( + (folder) => folder.uri.toString() === uri.toString(), + ); + if (found === present) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + assert.fail( + `workspace folder ${uri} did not become ${present ? 'present' : 'absent'}`, + ); +} diff --git a/packages/vscode/tests/e2e/lint/suite-noconfig/index.ts b/packages/vscode/tests/e2e/lint/suite-noconfig/index.ts new file mode 100644 index 0000000..b5620b8 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/suite-noconfig/index.ts @@ -0,0 +1,5 @@ +import { createRun } from '../runSuite'; + +// The no-config fixture must never light the lint stack (rslint is not +// zero-config). +export const run = createRun({ expectLintStack: false }); diff --git a/packages/vscode/tests/e2e/lint/suite-noconfig/noconfig.test.ts b/packages/vscode/tests/e2e/lint/suite-noconfig/noconfig.test.ts new file mode 100644 index 0000000..85d1b68 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/suite-noconfig/noconfig.test.ts @@ -0,0 +1,298 @@ +// Ported from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/suite-noconfig/noconfig.test.ts` +// (origin/main), with the expectations rewritten for this extension's +// detection rules: +// +// - Upstream treated `rslint.json` as a working fallback config: creating it +// produced diagnostics, and the suite walked a JSON β†’ JS β†’ JSON lifecycle. +// - This extension does not support `rslint.json` at all. It is not a +// detection signal, so a folder with only `rslint.json` stays +// `not detected`, the lint stack is never registered, and zero diagnostics +// is the *designed* outcome β€” not a fallback state. +// - `rslint.config.*` remains a live detection signal: creating one must +// register the stack without a window reload, and deleting the last one +// must deregister it (the config-glob + lockfile detection watcher). +// - Upstream's "a broken discovered JS config must not fall back to JSON" +// step is preserved: the Go server owns that behavior and is unchanged. +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import path from 'node:path'; +import fs from 'node:fs'; +import { + getRslintDiagnostics, + waitForRslintDiagnostics as waitForDiagnostics, +} from '../utils/diagnostics'; +import { + isLintStackRegistered, + waitForLintStackRegistration, +} from '../utils/extension'; + +suite('rslint no config fallback', function () { + this.timeout(120000); + + function getWorkspaceRoot(): string { + return vscode.workspace.workspaceFolders![0].uri.fsPath; + } + + async function openFixture(filename: string): Promise { + const filePath = path.join(getWorkspaceRoot(), 'src', filename); + return vscode.workspace.openTextDocument(filePath); + } + + // Upstream's JSON config, kept verbatim: in this suite it must be inert. + const jsonConfig = JSON.stringify( + [ + { + languageOptions: { + parserOptions: { + projectService: false, + project: ['./tsconfig.json'], + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-unsafe-member-access': 'off', + }, + plugins: ['@typescript-eslint'], + }, + ], + null, + 2, + ); + + const jsConfig = `export default [ + { + languageOptions: { + parserOptions: { + projectService: false, + project: ['./tsconfig.json'], + }, + }, + rules: { + '@typescript-eslint/no-unsafe-member-access': 'error', + '@typescript-eslint/no-explicit-any': 'off', + }, + plugins: ['@typescript-eslint'], + }, +]; +`; + + function configPaths(): { json: string; js: string } { + return { + json: path.join(getWorkspaceRoot(), 'rslint.json'), + js: path.join(getWorkspaceRoot(), 'rslint.config.js'), + }; + } + + async function withConfigFilesAbsent( + testFn: (paths: { json: string; js: string }) => Promise, + ): Promise { + const paths = configPaths(); + const removeConfigs = (): void => { + fs.rmSync(paths.json, { force: true }); + fs.rmSync(paths.js, { force: true }); + if (fs.existsSync(paths.json) || fs.existsSync(paths.js)) { + throw new Error('No-config fixture cleanup left a config file behind'); + } + }; + + let testError: unknown; + try { + removeConfigs(); + await testFn(paths); + } catch (error) { + testError = error; + } + + let cleanupError: unknown; + try { + removeConfigs(); + // Every test must hand the next one the designed ground state: no + // config, no registered lint stack. + await waitForLintStackRegistration(false); + } catch (error) { + cleanupError = error; + } + if (testError && cleanupError) { + throw new AggregateError( + [testError, cleanupError], + 'No-config test and config cleanup both failed', + ); + } + if (testError) throw testError; + if (cleanupError) throw cleanupError; + } + + /** Trigger a no-op edit cycle on the document to force diagnostic refresh. */ + async function triggerDiagnosticRefresh( + doc: vscode.TextDocument, + ): Promise { + const editor = await vscode.window.showTextDocument(doc); + await editor.edit((eb) => { + eb.insert(new vscode.Position(0, 0), ' '); + }); + await editor.edit((eb) => { + eb.delete(new vscode.Range(0, 0, 0, 1)); + }); + } + + /** + * Negative assertions need a bounded observation window: nothing ever fires + * an event that proves "the stack will not register". Poll the public + * registration state and the diagnostics collection for the whole window + * and fail on the first counter-example. + */ + async function assertStaysUndetected( + doc: vscode.TextDocument, + windowMs: number, + context: string, + ): Promise { + const deadline = Date.now() + windowMs; + while (Date.now() < deadline) { + assert.strictEqual( + isLintStackRegistered(), + false, + `${context}: the lint stack must not register`, + ); + const diagnostics = getRslintDiagnostics(doc); + assert.strictEqual( + diagnostics.length, + 0, + `${context}: expected zero rslint diagnostics, got: ${diagnostics + .map((d) => d.message) + .join(' | ')}`, + ); + await new Promise((resolve) => setTimeout(resolve, 200)); + } + } + + test('a folder without any config is not detected and publishes no diagnostics', async () => { + await withConfigFilesAbsent(async () => { + const doc = await openFixture('index.ts'); + await vscode.window.showTextDocument(doc); + await triggerDiagnosticRefresh(doc); + // rslint is not zero-config; a folder without a + // config gets no lint stack and no diagnostics. + await assertStaysUndetected(doc, 3_000, 'no config at all'); + }); + }); + + test('rslint.json alone is not a config and does not light the stack', async () => { + await withConfigFilesAbsent(async ({ json }) => { + const doc = await openFixture('index.ts'); + await vscode.window.showTextDocument(doc); + + // Upstream expected this write to produce `no-explicit-any` + // diagnostics. This extension deliberately drops the deprecated JSON + // format: it is not a detection signal, so nothing may happen. + fs.writeFileSync(json, jsonConfig, 'utf8'); + await triggerDiagnosticRefresh(doc); + await assertStaysUndetected(doc, 5_000, 'rslint.json only'); + }); + }); + + test('creating rslint.config.js lights the stack without a reload, deleting it returns to not-detected', async () => { + await withConfigFilesAbsent(async ({ js }) => { + const doc = await openFixture('index.ts'); + await vscode.window.showTextDocument(doc); + + // ── Step 1: create the JS config β†’ detection flips, the shell + // registers the lint stack and the server produces diagnostics. + fs.writeFileSync(js, jsConfig, 'utf8'); + await waitForLintStackRegistration(true); + const diags = await waitForDiagnostics(doc, (ds) => + ds.some((d) => d.message.includes('no-unsafe-member-access')), + ); + assert.ok( + diags.some((d) => d.message.includes('no-unsafe-member-access')), + 'Step 1: creating rslint.config.js should produce its diagnostics', + ); + + // ── Step 2: delete it β†’ the folder is no longer detected, the stack + // deregisters and every rslint diagnostic is dropped. + fs.rmSync(js); + await waitForLintStackRegistration(false); + const cleared = await waitForDiagnostics(doc, (ds) => ds.length === 0); + assert.strictEqual( + cleared.length, + 0, + 'Step 2: deleting the last config should clear all rslint diagnostics', + ); + }); + }); + + test('a broken JS config does not fall back to rslint.json', async () => { + // Upstream's lifecycle step 4: a *freshly discovered* broken JS config + // (no last-good catalog for its path) must yield an explicit no-lint + // state instead of falling back to JSON. A broken rewrite of an already + // loaded config would instead keep the last-good catalog active β€” that + // scenario belongs to the jsconfig suite ("broken higher-priority config + // preserves last-good"). + await withConfigFilesAbsent(async ({ json, js }) => { + const attemptedLoadPath = path.join( + getWorkspaceRoot(), + 'broken-config-attempted.txt', + ); + fs.rmSync(attemptedLoadPath, { force: true }); + try { + const doc = await openFixture('index.ts'); + await vscode.window.showTextDocument(doc); + + // Establish a positive publication first, so the later empty snapshot + // cannot be the document's not-yet-linted initial state. + fs.writeFileSync(json, jsonConfig, 'utf8'); + fs.writeFileSync(js, jsConfig, 'utf8'); + await waitForLintStackRegistration(true); + await waitForDiagnostics(doc, (ds) => + ds.some((d) => d.message.includes('no-unsafe-member-access')), + ); + + // Delete the JS config: the folder is un-detected (rslint.json does + // not count), the stack deregisters and its last-good + // catalog dies with the server. + fs.rmSync(js); + await waitForLintStackRegistration(false); + await waitForDiagnostics(doc, (ds) => ds.length === 0); + + // Create a broken JS config fresh. Detection lights the stack again; + // the new server evaluates the module (observable via the marker), + // fails, and has no last-good to keep β€” nor a JSON fallback to take. + fs.writeFileSync( + js, + `import fs from 'node:fs'; +fs.writeFileSync(${JSON.stringify(attemptedLoadPath)}, 'attempted'); +throw new Error('intentional broken config'); +export default []; +`, + 'utf8', + ); + await waitForLintStackRegistration(true); + const markerDeadline = Date.now() + 60_000; + while (!fs.existsSync(attemptedLoadPath)) { + if (Date.now() > markerDeadline) { + throw new Error('The broken JS config was never evaluated'); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + // The broken config was provably evaluated; the JSON rule may never + // surface and no rslint diagnostic may appear at all. + await triggerDiagnosticRefresh(doc); + const deadline = Date.now() + 3_000; + while (Date.now() < deadline) { + const current = getRslintDiagnostics(doc); + assert.strictEqual( + current.length, + 0, + `A broken discovered JS config must not fall back to rslint.json; got: ${current + .map((d) => d.message) + .join(' | ')}`, + ); + await new Promise((resolve) => setTimeout(resolve, 200)); + } + } finally { + fs.rmSync(attemptedLoadPath, { force: true }); + } + }); + }); +}); diff --git a/packages/vscode/tests/e2e/lint/suite-project-service-scope/index.ts b/packages/vscode/tests/e2e/lint/suite-project-service-scope/index.ts new file mode 100644 index 0000000..e8a41f6 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/suite-project-service-scope/index.ts @@ -0,0 +1,3 @@ +import { createRun } from '../runSuite'; + +export const run = createRun(); diff --git a/packages/vscode/tests/e2e/lint/suite-project-service-scope/project-service-scope.test.ts b/packages/vscode/tests/e2e/lint/suite-project-service-scope/project-service-scope.test.ts new file mode 100644 index 0000000..e833df9 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/suite-project-service-scope/project-service-scope.test.ts @@ -0,0 +1,125 @@ +// Ported verbatim from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/suite-project-service-scope/project-service-scope.test.ts` (origin/main). +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import path from 'node:path'; +import { findFixAllAction, requestFixAll } from '../suite/fixall-helpers'; +import { waitForRslintDiagnostics as waitForDiagnostics } from '../utils/diagnostics'; + +// Type-aware rule scope when parserOptions uses `projectService: true` +// (the shape `ts.configs.recommended` exports) without an explicit +// `project`. The LSP and CLI must agree: only files covered by the +// fallback tsconfig's `include` get type-aware rules, AND a nested +// config that has no tsconfig must not enable type-aware rules. +// +// Fixture: fixtures-project-service-scope +// - rslint.config.js β€” parserOptions.projectService: true (no explicit project). +// Also enables `no-console` as a non-type-aware marker +// rule so the negative test case can wait for rslint to +// finish linting a file without a fixed-duration sleep. +// - tsconfig.json β€” include: ["src"] +// - src/covered.ts β€” IN tsconfig: no-unused-vars SHOULD fire +// - test/skills.test.ts β€” NOT IN tsconfig: no-unused-vars should NOT fire. +// Contains a `console.log` so the marker rule triggers. +// - template-nested/rslint.config.js β€” nested config, also projectService: true, +// but the dir has NO tsconfig.json. +// - template-nested/orphan.ts β€” under the nested config. no-unused-vars +// should NOT fire, while native no-var should +// diagnose and participate in fixAll. +suite('rslint projectService type-aware scope', function () { + this.timeout(120000); + + function workspaceRoot(): string { + return vscode.workspace.workspaceFolders![0].uri.fsPath; + } + + // Only inspect rslint-originated diagnostics β€” TS's own 6133 ("declared but + // never read") is also emitted on the same lines and would otherwise confuse + // the assertion. + function rslintDiagnostics(diags: vscode.Diagnostic[]): vscode.Diagnostic[] { + return diags.filter((d) => d.source === 'rslint'); + } + + test('src/covered.ts (in tsconfig include) β€” no-unused-vars SHOULD fire', async () => { + const doc = await vscode.workspace.openTextDocument( + path.join(workspaceRoot(), 'src/covered.ts'), + ); + await vscode.window.showTextDocument(doc); + + const diagnostics = await waitForDiagnostics(doc, (diags) => + rslintDiagnostics(diags).some((d) => + d.message.includes('no-unused-vars'), + ), + ); + + const rslintDiags = rslintDiagnostics(diagnostics); + assert.ok( + rslintDiags.some((d) => d.message.includes('no-unused-vars')), + `Expected no-unused-vars on src/covered.ts. Got: ${rslintDiags.map((d) => d.message).join(' | ')}`, + ); + }); + + test('test/skills.test.ts (outside tsconfig include) β€” no-unused-vars should NOT fire', async () => { + const doc = await vscode.workspace.openTextDocument( + path.join(workspaceRoot(), 'test/skills.test.ts'), + ); + await vscode.window.showTextDocument(doc); + + // Wait for `no-console` (non-type-aware, must fire on the fixture's + // console.log) β€” its presence proves rslint has finalized this file's + // diagnostics, so the negative assertion below can run synchronously + // instead of waiting on a fixed-duration sleep. + const diagnostics = await waitForDiagnostics(doc, (diags) => + rslintDiagnostics(diags).some((d) => d.message.includes('no-console')), + ); + + const rslintDiags = rslintDiagnostics(diagnostics); + assert.ok( + rslintDiags.some((d) => d.message.includes('no-console')), + `Expected no-console marker to appear on test/skills.test.ts. Got: ${rslintDiags.map((d) => d.message).join(' | ')}`, + ); + assert.ok( + !rslintDiags.some((d) => d.message.includes('no-unused-vars')), + `no-unused-vars should NOT fire on a file outside tsconfig.include. Got: ${rslintDiags.map((d) => d.message).join(' | ')}`, + ); + }); + + test('template-nested/orphan.ts (nested config without tsconfig) filters type-aware rules', async () => { + const doc = await vscode.workspace.openTextDocument( + path.join(workspaceRoot(), 'template-nested/orphan.ts'), + ); + await vscode.window.showTextDocument(doc); + + const diagnostics = await waitForDiagnostics(doc, (diags) => + rslintDiagnostics(diags).some((d) => d.message.includes('no-var')), + ); + + const rslintDiags = rslintDiagnostics(diagnostics); + assert.ok( + rslintDiags.some((d) => d.message.includes('no-var')), + `Expected native no-var marker. Got: ${rslintDiags.map((d) => d.message).join(' | ')}`, + ); + assert.ok( + !rslintDiags.some((d) => d.message.includes('no-unused-vars')), + `no-unused-vars should not run without a resolved tsconfig. Got: ${rslintDiags.map((d) => d.message).join(' | ')}`, + ); + }); + + test('native fixAll remains available without a resolved tsconfig', async () => { + const doc = await vscode.workspace.openTextDocument( + path.join(workspaceRoot(), 'template-nested/orphan.ts'), + ); + await vscode.window.showTextDocument(doc); + await waitForDiagnostics(doc, (diags) => + rslintDiagnostics(diags).some((d) => d.message.includes('no-var')), + ); + + const fixAll = findFixAllAction(await requestFixAll(doc)); + const edits = fixAll?.edit?.get(doc.uri); + assert.ok(edits && edits.length > 0, 'Expected a native fixAll edit'); + assert.ok( + edits.some((edit) => edit.newText.includes('let output = command;')), + `Expected no-var fix in fixAll edit. Got: ${edits.map((edit) => edit.newText).join(' | ')}`, + ); + }); +}); diff --git a/packages/vscode/tests/e2e/lint/suite-type-aware-scope/index.ts b/packages/vscode/tests/e2e/lint/suite-type-aware-scope/index.ts new file mode 100644 index 0000000..e8a41f6 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/suite-type-aware-scope/index.ts @@ -0,0 +1,3 @@ +import { createRun } from '../runSuite'; + +export const run = createRun(); diff --git a/packages/vscode/tests/e2e/lint/suite-type-aware-scope/type-aware-scope.test.ts b/packages/vscode/tests/e2e/lint/suite-type-aware-scope/type-aware-scope.test.ts new file mode 100644 index 0000000..8dcea03 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/suite-type-aware-scope/type-aware-scope.test.ts @@ -0,0 +1,176 @@ +// Ported verbatim from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/suite-type-aware-scope/type-aware-scope.test.ts` (origin/main). +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import path from 'node:path'; +import { waitForRslintDiagnostics as waitForDiagnostics } from '../utils/diagnostics'; +import { closeTextEditor } from '../utils/documents'; + +// Tests that type-aware rules (e.g. require-await) only run on files covered +// by parserOptions.project, matching CLI behavior. +// +// Fixture: rslint.config.js has project: +// ['./packages/core/tsconfig.lint.json']. The default tsconfig.json deliberately +// excludes index.ts, so tsgo's main Session cannot own the declared lint +// project. This exercises rslint's standalone Program path. +// - packages/core/src/index.ts: IN tsconfig.lint.json, type-aware rules fire +// - packages/cli/src/preview.ts: NOT in tsconfig.lint.json, type-aware rules do not fire +suite('rslint type-aware rule scope', function () { + this.timeout(120000); + + function getWorkspaceRoot(): string { + return vscode.workspace.workspaceFolders![0].uri.fsPath; + } + + test('file IN parserOptions.project tsconfig should get type-aware rules', async () => { + const filePath = path.join( + getWorkspaceRoot(), + 'packages/core/src/index.ts', + ); + const doc = await vscode.workspace.openTextDocument(filePath); + await vscode.window.showTextDocument(doc); + + const diagnostics = await waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('require-await')), + ); + + // require-await should fire (type-aware, file IS in tsconfig) + assert.ok( + diagnostics.some((d) => d.message.includes('require-await')), + `Expected require-await for file in tsconfig. Got: ${diagnostics.map((d) => d.message).join(', ')}`, + ); + + // no-console should also fire (non-type-aware, always runs) + assert.ok( + diagnostics.some((d) => d.message.includes('no-console')), + 'Expected no-console for file in tsconfig', + ); + }); + + test('file NOT in parserOptions.project tsconfig should NOT get type-aware rules', async () => { + const filePath = path.join( + getWorkspaceRoot(), + 'packages/cli/src/preview.ts', + ); + const doc = await vscode.workspace.openTextDocument(filePath); + await vscode.window.showTextDocument(doc); + + // Wait for no-console (non-type-aware) to appear β€” proves rslint IS linting the file + const diagnostics = await waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('no-console')), + ); + + // no-console SHOULD fire (non-type-aware, always runs) + assert.ok( + diagnostics.some((d) => d.message.includes('no-console')), + `Expected no-console for file outside tsconfig. Got: ${diagnostics.map((d) => d.message).join(', ')}`, + ); + + // require-await should NOT fire (type-aware, file is NOT in configured tsconfig) + assert.ok( + !diagnostics.some((d) => d.message.includes('require-await')), + 'require-await should NOT fire for file outside parserOptions.project tsconfig', + ); + }); + + test('standalone project stays correct across edit and reopen', async () => { + const filePath = path.join( + getWorkspaceRoot(), + 'packages/core/src/index.ts', + ); + const doc = await vscode.workspace.openTextDocument(filePath); + const editor = await vscode.window.showTextDocument(doc); + const initial = await waitForDiagnostics(doc, (diags) => + diags.some((d) => d.message.includes('require-await')), + ); + assert.ok( + initial.some((d) => d.message.includes('require-await')), + 'Expected require-await before editing the standalone project source', + ); + + const original = doc.getText(); + const changed = original.replace( + " console.log('hello');", + " await Promise.resolve();\n console.log('hello');", + ); + assert.notStrictEqual( + changed, + original, + 'Fixture edit did not match source', + ); + const fullRange = new vscode.Range( + doc.positionAt(0), + doc.positionAt(original.length), + ); + await editor.edit((builder) => builder.replace(fullRange, changed)); + const updated = await waitForDiagnostics( + doc, + (diags) => + diags.some((d) => d.message.includes('no-console')) && + !diags.some((d) => d.message.includes('require-await')), + ); + assert.ok( + !updated.some((d) => d.message.includes('require-await')), + 'Incremental standalone Program retained a stale require-await diagnostic', + ); + + await closeTextEditor(doc); + const reopened = await vscode.workspace.openTextDocument(filePath); + await vscode.window.showTextDocument(reopened); + const reopenedDiagnostics = await waitForDiagnostics(reopened, (diags) => + diags.some((d) => d.message.includes('require-await')), + ); + assert.ok( + reopenedDiagnostics.some((d) => d.message.includes('require-await')), + 'Reopened standalone project source did not restore disk diagnostics', + ); + }); + + test('standalone project refreshes after an external dependency change', async () => { + const sourcePath = path.join( + getWorkspaceRoot(), + 'packages/core/src/index.ts', + ); + const dependencyUri = vscode.Uri.file( + path.join(getWorkspaceRoot(), 'packages/core/src/dependency.ts'), + ); + const source = await vscode.workspace.openTextDocument(sourcePath); + await vscode.window.showTextDocument(source); + const initial = await waitForDiagnostics(source, (diags) => + diags.some((d) => d.message.includes('no-unsafe-member-access')), + ); + assert.ok( + initial.some((d) => d.message.includes('no-unsafe-member-access')), + 'Expected the dependency any type to produce no-unsafe-member-access', + ); + + const originalDependency = + await vscode.workspace.fs.readFile(dependencyUri); + try { + await vscode.workspace.fs.writeFile( + dependencyUri, + Buffer.from('export const dependency = { value: 1 };\n'), + ); + const updated = await waitForDiagnostics( + source, + (diags) => + diags.some((d) => d.message.includes('no-console')) && + !diags.some((d) => d.message.includes('no-unsafe-member-access')), + ); + assert.ok( + !updated.some((d) => d.message.includes('no-unsafe-member-access')), + 'External dependency change left stale standalone Program diagnostics', + ); + } finally { + await vscode.workspace.fs.writeFile(dependencyUri, originalDependency); + } + + const restored = await waitForDiagnostics(source, (diags) => + diags.some((d) => d.message.includes('no-unsafe-member-access')), + ); + assert.ok( + restored.some((d) => d.message.includes('no-unsafe-member-access')), + 'Restored dependency did not restore standalone Program diagnostics', + ); + }); +}); diff --git a/packages/vscode/tests/e2e/lint/suite/extension.test.ts b/packages/vscode/tests/e2e/lint/suite/extension.test.ts new file mode 100644 index 0000000..c9a544c --- /dev/null +++ b/packages/vscode/tests/e2e/lint/suite/extension.test.ts @@ -0,0 +1,649 @@ +// Ported verbatim from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/suite/extension.test.ts` (origin/main). +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import path from 'node:path'; +import { executeCodeActionProvider, getFixturesDir } from './fixall-helpers'; +import { + getRslintDiagnostics, + waitForRslintDiagnostics as waitForDiagnostics, + waitForRslintDiagnosticsCount as waitForDiagnosticsCount, + waitForRslintDiagnosticsToChange as waitForDiagnosticsToChange, +} from '../utils/diagnostics'; +import { closeTextEditor, revertTextDocument } from '../utils/documents'; + +suite('rslint extension', function () { + this.timeout(90000); + + teardown(async () => { + const fixturesSource = path.resolve(getFixturesDir(), 'src'); + const dirtyFixtures = vscode.workspace.textDocuments.filter((document) => { + if (document.uri.scheme !== 'file') return false; + const relative = path.relative(fixturesSource, document.uri.fsPath); + return ( + document.isDirty && + relative !== '' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); + }); + for (const document of dirtyFixtures) { + await revertTextDocument(document); + } + }); + + function waitForDiagnosticsWithMessage( + doc: vscode.TextDocument, + messageSubstring: string, + timeoutMs = 30000, + ): Promise { + return waitForDiagnostics( + doc, + (diagnostics) => + diagnostics.some((diagnostic) => + diagnostic.message.includes(messageSubstring), + ), + timeoutMs, + ); + } + + // Helper function to open a test fixture + async function openFixture(filename: string): Promise { + return vscode.workspace.openTextDocument( + path.resolve(getFixturesDir(), 'src', filename), + ); + } + + test('diagnostics', async () => { + const doc = await openFixture('index.ts'); + await vscode.window.showTextDocument(doc); + + const diagnostics = await waitForDiagnostics(doc); + assert.ok( + diagnostics.length > 0, + `Expected diagnostics but got ${diagnostics.length}`, + ); + }); + + test('.gitignore excludes diagnostics for an opened file', async () => { + const doc = await openFixture('gitignored.ts'); + await vscode.window.showTextDocument(doc); + + const control = await openFixture('disable.ts'); + await vscode.window.showTextDocument(control); + const controlDiagnostics = await waitForDiagnosticsWithMessage( + control, + 'no-unsafe-member-access', + ); + assert.ok( + controlDiagnostics.some( + (diagnostic) => + diagnostic.source === 'rslint' && + diagnostic.message.includes('no-unsafe-member-access'), + ), + 'Expected the unignored control file to produce an rslint diagnostic', + ); + + const diagnostics = vscode.languages + .getDiagnostics(doc.uri) + .filter((diagnostic) => diagnostic.source === 'rslint'); + assert.strictEqual( + diagnostics.length, + 0, + `Expected no diagnostics for a gitignored file, got: ${diagnostics + .map((diagnostic) => diagnostic.message) + .join(', ')}`, + ); + }); + + test('code actions - auto fix', async () => { + const doc = await openFixture('autofix.ts'); + await vscode.window.showTextDocument(doc); + + const diagnostics = await waitForDiagnostics(doc); + assert.ok(diagnostics.length > 0, 'Should have diagnostics'); + + // Find the no-unnecessary-type-assertion diagnostic + const typeAssertionDiag = diagnostics.find( + (d) => + d.message.includes('no-unnecessary-type-assertion') || + (d.source === 'rslint' && d.message.includes('assertion')), + ); + assert.ok( + typeAssertionDiag, + `Expected a no-unnecessary-type-assertion diagnostic. Got: ${diagnostics + .map((diagnostic) => diagnostic.message) + .join(' | ')}`, + ); + + // Request code actions for the diagnostic range + const codeActions = await executeCodeActionProvider( + doc.uri, + typeAssertionDiag.range, + ); + + assert.ok(codeActions.length > 0, 'Should have code actions'); + + // Look for auto fix action + const autoFixAction = codeActions.find( + (action) => + action.title.toLowerCase().includes('fix') && + action.kind?.value === vscode.CodeActionKind.QuickFix.value, + ); + + assert.ok(autoFixAction, 'Should have auto fix action'); + assert.ok( + autoFixAction.isPreferred, + 'Auto fix should be marked as preferred', + ); + + // Verify the action has an edit + assert.ok(autoFixAction.edit, 'Auto fix action should have an edit'); + const autoFixEdits = autoFixAction.edit.get(doc.uri); + assert.ok( + autoFixEdits && autoFixEdits.length > 0, + 'Auto fix edit should not be empty', + ); + }); + + test('code actions - disable rule for line', async () => { + const doc = await openFixture('disable.ts'); + await vscode.window.showTextDocument(doc); + + const diagnostics = await waitForDiagnostics(doc); + assert.ok(diagnostics.length > 0, 'Should have diagnostics'); + + // Find an unsafe diagnostic (these typically don't have auto fixes) + const unsafeDiag = diagnostics.find( + (d) => d.message.includes('unsafe') || d.message.includes('Unsafe'), + ); + assert.ok( + unsafeDiag, + `Expected an unsafe diagnostic. Got: ${diagnostics + .map((diagnostic) => diagnostic.message) + .join(' | ')}`, + ); + + // Request code actions for the diagnostic range + const codeActions = await executeCodeActionProvider( + doc.uri, + unsafeDiag.range, + ); + + assert.ok(codeActions.length > 0, 'Should have code actions'); + + // Look for disable rule for line action + const disableLineAction = codeActions.find( + (action) => + action.title.toLowerCase().includes('disable') && + action.title.toLowerCase().includes('line'), + ); + + assert.ok(disableLineAction, 'Should have disable rule for line action'); + assert.ok( + !disableLineAction.isPreferred, + 'Disable action should not be marked as preferred', + ); + + // Verify the action has an edit + assert.ok(disableLineAction.edit, 'Disable action should have an edit'); + + // Verify the edit contains rslint-disable-next-line + const workspaceEdit = disableLineAction.edit; + const edits = workspaceEdit.get(doc.uri); + assert.ok( + edits && edits.length > 0, + 'Disable-line edit should not be empty', + ); + const editText = edits[0].newText; + assert.ok( + editText.includes('rslint-disable-next-line'), + 'Edit should contain rslint-disable-next-line comment', + ); + }); + + test('code actions - disable rule for file', async () => { + const doc = await openFixture('disable-file.ts'); + await vscode.window.showTextDocument(doc); + + const diagnostics = await waitForDiagnostics(doc); + assert.ok(diagnostics.length > 0, 'Should have diagnostics'); + + // Find an unsafe diagnostic + const unsafeDiag = diagnostics.find( + (d) => d.message.includes('unsafe') || d.message.includes('Unsafe'), + ); + assert.ok( + unsafeDiag, + `Expected an unsafe diagnostic. Got: ${diagnostics + .map((diagnostic) => diagnostic.message) + .join(' | ')}`, + ); + + // Request code actions for the diagnostic range + const codeActions = await executeCodeActionProvider( + doc.uri, + unsafeDiag.range, + ); + + assert.ok(codeActions.length > 0, 'Should have code actions'); + + // Look for disable rule for file action + const disableFileAction = codeActions.find( + (action) => + action.title.toLowerCase().includes('disable') && + action.title.toLowerCase().includes('file'), + ); + + assert.ok(disableFileAction, 'Should have disable rule for file action'); + assert.ok( + !disableFileAction.isPreferred, + 'Disable action should not be marked as preferred', + ); + + // Verify the action has an edit + assert.ok(disableFileAction.edit, 'Disable action should have an edit'); + + // Verify the edit contains rslint-disable comment + const workspaceEdit = disableFileAction.edit; + const edits = workspaceEdit.get(doc.uri); + assert.ok( + edits && edits.length > 0, + 'Disable-file edit should not be empty', + ); + const editText = edits[0].newText; + assert.ok( + editText.includes('rslint-disable') && !editText.includes('-next-line'), + 'Edit should contain rslint-disable comment for entire file', + ); + }); + + test('code actions - range overlap', async () => { + const doc = await openFixture('index.ts'); + await vscode.window.showTextDocument(doc); + + await waitForDiagnostics(doc); + + // Test that code actions are only provided for ranges that overlap with diagnostics + const codeActionsEmptyRange = await executeCodeActionProvider( + doc.uri, + new vscode.Range(100, 0, 100, 0), // Range with no diagnostics + ); + + // Should either be empty or only contain general actions (not diagnostic-specific) + if (codeActionsEmptyRange) { + const diagnosticSpecificActions = codeActionsEmptyRange.filter( + (action) => + action.title.toLowerCase().includes('fix') || + action.title.toLowerCase().includes('disable'), + ); + assert.strictEqual( + diagnosticSpecificActions.length, + 0, + 'Should not have diagnostic-specific actions for empty range', + ); + } + }); + + test('diagnostics refresh after edit - removing errors', async () => { + const doc = await openFixture('autofix.ts'); + const editor = await vscode.window.showTextDocument(doc); + + // 1. Wait for initial diagnostics + const initialDiags = await waitForDiagnostics(doc); + assert.ok( + initialDiags.length > 0, + `Expected initial diagnostics but got ${initialDiags.length}`, + ); + const initialCount = initialDiags.length; + + // 2. Replace file content with error-free code + const fullRange = new vscode.Range( + doc.positionAt(0), + doc.positionAt(doc.getText().length), + ); + await editor.edit((editBuilder) => { + editBuilder.replace(fullRange, '// no lint errors\nexport {};\n'); + }); + + // 3. Wait for the exact final state; accepting the first smaller + // intermediate publication could hide diagnostics that never clear. + const updatedDiags = await waitForDiagnosticsCount(doc, 0); + + assert.strictEqual( + updatedDiags.length, + 0, + `Expected zero diagnostics after removing errors. Before: ${initialCount}, After: ${updatedDiags.length}`, + ); + }); + + test('diagnostics refresh after edit - introducing errors', async () => { + const doc = await openFixture('autofix.ts'); + const editor = await vscode.window.showTextDocument(doc); + + // 1. Wait for initial diagnostics + const initialDiags = await waitForDiagnostics(doc); + const initialCount = initialDiags.length; + + // 2. Append code that introduces additional lint errors + const endPos = doc.positionAt(doc.getText().length); + await editor.edit((editBuilder) => { + editBuilder.insert( + endPos, + '\nconst anyVal: any = 123;\nanyVal.foo = 1;\n', + ); + }); + + // 3. Wait for diagnostics to update (should increase) + const updatedDiags = await waitForDiagnosticsToChange(doc, initialCount); + + assert.ok( + updatedDiags.length > initialCount, + `Expected more diagnostics after introducing errors. Before: ${initialCount}, After: ${updatedDiags.length}`, + ); + }); + + test('diagnostics refresh after rapid successive edits', async () => { + const doc = await openFixture('autofix.ts'); + const editor = await vscode.window.showTextDocument(doc); + + // 1. Wait for initial diagnostics + const initialDiags = await waitForDiagnostics(doc); + assert.ok(initialDiags.length > 0, 'Should have initial diagnostics'); + const initialCount = initialDiags.length; + + // 2. Perform rapid successive edits β€” simulates fast typing + // Each edit replaces the full content. The server should debounce + // and only produce diagnostics for the final state. + const fullRange = () => + new vscode.Range(doc.positionAt(0), doc.positionAt(doc.getText().length)); + + // Edit 1: still has errors + await editor.edit((b) => + b.replace(fullRange(), 'const x: any = 1;\nexport {};\n'), + ); + // Edit 2: still has errors + await editor.edit((b) => + b.replace( + fullRange(), + 'const y: any = 2;\nconst z: any = 3;\nexport {};\n', + ), + ); + // Edit 3: error-free β€” the final state that matters + await editor.edit((b) => + b.replace(fullRange(), '// all clean\nexport {};\n'), + ); + + // 3. Wait for diagnostics to settle β€” should reflect the error-free final state + const finalDiags = await waitForDiagnosticsCount(doc, 0); + + assert.strictEqual( + finalDiags.length, + 0, + `After rapid edits ending with clean code, expected zero diagnostics. Before: ${initialCount}, After: ${finalDiags.length}`, + ); + }); + + test('diagnostics clear completely when all errors removed', async () => { + const doc = await openFixture('index.ts'); + const editor = await vscode.window.showTextDocument(doc); + + // 1. Wait for initial diagnostics + const initialDiags = await waitForDiagnostics(doc); + assert.ok(initialDiags.length > 0, 'Should have initial diagnostics'); + + // 2. Replace with completely clean content + const fullRange = new vscode.Range( + doc.positionAt(0), + doc.positionAt(doc.getText().length), + ); + await editor.edit((b) => { + b.replace(fullRange, '// empty file\nexport {};\n'); + }); + + // 3. Wait for diagnostics to reach zero β€” use waitForDiagnosticsCount + // to avoid catching intermediate states from debounce + const finalDiags = await waitForDiagnosticsCount(doc, 0); + + assert.strictEqual( + finalDiags.length, + 0, + `Expected zero diagnostics after clearing all errors, got ${finalDiags.length}`, + ); + }); + + test('diagnostics update across multiple edit cycles', async () => { + const doc = await openFixture('disable-file.ts'); + const editor = await vscode.window.showTextDocument(doc); + + const fullRange = () => + new vscode.Range(doc.positionAt(0), doc.positionAt(doc.getText().length)); + + // Start from a published non-empty snapshot, so the following zero cannot + // be the document's not-yet-linted initial state. + await waitForDiagnostics(doc); + + // Step 1: start from clean state to establish baseline + await editor.edit((b) => + b.replace(fullRange(), '// no errors\nexport {};\n'), + ); + await waitForDiagnosticsCount(doc, 0, 10000); + const cleanCount = getRslintDiagnostics(doc).length; + + // Step 2: introduce errors + await editor.edit((b) => + b.replace( + fullRange(), + 'const x: any = 1;\nconst y: any = 2;\nx.foo;\ny.bar;\nexport {};\n', + ), + ); + const diags2 = await waitForDiagnosticsToChange(doc, cleanCount); + assert.ok( + diags2.length > cleanCount, + `After introducing errors: expected more diagnostics. Before: ${cleanCount}, After: ${diags2.length}`, + ); + const errorCount = diags2.length; + + // Step 3: clear errors again β€” use waitForDiagnosticsCount to avoid + // catching intermediate states from debounce on CI + await editor.edit((b) => + b.replace(fullRange(), '// clean again\nexport {};\n'), + ); + const diags3 = await waitForDiagnosticsCount(doc, 0); + assert.ok( + diags3.length < errorCount, + `After clearing errors: expected fewer diagnostics. Before: ${errorCount}, After: ${diags3.length}`, + ); + }); + + test('diagnostics transition: clean β†’ error A β†’ error B β†’ clean', async () => { + const doc = await openFixture('error-transitions.ts'); + const editor = await vscode.window.showTextDocument(doc); + + const fullRange = () => + new vscode.Range(doc.positionAt(0), doc.positionAt(doc.getText().length)); + + // Establish a published non-empty baseline first, so waiting for zero + // cannot pass on the clean document's not-yet-linted initial state. + await editor.edit((b) => + b.replace( + fullRange(), + 'const baseline: any = {};\nbaseline.member;\nexport {};\n', + ), + ); + await waitForDiagnosticsWithMessage(doc, 'no-unsafe-member-access'); + + // Step 1: Start with clean code β€” should have zero diagnostics + await editor.edit((b) => + b.replace(fullRange(), '// no errors\nexport {};\n'), + ); + const cleanDiags = await waitForDiagnosticsCount(doc, 0, 30_000); + assert.strictEqual( + cleanDiags.length, + 0, + `Step 1 (clean): expected 0 diagnostics, got ${cleanDiags.length}`, + ); + + // Step 2: Introduce error A β€” no-unsafe-member-access + await editor.edit((b) => + b.replace( + fullRange(), + 'const obj: any = {};\nobj.foo.bar;\nexport {};\n', + ), + ); + const errorADiags = await waitForDiagnosticsWithMessage( + doc, + 'no-unsafe-member-access', + ); + assert.ok( + errorADiags.length > 0, + `Step 2 (error A): expected diagnostics, got ${errorADiags.length}`, + ); + assert.ok( + errorADiags.some((d) => d.message.includes('no-unsafe-member-access')), + `Step 2 (error A): expected no-unsafe-member-access diagnostic, got: ${errorADiags.map((d) => d.message).join(', ')}`, + ); + + // Step 3: Change to error B β€” no-unnecessary-type-assertion (different rule) + await editor.edit((b) => + b.replace( + fullRange(), + "const someValue: string = 'hello';\nconst result = someValue as string;\nexport {};\n", + ), + ); + const errorBDiags = await waitForDiagnosticsWithMessage( + doc, + 'no-unnecessary-type-assertion', + ); + assert.ok( + errorBDiags.length > 0, + `Step 3 (error B): expected diagnostics, got ${errorBDiags.length}`, + ); + assert.ok( + errorBDiags.some((d) => + d.message.includes('no-unnecessary-type-assertion'), + ), + `Step 3 (error B): expected no-unnecessary-type-assertion diagnostic, got: ${errorBDiags.map((d) => d.message).join(', ')}`, + ); + // Verify error A is gone + assert.ok( + !errorBDiags.some((d) => d.message.includes('no-unsafe-member-access')), + `Step 3 (error B): no-unsafe-member-access should be gone`, + ); + + // Step 4: Back to clean code β€” should have zero diagnostics. + // Use waitForDiagnosticsCount instead of waitForDiagnosticsToChange + // because debounce can cause intermediate diagnostic states on CI. + await editor.edit((b) => + b.replace(fullRange(), '// all clean again\nexport {};\n'), + ); + const finalDiags = await waitForDiagnosticsCount(doc, 0); + assert.strictEqual( + finalDiags.length, + 0, + `Step 4 (clean again): expected 0 diagnostics, got ${finalDiags.length}`, + ); + }); + + test('code actions - preference order', async () => { + const doc = await openFixture('autofix.ts'); + await vscode.window.showTextDocument(doc); + + const diagnostics = await waitForDiagnostics(doc); + + let comparedAutoFixAndDisable = false; + for (const diagnostic of diagnostics) { + // Filter quick fixes + const codeActions = ( + await executeCodeActionProvider(doc.uri, diagnostic.range) + ).filter( + (action) => action.kind?.value === vscode.CodeActionKind.QuickFix.value, + ); + + // Check that if there are auto fixes, they are marked as preferred + const autoFixActions = codeActions.filter( + (action) => + action.title.toLowerCase().includes('fix') && + !action.title.toLowerCase().includes('disable'), + ); + + const disableActions = codeActions.filter((action) => + action.title.toLowerCase().includes('disable'), + ); + + // If both auto fix and disable actions exist, auto fix should be preferred + if (autoFixActions.length > 0 && disableActions.length > 0) { + comparedAutoFixAndDisable = true; + assert.ok( + autoFixActions.some((action) => action.isPreferred), + 'Auto fix actions should be marked as preferred', + ); + assert.ok( + !disableActions.some((action) => action.isPreferred), + 'Disable actions should not be marked as preferred when auto fixes exist', + ); + } + } + assert.ok( + comparedAutoFixAndDisable, + `Expected at least one diagnostic with both auto-fix and disable actions. Diagnostics: ${diagnostics + .map((diagnostic) => diagnostic.message) + .join(' | ')}`, + ); + }); + + test('diagnostics correct after reverting and reopening the editor tab', async () => { + // VS Code may retain a TextDocument model after its last editor closes, so + // this test deliberately covers editor-tab lifecycle rather than claiming + // an LSP didClose cycle: edit β†’ revert β†’ close tab β†’ reopen. + const doc = await openFixture('close-test.ts'); + const editor = await vscode.window.showTextDocument(doc); + + // 1. Wait for initial diagnostics + const initialDiags = await waitForDiagnostics(doc); + assert.ok( + initialDiags.length > 0, + `Expected diagnostics but got ${initialDiags.length}`, + ); + // 2. Edit to clean code β€” diagnostics should drop to 0 + const fullRange = new vscode.Range( + doc.positionAt(0), + doc.positionAt(doc.getText().length), + ); + await editor.edit((b) => b.replace(fullRange, '// clean\nexport {};\n')); + const cleanDiags = await waitForDiagnosticsCount(doc, 0); + assert.strictEqual( + cleanDiags.length, + 0, + `Expected 0 diagnostics after cleaning, got ${cleanDiags.length}`, + ); + + // 3. Revert the dirty overlay and close the exact editor tab before + // reopening the original error content from disk. + await closeTextEditor(doc); + const doc2 = await openFixture('close-test.ts'); + await vscode.window.showTextDocument(doc2); + + // 4. Diagnostics should reappear β€” server correctly handles the cycle + const reopenDiags = await waitForDiagnostics(doc2); + assert.ok( + reopenDiags.length > 0, + `Expected diagnostics after restoring errors, got ${reopenDiags.length}`, + ); + }); + + test('no diagnostics for non-TypeScript files', async () => { + const doc = await openFixture('styles.css'); + await vscode.window.showTextDocument(doc); + + // Wait a reasonable amount of time β€” diagnostics should NOT appear + await new Promise((r) => setTimeout(r, 3000)); + + const diagnostics = getRslintDiagnostics(doc); + assert.strictEqual( + diagnostics.length, + 0, + `Expected 0 diagnostics for CSS file, got ${diagnostics.length}`, + ); + }); +}); diff --git a/packages/vscode/tests/e2e/lint/suite/fixall-cascade.test.ts b/packages/vscode/tests/e2e/lint/suite/fixall-cascade.test.ts new file mode 100644 index 0000000..beaa88b --- /dev/null +++ b/packages/vscode/tests/e2e/lint/suite/fixall-cascade.test.ts @@ -0,0 +1,97 @@ +// Ported verbatim from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/suite/fixall-cascade.test.ts` (origin/main). +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { + waitForDiagnostics, + waitForContentChange, + findFixAllAction, + requestFixAll, + withTmpFile, + withOnSaveFixAll, + replaceAll, + saveDocumentOnce, +} from './fixall-helpers'; + +suite('rslint fixAll - cascade (multi-pass)', function () { + this.timeout(120000); + + test('no-wrapper-object-types triggers no-inferrable-types in second pass', async () => { + const cascadeContent = [ + "const csA: String = 'hello';", + 'const csB: Number = 42;', + 'const csC: Boolean = true;', + 'export { csA, csB, csC };', + '', + ].join('\n'); + await withTmpFile(cascadeContent, async (doc) => { + const initialDiags = await waitForDiagnostics(doc); + const wrapperDiags = initialDiags.filter((d) => + d.message.includes('no-wrapper-object-types'), + ); + assert.ok( + wrapperDiags.length > 0, + `Expected no-wrapper-object-types diagnostics. Got: ${initialDiags + .map((d) => d.message) + .join(' | ')}`, + ); + + const fixAllAction = findFixAllAction(await requestFixAll(doc)); + assert.ok(fixAllAction?.edit, 'Cascade fixAll should provide an edit'); + + assert.ok( + await vscode.workspace.applyEdit(fixAllAction.edit), + 'Cascade fixAll edit should apply', + ); + + const fixedContent = doc.getText(); + assert.ok( + !fixedContent.includes(': String') && + !fixedContent.includes(': Number') && + !fixedContent.includes(': Boolean'), + `no-wrapper-object-types should be fixed. Content: ${fixedContent}`, + ); + assert.ok( + !fixedContent.includes(': string') && + !fixedContent.includes(': number') && + !fixedContent.includes(': boolean'), + `no-inferrable-types should also be fixed (cascade). Content: ${fixedContent}`, + ); + }); + }); + + test('cascade on-save - single save fixes both passes', async () => { + await withOnSaveFixAll(async (doc, editor) => { + const cascadeContent = [ + "const osA: String = 'world';", + 'const osB: Number = 99;', + 'export { osA, osB };', + '', + ].join('\n'); + await replaceAll(editor, cascadeContent); + + const diags = await waitForDiagnostics(doc); + assert.ok( + diags.some((d) => d.message.includes('no-wrapper-object-types')), + `Expected no-wrapper-object-types before on-save cascade. Got: ${diags + .map((d) => d.message) + .join(' | ')}`, + ); + + await saveDocumentOnce(doc, 'Cascade document should save'); + + // Event-driven wait: resolves the moment the on-save fixAll edit + // lands on the document, instead of polling on a 500ms interval. + // 60s budget gives Windows runners headroom under load. + // The helper rejects with a descriptive timeout error including the + // last seen document content; let that propagate verbatim so the + // original stack survives. + await waitForContentChange( + doc, + (content) => + !content.includes(': String') && !content.includes(': string'), + 60000, + ); + }); + }); +}); diff --git a/packages/vscode/tests/e2e/lint/suite/fixall-error.test.ts b/packages/vscode/tests/e2e/lint/suite/fixall-error.test.ts new file mode 100644 index 0000000..15c5185 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/suite/fixall-error.test.ts @@ -0,0 +1,88 @@ +// Ported verbatim from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/suite/fixall-error.test.ts` (origin/main). +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { + waitForDiagnostics, + waitForContentChange, + findFixAllAction, + requestFixAll, + withTmpFile, + withOnSaveFixAll, + replaceAll, + saveDocumentOnce, +} from './fixall-helpers'; + +suite('rslint fixAll - error flows', function () { + this.timeout(120000); + + test('fixAll on file with syntax errors does not crash', async () => { + const brokenContent = 'const x: string = \nfunction (\nexport { \n'; + await withTmpFile(brokenContent, async (doc) => { + await new Promise((r) => setTimeout(r, 3000)); + + const codeActions = await requestFixAll(doc); + const fixAllAction = findFixAllAction(codeActions); + + if (fixAllAction?.edit) { + const edits = fixAllAction.edit.get(doc.uri); + if (edits && edits.length > 0) { + const applied = await vscode.workspace.applyEdit(fixAllAction.edit); + assert.ok(applied, 'Edit from fixAll on broken file should apply'); + } + } + }); + }); + + test('fixAll on empty file does not crash', async () => { + await withTmpFile('', async (doc) => { + await new Promise((r) => setTimeout(r, 2000)); + + const codeActions = await requestFixAll(doc); + const fixAllAction = findFixAllAction(codeActions); + + if (fixAllAction?.edit) { + const edits = fixAllAction.edit?.get(doc.uri); + assert.ok( + !edits || edits.length === 0, + 'fixAll should not produce edits for empty file', + ); + } + }); + }); + + test('on-save with syntax errors saves normally', async () => { + await withOnSaveFixAll(async (doc, editor) => { + await replaceAll( + editor, + "const pVal: string = 'x';\nconst pRes = (pVal as string).trim();\n", + ); + const probeDiags = await waitForDiagnostics(doc); + assert.ok( + probeDiags.some((d) => + d.message.includes('no-unnecessary-type-assertion'), + ), + `Expected fixable diagnostic before syntax-error save. Got: ${probeDiags + .map((d) => d.message) + .join(' | ')}`, + ); + await saveDocumentOnce(doc, 'Syntax-error probe should save'); + await waitForContentChange( + doc, + (content) => !content.includes('pVal as string'), + 60000, + ); + + const brokenContent = 'const x = \nfunction {\nexport {\n'; + await replaceAll(editor, brokenContent); + await saveDocumentOnce(doc, 'Broken document should save'); + + await new Promise((r) => setTimeout(r, 3000)); + + assert.ok( + doc.getText().length > 0, + 'Document should have content after save', + ); + }); + }); +}); diff --git a/packages/vscode/tests/e2e/lint/suite/fixall-helpers.ts b/packages/vscode/tests/e2e/lint/suite/fixall-helpers.ts new file mode 100644 index 0000000..998a223 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/suite/fixall-helpers.ts @@ -0,0 +1,205 @@ +// Ported verbatim from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/suite/fixall-helpers.ts` (origin/main). +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import path from 'node:path'; +import fs from 'node:fs'; +import { + waitForRslintDiagnostics, + waitForRslintDiagnosticsCount, + waitForRslintDiagnosticsToChange, +} from '../utils/diagnostics'; +import { withCodeActionsOnSave } from '../utils/configuration'; +import { + closeAndDeleteTemporaryDocument, + temporaryFilePath, +} from '../utils/documents'; +import { waitForCodeActionRegistryQuiescence } from '../utils/codeActionRegistry'; + +export { saveDocumentOnce } from '../utils/codeActionRegistry'; + +export const waitForDiagnostics = waitForRslintDiagnostics; +export const waitForDiagnosticsCount = waitForRslintDiagnosticsCount; +export const waitForDiagnosticsToChange = waitForRslintDiagnosticsToChange; + +export function getFixturesDir(): string { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) + throw new Error('VS Code test workspace is unavailable'); + return workspaceFolder.uri.fsPath; +} + +export async function openFixture( + filename: string, +): Promise { + return vscode.workspace.openTextDocument( + path.resolve(getFixturesDir(), 'src/', filename), + ); +} + +export function findFixAllAction( + codeActions: vscode.CodeAction[] | undefined, +): vscode.CodeAction | undefined { + return codeActions?.find( + (action) => + action.kind?.value === 'source.fixAll.rslint' || + action.kind?.value === 'source.fixAll', + ); +} + +export async function executeCodeActionProvider( + uri: vscode.Uri, + range: vscode.Range, + kind?: string, +): Promise { + await waitForCodeActionRegistryQuiescence(); + const args: unknown[] = ['vscode.executeCodeActionProvider', uri, range]; + if (kind !== undefined) args.push(kind); + const result = await vscode.commands.executeCommand( + ...(args as [string, vscode.Uri, vscode.Range, ...unknown[]]), + ); + return result ?? []; +} + +export async function requestFixAll( + doc: vscode.TextDocument, + kind: vscode.CodeActionKind = vscode.CodeActionKind.SourceFixAll.append( + 'rslint', + ), +): Promise { + return executeCodeActionProvider( + doc.uri, + new vscode.Range(0, 0, doc.lineCount, 0), + kind.value, + ); +} + +export async function withTmpFile( + content: string, + testFn: ( + doc: vscode.TextDocument, + editor: vscode.TextEditor, + ) => Promise, +): Promise { + const tmpFile = temporaryFilePath( + path.join(getFixturesDir(), 'src'), + '_fixall_tmp_', + ); + fs.writeFileSync(tmpFile, content, 'utf-8'); + let doc: vscode.TextDocument | undefined; + let testError: unknown; + try { + doc = await vscode.workspace.openTextDocument(tmpFile); + const editor = await vscode.window.showTextDocument(doc); + await testFn(doc, editor); + } catch (error) { + testError = error; + } + await finishTemporaryDocument(testError, doc, tmpFile); +} + +/** + * Wait until `predicate(content)` becomes true. Subscribes to + * `vscode.workspace.onDidChangeTextDocument` and returns the moment a + * matching content arrives, instead of polling on a fixed interval. + * + * Use this in preference to a `while (...) await sleep(500)` loop when the + * test is waiting for a server-driven content change (e.g. on-save fixAll + * applying an edit): the event-driven path resolves with sub-millisecond + * latency once the change lands, so the only wall-clock cost is the + * server's actual response time. + * + * Rejects with a descriptive error (including the last seen content) when + * `timeoutMs` elapses without the predicate being satisfied. + */ +export async function waitForContentChange( + doc: vscode.TextDocument, + predicate: (content: string) => boolean, + timeoutMs: number, +): Promise { + const initial = doc.getText(); + if (predicate(initial)) return initial; + return new Promise((resolve, reject) => { + const docUriString = doc.uri.toString(); + const disposable = vscode.workspace.onDidChangeTextDocument((e) => { + if (e.document.uri.toString() !== docUriString) return; + const current = doc.getText(); + if (predicate(current)) { + clearTimeout(timer); + disposable.dispose(); + resolve(current); + } + }); + const timer = setTimeout(() => { + disposable.dispose(); + reject( + new Error( + `waitForContentChange: predicate not satisfied within ${timeoutMs}ms. Last content:\n${doc.getText()}`, + ), + ); + }, timeoutMs); + }); +} + +export async function replaceAll( + editor: vscode.TextEditor, + newContent: string, +): Promise { + const doc = editor.document; + const fullRange = new vscode.Range( + doc.positionAt(0), + doc.positionAt(doc.getText().length), + ); + const ok = await editor.edit((b) => b.replace(fullRange, newContent)); + assert.ok(ok, 'editor.edit should succeed'); +} + +export async function withOnSaveFixAll( + testFn: ( + doc: vscode.TextDocument, + editor: vscode.TextEditor, + ) => Promise, + codeActionsOnSave: Record = { + 'source.fixAll.rslint': 'explicit', + }, +): Promise { + const tmpFile = temporaryFilePath( + path.join(getFixturesDir(), 'src'), + '_fixall_test_', + ); + fs.writeFileSync(tmpFile, '// placeholder\n', 'utf-8'); + + let doc: vscode.TextDocument | undefined; + let testError: unknown; + try { + const openedDocument = await vscode.workspace.openTextDocument(tmpFile); + doc = openedDocument; + const editor = await vscode.window.showTextDocument(openedDocument); + await withCodeActionsOnSave(openedDocument, codeActionsOnSave, async () => { + await testFn(openedDocument, editor); + }); + } catch (error) { + testError = error; + } + await finishTemporaryDocument(testError, doc, tmpFile); +} + +async function finishTemporaryDocument( + testError: unknown, + document: vscode.TextDocument | undefined, + filePath: string, +): Promise { + const errors: unknown[] = []; + if (testError) errors.push(testError); + + try { + await closeAndDeleteTemporaryDocument(document, filePath); + } catch (error) { + errors.push(error); + } + + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) { + throw new AggregateError(errors, 'Test and temporary-file cleanup failed'); + } +} diff --git a/packages/vscode/tests/e2e/lint/suite/fixall-onsave.test.ts b/packages/vscode/tests/e2e/lint/suite/fixall-onsave.test.ts new file mode 100644 index 0000000..4e658ce --- /dev/null +++ b/packages/vscode/tests/e2e/lint/suite/fixall-onsave.test.ts @@ -0,0 +1,215 @@ +// Ported verbatim from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/suite/fixall-onsave.test.ts` (origin/main). +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { getRslintDiagnostics } from '../utils/diagnostics'; +import { waitForCodeActionRegistryQuiescence } from '../utils/codeActionRegistry'; +import { + waitForDiagnostics, + waitForDiagnosticsCount, + waitForContentChange, + withOnSaveFixAll, + replaceAll, + saveDocumentOnce, +} from './fixall-helpers'; + +function assertHasFixableDiagnostic( + diagnostics: vscode.Diagnostic[], + context: string, +): void { + assert.ok( + diagnostics.some((d) => + d.message.includes('no-unnecessary-type-assertion'), + ), + `${context}: expected no-unnecessary-type-assertion. Got: ${diagnostics + .map((d) => d.message) + .join(' | ')}`, + ); +} + +suite('rslint fixAll - on-save', function () { + this.timeout(120000); + + test('generic source.fixAll triggers rslint via on-save', async () => { + await withOnSaveFixAll( + async (doc, editor) => { + await replaceAll( + editor, + "const gfVal: string = 'x';\nconst gfRes = (gfVal as string).trim();\n", + ); + + const diags = await waitForDiagnostics(doc); + assertHasFixableDiagnostic(diags, 'generic source.fixAll setup'); + + await saveDocumentOnce( + doc, + 'Document should complete the generic source.fixAll save pipeline', + ); + await waitForContentChange( + doc, + (content) => !content.includes('gfVal as string'), + 60000, + ); + + assert.ok( + !doc.getText().includes('gfVal as string'), + `Generic source.fixAll should trigger rslint fixAll.\nContent: ${doc.getText()}`, + ); + }, + { 'source.fixAll': 'explicit' }, + ); + }); + + test('fixable issues get auto-fixed', async () => { + await withOnSaveFixAll(async (doc, editor) => { + const fixableContent = [ + "const saveVal: string = 'hello';", + 'const saveResult = (saveVal as string).toUpperCase();', + '', + ].join('\n'); + await replaceAll(editor, fixableContent); + + const diags = await waitForDiagnostics(doc); + assertHasFixableDiagnostic(diags, 'fixable on-save setup'); + + await saveDocumentOnce(doc, 'Fixable document should save'); + await waitForContentChange( + doc, + (content) => !content.includes('saveVal as string'), + 60000, + ); + + assert.ok( + !doc.getText().includes('saveVal as string'), + `Type assertion should be removed after on-save fixAll.\nContent: ${doc.getText()}`, + ); + }); + }); + + test('clean file saves without content change', async () => { + await withOnSaveFixAll(async (doc, editor) => { + // Probe: prove on-save is active + await replaceAll( + editor, + "const probeVal: string = 'x';\nconst probeRes = (probeVal as string).trim();\n", + ); + const probeDiags = await waitForDiagnostics(doc); + assertHasFixableDiagnostic(probeDiags, 'clean-file probe setup'); + await saveDocumentOnce(doc, 'Clean-file probe should save'); + await waitForContentChange( + doc, + (content) => !content.includes('probeVal as string'), + 60000, + ); + assert.ok( + !doc.getText().includes('probeVal as string'), + 'Probe: on-save fixAll should be active', + ); + + // Clean content + const cleanContent = + '// no issues\nconst cleanOnSave = 42;\nexport {};\n'; + await replaceAll(editor, cleanContent); + await new Promise((r) => setTimeout(r, 3000)); + + await saveDocumentOnce(doc, 'Clean document should save'); + await new Promise((r) => setTimeout(r, 2000)); + + assert.strictEqual( + doc.getText(), + cleanContent, + 'Clean file content should not change after on-save with fixAll', + ); + }); + }); + + test('non-fixable diagnostics remain, content unchanged', async () => { + await withOnSaveFixAll(async (doc, editor) => { + // Probe + await replaceAll( + editor, + "const probeVal2: string = 'x';\nconst probeRes2 = (probeVal2 as string).trim();\n", + ); + const probeDiags = await waitForDiagnostics(doc); + assertHasFixableDiagnostic(probeDiags, 'non-fixable probe setup'); + await saveDocumentOnce(doc, 'Non-fixable probe should save'); + await waitForContentChange( + doc, + (content) => !content.includes('probeVal2 as string'), + 60000, + ); + assert.ok( + !doc.getText().includes('probeVal2 as string'), + 'Probe: on-save fixAll should be active', + ); + + // Non-fixable content + const content = 'const nfOnSave: any = {};\nnfOnSave.foo;\n'; + await replaceAll(editor, content); + + const diags = await waitForDiagnostics(doc); + assert.ok(diags.length > 0, 'Should have non-fixable diagnostics'); + + await saveDocumentOnce(doc, 'Non-fixable document should save'); + await new Promise((r) => setTimeout(r, 2000)); + + assert.strictEqual( + doc.getText(), + content, + 'Non-fixable file content should not change after on-save', + ); + + const diagsAfter = getRslintDiagnostics(doc); + assert.ok( + diagsAfter.length > 0, + 'Non-fixable diagnostics should remain after save', + ); + }); + }); + + test('edit then immediately save (debounce not fired)', async () => { + await withOnSaveFixAll(async (doc, editor) => { + await replaceAll(editor, '// clean start\nexport {};\n'); + await saveDocumentOnce(doc, 'Initial clean document should save'); + const cleanDiagnostics = await waitForDiagnosticsCount(doc, 0); + assert.strictEqual( + cleanDiagnostics.length, + 0, + `Initial clean document should have no diagnostics. Got: ${cleanDiagnostics + .map((d) => d.message) + .join(' | ')}`, + ); + + // Put the readiness delay before the edit. The save below intentionally + // follows the edit immediately, so this test cannot pass merely because + // the normal 1.5s registry barrier allowed the diagnostics debounce to + // fire. Any registry mutation in this tiny edit/save gap still makes the + // one-shot save fail rather than retrying it. + await waitForCodeActionRegistryQuiescence(); + await replaceAll( + editor, + "const quickVal: string = 'x';\nconst quickRes = (quickVal as string).trim();\n", + ); + + await saveDocumentOnce( + doc, + 'Edited document should complete the code-action-on-save pipeline', + async () => {}, + ); + + // Event-driven wait β€” Windows CI needs more headroom than the previous + // 20 s polling loop, and `onDidChangeTextDocument` resolves the moment + // the on-save fixAll edit lands (sub-ms vs. 500 ms poll cadence). + await waitForContentChange( + doc, + (content) => !content.includes('quickVal as string'), + 60000, + ); + + assert.ok( + !doc.getText().includes('quickVal as string'), + `On-save fixAll should work even when debounce has not fired.\nContent: ${doc.getText()}`, + ); + }); + }); +}); diff --git a/packages/vscode/tests/e2e/lint/suite/fixall.test.ts b/packages/vscode/tests/e2e/lint/suite/fixall.test.ts new file mode 100644 index 0000000..42803be --- /dev/null +++ b/packages/vscode/tests/e2e/lint/suite/fixall.test.ts @@ -0,0 +1,279 @@ +// Ported verbatim from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/suite/fixall.test.ts` (origin/main). +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { + waitForDiagnostics, + waitForDiagnosticsToChange, + waitForDiagnosticsCount, + openFixture, + findFixAllAction, + requestFixAll, + replaceAll, + withTmpFile, +} from './fixall-helpers'; + +suite('rslint fixAll - code actions', function () { + this.timeout(90000); + + // ======== Basic fixAll behavior (read-only, safe to use fixtures) ======== + + test('returns fixes for auto-fixable file', async () => { + const doc = await openFixture('fixall.ts'); + await vscode.window.showTextDocument(doc); + + await waitForDiagnostics(doc); + + const fixAllAction = findFixAllAction(await requestFixAll(doc)); + + assert.ok(fixAllAction, 'Should have a fixAll code action'); + assert.ok(fixAllAction.edit, 'fixAll action should have an edit'); + + const edits = fixAllAction.edit.get(doc.uri); + assert.ok( + edits && edits.length > 0, + `fixAll should produce edits, got ${edits?.length ?? 0}`, + ); + }); + + test('no action for non-TS file', async () => { + const doc = await openFixture('styles.css'); + await vscode.window.showTextDocument(doc); + + await new Promise((r) => setTimeout(r, 2000)); + + const fixAllAction = findFixAllAction(await requestFixAll(doc)); + + if (fixAllAction) { + const edits = fixAllAction.edit?.get(doc.uri); + assert.ok( + !edits || edits.length === 0, + 'fixAll should not produce edits for non-TS file', + ); + } + }); + + test('no fixes for file with only non-fixable diagnostics', async () => { + const doc = await openFixture('disable.ts'); + await vscode.window.showTextDocument(doc); + + const diagnostics = await waitForDiagnostics(doc); + assert.ok(diagnostics.length > 0, 'Should have diagnostics'); + + const fixAllAction = findFixAllAction(await requestFixAll(doc)); + + if (fixAllAction) { + const edits = fixAllAction.edit?.get(doc.uri); + assert.ok( + !edits || edits.length === 0, + 'fixAll should not produce edits for non-fixable diagnostics', + ); + } + }); + + // ======== Tests that modify content (use tmp files) ======== + + test('no action for clean file', async () => { + const fixableContent = + "const cleanProbe: string = 'hello';\nconst cleanResult = (cleanProbe as string).toUpperCase();\n"; + const cleanContent = '// no lint errors\nexport {};\n'; + await withTmpFile(fixableContent, async (doc, editor) => { + const initialDiagnostics = await waitForDiagnostics(doc, (diagnostics) => + diagnostics.some((diagnostic) => + diagnostic.message.includes('no-unnecessary-type-assertion'), + ), + ); + assert.ok( + initialDiagnostics.some((diagnostic) => + diagnostic.message.includes('no-unnecessary-type-assertion'), + ), + `Expected a fixable control diagnostic. Got: ${initialDiagnostics + .map((diagnostic) => diagnostic.message) + .join(' | ')}`, + ); + + const controlAction = findFixAllAction(await requestFixAll(doc)); + assert.ok( + controlAction?.edit, + 'Control state should provide fixAll edits', + ); + const controlEdits = controlAction.edit.get(doc.uri); + assert.ok( + controlEdits && controlEdits.length > 0, + 'Control state fixAll edit should not be empty', + ); + + await replaceAll(editor, cleanContent); + const cleanDiagnostics = await waitForDiagnosticsCount(doc, 0); + assert.strictEqual( + cleanDiagnostics.length, + 0, + `Expected clean state to publish zero diagnostics, got ${cleanDiagnostics.length}`, + ); + + const fixAllAction = findFixAllAction(await requestFixAll(doc)); + + if (fixAllAction) { + const edits = fixAllAction.edit?.get(doc.uri); + assert.ok( + !edits || edits.length === 0, + 'fixAll should not produce edits for clean file', + ); + } + }); + }); + + test('fixes reduce diagnostics after apply', async () => { + const fixableContent = + "const frVal: string = 'hello';\nconst frRes = (frVal as string).toUpperCase();\n"; + await withTmpFile(fixableContent, async (doc) => { + const initialDiags = await waitForDiagnostics(doc); + assert.ok(initialDiags.length > 0, 'Should have initial diagnostics'); + + const fixableDiags = initialDiags.filter((d) => + d.message.includes('no-unnecessary-type-assertion'), + ); + assert.ok( + fixableDiags.length > 0, + `Expected a fixable diagnostic. Got: ${initialDiags + .map((d) => d.message) + .join(' | ')}`, + ); + + const fixAllAction = findFixAllAction(await requestFixAll(doc)); + assert.ok(fixAllAction?.edit, 'fixAll should provide an edit'); + const applied = await vscode.workspace.applyEdit(fixAllAction.edit); + assert.ok(applied, 'fixAll edit should apply successfully'); + + const updatedDiags = await waitForDiagnosticsToChange( + doc, + initialDiags.length, + ); + + assert.ok( + updatedDiags.length < initialDiags.length, + `Diagnostics should decrease after fixAll. Before: ${initialDiags.length}, After: ${updatedDiags.length}`, + ); + }); + }); + + test('mixed fixable and non-fixable - only fixes fixable', async () => { + const mixedContent = [ + "const mfVal: string = 'hello';", + 'const mfRes = (mfVal as string).toUpperCase();', + 'const mfUnsafe: any = {};', + 'mfUnsafe.foo;', + '', + ].join('\n'); + await withTmpFile(mixedContent, async (doc) => { + const initialDiags = await waitForDiagnostics(doc); + assert.ok(initialDiags.length > 0, 'Should have diagnostics'); + + const fixableBefore = initialDiags.filter((d) => + d.message.includes('no-unnecessary-type-assertion'), + ); + const nonFixableBefore = initialDiags.filter((d) => + d.message.includes('no-unsafe'), + ); + assert.ok( + fixableBefore.length > 0, + `Expected a fixable diagnostic. Got: ${initialDiags + .map((d) => d.message) + .join(' | ')}`, + ); + assert.ok( + nonFixableBefore.length > 0, + `Expected a non-fixable diagnostic. Got: ${initialDiags + .map((d) => d.message) + .join(' | ')}`, + ); + + const fixAllAction = findFixAllAction(await requestFixAll(doc)); + assert.ok(fixAllAction?.edit, 'Mixed fixAll should provide an edit'); + assert.ok( + await vscode.workspace.applyEdit(fixAllAction.edit), + 'Mixed fixAll edit should apply', + ); + + const updatedDiags = await waitForDiagnosticsToChange( + doc, + initialDiags.length, + ); + + const fixableAfter = updatedDiags.filter((d) => + d.message.includes('no-unnecessary-type-assertion'), + ); + assert.ok( + fixableAfter.length < fixableBefore.length, + `Fixable diagnostics should decrease. Before: ${fixableBefore.length}, After: ${fixableAfter.length}`, + ); + + const nonFixableAfter = updatedDiags.filter((d) => + d.message.includes('no-unsafe'), + ); + assert.ok( + nonFixableAfter.length > 0, + `Non-fixable diagnostics should remain after fixAll, got ${nonFixableAfter.length}`, + ); + }); + }); + + test('works after rapid edit without debounce', async () => { + // Upstream re-imported `replaceAll` dynamically here; the static import at + // the top of this file is the same binding (nodenext forbids the + // extensionless dynamic form). + await withTmpFile('// initial\nexport {};\n', async (doc, editor) => { + await new Promise((r) => setTimeout(r, 2000)); + + await replaceAll( + editor, + "const rapidVal: string = 'test';\nconst rapidResult = (rapidVal as string).trim();\n", + ); + + const fixAllAction = findFixAllAction(await requestFixAll(doc)); + + assert.ok( + fixAllAction?.edit, + 'fixAll should provide an edit even before debounce fires', + ); + const edits = fixAllAction.edit.get(doc.uri); + assert.ok( + edits && edits.length > 0, + 'fixAll should produce edits for newly edited content', + ); + }); + }); + + test('second fixAll after first has fewer fixes', async () => { + const fixableContent = + "const sfVal: string = 'x';\nconst sfRes = (sfVal as string).trim();\n"; + await withTmpFile(fixableContent, async (doc) => { + const initialDiags = await waitForDiagnostics(doc); + const fixableCount = initialDiags.filter((d) => + d.message.includes('no-unnecessary-type-assertion'), + ).length; + assert.ok( + fixableCount > 0, + `Expected a fixable diagnostic before first fixAll. Got: ${initialDiags + .map((d) => d.message) + .join(' | ')}`, + ); + + const fixAll1 = findFixAllAction(await requestFixAll(doc)); + assert.ok(fixAll1?.edit, 'First fixAll should have edits'); + await vscode.workspace.applyEdit(fixAll1!.edit!); + + await waitForDiagnosticsToChange(doc, initialDiags.length); + + const fixAll2 = findFixAllAction(await requestFixAll(doc)); + + if (fixAll2?.edit) { + const edits2 = fixAll2.edit.get(doc.uri); + assert.ok( + !edits2 || edits2.length === 0, + `Second fixAll should have no edits, got ${edits2?.length ?? 0}`, + ); + } + }); + }); +}); diff --git a/packages/vscode/tests/e2e/lint/suite/index.ts b/packages/vscode/tests/e2e/lint/suite/index.ts new file mode 100644 index 0000000..e8a41f6 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/suite/index.ts @@ -0,0 +1,3 @@ +import { createRun } from '../runSuite'; + +export const run = createRun(); diff --git a/packages/vscode/tests/e2e/lint/suite/registry-harness.test.ts b/packages/vscode/tests/e2e/lint/suite/registry-harness.test.ts new file mode 100644 index 0000000..edc3f05 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/suite/registry-harness.test.ts @@ -0,0 +1,282 @@ +// Ported verbatim from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/suite/registry-harness.test.ts` (origin/main). +import * as assert from 'assert'; +import { randomUUID } from 'node:crypto'; +import * as vscode from 'vscode'; +import { + CodeActionRegistryProbe, + saveDocumentOnce, + waitForConsecutiveSuccessfulProbeWindows, +} from '../utils/codeActionRegistry'; +import { runBeforeDeadline } from '../utils/deadline'; + +suite('VS Code test harness fail-closed guards', function () { + this.timeout(15_000); + + test('an interrupted window resets consecutive readiness', async () => { + const outcomes = [true, false, true, true]; + const result = await waitForConsecutiveSuccessfulProbeWindows( + async (attempt) => outcomes[attempt - 1] ?? false, + { + consecutiveSuccessfulWindows: 2, + timeoutMs: 1_000, + retryDelayMs: 0, + description: 'the injected readiness sequence', + }, + ); + + assert.deepStrictEqual(result, { + attempts: 4, + interruptedWindows: 1, + }); + }); + + test('continuous interruption times out instead of passing partially', async () => { + let attempts = 0; + await assert.rejects( + waitForConsecutiveSuccessfulProbeWindows( + async () => { + attempts += 1; + return false; + }, + { + consecutiveSuccessfulWindows: 2, + timeoutMs: 50, + retryDelayMs: 5, + description: 'the continuously interrupted injected probe', + }, + ), + /Timed out.*interruptedWindows=/, + ); + assert.ok(attempts > 1, 'The probe should retry readiness windows'); + }); + + test('a non-settling probe window is bounded by the hard deadline', async () => { + let attempts = 0; + let saveCalls = 0; + await assert.rejects( + saveDocumentOnce( + { + save: async () => { + saveCalls += 1; + return true; + }, + }, + 'non-settling readiness failure', + () => + waitForConsecutiveSuccessfulProbeWindows( + () => { + attempts += 1; + return new Promise(() => {}); + }, + { + consecutiveSuccessfulWindows: 2, + timeoutMs: 50, + retryDelayMs: 0, + description: 'the non-settling injected probe', + }, + ), + ), + /Timed out.*attempts=1/, + ); + assert.strictEqual(attempts, 1); + assert.strictEqual(saveCalls, 0, 'A timed-out probe must block the save'); + }); + + test('an unexpected probe error fails immediately and blocks save', async () => { + let attempts = 0; + let saveCalls = 0; + const readiness = () => + waitForConsecutiveSuccessfulProbeWindows( + async () => { + attempts += 1; + throw new Error('unexpected probe failure'); + }, + { + consecutiveSuccessfulWindows: 2, + timeoutMs: 1_000, + retryDelayMs: 0, + description: 'the unexpectedly failing probe', + }, + ); + + await assert.rejects( + saveDocumentOnce( + { + save: async () => { + saveCalls += 1; + return true; + }, + }, + 'unexpected readiness failure', + readiness, + ), + /unexpected probe failure/, + ); + assert.strictEqual(attempts, 1, 'Unexpected errors must not be retried'); + assert.strictEqual(saveCalls, 0, 'An unexpected error must block the save'); + }); + + test('a never-settling startup operation is bounded by its shared deadline', async () => { + let attempts = 0; + await assert.rejects( + runBeforeDeadline( + () => { + attempts += 1; + return new Promise(() => {}); + }, + Date.now() + 50, + 'the injected startup operation', + ), + /Timed out waiting for the injected startup operation.*shared startup deadline/, + ); + assert.strictEqual( + attempts, + 1, + 'The startup operation must not be retried', + ); + }); + + test('an expired startup deadline prevents the operation from starting', async () => { + let attempts = 0; + await assert.rejects( + runBeforeDeadline( + () => { + attempts += 1; + }, + Date.now() - 1, + 'the expired injected startup operation', + ), + /shared startup deadline has expired/, + ); + assert.strictEqual(attempts, 0, 'Expired startup work must not begin'); + }); + + test('readiness failure blocks save and a false save is never retried', async () => { + let saveCalls = 0; + const document = { + save: async () => { + saveCalls += 1; + return false; + }, + }; + + await assert.rejects( + saveDocumentOnce(document, 'injected save failure', async () => { + throw new Error('injected readiness failure'); + }), + /injected readiness failure/, + ); + assert.strictEqual( + saveCalls, + 0, + 'A failed readiness probe must block save', + ); + + await assert.rejects( + saveDocumentOnce(document, 'injected save failure', async () => {}), + /returned false; the real save was not retried/, + ); + assert.strictEqual( + saveCalls, + 1, + 'The real save must be invoked exactly once', + ); + }); + + test('an unrelated delayed provider mutation interrupts vulnerable VS Code', async () => { + const probe = new CodeActionRegistryProbe(); + let injected = false; + let unrelatedProvider: vscode.Disposable | undefined; + let resolveMutation: (() => void) | undefined; + const mutationCompleted = new Promise((resolve) => { + resolveMutation = resolve; + }); + + try { + const result = await probe.wait({ + quietWindowMs: 100, + consecutiveSuccessfulWindows: 2, + timeoutMs: 5_000, + retryDelayMs: 5, + onAttemptStarted: (attempt) => { + if (attempt !== 1 || injected) return; + injected = true; + setTimeout(() => { + unrelatedProvider = vscode.languages.registerCodeActionsProvider( + { scheme: `rslint-unrelated-${randomUUID()}` }, + { provideCodeActions: () => [] }, + ); + setTimeout(() => { + unrelatedProvider?.dispose(); + unrelatedProvider = undefined; + resolveMutation?.(); + }, 10); + }, 20); + }, + }); + await mutationCompleted; + + assert.ok(injected, 'The provider mutation must be injected in-flight'); + if (result.interruptedWindows > 0) { + assert.ok( + result.attempts >= 3, + 'A cancelled window must reset the two-window readiness sequence', + ); + } else { + // Forward-compatible path: once VS Code fixes the registry comparison, + // unrelated providers no longer cancel the filtered request. + assert.strictEqual(result.attempts, 2); + } + } finally { + unrelatedProvider?.dispose(); + probe.dispose(); + } + }); + + test('a relevant delayed provider mutation always resets readiness', async () => { + const probe = new CodeActionRegistryProbe(); + let relevantProvider: vscode.Disposable | undefined; + let mutationCompleted: Promise | undefined; + + try { + const result = await probe.wait({ + quietWindowMs: 100, + consecutiveSuccessfulWindows: 2, + timeoutMs: 5_000, + retryDelayMs: 5, + onAttemptStarted: (attempt, probeUri) => { + if (attempt !== 1 || mutationCompleted) return; + mutationCompleted = new Promise((resolve) => { + setTimeout(() => { + // No kind metadata means this provider is part of the filtered + // request on both vulnerable and fixed VS Code versions. + relevantProvider = vscode.languages.registerCodeActionsProvider( + { scheme: probeUri.scheme }, + { provideCodeActions: () => [] }, + ); + setTimeout(() => { + relevantProvider?.dispose(); + relevantProvider = undefined; + resolve(); + }, 10); + }, 20); + }); + }, + }); + await mutationCompleted; + + assert.ok( + result.interruptedWindows > 0, + 'A relevant registry mutation must interrupt an in-flight probe', + ); + assert.ok( + result.attempts >= 3, + 'The interrupted window must reset the two-window readiness sequence', + ); + } finally { + relevantProvider?.dispose(); + probe.dispose(); + } + }); +}); diff --git a/packages/vscode/tests/e2e/lint/utils/codeActionRegistry.ts b/packages/vscode/tests/e2e/lint/utils/codeActionRegistry.ts new file mode 100644 index 0000000..6650454 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/utils/codeActionRegistry.ts @@ -0,0 +1,304 @@ +// Ported verbatim from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/utils/codeActionRegistry.ts` (origin/main). +import { randomUUID } from 'node:crypto'; +import * as vscode from 'vscode'; + +const defaultQuietWindowMs = 750; +const defaultSuccessfulWindows = 2; +const defaultTimeoutMs = 60_000; +const defaultRetryDelayMs = 25; + +export interface CodeActionRegistryProbeOptions { + quietWindowMs?: number; + consecutiveSuccessfulWindows?: number; + timeoutMs?: number; + retryDelayMs?: number; + /** Test-only hook. It runs after the probe provider receives the request. */ + onAttemptStarted?: (attempt: number, probeUri: vscode.Uri) => void; +} + +export interface CodeActionRegistryProbeResult { + attempts: number; + interruptedWindows: number; +} + +interface ProbeLoopOptions { + consecutiveSuccessfulWindows: number; + timeoutMs: number; + retryDelayMs: number; + description: string; +} + +interface SaveableDocument { + save(): Thenable; +} + +function delay(timeoutMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, timeoutMs)); +} + +export function isCodeActionCancellation(error: unknown): boolean { + return ( + error instanceof vscode.CancellationError || + (error instanceof Error && + error.name === 'Canceled' && + error.message === 'Canceled') + ); +} + +/** + * Require N consecutive successful readiness windows. A failed or cancelled + * window resets the count; timeout rejects instead of accepting a partial run. + * The executor is injectable so the fail-closed state machine can be tested + * without relying on a particular VS Code release's cancellation behavior. + */ +export async function waitForConsecutiveSuccessfulProbeWindows( + executeWindow: (attempt: number) => Promise, + options: ProbeLoopOptions, +): Promise { + if (options.consecutiveSuccessfulWindows < 1) { + throw new Error('consecutiveSuccessfulWindows must be at least 1'); + } + if (options.timeoutMs < 1) { + throw new Error('timeoutMs must be at least 1'); + } + + const deadline = Date.now() + options.timeoutMs; + let attempts = 0; + let interruptedWindows = 0; + let successfulWindows = 0; + + const timeoutError = (): Error => + new Error( + `Timed out after ${options.timeoutMs}ms waiting for ${options.description}; ` + + `attempts=${attempts}, interruptedWindows=${interruptedWindows}`, + ); + + while (Date.now() < deadline) { + attempts += 1; + let timer: ReturnType | undefined; + const remainingMs = Math.max(1, deadline - Date.now()); + const hardTimeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(timeoutError()), remainingMs); + }); + let completed: boolean; + try { + completed = await Promise.race([executeWindow(attempts), hardTimeout]); + } finally { + if (timer) clearTimeout(timer); + } + if (Date.now() >= deadline) throw timeoutError(); + + if (completed) { + successfulWindows += 1; + if (successfulWindows >= options.consecutiveSuccessfulWindows) { + return { attempts, interruptedWindows }; + } + continue; + } + + interruptedWindows += 1; + successfulWindows = 0; + if (options.retryDelayMs > 0) { + await delay( + Math.min(options.retryDelayMs, Math.max(0, deadline - Date.now())), + ); + } + } + + throw timeoutError(); +} + +/** + * Public-API sentinel for VS Code's code-action provider registry. + * + * VS Code versions affected by microsoft/vscode's filtered-provider comparison + * bug cancel an in-flight source action whenever any provider is registered or + * disposed. Two providers match this private URI scheme, but only one matches + * the requested kind. That makes an unrelated registry mutation cancel this + * harmless request in exactly the same way it cancels code actions on save. + * + * Providers remain registered for the lifetime of the probe so their own + * disposal cannot race the real save. They match only a randomized private URI + * scheme and therefore never participate in normal test documents. + */ +export class CodeActionRegistryProbe implements vscode.Disposable { + private readonly probeKind: vscode.CodeActionKind; + private readonly documentPromise: Thenable; + private readonly disposables: vscode.Disposable[]; + private queue: Promise = Promise.resolve(); + private disposed = false; + private activeQuietWindowMs = defaultQuietWindowMs; + private activeAttempt = 0; + private activeAttemptHook: + ((attempt: number, probeUri: vscode.Uri) => void) | undefined; + + constructor() { + const id = randomUUID().replaceAll('-', ''); + const scheme = `rslint-registry-probe-${id}`; + const selector: vscode.DocumentSelector = { scheme }; + this.probeKind = vscode.CodeActionKind.Source.append( + `rslintTest.registryProbe.${id}`, + ); + const excludedKind = vscode.CodeActionKind.QuickFix.append( + `rslintTest.registryProbe.${id}`, + ); + + const contentProvider = + vscode.workspace.registerTextDocumentContentProvider(scheme, { + provideTextDocumentContent: () => '// registry probe\n', + }); + const includedProvider = vscode.languages.registerCodeActionsProvider( + selector, + { + provideCodeActions: (document, _range, _context, token) => + this.provideProbeAction(document.uri, token), + }, + { providedCodeActionKinds: [this.probeKind] }, + ); + // This provider deliberately matches the document but not the requested + // kind. It exposes VS Code's filtered-vs-unfiltered registry comparison. + const excludedProvider = vscode.languages.registerCodeActionsProvider( + selector, + { provideCodeActions: () => [] }, + { providedCodeActionKinds: [excludedKind] }, + ); + + this.disposables = [excludedProvider, includedProvider, contentProvider]; + this.documentPromise = vscode.workspace.openTextDocument( + vscode.Uri.parse(`${scheme}:/probe.ts`), + ); + } + + wait( + options: CodeActionRegistryProbeOptions = {}, + ): Promise { + if (this.disposed) { + return Promise.reject(new Error('CodeActionRegistryProbe is disposed')); + } + + const execution = this.queue.then(() => this.waitUnqueued(options)); + this.queue = execution.then( + () => undefined, + () => undefined, + ); + return execution; + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + for (const disposable of this.disposables) disposable.dispose(); + } + + private async waitUnqueued( + options: CodeActionRegistryProbeOptions, + ): Promise { + if (this.disposed) { + throw new Error('CodeActionRegistryProbe is disposed'); + } + + const document = await this.documentPromise; + const quietWindowMs = options.quietWindowMs ?? defaultQuietWindowMs; + if (quietWindowMs < 1) { + throw new Error('quietWindowMs must be at least 1'); + } + + this.activeQuietWindowMs = quietWindowMs; + this.activeAttemptHook = options.onAttemptStarted; + try { + return await waitForConsecutiveSuccessfulProbeWindows( + async (attempt) => { + this.activeAttempt = attempt; + let actions: vscode.CodeAction[] | undefined; + try { + actions = await vscode.commands.executeCommand( + 'vscode.executeCodeActionProvider', + document.uri, + new vscode.Range(0, 0, 0, 0), + this.probeKind.value, + ); + } catch (error) { + if (isCodeActionCancellation(error)) return false; + throw error; + } + return Boolean( + actions?.some( + (action) => action.kind?.value === this.probeKind.value, + ), + ); + }, + { + consecutiveSuccessfulWindows: + options.consecutiveSuccessfulWindows ?? defaultSuccessfulWindows, + timeoutMs: options.timeoutMs ?? defaultTimeoutMs, + retryDelayMs: options.retryDelayMs ?? defaultRetryDelayMs, + description: 'the VS Code code-action registry to become quiescent', + }, + ); + } finally { + this.activeAttempt = 0; + this.activeAttemptHook = undefined; + } + } + + private provideProbeAction( + probeUri: vscode.Uri, + token: vscode.CancellationToken, + ): Promise { + const attempt = this.activeAttempt; + this.activeAttemptHook?.(attempt, probeUri); + + return new Promise((resolve, reject) => { + let settled = false; + const finish = ( + actions: vscode.CodeAction[] | undefined, + error?: unknown, + ): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + cancellation.dispose(); + if (error) reject(error); + else resolve(actions ?? []); + }; + const timer = setTimeout(() => { + finish([ + new vscode.CodeAction('rslint test registry probe', this.probeKind), + ]); + }, this.activeQuietWindowMs); + const cancellation = token.onCancellationRequested(() => + finish(undefined, new vscode.CancellationError()), + ); + if (token.isCancellationRequested) { + finish(undefined, new vscode.CancellationError()); + } + }); + } +} + +let sharedProbe: CodeActionRegistryProbe | undefined; + +export function waitForCodeActionRegistryQuiescence(): Promise { + sharedProbe ??= new CodeActionRegistryProbe(); + return sharedProbe.wait(); +} + +/** + * Wait for readiness, then invoke the real save exactly once. A false result is + * surfaced as a failure and is never retried, preventing a cancelled first save + * from being hidden by a successful second save. + */ +export async function saveDocumentOnce( + document: SaveableDocument, + failureMessage: string, + waitForReadiness: () => Promise = waitForCodeActionRegistryQuiescence, +): Promise { + await waitForReadiness(); + const saved = await document.save(); + if (!saved) { + throw new Error( + `${failureMessage}: TextDocument.save() returned false; the real save was not retried`, + ); + } +} diff --git a/packages/vscode/tests/e2e/lint/utils/configuration.ts b/packages/vscode/tests/e2e/lint/utils/configuration.ts new file mode 100644 index 0000000..2021e8f --- /dev/null +++ b/packages/vscode/tests/e2e/lint/utils/configuration.ts @@ -0,0 +1,215 @@ +// Ported verbatim from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/utils/configuration.ts` (origin/main). +import fs from 'node:fs'; +import path from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; +import * as vscode from 'vscode'; + +type CodeActionsOnSave = Record; + +interface EventWaiter { + promise: Promise; + dispose(): void; +} + +interface WorkspaceSettingsSnapshot { + directoryPath: string; + directoryExisted: boolean; + filePath: string; + content: Buffer | undefined; +} + +function captureWorkspaceSettings( + document: vscode.TextDocument, +): WorkspaceSettingsSnapshot { + const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri); + if (!workspaceFolder) { + throw new Error(`No workspace folder contains ${document.uri.toString()}`); + } + const directoryPath = path.join(workspaceFolder.uri.fsPath, '.vscode'); + const filePath = path.join(directoryPath, 'settings.json'); + return { + directoryPath, + directoryExisted: fs.existsSync(directoryPath), + filePath, + content: fs.existsSync(filePath) ? fs.readFileSync(filePath) : undefined, + }; +} + +function restoreWorkspaceSettings(snapshot: WorkspaceSettingsSnapshot): void { + if (snapshot.content) { + fs.mkdirSync(snapshot.directoryPath, { recursive: true }); + fs.writeFileSync(snapshot.filePath, snapshot.content); + if (!fs.readFileSync(snapshot.filePath).equals(snapshot.content)) { + throw new Error( + `Could not restore workspace settings: ${snapshot.filePath}`, + ); + } + return; + } + + if (fs.existsSync(snapshot.filePath)) fs.unlinkSync(snapshot.filePath); + if (!snapshot.directoryExisted && fs.existsSync(snapshot.directoryPath)) { + const entries = fs.readdirSync(snapshot.directoryPath); + if (entries.length === 0) fs.rmdirSync(snapshot.directoryPath); + } + if (fs.existsSync(snapshot.filePath)) { + throw new Error( + `Could not remove generated settings: ${snapshot.filePath}`, + ); + } +} + +function configurationEventWaiter( + section: string, + scope: vscode.ConfigurationScope, + timeoutMs = 10_000, +): EventWaiter { + let finish: (observed: boolean) => void; + let settled = false; + const promise = new Promise((resolve) => { + finish = (observed) => { + if (settled) return; + settled = true; + subscription.dispose(); + clearTimeout(timer); + resolve(observed); + }; + }); + const subscription = vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration(section, scope)) finish(true); + }); + const timer = setTimeout(() => finish(false), timeoutMs); + return { promise, dispose: () => finish(false) }; +} + +async function updateWorkspaceLanguageValue( + document: vscode.TextDocument, + value: CodeActionsOnSave | undefined, +): Promise { + const section = 'editor.codeActionsOnSave'; + const scope = { uri: document.uri, languageId: document.languageId }; + const configuration = vscode.workspace.getConfiguration('editor', scope); + if ( + isDeepStrictEqual( + configuration.inspect('codeActionsOnSave') + ?.workspaceLanguageValue, + value, + ) + ) { + return; + } + + const waiter = configurationEventWaiter(section, scope); + try { + await configuration.update( + 'codeActionsOnSave', + value, + vscode.ConfigurationTarget.Workspace, + true, + ); + if (!(await waiter.promise)) { + throw new Error(`No configuration change event received for ${section}`); + } + } finally { + waiter.dispose(); + } +} + +/** + * Set the resource/language-specific on-save value and restore the exact prior + * workspace-language value. Never writes an inherited effective value back to + * workspace settings. + */ +export async function withCodeActionsOnSave( + document: vscode.TextDocument, + value: CodeActionsOnSave, + callback: () => Promise, +): Promise { + const scope = { uri: document.uri, languageId: document.languageId }; + const configuration = vscode.workspace.getConfiguration('editor', scope); + const inspection = + configuration.inspect('codeActionsOnSave'); + if (!inspection) { + throw new Error('editor.codeActionsOnSave is unavailable'); + } + + const previousEffective = + configuration.get('codeActionsOnSave'); + const previousWorkspaceLanguageValue = inspection.workspaceLanguageValue; + const changed = !isDeepStrictEqual(previousEffective, value); + const settingsSnapshot = changed + ? captureWorkspaceSettings(document) + : undefined; + + let result: T | undefined; + let callbackCompleted = false; + let callbackError: unknown; + try { + if (changed) { + await updateWorkspaceLanguageValue(document, value); + const effective = vscode.workspace + .getConfiguration('editor', scope) + .get('codeActionsOnSave'); + if (!isDeepStrictEqual(effective, value)) { + throw new Error( + 'editor.codeActionsOnSave did not resolve to the requested test value', + ); + } + } + result = await callback(); + callbackCompleted = true; + } catch (error) { + callbackError = error; + } + + const restoreErrors: unknown[] = []; + if (changed) { + try { + await updateWorkspaceLanguageValue( + document, + previousWorkspaceLanguageValue, + ); + const restored = vscode.workspace + .getConfiguration('editor', scope) + .get('codeActionsOnSave'); + if (!isDeepStrictEqual(restored, previousEffective)) { + throw new Error( + 'editor.codeActionsOnSave was not restored to its prior effective value', + ); + } + } catch (error) { + restoreErrors.push(error); + } + if (!settingsSnapshot) { + restoreErrors.push(new Error('Workspace settings snapshot is missing')); + } else { + try { + restoreWorkspaceSettings(settingsSnapshot); + } catch (error) { + restoreErrors.push(error); + } + } + } + + const restoreError = + restoreErrors.length > 1 + ? new AggregateError( + restoreErrors, + 'Multiple editor.codeActionsOnSave restoration steps failed', + ) + : restoreErrors[0]; + + if (callbackError && restoreError) { + throw new AggregateError( + [callbackError, restoreError], + 'Test callback and editor.codeActionsOnSave restoration both failed', + ); + } + if (callbackError) throw callbackError; + if (restoreError) throw restoreError; + if (!callbackCompleted) { + throw new Error('editor.codeActionsOnSave callback did not complete'); + } + return result as T; +} diff --git a/packages/vscode/tests/e2e/lint/utils/deadline.ts b/packages/vscode/tests/e2e/lint/utils/deadline.ts new file mode 100644 index 0000000..6c1ab41 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/utils/deadline.ts @@ -0,0 +1,39 @@ +// Ported verbatim from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/utils/deadline.ts` (origin/main). +/** + * Run one asynchronous startup step within a shared absolute deadline. + * A never-settling operation rejects at the deadline instead of hanging the + * Extension Host before Mocha (and its per-test timeouts) has started. + */ +export async function runBeforeDeadline( + operation: () => T | PromiseLike, + deadline: number, + description: string, +): Promise { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + throw new Error( + `Timed out waiting for ${description}: the shared startup deadline has expired`, + ); + } + + let timer: ReturnType | undefined; + try { + return await Promise.race([ + Promise.resolve().then(operation), + new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new Error( + `Timed out waiting for ${description} before the shared startup deadline`, + ), + ), + remainingMs, + ); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} diff --git a/packages/vscode/tests/e2e/lint/utils/diagnostics.ts b/packages/vscode/tests/e2e/lint/utils/diagnostics.ts new file mode 100644 index 0000000..8444a79 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/utils/diagnostics.ts @@ -0,0 +1,118 @@ +// Ported verbatim from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/utils/diagnostics.ts` (origin/main). +import * as vscode from 'vscode'; + +export const rslintDiagnosticSource = 'rslint'; + +export function getRslintDiagnostics( + documentOrUri: vscode.TextDocument | vscode.Uri, +): vscode.Diagnostic[] { + const uri = + documentOrUri instanceof vscode.Uri ? documentOrUri : documentOrUri.uri; + return vscode.languages + .getDiagnostics(uri) + .filter((diagnostic) => diagnostic.source === rslintDiagnosticSource); +} + +function describeDiagnostics( + diagnostics: readonly vscode.Diagnostic[], +): string { + if (diagnostics.length === 0) return ''; + return diagnostics + .map( + (diagnostic) => + `${diagnostic.source ?? ''}: ${diagnostic.message}`, + ) + .join(' | '); +} + +const diagnosticsPollIntervalMs = 100; + +/** + * Wait for matching rslint diagnostics. + * + * VS Code may coalesce diagnostic-change notifications when multiple documents + * are updated in one language-server publish cycle. Keep the event subscription + * for the fast path, but also sample the authoritative diagnostics collection so + * a missed notification cannot turn an already-satisfied predicate into a + * timeout. The timeout performs one final read for the same reason. + */ +export function waitForRslintDiagnostics( + document: vscode.TextDocument, + predicate: (diagnostics: vscode.Diagnostic[]) => boolean = (diagnostics) => + diagnostics.length > 0, + timeoutMs = 60_000, +): Promise { + return new Promise((resolve, reject) => { + const uriString = document.uri.toString(); + let settled = false; + + const finish = ( + diagnostics: vscode.Diagnostic[] | undefined, + error?: unknown, + ): void => { + if (settled) return; + settled = true; + subscription.dispose(); + clearTimeout(timer); + clearInterval(poller); + if (error) reject(error); + else resolve(diagnostics ?? []); + }; + const check = (): boolean => { + if (settled) return true; + const diagnostics = getRslintDiagnostics(document); + try { + if (predicate(diagnostics)) { + finish(diagnostics); + return true; + } + } catch (error) { + finish(undefined, error); + return true; + } + return false; + }; + const subscription = vscode.languages.onDidChangeDiagnostics((event) => { + if (event.uris.some((uri) => uri.toString() === uriString)) check(); + }); + + const timer = setTimeout(() => { + if (check()) return; + const diagnostics = getRslintDiagnostics(document); + finish( + undefined, + new Error( + `Timed out after ${timeoutMs}ms waiting for rslint diagnostics for ${document.uri.toString()}. ` + + `Last rslint diagnostics: ${describeDiagnostics(diagnostics)}`, + ), + ); + }, timeoutMs); + const poller = setInterval(check, diagnosticsPollIntervalMs); + check(); + }); +} + +export function waitForRslintDiagnosticsToChange( + document: vscode.TextDocument, + previousCount: number, + timeoutMs = 30_000, +): Promise { + return waitForRslintDiagnostics( + document, + (diagnostics) => diagnostics.length !== previousCount, + timeoutMs, + ); +} + +export function waitForRslintDiagnosticsCount( + document: vscode.TextDocument, + expectedCount: number, + timeoutMs = 30_000, +): Promise { + return waitForRslintDiagnostics( + document, + (diagnostics) => diagnostics.length === expectedCount, + timeoutMs, + ); +} diff --git a/packages/vscode/tests/e2e/lint/utils/documents.ts b/packages/vscode/tests/e2e/lint/utils/documents.ts new file mode 100644 index 0000000..beb92e8 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/utils/documents.ts @@ -0,0 +1,87 @@ +// Ported verbatim from web-infra-dev/rslint +// `packages/vscode-extension/__tests__/utils/documents.ts` (origin/main). +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import * as vscode from 'vscode'; + +export function temporaryFilePath( + directory: string, + prefix: string, + extension = '.ts', +): string { + return path.join(directory, `${prefix}${randomUUID()}${extension}`); +} + +async function focusTextDocument(document: vscode.TextDocument): Promise { + await vscode.window.showTextDocument(document, { + preview: false, + preserveFocus: false, + }); + if ( + vscode.window.activeTextEditor?.document.uri.toString() !== + document.uri.toString() + ) { + throw new Error(`Could not focus document: ${document.uri}`); + } +} + +export async function revertTextDocument( + document: vscode.TextDocument | undefined, +): Promise { + if (!document || document.isClosed) return; + if (!document.isDirty) return; + await focusTextDocument(document); + await vscode.commands.executeCommand('workbench.action.files.revert'); + if (document.isDirty) { + throw new Error(`Could not revert dirty document: ${document.uri}`); + } +} + +/** Close the exact editor tab. VS Code may retain its TextDocument model. */ +export async function closeTextEditor( + document: vscode.TextDocument | undefined, +): Promise { + if (!document || document.isClosed) return; + await focusTextDocument(document); + await revertTextDocument(document); + await vscode.commands.executeCommand('workbench.action.closeActiveEditor'); + if ( + vscode.window.activeTextEditor?.document.uri.toString() === + document.uri.toString() + ) { + throw new Error(`Could not close editor tab: ${document.uri}`); + } +} + +export function deleteTemporaryFile(filePath: string): void { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); +} + +/** Close/revert the exact tab, delete its unique file, and verify deletion. */ +export async function closeAndDeleteTemporaryDocument( + document: vscode.TextDocument | undefined, + filePath: string, +): Promise { + const errors: unknown[] = []; + try { + await closeTextEditor(document); + } catch (error) { + errors.push(error); + } + try { + deleteTemporaryFile(filePath); + } catch (error) { + errors.push(error); + } + if (fs.existsSync(filePath)) { + errors.push( + new Error(`Temporary file still exists after delete: ${filePath}`), + ); + } + + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) { + throw new AggregateError(errors, 'Temporary document cleanup failed'); + } +} diff --git a/packages/vscode/tests/e2e/lint/utils/extension.ts b/packages/vscode/tests/e2e/lint/utils/extension.ts new file mode 100644 index 0000000..a70b8cb --- /dev/null +++ b/packages/vscode/tests/e2e/lint/utils/extension.ts @@ -0,0 +1,57 @@ +/** + * Access to the unified extension's public exports channel + * (`RstackExtensionExports`, see `src/types.ts`). Upstream suites relied on + * `extension.activate()` resolving only once a language-server root was up; + * this extension's shell activates without blocking on stack startup + * (the shell-activation adaptation), so lint-stack lifecycle assertions go through + * `getStackExports('rslint')` / `whenStackActive('rslint')` instead. + */ +import * as vscode from 'vscode'; +import type { RstackExtensionExports } from '../../../../src/types'; + +export const EXTENSION_ID = 'rstack.rstack'; + +/** Must match the marker `runTest.ts` writes into every sandbox copy. */ +export const workspaceMarkerFile = '.rstack-vscode-test-sandbox.json'; + +export function extensionExports(): RstackExtensionExports { + const extension = + vscode.extensions.getExtension(EXTENSION_ID); + if (!extension) { + throw new Error(`Extension ${EXTENSION_ID} is unavailable`); + } + if (!extension.isActive) { + throw new Error( + `Extension ${EXTENSION_ID} is not active; runSuite.ts activates it before any test runs`, + ); + } + return extension.exports; +} + +/** True while the shell has the lint controller registered (gate passed). */ +export function isLintStackRegistered(): boolean { + return extensionExports().getStackExports('rslint') !== undefined; +} + +const pollIntervalMs = 100; + +/** + * Bounded poll until the lint stack reaches the requested registration state. + * Detection flips are watcher-driven and debounced, so a + * single-shot assertion right after a config mutation would be a coin flip. + */ +export async function waitForLintStackRegistration( + registered: boolean, + timeoutMs = 60_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (isLintStackRegistered() === registered) return; + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + throw new Error( + `Timed out after ${timeoutMs}ms waiting for the Rslint stack to become ${ + registered ? 'registered' : 'unregistered' + }`, + ); +} diff --git a/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/config/trailing-slash.config.ts b/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/config/trailing-slash.config.ts new file mode 100644 index 0000000..f1d89e6 --- /dev/null +++ b/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/config/trailing-slash.config.ts @@ -0,0 +1,6 @@ +import path from 'node:path'; +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + root: `${path.resolve(__dirname, '..')}${path.sep}`, +}); diff --git a/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/package.json b/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/package.json new file mode 100644 index 0000000..eb1179f --- /dev/null +++ b/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/package.json @@ -0,0 +1,9 @@ +{ + "name": "rstack-editor-rstest-e2e-workspace-1", + "version": "0.0.0", + "private": true, + "description": "E2E fixture for the ported Rstest suites: upstream `tests/fixtures/workspace-1`, made self-contained on the published `@rstest/core`.", + "dependencies": { + "@rstest/core": "^0.11.5" + } +} diff --git a/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/rstest.config.ts b/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/rstest.config.ts new file mode 100644 index 0000000..9ee3cba --- /dev/null +++ b/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/rstest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({}); diff --git a/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/src/foo.ts b/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/src/foo.ts new file mode 100644 index 0000000..e226334 --- /dev/null +++ b/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/src/foo.ts @@ -0,0 +1,2 @@ +export const sayFoo = () => 'foo'; +export const sayFoo1 = () => 'foo1'; diff --git a/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/src/index.ts b/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/src/index.ts new file mode 100644 index 0000000..eae921c --- /dev/null +++ b/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/src/index.ts @@ -0,0 +1 @@ +export const sayHi = () => 'hi'; diff --git a/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/each.test.ts b/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/each.test.ts new file mode 100644 index 0000000..8a3db22 --- /dev/null +++ b/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/each.test.ts @@ -0,0 +1,9 @@ +import { describe, it } from '@rstest/core'; + +describe('suite', () => { + it('case', () => {}); +}); + +describe.each([1, 2])('suite %i', (index) => { + it.each([1, 2])(`suite ${index} case %i`, () => {}); +}); diff --git a/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/foo.test.ts b/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/foo.test.ts new file mode 100644 index 0000000..b224754 --- /dev/null +++ b/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/foo.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from '@rstest/core'; +import { sayFoo, sayFoo1 } from '../src/foo'; + +describe('l1', () => { + describe('l2', () => { + it('should return "foo"', () => { + expect(sayFoo()).toBe('foo'); + }); + + it('should also return "foo"', () => { + expect(sayFoo()).toBe('foo'); + }); + + describe('l3', () => { + it('should return "foo1"', () => { + expect(sayFoo1()).toBe('foo2'); + }); + + it('should also return "foo1"', () => { + expect(sayFoo1()).toBe('foo3'); + }); + }); + }); +}); diff --git a/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/index.test.ts b/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/index.test.ts new file mode 100644 index 0000000..28f8a8e --- /dev/null +++ b/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/index.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from '@rstest/core'; +import { sayHi } from '../src/index'; + +describe('Index', () => { + it('should add two numbers correctly', () => { + expect(1 + 1).toBe(2); + }); + + it('should test source code correctly', () => { + expect(sayHi()).toBe('hi'); + }); +}); diff --git a/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/jsFile.spec.js b/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/jsFile.spec.js new file mode 100644 index 0000000..09b9d01 --- /dev/null +++ b/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/jsFile.spec.js @@ -0,0 +1,8 @@ +// JavaScript test file to verify discovery of *.spec.* patterns +const { describe, it, expect } = require('@rstest/core'); + +describe('JS', () => { + it('should run in JS', () => { + expect(1).toBe(1); + }); +}); diff --git a/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/jsFile.spec.js.txt b/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/jsFile.spec.js.txt new file mode 100644 index 0000000..038d616 --- /dev/null +++ b/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/jsFile.spec.js.txt @@ -0,0 +1,8 @@ +// Test fixture file to verify that non-JS/TS files are excluded from test discovery +const { describe, it, expect } = require('@rstest/core'); + +describe('JS', () => { + it('should run in JS', () => { + expect(1).toBe(1); + }); +}); diff --git a/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/jsxFile.test.jsx b/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/jsxFile.test.jsx new file mode 100644 index 0000000..09b9d01 --- /dev/null +++ b/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/jsxFile.test.jsx @@ -0,0 +1,8 @@ +// JavaScript test file to verify discovery of *.spec.* patterns +const { describe, it, expect } = require('@rstest/core'); + +describe('JS', () => { + it('should run in JS', () => { + expect(1).toBe(1); + }); +}); diff --git a/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/progress.test.ts b/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/progress.test.ts new file mode 100644 index 0000000..de3c167 --- /dev/null +++ b/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/progress.test.ts @@ -0,0 +1,28 @@ +import { afterAll, describe, expect, it } from '@rstest/core'; + +afterAll(() => { + throw new Error('after root suite'); +}); + +describe('s1', () => { + afterAll(() => { + throw new Error('after suite'); + }); + + it('should pass', () => { + // Ensure stderr output is forwarded to VS Code test output. + console.log('stdout: progress.test.ts'); + console.error('stderr: progress.test.ts'); + expect(1).equal(1); + }); + it('should mismatch number', () => { + expect(1).equal(2); + }); + it('should mismatch object', () => { + expect({ a: 1 }).equal({ b: 1 }); + }); + it('should mismatch inline snapshot', () => { + expect('hello').toMatchInlineSnapshot(`"world"`); + }); + it.skip('should skipped', () => {}); +}); diff --git a/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/tsxFile.test.tsx b/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/tsxFile.test.tsx new file mode 100644 index 0000000..1d6be3a --- /dev/null +++ b/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/test/tsxFile.test.tsx @@ -0,0 +1,8 @@ +// JavaScript test file to verify discovery of *.spec.* patterns +import { describe, expect, it } from '@rstest/core'; + +describe('JS', () => { + it('should run in JS', () => { + expect(1).toBe(1); + }); +}); diff --git a/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/tsconfig.json b/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/tsconfig.json new file mode 100644 index 0000000..e6b9bdf --- /dev/null +++ b/packages/vscode/tests/e2e/rstest/fixtures/workspace-1/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["DOM", "ES2020"], + "module": "ESNext", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "resolveJsonModule": true, + "moduleResolution": "bundler", + "useDefineForClassFields": true + }, + "include": ["src"] +} diff --git a/packages/vscode/tests/e2e/rstest/fixtures/workspace-2/folder/project-2/rstest.config.ts b/packages/vscode/tests/e2e/rstest/fixtures/workspace-2/folder/project-2/rstest.config.ts new file mode 100644 index 0000000..9ee3cba --- /dev/null +++ b/packages/vscode/tests/e2e/rstest/fixtures/workspace-2/folder/project-2/rstest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({}); diff --git a/packages/vscode/tests/e2e/rstest/fixtures/workspace-2/folder/project-2/test/foo.test.ts b/packages/vscode/tests/e2e/rstest/fixtures/workspace-2/folder/project-2/test/foo.test.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/vscode/tests/e2e/rstest/fixtures/workspace-2/package.json b/packages/vscode/tests/e2e/rstest/fixtures/workspace-2/package.json new file mode 100644 index 0000000..0a2c062 --- /dev/null +++ b/packages/vscode/tests/e2e/rstest/fixtures/workspace-2/package.json @@ -0,0 +1,9 @@ +{ + "name": "rstack-editor-rstest-e2e-workspace-2", + "version": "0.0.0", + "private": true, + "description": "E2E fixture for the ported Rstest suites: upstream `tests/fixtures/workspace-2`. One install at the root serves both nested projects via the normal node_modules walk-up.", + "dependencies": { + "@rstest/core": "^0.11.5" + } +} diff --git a/packages/vscode/tests/e2e/rstest/fixtures/workspace-2/project-1/rstest.config.ts b/packages/vscode/tests/e2e/rstest/fixtures/workspace-2/project-1/rstest.config.ts new file mode 100644 index 0000000..9ee3cba --- /dev/null +++ b/packages/vscode/tests/e2e/rstest/fixtures/workspace-2/project-1/rstest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({}); diff --git a/packages/vscode/tests/e2e/rstest/fixtures/workspace-2/project-1/test/foo.test.ts b/packages/vscode/tests/e2e/rstest/fixtures/workspace-2/project-1/test/foo.test.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/vscode/tests/e2e/rstest/runTest.ts b/packages/vscode/tests/e2e/rstest/runTest.ts new file mode 100644 index 0000000..0604746 --- /dev/null +++ b/packages/vscode/tests/e2e/rstest/runTest.ts @@ -0,0 +1,100 @@ +/** + * The `@vscode/test-electron` entry point for the ported Rstest suites + * (upstream `rstest/packages/vscode/tests/runTest.ts`, restated on this repo's + * harness patterns β€” see `tests/e2e/runTest.ts`). + * + * Unlike the shell/detection harness this one does **not** open a checked-in + * workspace file: `suite/workspace.test.ts` calls `updateWorkspaceFolders` to + * add and remove `workspace-2`, and VS Code persists folder changes back into + * the file it opened. The workspace file is therefore generated per run in the + * scratch dir, so every run starts from the identical single-folder state and + * a failure between the add and the remove never reaches the repository. + */ +import { createHash } from 'node:crypto'; +import { existsSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { runTests } from '@vscode/test-electron'; + +const FIXTURE_DIRS = ['workspace-1', 'workspace-2'] as const; + +async function main() { + // `__dirname` is `/tests-dist/tests/e2e/rstest` (see tsconfig.e2e.json). + const extensionDevelopmentPath = path.resolve(__dirname, '../../../..'); + const extensionTestsPath = path.resolve(__dirname, './suite/index'); + const fixturesRoot = path.join( + extensionDevelopmentPath, + 'tests/e2e/rstest/fixtures', + ); + + // The extension host loads `main` from `package.json`; an unbuilt repo would + // otherwise fail deep inside VS Code with an unhelpful activation error. + if (!existsSync(path.join(extensionDevelopmentPath, 'dist/extension.js'))) { + throw new Error( + 'dist/extension.js is missing β€” run `pnpm build` before `pnpm test:e2e:rstest`.', + ); + } + // workspace-2 has no node_modules of its own per project; the root install + // serves both nested projects, so the root is what the guard probes. + for (const name of FIXTURE_DIRS) { + if (!existsSync(path.join(fixturesRoot, name, 'node_modules'))) { + throw new Error( + `the rstest/${name} E2E fixture is not installed β€” run \`pnpm test:e2e:fixtures\`.`, + ); + } + } + + // A short user-data dir keeps the Unix socket paths below the macOS limit. + const hash = createHash('sha1') + .update(`${extensionDevelopmentPath}:rstest`) + .digest('hex') + .slice(0, 8); + const scratchDir = mkdtempSync(path.join(tmpdir(), `rstack-${hash}-`)); + + // Only workspace-1 to start with; workspace.test.ts adds workspace-2 itself. + const workspaceFile = path.join(scratchDir, 'rstest-e2e.code-workspace'); + writeFileSync( + workspaceFile, + `${JSON.stringify( + { + folders: [{ path: path.join(fixturesRoot, 'workspace-1') }], + settings: { 'files.exclude': { '**/node_modules': true } }, + }, + null, + 2, + )}\n`, + ); + + await runTests({ + // Pinnable for CI; `stable` locally. `runTests` forwards the whole options + // object to the downloader, so `version`/`timeout`/`vscodeExecutablePath` + // all apply to it. A cached download under `.vscode-test/` is reused. + version: process.env.VSCODE_TEST_VERSION ?? 'stable', + timeout: 60_000, + // Escape hatch for offline / restricted environments: point at an existing + // VS Code and nothing is downloaded at all. + vscodeExecutablePath: process.env.VSCODE_TEST_EXECUTABLE || undefined, + extensionDevelopmentPath, + extensionTestsPath, + launchArgs: [ + workspaceFile, + // Only the extension under development runs: no user extension may + // register a competing test controller. + '--disable-extensions', + // The fixtures spawn project-local workers, which Restricted Mode + // forbids by design. + '--disable-workspace-trust', + '--disable-updates', + '--skip-welcome', + '--skip-release-notes', + '--user-data-dir', + scratchDir, + ], + }); +} + +main().catch((error) => { + console.error('Failed to run Rstest E2E tests'); + console.error(error); + process.exit(1); +}); diff --git a/packages/vscode/tests/e2e/rstest/suite/helpers.ts b/packages/vscode/tests/e2e/rstest/suite/helpers.ts new file mode 100644 index 0000000..491217b --- /dev/null +++ b/packages/vscode/tests/e2e/rstest/suite/helpers.ts @@ -0,0 +1,147 @@ +// Ported from upstream `rstest/packages/vscode/tests/suite/helpers.ts`, plus +// the exports plumbing this extension needs: upstream's `activate()` returned +// the `Rstest` instance directly, while here the shell republishes the stack's +// exports through `RstackExtensionExports` (see `src/types.ts`). +import assert from 'node:assert'; +import path from 'node:path'; +import vscode from 'vscode'; +import type { RstackExtensionExports } from '../../../../src/types'; + +/** + * The stack exports the suites consume β€” the shape + * `stacks/test/index.ts#Rstest.buildExports()` returns. + */ +export interface RstestExports { + testController: vscode.TestController; + runProfile: vscode.TestRunProfile; + startTestRun: ( + request: vscode.TestRunRequest, + token: vscode.CancellationToken, + updateSnapshot?: boolean, + createTestRun?: (request: vscode.TestRunRequest) => vscode.TestRun, + ) => Promise; +} + +/** `/tests/e2e/rstest/fixtures` (β€” `__dirname` is under `tests-dist/`). */ +export const FIXTURES_ROOT = path.resolve( + __dirname, + '../../../../..', + 'tests/e2e/rstest/fixtures', +); + +const EXTENSION_ID = 'rstack.rstack'; + +/** + * Activates the extension (upstream: `getExtension('rstack.rstest')` + + * `activate()`) and waits until the shell registered the Rstest stack. + */ +export async function getRstestExports(): Promise { + const extension = + vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(extension, `extension ${EXTENSION_ID} should be present`); + const api = await extension.activate(); + return (await api.whenStackActive('rstest')) as unknown as RstestExports; +} + +/** + * The *current* Rstest exports, synchronously. The workspace suite drives + * detection through states where the stack may be disposed and re-registered + * (a re-registration publishes a fresh `TestController`), so its polling + * probes must re-resolve the live controller instead of holding the first one. + */ +export function currentRstestExports(): RstestExports { + const api = + vscode.extensions.getExtension( + EXTENSION_ID, + )?.exports; + assert.ok(api, `extension ${EXTENSION_ID} should be activated`); + const stackExports = api.getStackExports('rstest'); + assert.ok(stackExports, 'the Rstest stack should be active'); + return stackExports as unknown as RstestExports; +} + +export async function delay(ms: number) { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Upstream's `waitFor`, made async-aware (a probe that returns a rejected +// promise is retried like a throwing one) β€” the assertions inside stay as-is. +export async function waitFor( + cb: () => T | Promise, + { + timeoutMs = 20_000, + pollMs = 25, + }: { + timeoutMs?: number; + pollMs?: number; + } = {}, +): Promise { + const start = Date.now(); + for (;;) { + try { + return await cb(); + } catch (error) { + if (Date.now() - start > timeoutMs) { + throw error; + } + } + await delay(pollMs); + } +} + +export function getTestItems(collection: vscode.TestItemCollection) { + const items: vscode.TestItem[] = []; + collection.forEach((item) => { + items.push(item); + }); + return items; +} + +export function getProjectItems(testController: vscode.TestController) { + const folders = getTestItems(testController.items); + assert.equal(folders.length, 1); + return getTestItems(folders[0].children); +} + +export function getTestItemByLabels( + collection: vscode.TestItemCollection, + labels: string[], +) { + const item = labels.reduce( + (item, label) => + item && + getTestItems(item.children).find( + // normalize to linux path style, matching `toLabelTree`, so labels + // built with `path.sep` still match on Windows + (child) => child.label.replaceAll(path.sep, '/') === label, + ), + { + children: collection, + } as vscode.TestItem | undefined, + ); + assert.ok(item); + return item; +} + +// Helper: recursively transform a TestItem into a label-only tree. +// Children are sorted by label for stable comparisons. +export function toLabelTree( + collection: vscode.TestItemCollection, + fileOnly?: boolean, +): { + label: string; + children?: { label: string; children?: any[] }[]; +}[] { + const nodes: { label: string; children?: any[] }[] = []; + collection.forEach((child) => { + const children = + child.label.match(/\.(test|spec)\.[cm]?[jt]sx?/) && fileOnly + ? [] + : toLabelTree(child.children, fileOnly); + // normalize to linux path style + const label = child.label.replaceAll(path.sep, '/'); + nodes.push(children.length ? { label, children } : { label }); + }); + nodes.sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0)); + return nodes; +} diff --git a/packages/vscode/tests/e2e/rstest/suite/index.test.ts b/packages/vscode/tests/e2e/rstest/suite/index.test.ts new file mode 100644 index 0000000..8e9a021 --- /dev/null +++ b/packages/vscode/tests/e2e/rstest/suite/index.test.ts @@ -0,0 +1,123 @@ +// Ported from upstream `rstest/packages/vscode/tests/suite/index.test.ts`. +// +// Adaptations: the extension is `rstack.rstack` and the Rstest internals are +// reached through the shell's exports channel (`whenStackActive('rstest')`) +// instead of `activate()`'s return value; upstream's fixed sleeps around +// discovery are replaced by bounded polling (`waitFor`) on the same +// assertions, because the fixture resolves the *published* `@rstest/core` +// from its own node_modules and a cold worker spawn can +// outlast the sleeps. +import assert from 'node:assert'; +import vscode from 'vscode'; +import { + getProjectItems, + getRstestExports, + toLabelTree, + waitFor, +} from './helpers'; + +suite('Extension Test Suite', () => { + vscode.window.showInformationMessage('Start all tests.'); + + test('Extension should discover test items', async () => { + // Check if workspace is opened correctly + const workspaceFolders = vscode.workspace.workspaceFolders; + + assert.ok( + workspaceFolders && workspaceFolders.length > 0, + 'Workspace should be opened', + ); + assert.ok( + workspaceFolders[0].uri.path.includes('fixtures'), + 'Should open the fixtures workspace', + ); + + // Waits for the `onStartupFinished` activation and the shell registering + // the Rstest stack (the `rstack.enable && rstack.rstest.enable && + // detected` gate). + const rstestInstance = await getRstestExports(); + const testController = rstestInstance.testController; + assert.ok( + testController, + 'Test controller should be accessible through extension exports', + ); + + // focus on the testing view of this extension + await vscode.commands.executeCommand('workbench.view.testing.focus'); + + console.log(`Test controller found with ID: ${testController.id}`); + + // Assert that we have test items and check for specific items + await waitFor(() => { + assert.ok( + testController.items.size > 0, + 'Test controller should have discovered test items', + ); + }); + + const { foo, index } = await waitFor(() => { + const itemsArray = getProjectItems(testController); + + const foo = itemsArray.find((it) => it.id.endsWith('/test/foo.test.ts')); + const index = itemsArray.find((it) => + it.id.endsWith('/test/index.test.ts'), + ); + const jsSpec = itemsArray.find((it) => + it.id.endsWith('/test/jsFile.spec.js'), + ); + const jsxFile = itemsArray.find((it) => + it.id.endsWith('/test/tsxFile.test.tsx'), + ); + const tsxFile = itemsArray.find((it) => + it.id.endsWith('/test/tsxFile.test.tsx'), + ); + + assert.ok(foo, 'foo.test.ts should be discovered'); + assert.ok(index, 'index.test.ts should be discovered'); + assert.ok(jsSpec, 'jsFile.spec.js should be discovered'); + assert.ok(jsxFile, 'tsxFile.test.tsx should be discovered'); + assert.ok(tsxFile, 'tsxFile.test.tsx should be discovered'); + return { foo, index, jsSpec, jsxFile, tsxFile }; + }); + + // Validate foo.test.ts structure via label-only tree. The test-case level + // is filled in asynchronously by the AST collection, hence the polling. + await waitFor(() => { + const fooTree = toLabelTree(foo.children); + assert.deepStrictEqual(fooTree, [ + { + label: 'l1', + children: [ + { + label: 'l2', + children: [ + { + label: 'l3', + children: [ + { label: 'should also return "foo1"' }, + { label: 'should return "foo1"' }, + ], + }, + { label: 'should also return "foo"' }, + { label: 'should return "foo"' }, + ], + }, + ], + }, + ]); + }); + + await waitFor(() => { + const indexTree = toLabelTree(index.children); + assert.deepStrictEqual(indexTree, [ + { + label: 'Index', + children: [ + { label: 'should add two numbers correctly' }, + { label: 'should test source code correctly' }, + ], + }, + ]); + }); + }); +}); diff --git a/packages/vscode/tests/e2e/rstest/suite/index.ts b/packages/vscode/tests/e2e/rstest/suite/index.ts new file mode 100644 index 0000000..b9c3a2e --- /dev/null +++ b/packages/vscode/tests/e2e/rstest/suite/index.ts @@ -0,0 +1,47 @@ +import { readdirSync, statSync } from 'node:fs'; +import path from 'node:path'; +import Mocha from 'mocha'; + +const collectTests = (dir: string): string[] => + readdirSync(dir) + .sort() + .flatMap((entry) => { + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) { + return collectTests(full); + } + return full.endsWith('.test.js') ? [full] : []; + }); + +/** + * The extension host's entry point into the ported Rstest suites (upstream + * `tests/suite/index.ts`). VS Code calls `run()` once the window has started, + * so the tests observe the real `onStartupFinished` activation. + * + * The timeout is larger than upstream's 20s: the fixtures resolve the + * *published* `@rstest/core` from their own node_modules, and + * the first worker spawn in a cold Electron pays the whole Rstest/Rspack init. + */ +export function run(): Promise { + process.env.FORCE_COLOR = '1'; + process.env.NO_COLOR = ''; + + const mocha = new Mocha({ ui: 'tdd', color: true, timeout: 120_000 }); + for (const file of collectTests(__dirname)) { + mocha.addFile(file); + } + + return new Promise((resolve, reject) => { + try { + mocha.run((failures) => { + if (failures > 0) { + reject(new Error(`${failures} Rstest E2E test(s) failed.`)); + } else { + resolve(); + } + }); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + } + }); +} diff --git a/packages/vscode/tests/e2e/rstest/suite/progress.test.ts b/packages/vscode/tests/e2e/rstest/suite/progress.test.ts new file mode 100644 index 0000000..76a0d3f --- /dev/null +++ b/packages/vscode/tests/e2e/rstest/suite/progress.test.ts @@ -0,0 +1,258 @@ +// Ported from upstream `rstest/packages/vscode/tests/suite/progress.test.ts`. +// +// Adaptations: exports come through the shell's exports channel +// (`whenStackActive('rstest')`), and the fixture path is resolved through +// `FIXTURES_ROOT` (the compiled suite lives deeper under `tests-dist/` than +// upstream's). All progress/diagnostic assertions are upstream's, unchanged β€” +// the copied stack keeps `diagnostic.source === 'rstest'` and the reporter +// output format. +import assert from 'node:assert'; +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import vscode from 'vscode'; +import { + delay, + FIXTURES_ROOT, + getRstestExports, + getTestItemByLabels, + waitFor, +} from './helpers'; + +suite('Test Progress Reporting', () => { + let deferred = Promise.withResolvers(); + let output = ''; + let failedMessages: vscode.TestMessage[] = []; + let passedItems: vscode.TestItem[] = []; + let skippedItems: vscode.TestItem[] = []; + let createMockRunCalledTimes = 0; + + const createMockRun = () => { + createMockRunCalledTimes++; + + deferred = Promise.withResolvers(); + output = ''; + failedMessages = []; + passedItems = []; + skippedItems = []; + + const mockRun: vscode.TestRun = { + isPersisted: true, + name: '', + token: new vscode.CancellationTokenSource().token, + onDidDispose: new vscode.EventEmitter().event, + addCoverage: () => { + // ignore + }, + appendOutput: (message) => { + output += message; + }, + end: () => { + deferred.resolve(null); + }, + enqueued: () => { + // ignore + }, + errored: () => { + // ignore + }, + failed: (_test, message = []) => { + failedMessages.push(...(message as vscode.TestMessage[])); + }, + passed: (test) => { + passedItems.push(test); + }, + skipped: (test) => { + skippedItems.push(test); + }, + started: () => { + // ignore + }, + }; + + return mockRun; + }; + + test('reports test progress with error details and snapshots', async () => { + const rstestInstance = await getRstestExports(); + const testController = rstestInstance.testController; + assert.ok(testController, 'Test controller should be exported'); + + const item = await waitFor(() => + getTestItemByLabels(testController.items, ['test', 'progress.test.ts']), + ); + + rstestInstance.startTestRun( + new vscode.TestRunRequest([item], undefined, rstestInstance.runProfile), + new vscode.CancellationTokenSource().token, + false, + createMockRun, + ); + + await deferred.promise; + + assert.match(output, /3 failed/); + assert.match(output, /1 passed/); + assert.match(output, /1 skipped/); + + // should include stderr output from test file + assert.match(output, /stdout: progress\.test\.ts/); + assert.match(output, /stderr: progress\.test\.ts/); + + assert.equal(passedItems.length, 1); + assert.equal(skippedItems.length, 1); + assert.equal(failedMessages.length, 5); + + assert.equal(failedMessages[0].message, 'expected 1 to equal 2'); + assert.equal(failedMessages[0].expectedOutput, '2'); + assert.equal(failedMessages[0].actualOutput, '1'); + + assert.equal( + failedMessages[1].message, + 'expected { a: 1 } to equal { b: 1 }', + ); + assert.equal( + failedMessages[1].expectedOutput, + `Object { + "b": 1, +}`, + ); + assert.equal( + failedMessages[1].actualOutput, + `Object { + "a": 1, +}`, + ); + + assert.equal( + failedMessages[2].message, + 'Snapshot `s1 > should mismatch inline snapshot 1` mismatched', + ); + assert.equal(failedMessages[2].expectedOutput, '"world"'); + assert.equal(failedMessages[2].actualOutput, '"hello"'); + assert.equal(failedMessages[2].contextValue, 'canUpdateSnapshot'); + + assert.equal(failedMessages[3].message, 'after suite'); + assert.equal(failedMessages[4].message, 'after root suite'); + + assert.ok(item.uri, 'Progress test item should have a file uri'); + const diagnostics = vscode.languages.getDiagnostics(item.uri); + assert.ok(diagnostics.length > 0, 'Failed run should publish diagnostics'); + assert.ok( + diagnostics.some((diagnostic) => diagnostic.source === 'rstest'), + 'Diagnostics source should be rstest', + ); + assert.ok( + diagnostics.some((diagnostic) => + diagnostic.message.includes('expected 1 to equal 2'), + ), + 'Diagnostics should include assertion error messages', + ); + }); + + test('can run a single test case', async () => { + const rstestInstance = await getRstestExports(); + const testController = rstestInstance.testController; + assert.ok(testController, 'Test controller should be exported'); + + const item = await waitFor(() => + getTestItemByLabels(testController.items, [ + 'test', + 'index.test.ts', + 'Index', + 'should add two numbers correctly', + ]), + ); + + rstestInstance.startTestRun( + new vscode.TestRunRequest([item], undefined, rstestInstance.runProfile), + new vscode.CancellationTokenSource().token, + false, + createMockRun, + ); + + await deferred.promise; + + assert.equal(failedMessages.length, 0); + assert.equal(skippedItems.length, 0); + assert.equal(passedItems.length, 1); + assert.equal(passedItems[0]?.label, 'should add two numbers correctly'); + assert.match(output, /1 passed/); + + const progressFileUri = vscode.Uri.file( + path.resolve(FIXTURES_ROOT, 'workspace-1/test/progress.test.ts'), + ); + assert.equal( + vscode.languages.getDiagnostics(progressFileUri).length, + 0, + 'Successful run should clear previous diagnostics', + ); + }); + + test('reports test progress with continuous run', async () => { + const rstestInstance = await getRstestExports(); + const testController = rstestInstance.testController; + assert.ok(testController, 'Test controller should be exported'); + + const item = await waitFor(() => + getTestItemByLabels(testController.items, ['test', 'progress.test.ts']), + ); + + const cancellationSource = new vscode.CancellationTokenSource(); + rstestInstance.startTestRun( + new vscode.TestRunRequest( + [item], + undefined, + rstestInstance.runProfile, + true, + ), + cancellationSource.token, + false, + createMockRun, + ); + + await deferred.promise; + + assert.match(output, /3 failed/); + assert.match(output, /1 passed/); + assert.match(output, /1 skipped/); + + // File watchers can be noisy on CI; only rely on "next run happened" + // semantics rather than absolute run counts. + const waitForNextRun = async (trigger: () => Promise) => { + const prev = createMockRunCalledTimes; + await trigger(); + await waitFor(() => assert.ok(createMockRunCalledTimes > prev)); + await deferred.promise; + }; + + const replaceContentInFile = async ( + file: string, + searchValue: string, + replaceValue: string, + ) => { + const fullPath = path.resolve(FIXTURES_ROOT, 'workspace-1/test', file); + await writeFile( + fullPath, + (await readFile(fullPath, 'utf-8')).replace(searchValue, replaceValue), + ); + }; + + await waitForNextRun(() => + replaceContentInFile('progress.test.ts', 'hello', 'world'), + ); + assert.match(output, /2 failed/); + assert.match(output, /2 passed/); + assert.match(output, /1 skipped/); + + const canceledAt = createMockRunCalledTimes; + cancellationSource.cancel(); + + await replaceContentInFile('progress.test.ts', 'world', 'hello'); + await delay(2000); + assert.equal( + createMockRunCalledTimes, + canceledAt, + 'should not re-run after canceled', + ); + }); +}); diff --git a/packages/vscode/tests/e2e/rstest/suite/runtimeList.test.ts b/packages/vscode/tests/e2e/rstest/suite/runtimeList.test.ts new file mode 100644 index 0000000..aa67cf1 --- /dev/null +++ b/packages/vscode/tests/e2e/rstest/suite/runtimeList.test.ts @@ -0,0 +1,163 @@ +// Ported from upstream `rstest/packages/vscode/tests/suite/runtimeList.test.ts`. +// +// Adaptations: exports via the shell's exports channel, and the settings +// section is `rstack.rstest` (unified namespace) instead of +// `rstest`. The expected label trees are upstream's, unchanged. +import assert from 'node:assert'; +import vscode from 'vscode'; +import { + getRstestExports, + getTestItemByLabels, + toLabelTree, + waitFor, +} from './helpers'; + +suite('Runtime list suite', () => { + test('Extension should discover test cases from runtime', async () => { + const rstestInstance = await getRstestExports(); + const testController = rstestInstance.testController; + + const config = vscode.workspace.getConfiguration('rstack.rstest'); + + await waitFor(() => { + const item = getTestItemByLabels(testController.items, [ + 'test', + 'each.test.ts', + ]); + assert.deepStrictEqual(toLabelTree(item.children), [ + { + children: [ + { + label: 'case', + }, + ], + label: 'suite', + }, + { + label: 'unnamed test', + }, + { + label: 'unnamed test', + }, + ]); + }); + + // change config to runtime + await config.update('testCaseCollectMethod', 'runtime'); + await waitFor(() => { + const item = getTestItemByLabels(testController.items, [ + 'test', + 'each.test.ts', + ]); + assert.deepStrictEqual(toLabelTree(item.children), [ + { + children: [ + { + label: 'case', + }, + ], + label: 'suite', + }, + { + children: [ + { + label: 'suite 1 case 1', + }, + { + label: 'suite 1 case 2', + }, + ], + label: 'suite 1', + }, + { + children: [ + { + label: 'suite 2 case 1', + }, + { + label: 'suite 2 case 2', + }, + ], + label: 'suite 2', + }, + ]); + }); + + // restore config + await config.update('testCaseCollectMethod', undefined); + await waitFor(() => { + const item = getTestItemByLabels(testController.items, [ + 'test', + 'each.test.ts', + ]); + assert.deepStrictEqual(toLabelTree(item.children), [ + { + children: [ + { + label: 'case', + }, + ], + label: 'suite', + }, + { + label: 'unnamed test', + }, + { + label: 'unnamed test', + }, + ]); + }); + + // test list should be updated after test run + rstestInstance.startTestRun( + new vscode.TestRunRequest( + undefined, + undefined, + rstestInstance.runProfile, + ), + new vscode.CancellationTokenSource().token, + false, + ); + await waitFor( + () => { + const item = getTestItemByLabels(testController.items, [ + 'test', + 'each.test.ts', + ]); + assert.deepStrictEqual(toLabelTree(item.children), [ + { + children: [ + { + label: 'case', + }, + ], + label: 'suite', + }, + { + children: [ + { + label: 'suite 1 case 1', + }, + { + label: 'suite 1 case 2', + }, + ], + label: 'suite 1', + }, + { + children: [ + { + label: 'suite 2 case 1', + }, + { + label: 'suite 2 case 2', + }, + ], + label: 'suite 2', + }, + ]); + }, + { timeoutMs: 20_000 }, + ); + }); +}); diff --git a/packages/vscode/tests/e2e/rstest/suite/uxCommands.test.ts b/packages/vscode/tests/e2e/rstest/suite/uxCommands.test.ts new file mode 100644 index 0000000..08a1245 --- /dev/null +++ b/packages/vscode/tests/e2e/rstest/suite/uxCommands.test.ts @@ -0,0 +1,79 @@ +// Ported from upstream `rstest/packages/vscode/tests/suite/uxCommands.test.ts`. +// +// Adaptations: command ids are `rstack.rstest.*` (unified namespace), +// and upstream's `rstest.openOutput` no longer exists β€” the +// shell owns the four output channels and registers +// `rstack.rstest.output.focus` instead, which is what is exercised here. +// The command behaviors asserted (clipboard contents, reveal, terminal +// creation named "Rstest") are upstream's, unchanged. +import assert from 'node:assert'; +import vscode from 'vscode'; +import { getRstestExports, getTestItemByLabels, waitFor } from './helpers'; + +suite('Editor / Test Explorer UX commands', () => { + test('openOutput, copyErrorOutput, revealInTestExplorer, copyTestItemErrors', async () => { + const rstestInstance = await getRstestExports(); + const controller = rstestInstance.testController; + assert.ok(controller, 'Test controller should be exported'); + + // Focusing the output channel just reveals it β€” it must not throw. + // (Upstream: `rstest.openOutput`; here the shell-owned equivalent.) + await vscode.commands.executeCommand('rstack.rstest.output.focus'); + + const fileItem = await waitFor(() => + getTestItemByLabels(controller.items, ['test', 'progress.test.ts']), + ); + + // copyErrorOutput copies the given message's text to the clipboard. + await vscode.env.clipboard.writeText(''); + await vscode.commands.executeCommand('rstack.rstest.copyErrorOutput', { + test: fileItem, + message: new vscode.TestMessage('copied error text'), + }); + assert.strictEqual( + await vscode.env.clipboard.readText(), + 'copied error text', + ); + + // revealInTestExplorer delegates to the built-in reveal command; this + // fails loudly if the command id or argument shape is wrong. + assert.ok(fileItem.uri, 'test file item should have a uri'); + await vscode.commands.executeCommand( + 'rstack.rstest.revealInTestExplorer', + fileItem.uri, + ); + + // Run the failing fixture so the error store is populated, then copy the + // file item's aggregated errors. + await rstestInstance.startTestRun( + new vscode.TestRunRequest( + [fileItem], + undefined, + rstestInstance.runProfile, + ), + new vscode.CancellationTokenSource().token, + false, + ); + + await vscode.commands.executeCommand( + 'rstack.rstest.copyTestItemErrors', + fileItem, + ); + assert.match( + await vscode.env.clipboard.readText(), + /expected 1 to equal 2/, + ); + + // runInTerminal builds the real rstest command and opens a shell terminal; + // it must resolve the CLI and not throw. + await vscode.commands.executeCommand( + 'rstack.rstest.runInTerminal', + fileItem, + ); + const terminal = await waitFor(() => + vscode.window.terminals.find((candidate) => candidate.name === 'Rstest'), + ); + assert.ok(terminal, 'a Rstest terminal should be created'); + terminal.dispose(); + }); +}); diff --git a/packages/vscode/tests/e2e/rstest/suite/workspace.test.ts b/packages/vscode/tests/e2e/rstest/suite/workspace.test.ts new file mode 100644 index 0000000..c1b211b --- /dev/null +++ b/packages/vscode/tests/e2e/rstest/suite/workspace.test.ts @@ -0,0 +1,297 @@ +// Ported from upstream `rstest/packages/vscode/tests/suite/workspace.test.ts`. +// +// Adaptations beyond the namespace (`rstack.rstest` settings section): +// +// - Detection enables a stack per folder: upstream scanned +// *every* workspace folder and kept an empty workspace node for a folder +// with no matching config. Here the shell's detection scopes the scan, so a +// folder without a matching `rstest.config.*`/custom-glob config is not +// merely empty β€” it is undetected and gets no `WorkspaceManager` at all. +// Consequently (see `Rstest#refreshAllWorkspaces` in +// `src/stacks/test/index.ts`) the wrap-in-workspace-node decision keys off +// the number of *detected* folders, not `workspaceFolders.length`: whenever +// exactly one folder remains detected its tree is shown unwrapped. The +// intermediate expectations below are adapted to that intended behavior; +// the multi-folder expectations are upstream's, unchanged. +// - Detection changes can deregister and re-register the whole stack, which +// publishes a fresh `TestController`; the polling probes therefore +// re-resolve the live controller via `currentRstestExports()` instead of +// holding the first one. +import assert from 'node:assert'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import vscode from 'vscode'; +import { + currentRstestExports, + FIXTURES_ROOT, + getRstestExports, + getTestItemByLabels, + toLabelTree, + waitFor, +} from './helpers'; + +// The unwrapped single-folder tree of workspace-1 (the standard layout: one +// root `rstest.config.ts`, so no workspace node and no project node). +const WORKSPACE_1_UNWRAPPED = [ + { + label: 'test', + children: [ + { label: 'each.test.ts' }, + { label: 'foo.test.ts' }, + { label: 'index.test.ts' }, + { label: 'jsFile.spec.js' }, + { label: 'jsxFile.test.jsx' }, + { label: 'progress.test.ts' }, + { label: 'tsxFile.test.tsx' }, + ], + }, +]; + +suite('Workspace discover suite', () => { + test('Extension should discover workspaces and projects', async () => { + await getRstestExports(); + + const config = vscode.workspace.getConfiguration('rstack.rstest'); + const fixturesRoot = FIXTURES_ROOT; + + // initial workspaces + await waitFor(() => { + const testController = currentRstestExports().testController; + assert.deepStrictEqual( + toLabelTree(testController.items, true), + WORKSPACE_1_UNWRAPPED, + ); + }); + + // add workspace-2 + vscode.workspace.updateWorkspaceFolders( + vscode.workspace.workspaceFolders?.length || 0, + 0, + { + uri: vscode.Uri.file(path.resolve(fixturesRoot, 'workspace-2')), + }, + ); + await waitFor(() => { + const testController = currentRstestExports().testController; + assert.deepStrictEqual(toLabelTree(testController.items, true), [ + { + label: 'workspace-1', + children: [ + { + label: 'test', + children: [ + { label: 'each.test.ts' }, + { label: 'foo.test.ts' }, + { label: 'index.test.ts' }, + { label: 'jsFile.spec.js' }, + { label: 'jsxFile.test.jsx' }, + { label: 'progress.test.ts' }, + { label: 'tsxFile.test.tsx' }, + ], + }, + ], + }, + { + label: 'workspace-2', + children: [ + { + label: 'folder/project-2', + children: [ + { + label: 'test', + children: [{ label: 'foo.test.ts' }], + }, + ], + }, + { + label: 'project-1', + children: [ + { + label: 'test', + children: [{ label: 'foo.test.ts' }], + }, + ], + }, + ], + }, + ]); + }); + + // remove config file. + // Upstream expected `workspace-2` to stay as an empty node. Adapted + // (detection scopes per folder): with no matching config the folder is undetected and + // dropped entirely, and workspace-1 β€” the only detected folder left β€” + // is shown unwrapped again. + await fs.rename( + path.resolve(fixturesRoot, 'workspace-2/project-1/rstest.config.ts'), + path.resolve(fixturesRoot, 'workspace-2/project-1/foo.config.ts'), + ); + await fs.rename( + path.resolve( + fixturesRoot, + 'workspace-2/folder/project-2/rstest.config.ts', + ), + path.resolve(fixturesRoot, 'workspace-2/folder/project-2/bar.config.ts'), + ); + await waitFor(() => { + const testController = currentRstestExports().testController; + assert.deepStrictEqual( + toLabelTree(testController.items, true), + WORKSPACE_1_UNWRAPPED, + ); + }); + + // change configFileGlobPattern. + // Upstream expected an empty `workspace-1` node next to a wrapped + // `workspace-2`. Adapted (detection scopes per folder): under the `foo.config.*` glob + // workspace-1 is undetected and workspace-2 is the only detected folder, + // so its single matching project is shown unwrapped. + await config.update('configFileGlobPattern', [ + '**/foo.config.{mjs,ts,js,cjs,mts,cts}', + ]); + await waitFor(() => { + const testController = currentRstestExports().testController; + assert.deepStrictEqual(toLabelTree(testController.items, true), [ + { + label: 'project-1', + children: [ + { + label: 'test', + children: [{ label: 'foo.test.ts' }], + }, + ], + }, + ]); + }); + + // add config file. + // Adapted like the step above: workspace-2 is still the only detected + // folder, so its two projects are shown unwrapped. + await fs.rename( + path.resolve(fixturesRoot, 'workspace-2/folder/project-2/bar.config.ts'), + path.resolve(fixturesRoot, 'workspace-2/folder/project-2/foo.config.ts'), + ); + await waitFor(() => { + const testController = currentRstestExports().testController; + assert.deepStrictEqual(toLabelTree(testController.items, true), [ + { + label: 'folder/project-2', + children: [ + { + label: 'test', + children: [{ label: 'foo.test.ts' }], + }, + ], + }, + { + label: 'project-1', + children: [ + { + label: 'test', + children: [{ label: 'foo.test.ts' }], + }, + ], + }, + ]); + }); + + // restore config file and configFileGlobPattern + await fs.rename( + path.resolve(fixturesRoot, 'workspace-2/project-1/foo.config.ts'), + path.resolve(fixturesRoot, 'workspace-2/project-1/rstest.config.ts'), + ); + await fs.rename( + path.resolve(fixturesRoot, 'workspace-2/folder/project-2/foo.config.ts'), + path.resolve( + fixturesRoot, + 'workspace-2/folder/project-2/rstest.config.ts', + ), + ); + await config.update('configFileGlobPattern', undefined); + await waitFor(() => { + const testController = currentRstestExports().testController; + assert.deepStrictEqual(toLabelTree(testController.items, true), [ + { + label: 'workspace-1', + children: [ + { + label: 'test', + children: [ + { label: 'each.test.ts' }, + { label: 'foo.test.ts' }, + { label: 'index.test.ts' }, + { label: 'jsFile.spec.js' }, + { label: 'jsxFile.test.jsx' }, + { label: 'progress.test.ts' }, + { label: 'tsxFile.test.tsx' }, + ], + }, + ], + }, + { + label: 'workspace-2', + children: [ + { + label: 'folder/project-2', + children: [ + { + label: 'test', + children: [{ label: 'foo.test.ts' }], + }, + ], + }, + { + label: 'project-1', + children: [ + { + label: 'test', + children: [{ label: 'foo.test.ts' }], + }, + ], + }, + ], + }, + ]); + }); + + // remove workspace-2 + vscode.workspace.updateWorkspaceFolders(1, 1); + + await waitFor(() => { + const testController = currentRstestExports().testController; + assert.deepStrictEqual( + toLabelTree(testController.items, true), + WORKSPACE_1_UNWRAPPED, + ); + }); + }); + + test('discovers test cases when project root has a trailing separator', async () => { + await getRstestExports(); + + const config = vscode.workspace.getConfiguration('rstack.rstest'); + const configFileGlobPattern = config.get('configFileGlobPattern'); + assert.ok(configFileGlobPattern); + await config.update('configFileGlobPattern', [ + ...configFileGlobPattern, + '**/trailing-slash.config.ts', + ]); + + try { + await waitFor(() => { + const testController = currentRstestExports().testController; + const testFile = getTestItemByLabels(testController.items, [ + 'config', + 'test', + 'foo.test.ts', + ]); + assert.ok( + testFile.children.size > 0, + 'Test file should be associated with its test cases', + ); + }); + } finally { + await config.update('configFileGlobPattern', undefined); + } + }); +}); diff --git a/packages/vscode/tests/e2e/runTest.ts b/packages/vscode/tests/e2e/runTest.ts new file mode 100644 index 0000000..1700787 --- /dev/null +++ b/packages/vscode/tests/e2e/runTest.ts @@ -0,0 +1,82 @@ +/** + * The `@vscode/test-electron` entry point. + * + * It downloads (and caches) a real VS Code, launches it with this repo as the + * extension under development, opens the multi-root fixture workspace and hands + * control to `suite/index.ts` inside the extension host. + */ +import { createHash } from 'node:crypto'; +import { existsSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { runTests } from '@vscode/test-electron'; + +const FIXTURE_NAMES = ['rslint', 'rstest', 'rstack'] as const; + +async function main() { + // `__dirname` is `/tests-dist/tests/e2e` (see tsconfig.e2e.json). + const extensionDevelopmentPath = path.resolve(__dirname, '../../..'); + const extensionTestsPath = path.resolve(__dirname, './suite/index'); + const fixturesDir = path.join(extensionDevelopmentPath, 'tests/e2e/fixtures'); + const workspaceFile = path.join(fixturesDir, 'e2e.code-workspace'); + + // The extension host loads `main` from `package.json`; an unbuilt repo would + // otherwise fail deep inside VS Code with an unhelpful activation error. + if (!existsSync(path.join(extensionDevelopmentPath, 'dist/extension.js'))) { + throw new Error( + 'dist/extension.js is missing β€” run `pnpm build` before `pnpm test:e2e`.', + ); + } + for (const name of FIXTURE_NAMES) { + if (!existsSync(path.join(fixturesDir, name, 'node_modules'))) { + throw new Error( + `the ${name} E2E fixture is not installed β€” run \`pnpm test:e2e:fixtures\`.`, + ); + } + } + + // A short user-data dir keeps the Unix socket paths below the macOS limit. + const hash = createHash('sha1') + .update(extensionDevelopmentPath) + .digest('hex') + .slice(0, 8); + const userDataDir = mkdtempSync(path.join(tmpdir(), `rstack-${hash}-`)); + + await runTests({ + // Pinnable for CI; `stable` locally. `runTests` forwards the whole options + // object to the downloader, so `version`/`timeout`/`vscodeExecutablePath` + // all apply to it. + version: process.env.VSCODE_TEST_VERSION ?? 'stable', + // The default per-request timeout is 15s, which a 300 MB download on a slow + // or proxied link loses to before it ever starts making progress. + timeout: 60_000, + // Escape hatch for offline / restricted environments: point at an existing + // VS Code (`.../Visual Studio Code.app/Contents/MacOS/Electron`, `Code.exe`, + // `code`) and nothing is downloaded at all. + vscodeExecutablePath: process.env.VSCODE_TEST_EXECUTABLE || undefined, + extensionDevelopmentPath, + extensionTestsPath, + launchArgs: [ + workspaceFile, + // Only the extension under development runs: no user extension may + // register a competing formatter, test controller or language client. + '--disable-extensions', + // The fixtures spawn project-local binaries, which Restricted Mode + // forbids by design. Trust is granted up front so the + // suite tests the trusted path; the Restricted Mode path needs its own + // launch and is not covered in phase 1. + '--disable-workspace-trust', + '--disable-updates', + '--skip-welcome', + '--skip-release-notes', + '--user-data-dir', + userDataDir, + ], + }); +} + +main().catch((error) => { + console.error('Failed to run E2E tests'); + console.error(error); + process.exit(1); +}); diff --git a/packages/vscode/tests/e2e/setupFixtures.mjs b/packages/vscode/tests/e2e/setupFixtures.mjs new file mode 100644 index 0000000..871ce10 --- /dev/null +++ b/packages/vscode/tests/e2e/setupFixtures.mjs @@ -0,0 +1,97 @@ +// Installs the E2E fixture workspaces. +// +// The fixtures install **published npm versions** of +// `@rslint/core` / `@rstest/core` / `rstack` β€” the extension resolves all three +// from the project, so a fixture that linked this repo's own node_modules would +// test nothing. Each fixture is its own independent install; `--ignore-workspace` +// makes sure pnpm never folds them into a parent workspace. +// +// Idempotent: pnpm is a no-op when the fixture is already up to date, so +// `test:e2e` can always run it. +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +export const FIXTURES_DIR = path.join(here, 'fixtures'); +/** + * Fixture name -> project directory. The `rstest-workspace-*` entries are the + * projects the ported Rstest suites (`tests/e2e/rstest/`) run against; + * workspace-2 is one install at its root serving both nested projects. The + * `lint` entry is the shared install root serving every ported Rslint suite + * workspace (`tests/e2e/lint/fixtures/*` β€” the workspaces themselves have no + * package.json; @rslint/core resolves via Node's walk-up from one install). + */ +export const FIXTURES = { + rslint: path.join(FIXTURES_DIR, 'rslint'), + rstest: path.join(FIXTURES_DIR, 'rstest'), + rstack: path.join(FIXTURES_DIR, 'rstack'), + 'rstest-workspace-1': path.join(here, 'rstest', 'fixtures', 'workspace-1'), + 'rstest-workspace-2': path.join(here, 'rstest', 'fixtures', 'workspace-2'), + lint: path.join(here, 'lint', 'fixtures'), +}; +export const FIXTURE_NAMES = Object.keys(FIXTURES); + +const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; + +/** @param {string} name */ +const install = (name) => { + const cwd = FIXTURES[name]; + if (!existsSync(path.join(cwd, 'package.json'))) { + throw new Error(`E2E fixture ${name} has no package.json at ${cwd}`); + } + console.log(`[e2e] installing fixture: ${name}`); + const result = spawnSync( + pnpmCommand, + [ + 'install', + // A fixture is a standalone project, never a workspace member of this + // repo: the whole point is a plain, published-versions install. + '--ignore-workspace', + // Fixtures pin ranges, not a lockfile β€” a frozen lockfile would fail CI + // the moment a patch release lands. + '--no-frozen-lockfile', + '--prefer-offline', + // Changing a fixture's `.npmrc` makes pnpm want to purge `node_modules`, + // which it refuses to do without a TTY. The directory is disposable. + '--config.confirmModulesPurge=false', + // Fixtures deliberately install pinned published versions of the Rstack + // toolchain, which are often hours old β€” disable pnpm's + // minimum-release-age supply-chain gate for these sandboxes. It must be + // a CLI flag: `--ignore-workspace` also ignores a local + // pnpm-workspace.yaml, and pnpm would otherwise auto-write exclusion + // files into the fixture. + '--config.minimumReleaseAge=0', + // pnpm's build-script gate exits non-zero on unapproved postinstalls + // (e.g. core-js in the rstest fixture). These sandboxes install real + // published packages exactly like a user project would, so run their + // build scripts as-is. + '--config.dangerouslyAllowAllBuilds=true', + ], + { cwd, stdio: 'inherit', env: process.env }, + ); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error( + `pnpm install failed for the ${name} fixture (exit code ${String(result.status)})`, + ); + } +}; + +const main = () => { + const requested = process.argv.slice(2); + const names = requested.length > 0 ? requested : FIXTURE_NAMES; + for (const name of names) { + if (!FIXTURE_NAMES.includes(name)) { + throw new Error( + `unknown E2E fixture: ${name} (known: ${FIXTURE_NAMES.join(', ')})`, + ); + } + install(name); + } +}; + +main(); diff --git a/packages/vscode/tests/e2e/smoke/rslintPluginHost.mjs b/packages/vscode/tests/e2e/smoke/rslintPluginHost.mjs new file mode 100644 index 0000000..f689570 --- /dev/null +++ b/packages/vscode/tests/e2e/smoke/rslintPluginHost.mjs @@ -0,0 +1,137 @@ +/** + * Regression smoke test β€” "rslint eslint-plugin host path". + * + * This is a *verified non-requirement*: with a plain project + * install of `@rslint/core`, a bare-specifier `import('@rslint/core/eslint-plugin')` + * β†’ `createPluginLintHost` β†’ `host.lint()` runs the whole pipeline (worker + * spawned from the sibling `lint-worker.js`, napi parser resolved by walking up + * `node_modules`, an object-form plugin rule producing a diagnostic, clean + * shutdown). Nothing rslint-related ships in the VSIX, so if that ever stops + * being true, this extension's plugin-lint path is dead β€” hence a regression + * test rather than a one-off manual verification. + * + * It needs no VS Code: it is a plain Node script, run by `pnpm test:e2e:smoke` + * as well as by the full `pnpm test:e2e`. It deliberately mirrors the + * extension's own resolution steps (`src/stacks/lint/resolution.ts`): + * + * 1. `require.resolve('@rslint/core/package.json', { paths: [projectDir] })` + * β€” the project's install, never this repo's; + * 2. `createRequire().resolve('@rslint/core/eslint-plugin')` + * β€” self-reference resolution, pinning the subpath to that same install; + * 3. the one-resolution-root assertion; + * 4. a dynamic `import()` of the resolved path β€” the host is ESM and spawns + * its sibling worker via `import.meta.url`, so it can never be `require`d. + */ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const PROJECT_DIR = path.resolve(here, '../fixtures/rslint'); +const CONFIG_PATH = path.join(PROJECT_DIR, 'rslint.config.mjs'); +const SOURCE_PATH = path.join(PROJECT_DIR, 'src/index.ts'); +const RULE_NAME = 'local/no-null'; + +const nodeRequire = createRequire(import.meta.url); + +const isInside = (child, parent) => { + const relative = path.relative(parent, child); + return ( + relative.length > 0 && + !relative.startsWith('..') && + !path.isAbsolute(relative) + ); +}; + +const main = async () => { + let corePackageJsonPath; + try { + corePackageJsonPath = nodeRequire.resolve('@rslint/core/package.json', { + paths: [PROJECT_DIR], + }); + } catch (error) { + throw new Error( + `@rslint/core is not installed in the rslint fixture (${PROJECT_DIR}). Run \`pnpm test:e2e:fixtures\` first.`, + { cause: error }, + ); + } + const coreDir = path.dirname(corePackageJsonPath); + + const eslintPluginPath = createRequire(corePackageJsonPath).resolve( + '@rslint/core/eslint-plugin', + ); + assert.ok( + isInside(eslintPluginPath, coreDir), + `one resolution root violated: ${eslintPluginPath} is outside ${coreDir}`, + ); + + console.log(`[smoke] project: ${PROJECT_DIR}`); + console.log(`[smoke] @rslint/core: ${coreDir}`); + console.log(`[smoke] eslint-plugin: ${eslintPluginPath}`); + + const logs = []; + const module = await import(pathToFileURL(eslintPluginPath).href); + assert.equal( + typeof module.createPluginLintHost, + 'function', + `${eslintPluginPath} does not export createPluginLintHost`, + ); + + const host = await module.createPluginLintHost( + [{ configPath: CONFIG_PATH, configDirectory: PROJECT_DIR }], + (record) => { + logs.push(record); + }, + ); + + let result; + try { + result = await host.lint({ + // No `text`: the worker reads the file from disk, the path the CLI uses. + files: [{ path: SOURCE_PATH, configKey: PROJECT_DIR }], + // The rule set Go would have computed from the config for this file. + rules: { [RULE_NAME]: { options: [] } }, + fix: false, + suggestionsMode: 'off', + }); + } finally { + await host.shutdown(); + } + + const fileResult = result?.results?.[0]; + assert.ok(fileResult, `no per-file result: ${JSON.stringify(result)}`); + assert.equal( + fileResult.parseError, + undefined, + `the napi parser failed: ${String(fileResult.parseError)}`, + ); + assert.deepEqual( + fileResult.ruleErrors ?? [], + [], + `the plugin rule threw: ${JSON.stringify(fileResult.ruleErrors)}`, + ); + + const diagnostics = fileResult.diagnostics ?? []; + assert.equal( + diagnostics.length, + 1, + `expected exactly one ${RULE_NAME} diagnostic, got ${JSON.stringify(diagnostics)}`, + ); + assert.equal(diagnostics[0].ruleName, RULE_NAME); + + console.log( + `[smoke] diagnostic: ${diagnostics[0].ruleName} β€” ${String(diagnostics[0].message)}`, + ); + const errors = logs.filter((record) => record.level === 'error'); + assert.deepEqual(errors, [], `host logged errors: ${JSON.stringify(errors)}`); + console.log( + '[smoke] OK β€” project-resolved plugin lint host produced a diagnostic', + ); +}; + +main().catch((error) => { + console.error('[smoke] FAILED'); + console.error(error); + process.exit(1); +}); diff --git a/packages/vscode/tests/e2e/suite/detection.test.ts b/packages/vscode/tests/e2e/suite/detection.test.ts new file mode 100644 index 0000000..ee6b461 --- /dev/null +++ b/packages/vscode/tests/e2e/suite/detection.test.ts @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import * as vscode from 'vscode'; +import { detectFolder } from '../../../src/detection'; +import type { StackId } from '../../../src/types'; +import { eventually } from './helpers'; + +/** + * Detection is per workspace folder, driven by the config-glob + * table plus the `node_modules/.bin/{rs,rstack}` probe. The three fixtures are + * chosen so that every column of that table is exercised at least once: + * + * | fixture | config present | rslint | rstest | fmt | + * | ------- | --------------------- | ------ | ------ | --- | + * | rslint | `rslint.config.mjs` | yes | no | no | + * | rstest | `rstest.config.ts` | no | yes | no | + * | rstack | `rstack.config.ts` | no | yes | yes | + * + * The rstack row is the interesting one: a single `rstack.config.*` lights + * Rstest and rs fmt even though no tool-native config exists (Rslint is + * deliberately NOT lit β€” TODO(rstack-bridge), the earlier lint bridge was + * removed pending upstream support), and `rs fmt` + * is additionally confirmed by the bin probe (`rstack`'s two bins are `rs` and + * `rstack`). + * + * This runs the extension's own `detectFolder` inside the extension host, + * against the real fixture workspaces, through the real `workspace.findFiles` + * and `workspace.fs` β€” the parts a unit test has to fake. + */ + +interface Expectation { + readonly detected: Readonly>; + /** Tool-native config file names expected per stack. */ + readonly configFiles: Readonly>; + readonly rstackConfigFiles: readonly string[]; + /** Basename of the expected `rs fmt` bin, if the probe must find one. */ + readonly fmtBin?: string; +} + +const EXPECTED: Readonly> = { + rslint: { + detected: { rslint: true, rstest: false, fmt: false }, + configFiles: { rslint: ['rslint.config.mjs'], rstest: [], fmt: [] }, + rstackConfigFiles: [], + }, + rstest: { + detected: { rslint: false, rstest: true, fmt: false }, + configFiles: { rslint: [], rstest: ['rstest.config.ts'], fmt: [] }, + rstackConfigFiles: [], + }, + rstack: { + detected: { rslint: false, rstest: true, fmt: true }, + configFiles: { rslint: [], rstest: [], fmt: [] }, + rstackConfigFiles: ['rstack.config.ts'], + fmtBin: 'rs', + }, +}; + +const basenames = (uris: readonly vscode.Uri[]): string[] => + uris.map((uri) => path.basename(uri.fsPath)).sort(); + +const folderNamed = (name: string): vscode.WorkspaceFolder => { + const folder = (vscode.workspace.workspaceFolders ?? []).find( + (candidate) => candidate.name === name, + ); + assert.ok(folder, `the ${name} fixture folder is not in the workspace`); + return folder; +}; + +suite('detection', () => { + for (const [name, expected] of Object.entries(EXPECTED)) { + test(`lights the right stacks in the ${name} fixture`, async () => { + const folder = folderNamed(name); + + // `findFiles` answers from the file index, which is still warming up when + // the window has just opened, so the whole assertion block is retried. + await eventually(async () => { + const detection = await detectFolder(folder); + + const detected = { + rslint: detection.stacks.rslint.detected, + rstest: detection.stacks.rstest.detected, + fmt: detection.stacks.fmt.detected, + }; + assert.deepEqual(detected, expected.detected); + + for (const stack of ['rslint', 'rstest', 'fmt'] as const) { + assert.deepEqual( + basenames(detection.stacks[stack].configFiles), + [...expected.configFiles[stack]].sort(), + `${name}: unexpected ${stack} config files`, + ); + assert.deepEqual( + basenames(detection.stacks[stack].rstackConfigFiles), + [...expected.rstackConfigFiles].sort(), + `${name}: unexpected ${stack} rstack config files`, + ); + } + + const binPath = detection.stacks.fmt.binPath; + if (expected.fmtBin) { + assert.ok(binPath, `${name}: the rs fmt bin probe found nothing`); + assert.equal(path.basename(binPath), expected.fmtBin); + assert.ok( + binPath.includes( + `${path.sep}node_modules${path.sep}.bin${path.sep}`, + ), + `${name}: the bin must come from node_modules/.bin, got ${binPath}`, + ); + } else { + assert.equal( + binPath, + undefined, + `${name}: the rs fmt bin probe must find nothing`, + ); + } + }, `detection of the ${name} fixture`); + }); + } + + test('never treats a fixture node_modules as a config source', async () => { + // `**/node_modules/**` is excluded on purpose: a nested config inside a + // dependency would light a stack no tool would ever load there. + const found = await vscode.workspace.findFiles( + new vscode.RelativePattern( + folderNamed('rstack'), + '**/rstack.config.{ts,js,mts,mjs}', + ), + '**/node_modules/**', + ); + assert.deepEqual(basenames(found), ['rstack.config.ts']); + }); +}); diff --git a/packages/vscode/tests/e2e/suite/helpers.ts b/packages/vscode/tests/e2e/suite/helpers.ts new file mode 100644 index 0000000..559582e --- /dev/null +++ b/packages/vscode/tests/e2e/suite/helpers.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; + +export const delay = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Polls until `probe` returns a value instead of throwing. + * + * Detection is asynchronous and file-watcher driven, and VS Code's file index + * is warm only some time after the window opens β€” a single-shot assertion right + * after startup would be a coin flip. `probe` throwing is the retry signal, so + * the failure message of the *last* attempt is what the test reports. + */ +export const eventually = async ( + probe: () => T | Promise, + what: string, + timeoutMs = 60_000, + intervalMs = 250, +): Promise => { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + for (;;) { + try { + return await probe(); + } catch (error) { + lastError = error; + } + if (Date.now() >= deadline) { + assert.fail( + `Timed out after ${timeoutMs}ms waiting for ${what}: ${ + lastError instanceof Error ? lastError.message : String(lastError) + }`, + ); + } + await delay(intervalMs); + } +}; diff --git a/packages/vscode/tests/e2e/suite/index.ts b/packages/vscode/tests/e2e/suite/index.ts new file mode 100644 index 0000000..1695693 --- /dev/null +++ b/packages/vscode/tests/e2e/suite/index.ts @@ -0,0 +1,38 @@ +import { readdirSync, statSync } from 'node:fs'; +import path from 'node:path'; +import Mocha from 'mocha'; + +const collectTests = (dir: string): string[] => + readdirSync(dir).flatMap((entry) => { + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) { + return collectTests(full); + } + return full.endsWith('.test.js') ? [full] : []; + }); + +/** + * The extension host's entry point into the suite. VS Code calls `run()` once + * the window has started, so the tests observe the real `onStartupFinished` + * activation instead of forcing it. + */ +export function run(): Promise { + const mocha = new Mocha({ ui: 'tdd', color: true, timeout: 120_000 }); + for (const file of collectTests(__dirname)) { + mocha.addFile(file); + } + + return new Promise((resolve, reject) => { + try { + mocha.run((failures) => { + if (failures > 0) { + reject(new Error(`${failures} E2E test(s) failed.`)); + } else { + resolve(); + } + }); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + } + }); +} diff --git a/packages/vscode/tests/e2e/suite/shell.test.ts b/packages/vscode/tests/e2e/suite/shell.test.ts new file mode 100644 index 0000000..8431366 --- /dev/null +++ b/packages/vscode/tests/e2e/suite/shell.test.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import * as vscode from 'vscode'; +import { delay, eventually } from './helpers'; + +const EXTENSION_ID = 'rstack.rstack'; + +/** + * The shell always activates on `onStartupFinished` and does + * exactly three things β€” create the status bar item, run detection, register the + * stacks that pass the gate. + */ +suite('shell', () => { + test('activates on startup without being asked to', async () => { + const extension = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(extension, `${EXTENSION_ID} is not installed in the test host`); + + assert.deepEqual( + extension.packageJSON.activationEvents, + ['onStartupFinished'], + 'the shell must have exactly one activation event', + ); + + // Deliberately no `extension.activate()`: activating it here would prove + // nothing about `onStartupFinished`. The suite starts right after the + // window is up, so the event may still be in flight β€” hence the poll. + await eventually(() => { + assert.equal(extension.isActive, true, 'extension is not active yet'); + }, 'the extension to activate itself'); + }); + + test('registers the shell commands', async () => { + const commands = await vscode.commands.getCommands(true); + for (const command of [ + 'rstack.showMenu', + 'rstack.showOutput', + 'rstack.migrateSettings', + 'rstack.rslint.output.focus', + 'rstack.rstest.output.focus', + 'rstack.fmt.output.focus', + ]) { + assert.ok(commands.includes(command), `missing command ${command}`); + } + }); + + test('has a status bar item whose menu opens', async () => { + // VS Code exposes no API to enumerate another extension's status bar items, + // so the item itself cannot be asserted on directly. What *is* observable + // is its command: the item is created with `command = 'rstack.showMenu'` + // and shown unconditionally, so a `showMenu` that opens a QuickPick without + // throwing is the strongest available evidence that the always-present + // status bar item exists and is wired up. + let failure: unknown; + const menu = Promise.resolve( + vscode.commands.executeCommand('rstack.showMenu'), + ).catch((error: unknown) => { + failure = error; + }); + + await delay(1_000); + await vscode.commands.executeCommand('workbench.action.closeQuickOpen'); + await Promise.race([menu, delay(5_000)]); + + assert.equal( + failure, + undefined, + `rstack.showMenu failed: ${String(failure)}`, + ); + }); + + test('opens the three fixture folders', () => { + const names = (vscode.workspace.workspaceFolders ?? []).map( + (folder) => folder.name, + ); + assert.deepEqual(names.slice().sort(), ['rslint', 'rstack', 'rstest']); + }); +}); diff --git a/packages/vscode/tests/unit/loadRstackConfig.test.ts b/packages/vscode/tests/unit/loadRstackConfig.test.ts new file mode 100644 index 0000000..076bcfa --- /dev/null +++ b/packages/vscode/tests/unit/loadRstackConfig.test.ts @@ -0,0 +1,72 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from '@rstest/core'; +import { loadRstackConfig } from '../../src/shared/vendored/loadRstackConfig'; + +// A stand-in for the project's own `rstack` install. It talks to the session +// storage exactly the way rstack's shipped `dist` chunk does β€” through +// `globalThis.__rstackConfigSessionStorage` β€” which is the interop contract the +// vendored loader depends on. +const FAKE_RSTACK = ` +const getSession = () => globalThis.__rstackConfigSessionStorage?.getStore(); + +const setConfig = (type, config) => { + const session = getSession(); + if (!session?.active) { + throw new Error('The "' + type + '" config must be defined while loading an Rstack config.'); + } + session.configs[type] = config; +}; + +export const define = { + lint: (config) => setConfig('lint', config), + test: (config) => setConfig('test', config), +}; +`; + +describe('vendored loadRstackConfig', () => { + let dir: string; + + beforeAll(() => { + dir = mkdtempSync(path.join(tmpdir(), 'rstack-config-')); + writeFileSync(path.join(dir, 'fake-rstack.mjs'), FAKE_RSTACK); + writeFileSync( + path.join(dir, 'rstack.config.mjs'), + [ + "import { define } from './fake-rstack.mjs';", + "define.lint([{ name: 'from-rstack-config' }]);", + "define.test({ name: 'test-project' });", + ].join('\n'), + ); + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('collects define.* calls made by a foreign module instance', async () => { + const configFilePath = path.join(dir, 'rstack.config.mjs'); + const { configs, filePath } = await loadRstackConfig({ configFilePath }); + + expect(filePath).toBe(configFilePath); + expect(configs.lint).toEqual([{ name: 'from-rstack-config' }]); + expect(configs.test).toEqual({ name: 'test-project' }); + }); + + it('probes a directory when no config path is given', async () => { + const { filePath } = await loadRstackConfig({ cwd: dir }); + expect(filePath).toBe(path.join(dir, 'rstack.config.mjs')); + }); + + it('reports "no stacks defined" for a directory without a config', async () => { + const empty = mkdtempSync(path.join(tmpdir(), 'rstack-empty-')); + try { + const { configs, filePath } = await loadRstackConfig({ cwd: empty }); + expect(filePath).toBeNull(); + expect(configs).toEqual({}); + } finally { + rmSync(empty, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/vscode/tests/unit/versionCheck.test.ts b/packages/vscode/tests/unit/versionCheck.test.ts new file mode 100644 index 0000000..16c3563 --- /dev/null +++ b/packages/vscode/tests/unit/versionCheck.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from '@rstest/core'; +import { + checkPackageVersion, + formatVersionMismatch, + isSupportedConfigDiscoveryProtocolVersion, + SUPPORT_MATRIX, +} from '../../src/shared/versionCheck'; + +describe('support matrix', () => { + it('pins the launch support floors', () => { + expect(SUPPORT_MATRIX).toEqual({ + '@rslint/core': '>=0.7.2', + '@rstest/core': '>=0.6.0', + rstack: '>=0.3.2', + }); + }); +}); + +describe('checkPackageVersion', () => { + it('accepts versions at and above the floor', () => { + expect(checkPackageVersion('@rslint/core', '0.7.2').kind).toBe('ok'); + expect(checkPackageVersion('@rslint/core', '1.2.3').kind).toBe('ok'); + expect(checkPackageVersion('@rstest/core', '0.11.5').kind).toBe('ok'); + expect(checkPackageVersion('rstack', '0.3.2').kind).toBe('ok'); + }); + + it('accepts prereleases of a supported range', () => { + expect(checkPackageVersion('@rstest/core', '1.0.0-beta.1').kind).toBe('ok'); + }); + + it('rejects versions below the floor', () => { + const result = checkPackageVersion('@rstest/core', '0.5.9'); + expect(result.kind).toBe('mismatch'); + if (result.kind === 'mismatch') { + expect(formatVersionMismatch('@rstest/core', result)).toContain( + '>=0.6.0', + ); + } + }); + + it('never hard-fails on an unreadable version', () => { + expect(checkPackageVersion('rstack', undefined).kind).toBe('unknown'); + expect(checkPackageVersion('rstack', 'not-a-version').kind).toBe('unknown'); + }); +}); + +describe('config discovery protocol', () => { + it('supports exactly the protocol versions the copied client speaks', () => { + expect(isSupportedConfigDiscoveryProtocolVersion(1)).toBe(true); + expect(isSupportedConfigDiscoveryProtocolVersion(2)).toBe(false); + }); +}); diff --git a/packages/vscode/tsconfig.e2e.json b/packages/vscode/tsconfig.e2e.json new file mode 100644 index 0000000..deffa62 --- /dev/null +++ b/packages/vscode/tsconfig.e2e.json @@ -0,0 +1,45 @@ +{ + // The E2E harness runs inside the VS Code extension host as plain CommonJS, + // so it is compiled with tsc (not rslib) into `tests-dist/`. + // + // `rootDir` is the repo root because `tests/e2e/suite/detection.test.ts` + // imports the shell's own `detectFolder` and runs it against the real fixture + // workspaces β€” the extension host is the only place where `workspace.findFiles` + // is real. Only the two modules it needs are compiled; everything else the + // extension does comes from the bundle VS Code loads via `main`. + "compilerOptions": { + // `nodenext` (not plain commonjs/node10): the ported Rslint suites compile + // `src/stacks/lint/*` into `tests-dist`, and those modules import + // `@rslint/core/config-loader` β€” an `exports`-map subpath that node10 + // resolution cannot see. This package has no `"type": "module"`, so every + // file still emits CommonJS, which is what the extension host loads; the + // ESM-only imports compile to `require(esm)`, supported by the host's + // Node >= 20.19. + "module": "nodenext", + "moduleResolution": "nodenext", + "target": "ES2024", + "lib": ["ES2024"], + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "outDir": "tests-dist", + "rootDir": ".", + "sourceMap": true, + "strict": true, + "skipLibCheck": true + }, + "include": [ + "tests/e2e/**/*.ts", + "src/shell/detection.ts", + "src/shell/types.ts" + ], + // The fixtures are standalone projects with their own dependencies; they are + // never compiled by this repo's tsc. + "exclude": [ + "node_modules", + "dist", + "tests-dist", + "tests/e2e/fixtures", + "tests/e2e/rstest/fixtures", + "tests/e2e/lint/fixtures" + ] +} diff --git a/packages/vscode/tsconfig.json b/packages/vscode/tsconfig.json new file mode 100644 index 0000000..c7db706 --- /dev/null +++ b/packages/vscode/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + // The bundle is produced by rslib/rspack; tsc is typecheck-only. + // `module: preserve` implies `moduleResolution: bundler`, which is what the + // bundler actually does and is required to resolve ESM-only packages such + // as `@rstackjs/load-config` through their `exports` map. + "module": "preserve", + "target": "ES2024", + "lib": ["ES2024"], + "outDir": "dist", + "rootDir": ".", + "sourceMap": true, + "noEmit": true, + "strict": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["node"] + }, + "include": ["src", "tests/unit", "tests/e2e"], + // The E2E fixtures are standalone projects resolving their own published + // dependencies β€” not part of this repo's compilation. + "exclude": [ + "node_modules", + "dist", + "tests-dist", + "tests/e2e/fixtures", + "tests/e2e/rstest/fixtures", + "tests/e2e/lint/fixtures" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..60efde3 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,4614 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + rstack: + specifier: ^0.3.2 + version: 0.3.2(jiti@2.7.0)(typescript@5.9.3) + + packages/vscode: + devDependencies: + '@rsbuild/core': + specifier: ~2.1.9 + version: 2.1.9 + '@rslib/core': + specifier: ^1.0.0-beta.1 + version: 1.0.0-beta.1(typescript@5.9.3) + '@rslint/core': + specifier: ^0.7.2 + version: 0.7.2(jiti@2.7.0) + '@rstackjs/load-config': + specifier: ^0.1.2 + version: 0.1.2(jiti@2.7.0) + '@rstest/core': + specifier: ^0.11.5 + version: 0.11.5 + '@types/istanbul-lib-report': + specifier: ^3.0.3 + version: 3.0.3 + '@types/mocha': + specifier: ^10.0.10 + version: 10.0.10 + '@types/node': + specifier: ^22.16.5 + version: 22.20.1 + '@types/picomatch': + specifier: ^4.0.3 + version: 4.0.3 + '@types/semver': + specifier: ^7.7.1 + version: 7.8.0 + '@types/vscode': + specifier: 1.97.0 + version: 1.97.0 + '@vscode/test-electron': + specifier: ^3.1.0 + version: 3.1.0 + '@vscode/vsce': + specifier: ^3.9.2 + version: 3.9.2 + birpc: + specifier: ^4.0.0 + version: 4.0.0 + core-js-pure: + specifier: ^3.49.0 + version: 3.49.0 + cross-env: + specifier: ^7.0.3 + version: 7.0.3 + mocha: + specifier: ^11.7.6 + version: 11.8.0 + ovsx: + specifier: ^1.0.2 + version: 1.1.0(@types/node@22.20.1) + picomatch: + specifier: ^4.0.5 + version: 4.0.5 + semver: + specifier: ^7.8.5 + version: 7.8.5 + stacktrace-parser: + specifier: ^0.1.11 + version: 0.1.11 + tinyglobby: + specifier: ^0.2.17 + version: 0.2.17 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + valibot: + specifier: ^1.4.2 + version: 1.4.2(typescript@5.9.3) + vscode-languageclient: + specifier: ^9.0.1 + version: 9.0.1 + yuku-parser: + specifier: ^0.8.3 + version: 0.8.3 + +packages: + + '@ast-grep/napi-darwin-arm64@0.37.0': + resolution: {integrity: sha512-QAiIiaAbLvMEg/yBbyKn+p1gX2/FuaC0SMf7D7capm/oG4xGMzdeaQIcSosF4TCxxV+hIH4Bz9e4/u7w6Bnk3Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@ast-grep/napi-darwin-x64@0.37.0': + resolution: {integrity: sha512-zvcvdgekd4ySV3zUbUp8HF5nk5zqwiMXTuVzTUdl/w08O7JjM6XPOIVT+d2o/MqwM9rsXdzdergY5oY2RdhSPA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@ast-grep/napi-linux-arm64-gnu@0.37.0': + resolution: {integrity: sha512-L7Sj0lXy8X+BqSMgr1LB8cCoWk0rericdeu+dC8/c8zpsav5Oo2IQKY1PmiZ7H8IHoFBbURLf8iklY9wsD+cyA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@ast-grep/napi-linux-arm64-musl@0.37.0': + resolution: {integrity: sha512-LF9sAvYy6es/OdyJDO3RwkX3I82Vkfsng1sqUBcoWC1jVb1wX5YVzHtpQox9JrEhGl+bNp7FYxB4Qba9OdA5GA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@ast-grep/napi-linux-x64-gnu@0.37.0': + resolution: {integrity: sha512-TViz5/klqre6aSmJzswEIjApnGjJzstG/SE8VDWsrftMBMYt2PTu3MeluZVwzSqDao8doT/P+6U11dU05UOgxw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@ast-grep/napi-linux-x64-musl@0.37.0': + resolution: {integrity: sha512-/BcCH33S9E3ovOAEoxYngUNXgb+JLg991sdyiNP2bSoYd30a9RHrG7CYwW6fMgua3ijQ474eV6cq9yZO1bCpXg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@ast-grep/napi-win32-arm64-msvc@0.37.0': + resolution: {integrity: sha512-TjQA4cFoIEW2bgjLkaL9yqT4XWuuLa5MCNd0VCDhGRDMNQ9+rhwi9eLOWRaap3xzT7g+nlbcEHL3AkVCD2+b3A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@ast-grep/napi-win32-ia32-msvc@0.37.0': + resolution: {integrity: sha512-uNmVka8fJCdYsyOlF9aZqQMLTatEYBynjChVTzUfFMDfmZ0bihs/YTqJVbkSm8TZM7CUX82apvn50z/dX5iWRA==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@ast-grep/napi-win32-x64-msvc@0.37.0': + resolution: {integrity: sha512-vCiFOT3hSCQuHHfZ933GAwnPzmL0G04JxQEsBRfqONywyT8bSdDc/ECpAfr3S9VcS4JZ9/F6tkePKW/Om2Dq2g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@ast-grep/napi@0.37.0': + resolution: {integrity: sha512-Hb4o6h1Pf6yRUAX07DR4JVY7dmQw+RVQMW5/m55GoiAT/VRoKCWBtIUPPOnqDVhbx1Cjfil9b6EDrgJsUAujEQ==} + engines: {node: '>= 10'} + + '@azu/format-text@1.0.2': + resolution: {integrity: sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==} + + '@azu/style-format@1.0.1': + resolution: {integrity: sha512-AHcTojlNBdD/3/KxIKlg8sxIWHfOtQszLvOpagLTO+bjC3u7SAszu1lf//u7JJC50aUSH+BVWDD/KvaA6Gfn5g==} + + '@azure/abort-controller@2.2.0': + resolution: {integrity: sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg==} + engines: {node: '>=22.0.0'} + + '@azure/core-auth@1.11.0': + resolution: {integrity: sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg==} + engines: {node: '>=22.0.0'} + + '@azure/core-client@1.11.0': + resolution: {integrity: sha512-JjQWO6akOck45PH/XBrxzsQGAiKrfFl4m5iggJ0ItMIz5omRufOXWpqCPpdjKN3vKDzlSUvFjaMb7Zwf0gvAdA==} + engines: {node: '>=22.0.0'} + + '@azure/core-rest-pipeline@1.25.0': + resolution: {integrity: sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA==} + engines: {node: '>=22.0.0'} + + '@azure/core-tracing@1.4.0': + resolution: {integrity: sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ==} + engines: {node: '>=22.0.0'} + + '@azure/core-util@1.14.0': + resolution: {integrity: sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw==} + engines: {node: '>=22.0.0'} + + '@azure/identity@4.13.1': + resolution: {integrity: sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==} + engines: {node: '>=20.0.0'} + + '@azure/logger@1.4.0': + resolution: {integrity: sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA==} + engines: {node: '>=22.0.0'} + + '@azure/msal-browser@5.17.3': + resolution: {integrity: sha512-qMabD7Xrm/UgRhs+/IVyCTZRjUl8Qb+uGsRrCkaKNWsiTwRaeyStJbChE7/ySNTNYtiWo7khfF7vUDd0wGMnLw==} + engines: {node: '>=0.8.0'} + + '@azure/msal-common@16.11.3': + resolution: {integrity: sha512-VeXOW+t3Rdd9XGX6lVyIg3DhtjMR1JD8ARKcsnGbJFUWwAmF3sHL7GwZc/ZjEUfHESResAonETRYCuG06OBT7A==} + engines: {node: '>=0.8.0'} + + '@azure/msal-node@5.4.3': + resolution: {integrity: sha512-tumCMmzrRhKmTbYQg/7OlfbrIKcKaf8Ed0Fw3suUpRT3owFYljznVgxcfHe8RycQXY9uyROGiLD1GjhpF45AwA==} + engines: {node: '>=20'} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + + '@emnapi/core@1.11.3': + resolution: {integrity: sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==} + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@emnapi/wasi-threads@1.2.3': + resolution: {integrity: sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==} + + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/checkbox@4.3.2': + resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.23': + resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.23': + resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/input@4.3.1': + resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.23': + resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.23': + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.10.1': + resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.11': + resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.2.2': + resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.4.2': + resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@napi-rs/keyring-darwin-arm64@1.3.0': + resolution: {integrity: sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/keyring-darwin-x64@1.3.0': + resolution: {integrity: sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/keyring-freebsd-x64@1.3.0': + resolution: {integrity: sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': + resolution: {integrity: sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/keyring-linux-arm64-gnu@1.3.0': + resolution: {integrity: sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-arm64-musl@1.3.0': + resolution: {integrity: sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': + resolution: {integrity: sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-x64-gnu@1.3.0': + resolution: {integrity: sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-x64-musl@1.3.0': + resolution: {integrity: sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/keyring-win32-arm64-msvc@1.3.0': + resolution: {integrity: sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/keyring-win32-ia32-msvc@1.3.0': + resolution: {integrity: sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/keyring-win32-x64-msvc@1.3.0': + resolution: {integrity: sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/keyring@1.3.0': + resolution: {integrity: sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==} + engines: {node: '>= 10'} + + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@node-rs/crc32-android-arm-eabi@1.10.6': + resolution: {integrity: sha512-vZAMuJXm3TpWPOkkhxdrofWDv+Q+I2oO7ucLRbXyAPmXFNDhHtBxbO1rk9Qzz+M3eep8ieS4/+jCL1Q0zacNMQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@node-rs/crc32-android-arm64@1.10.6': + resolution: {integrity: sha512-Vl/JbjCinCw/H9gEpZveWCMjxjcEChDcDBM8S4hKay5yyoRCUHJPuKr4sjVDBeOm+1nwU3oOm6Ca8dyblwp4/w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@node-rs/crc32-darwin-arm64@1.10.6': + resolution: {integrity: sha512-kARYANp5GnmsQiViA5Qu74weYQ3phOHSYQf0G+U5wB3NB5JmBHnZcOc46Ig21tTypWtdv7u63TaltJQE41noyg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@node-rs/crc32-darwin-x64@1.10.6': + resolution: {integrity: sha512-Q99bevJVMfLTISpkpKBlXgtPUItrvTWKFyiqoKH5IvscZmLV++NH4V13Pa17GTBmv9n18OwzgQY4/SRq6PQNVA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@node-rs/crc32-freebsd-x64@1.10.6': + resolution: {integrity: sha512-66hpawbNjrgnS9EDMErta/lpaqOMrL6a6ee+nlI2viduVOmRZWm9Rg9XdGTK/+c4bQLdtC6jOd+Kp4EyGRYkAg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@node-rs/crc32-linux-arm-gnueabihf@1.10.6': + resolution: {integrity: sha512-E8Z0WChH7X6ankbVm8J/Yym19Cq3otx6l4NFPS6JW/cWdjv7iw+Sps2huSug+TBprjbcEA+s4TvEwfDI1KScjg==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@node-rs/crc32-linux-arm64-gnu@1.10.6': + resolution: {integrity: sha512-LmWcfDbqAvypX0bQjQVPmQGazh4dLiVklkgHxpV4P0TcQ1DT86H/SWpMBMs/ncF8DGuCQ05cNyMv1iddUDugoQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@node-rs/crc32-linux-arm64-musl@1.10.6': + resolution: {integrity: sha512-k8ra/bmg0hwRrIEE8JL1p32WfaN9gDlUUpQRWsbxd1WhjqvXea7kKO6K4DwVxyxlPhBS9Gkb5Urq7Y4mXANzaw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@node-rs/crc32-linux-x64-gnu@1.10.6': + resolution: {integrity: sha512-IfjtqcuFK7JrSZ9mlAFhb83xgium30PguvRjIMI45C3FJwu18bnLk1oR619IYb/zetQT82MObgmqfKOtgemEKw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@node-rs/crc32-linux-x64-musl@1.10.6': + resolution: {integrity: sha512-LbFYsA5M9pNunOweSt6uhxenYQF94v3bHDAQRPTQ3rnjn+mK6IC7YTAYoBjvoJP8lVzcvk9hRj8wp4Jyh6Y80g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@node-rs/crc32-wasm32-wasi@1.10.6': + resolution: {integrity: sha512-KaejdLgHMPsRaxnM+OG9L9XdWL2TabNx80HLdsCOoX9BVhEkfh39OeahBo8lBmidylKbLGMQoGfIKDjq0YMStw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@node-rs/crc32-win32-arm64-msvc@1.10.6': + resolution: {integrity: sha512-x50AXiSxn5Ccn+dCjLf1T7ZpdBiV1Sp5aC+H2ijhJO4alwznvXgWbopPRVhbp2nj0i+Gb6kkDUEyU+508KAdGQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@node-rs/crc32-win32-ia32-msvc@1.10.6': + resolution: {integrity: sha512-DpDxQLaErJF9l36aghe1Mx+cOnYLKYo6qVPqPL9ukJ5rAGLtCdU0C+Zoi3gs9ySm8zmbFgazq/LvmsZYU42aBw==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@node-rs/crc32-win32-x64-msvc@1.10.6': + resolution: {integrity: sha512-5B1vXosIIBw1m2Rcnw62IIfH7W9s9f7H7Ma0rRuhT8HR4Xh8QCgw6NJSI2S2MCngsGktYnAhyUvs81b7efTyQw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@node-rs/crc32@1.10.6': + resolution: {integrity: sha512-+llXfqt+UzgoDzT9of5vPQPGqTAVCohU74I9zIBkNo5TH6s2P31DFJOGsJQKN207f0GHnYv5pV3wh3BCY/un/A==} + engines: {node: '>= 10'} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@rsbuild/core@2.1.9': + resolution: {integrity: sha512-yqf1hFZ3wbMYI431LqsxLH3r0VZkfyarVKTf7kMeIiGe0YLwsrgsfp+sKpIyVkQkq60J0qyp6l/CoqqsQZqEwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + core-js: '>= 3.0.0' + peerDependenciesMeta: + core-js: + optional: true + + '@rslib/core@1.0.0-beta.1': + resolution: {integrity: sha512-HHPZ+wTUKT/3bEhw2y0JB0O62wMuljkSHVZelLbSGhBflCaUt+D3LohBcIxYW6bhzPbUp4gkJXo1pdTpxiuNLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7 + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + typescript: + optional: true + + '@rslint/core@0.7.2': + resolution: {integrity: sha512-jzSu6fMEWnuNEIZZk016z5Zk1AHYhNdfsCkvVvYfcduGyEAOo4QZDx+eXJ8R2bJqunAXLJPMx3JykXTjxyGjlQ==} + hasBin: true + peerDependencies: + jiti: ^2.0.0 + peerDependenciesMeta: + jiti: + optional: true + + '@rslint/native-darwin-arm64@0.7.2': + resolution: {integrity: sha512-Q7Nx26S7O1zlELKNIyi3+ZBn6s+ZrGFmyMkqWT2UsXsq9jW3sUGJG44/BcvAFXtFYg7ONUl7LDYakz/VP7DzXQ==} + cpu: [arm64] + os: [darwin] + + '@rslint/native-darwin-x64@0.7.2': + resolution: {integrity: sha512-ONbEKiPd/StrV+/enPMJz60/+oJCiuVK9cbMpymWjAv1qDNCiuTNIqb5RUc4OHxWy7QZ9LWbVw4X/5XcJf0ebQ==} + cpu: [x64] + os: [darwin] + + '@rslint/native-linux-arm64-gnu@0.7.2': + resolution: {integrity: sha512-06C0QJF6gJ/VkLPBw6+SauH91PnUM83Kd7tBIqU5QP11q3iIK+aPFGMbSrKsKu6/+yVig424Z4nSxcQ2MzCmag==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rslint/native-linux-arm64-musl@0.7.2': + resolution: {integrity: sha512-KpgwL3sgRVNx3LciBcfmRxxIymuQKBo3vinEewHWdll+WkRlS08Ow1XhSu2YIrenOYQ5cKSD26PW57AUE8s2zA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rslint/native-linux-x64-gnu@0.7.2': + resolution: {integrity: sha512-TOTVGJvFW2uxMthV3g05HNik0BWUE8gabZp5mYiDF4dc+yFihqWw1kRO//4hZyd4m0CPkRpsBL89UCwAX6lBzA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rslint/native-linux-x64-musl@0.7.2': + resolution: {integrity: sha512-rn4g1i8VVZeVnc/Qa1IJVvX0e4XQncB144CTwHeFnC2V8aMBL6kmfvBox2mL59nPRTlILf4GAMOMlD3tSD5N5Q==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rslint/native-win32-arm64-msvc@0.7.2': + resolution: {integrity: sha512-j2kdE1+3TdXhjtmu+b9lWJThSaT3SZKcMa5dlEy20+bDwTCBmuhO6lR79/4BllXz8/avP8W0YmkfORitoVPxrA==} + cpu: [arm64] + os: [win32] + + '@rslint/native-win32-x64-msvc@0.7.2': + resolution: {integrity: sha512-qOXNWTn4Q9gf6/GCmJlJt5heVD+WIdbVSLRb2KbJLt055ZuVQV40Md8NkUSWF94j/J9+1d21/UoOvKDNt144fg==} + cpu: [x64] + os: [win32] + + '@rspack/binding-darwin-arm64@2.1.7': + resolution: {integrity: sha512-DwxzrXRctueP/3Pyom9JHcIsRShuEAlHb+mrE5OPT+4cdHI1UnJpbzEvEDLTo4IKJhDb3vjXdHLtjqtL0SYbeA==} + cpu: [arm64] + os: [darwin] + + '@rspack/binding-darwin-x64@2.1.7': + resolution: {integrity: sha512-kPbrYvR/XUHfAMgRVq3QnC71DW/qjwsPj+3hEUuEnRmlploPNy9u8Szf1IHKSVUSrVZBTgDyMoZQdxYLfhResw==} + cpu: [x64] + os: [darwin] + + '@rspack/binding-linux-arm64-gnu@2.1.7': + resolution: {integrity: sha512-VFB+YXM3kZ6IIuLV64H3vgnwqvQIIaqfR/aeGwuxYvwcZsrgblSBmXMeDULdgDjqP8Yr0VaFMBBiD9OtG5KdFw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rspack/binding-linux-arm64-musl@2.1.7': + resolution: {integrity: sha512-Mzbxyg0aJ+ITj526Iuz0enEDYY6WxhFIwEKXqwjQh+Vpd5v/+aPzPo83sSQVX/3puBV1sbmviTURbh6N9e1fvA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rspack/binding-linux-riscv64-gnu@2.1.7': + resolution: {integrity: sha512-mpazwgT/Pse1720mvEJsoXfPkJ+enj0xUqpbe/wL6aedwjGT+9jJNB8HTJXE4XBX0UO7umGqcJMeKA6YsD2CDA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rspack/binding-linux-riscv64-musl@2.1.7': + resolution: {integrity: sha512-oU/l3soPRsDEWn7KZic+npyTMM2N1kRdHjoJ+L5IUBXs8bjdTXPLoyTbTdIOza5ZSoT4+UeEiEryj4BB0tQE5w==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rspack/binding-linux-x64-gnu@2.1.7': + resolution: {integrity: sha512-7Gtpl3h3jtnOpk1mYQE8mRndXAO2ibI8mnAbs7klevdKey+ZHneWMoMi2yOMQhhI/ifWEFxDzyGJ8bdxo0XTsA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rspack/binding-linux-x64-musl@2.1.7': + resolution: {integrity: sha512-w+whI2Uy+DYkGN+MVkzMFWweL7B/s1gMqX+nvTE1vhOy3hGV0VyA9H6lqWjSD3I+eGkpYhN9Pr244cYnLpZOUQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rspack/binding-wasm32-wasi@2.1.7': + resolution: {integrity: sha512-cDVgvzRdTgxaeM+a5Lx0+7/VAvunvwO0wNtQ3ATQGOtFCW5b7cUzhNPcytH5ZSJTnFWuxinlGwtar5yfcnkdZQ==} + cpu: [wasm32] + + '@rspack/binding-win32-arm64-msvc@2.1.7': + resolution: {integrity: sha512-JDd85+iYwUvaG9Zrt5X7oIxRZRiTW+76FwkRakoXNy/5VAWQW32Jq4ESjSVz6l6mh0KnZxPq3TLMugacCPnLjw==} + cpu: [arm64] + os: [win32] + + '@rspack/binding-win32-ia32-msvc@2.1.7': + resolution: {integrity: sha512-y9PKEs6v9BLHV0i/4eaIRtxpATvSgcf/VYQkMT8mp+qWlPjUwDQNwU2ueWVGpff6INO+YAa7zobzziNFRgO7Lg==} + cpu: [ia32] + os: [win32] + + '@rspack/binding-win32-x64-msvc@2.1.7': + resolution: {integrity: sha512-BjkOzcPY/K8YlRRvyywz0mDWk89MMxqAMhDmgBXCWorh1IjgKTsWDJ2lCGIM8M9CZXUG3khom8AfrOGwRT2I+g==} + cpu: [x64] + os: [win32] + + '@rspack/binding@2.1.7': + resolution: {integrity: sha512-wYqi8TY30hsIzLry503o/Uqu7y9Ec7pEwN5TVmB7Pb3xHrR2eHsQPzdpF/GkCLUjQSgD2Es3CDVV1mr6zO/78g==} + + '@rspack/core@2.1.7': + resolution: {integrity: sha512-d5Ju3zXzGgbqQWvlMlLUtek2eFPIzsFe2QOF4nwTAknxo/4OZ64t+kPT9nM6fr3aZX93VK0R3v02/kZYIRrV9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@module-federation/runtime-tools': ^0.24.1 || ^2.0.0 + '@swc/helpers': ^0.5.23 + peerDependenciesMeta: + '@module-federation/runtime-tools': + optional: true + '@swc/helpers': + optional: true + + '@rstackjs/load-config@0.1.2': + resolution: {integrity: sha512-6hChPVosmh2rzEt1M1CvGpsaE/+gGlt51ci5pbyd4Bd1FXyH+Owlg99ECvdcWtD7zdDwDM3jGkQL05xn9oWSIA==} + peerDependencies: + jiti: ^2.0.0 + peerDependenciesMeta: + jiti: + optional: true + + '@rstest/core@0.11.5': + resolution: {integrity: sha512-ySXZaFqU1mJonm79ko7OwagG2AurULg1dLDErJguhv/sepEyjt39sq2Bpt42mOxHehiFcl0Pr0PrlaPltmYbbA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + happy-dom: ^20.8.3 + jsdom: '*' + peerDependenciesMeta: + happy-dom: + optional: true + jsdom: + optional: true + + '@secretlint/config-creator@10.2.2': + resolution: {integrity: sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==} + engines: {node: '>=20.0.0'} + + '@secretlint/config-loader@10.2.2': + resolution: {integrity: sha512-ndjjQNgLg4DIcMJp4iaRD6xb9ijWQZVbd9694Ol2IszBIbGPPkwZHzJYKICbTBmh6AH/pLr0CiCaWdGJU7RbpQ==} + engines: {node: '>=20.0.0'} + + '@secretlint/core@10.2.2': + resolution: {integrity: sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw==} + engines: {node: '>=20.0.0'} + + '@secretlint/formatter@10.2.2': + resolution: {integrity: sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA==} + engines: {node: '>=20.0.0'} + + '@secretlint/node@10.2.2': + resolution: {integrity: sha512-eZGJQgcg/3WRBwX1bRnss7RmHHK/YlP/l7zOQsrjexYt6l+JJa5YhUmHbuGXS94yW0++3YkEJp0kQGYhiw1DMQ==} + engines: {node: '>=20.0.0'} + + '@secretlint/profiler@10.2.2': + resolution: {integrity: sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig==} + + '@secretlint/resolver@10.2.2': + resolution: {integrity: sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w==} + + '@secretlint/secretlint-formatter-sarif@10.2.2': + resolution: {integrity: sha512-ojiF9TGRKJJw308DnYBucHxkpNovDNu1XvPh7IfUp0A12gzTtxuWDqdpuVezL7/IP8Ua7mp5/VkDMN9OLp1doQ==} + + '@secretlint/secretlint-rule-no-dotenv@10.2.2': + resolution: {integrity: sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg==} + engines: {node: '>=20.0.0'} + + '@secretlint/secretlint-rule-preset-recommend@10.2.2': + resolution: {integrity: sha512-K3jPqjva8bQndDKJqctnGfwuAxU2n9XNCPtbXVI5JvC7FnQiNg/yWlQPbMUlBXtBoBGFYp08A94m6fvtc9v+zA==} + engines: {node: '>=20.0.0'} + + '@secretlint/source-creator@10.2.2': + resolution: {integrity: sha512-h6I87xJfwfUTgQ7irWq7UTdq/Bm1RuQ/fYhA3dtTIAop5BwSFmZyrchph4WcoEvbN460BWKmk4RYSvPElIIvxw==} + engines: {node: '>=20.0.0'} + + '@secretlint/types@10.2.2': + resolution: {integrity: sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg==} + engines: {node: '>=20.0.0'} + + '@sindresorhus/merge-streams@2.3.0': + resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} + engines: {node: '>=18'} + + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + + '@textlint/ast-node-types@15.8.0': + resolution: {integrity: sha512-5CiH9COYmovWmExQgs7763DzX6Gy9zjkjJ7JxCC95wyTcjwQn/8poNF6fv3qzRlmx8CRRde8DHr9FcgAAiPzgw==} + + '@textlint/linter-formatter@15.8.0': + resolution: {integrity: sha512-+oU3A235NATv6Lzi4xa4kJ65PuNJlIxesaO4AvDhDWA9FWm7y4XKWaoQCW1esgaQQ6dwnUiFKArQ8TcJ86mC4w==} + engines: {node: '>=20.18.0'} + + '@textlint/module-interop@15.8.0': + resolution: {integrity: sha512-rt+OR1WYGoLOY8HkA/aBPrqufF6yUUEsKEAh7XohTsT3lp9IyZFT6zOIbjul9P4FAzsmSPkcrYjVx3Bz/IUfkg==} + + '@textlint/resolver@15.8.0': + resolution: {integrity: sha512-E88tzfX3K8Jykk+38aJ9cy8RquD8ABVOPTO2rFEESq0wcg8x6/ypdAS8ZgR7OKiGqlRF0hkO/m5PbQwVfKM3VA==} + + '@textlint/types@15.8.0': + resolution: {integrity: sha512-Anhc6y5736YIsvqae0U6k0YmB2M/QVHkEeOv2aydAn/WIkdI69dCOiDbe3/+RagS3qstFTSFWJzNRA2lUjv19w==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/mocha@10.0.10': + resolution: {integrity: sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@types/normalize-package-data@2.4.4': + resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + + '@types/picomatch@4.0.3': + resolution: {integrity: sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==} + + '@types/sarif@2.1.7': + resolution: {integrity: sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==} + + '@types/semver@7.8.0': + resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} + + '@types/vscode@1.97.0': + resolution: {integrity: sha512-ueE73loeOTe7olaVyqP9mrRI54kVPJifUPjblZo9fYcv1CuVLPOEKEkqW0GkqPC454+nCEoigLWnC2Pp7prZ9w==} + + '@typespec/ts-http-runtime@0.3.8': + resolution: {integrity: sha512-bLMpVcWZNzq6lYOybwFwOAR1IXKcHnhUNqYeHjl1bET/qE3jFPFH+p8Wrh3rU4xwdnifPxmKNESBYnvnmc75aA==} + engines: {node: '>=22.0.0'} + + '@vscode/test-electron@3.1.0': + resolution: {integrity: sha512-CRqv5u+YYoseuNVJ6Tyo4k0sF0mx4qnKMihRB0PjsUF8Dc0WKtCXo6CNL6nWWm5esfFQsQA/pejMj4ZbpJVLTw==} + engines: {node: '>=22'} + + '@vscode/vsce-sign-alpine-arm64@2.0.6': + resolution: {integrity: sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==} + cpu: [arm64] + os: [alpine] + + '@vscode/vsce-sign-alpine-x64@2.0.6': + resolution: {integrity: sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w==} + cpu: [x64] + os: [alpine] + + '@vscode/vsce-sign-darwin-arm64@2.0.6': + resolution: {integrity: sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ==} + cpu: [arm64] + os: [darwin] + + '@vscode/vsce-sign-darwin-x64@2.0.6': + resolution: {integrity: sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==} + cpu: [x64] + os: [darwin] + + '@vscode/vsce-sign-linux-arm64@2.0.6': + resolution: {integrity: sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==} + cpu: [arm64] + os: [linux] + + '@vscode/vsce-sign-linux-arm@2.0.6': + resolution: {integrity: sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==} + cpu: [arm] + os: [linux] + + '@vscode/vsce-sign-linux-x64@2.0.6': + resolution: {integrity: sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==} + cpu: [x64] + os: [linux] + + '@vscode/vsce-sign-win32-arm64@2.0.6': + resolution: {integrity: sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==} + cpu: [arm64] + os: [win32] + + '@vscode/vsce-sign-win32-x64@2.0.6': + resolution: {integrity: sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==} + cpu: [x64] + os: [win32] + + '@vscode/vsce-sign@2.0.9': + resolution: {integrity: sha512-8IvaRvtFyzUnGGl3f5+1Cnor3LqaUWvhaUjAYO8Y39OUYlOf3cRd+dowuQYLpZcP3uwSG+mURwjEBOSq4SOJ0g==} + + '@vscode/vsce@3.9.2': + resolution: {integrity: sha512-XSxMosEEDO6vLxELAHVkwmhC0qe0ijZni2jB9Rcs8kQsW4lhTDQ/wMzmwFs/buotAWSnpmUp/dRWD2ufG3UYKA==} + engines: {node: '>= 20'} + hasBin: true + + '@yuku-parser/binding-android-arm64@0.8.3': + resolution: {integrity: sha512-vySYRsMeul9ssvxeHdxgS9ZUIcq7gqljWNqgokjJE0uQWvVvOprihJ6hOsiifVqWsla0BMc3vAFBvNS9QqCw7g==} + cpu: [arm64] + os: [android] + + '@yuku-parser/binding-darwin-arm64@0.8.3': + resolution: {integrity: sha512-+wpB/wqhiZ685Y77I+lj6v9pHSAJ3Y+QMHJmvch0Q0ahIMbNwtKk3s54MhtjCMKO1qpjPbyN/PjuHDg2hbKaVQ==} + cpu: [arm64] + os: [darwin] + + '@yuku-parser/binding-darwin-x64@0.8.3': + resolution: {integrity: sha512-jKqiWejj4zVy7pPtEGu4/Ty+pG1h7ooQOXIkm7shKZTSwTU9X8X+eoH11uIeKHZi2SQWV0GhNz0J56eerseysQ==} + cpu: [x64] + os: [darwin] + + '@yuku-parser/binding-freebsd-x64@0.8.3': + resolution: {integrity: sha512-FC7zSwzFzd4z9bsId07CiHLR+Iw6yW/LzIQhL5AUtPUuVXLgEyx0rilgbRUYkl1CT3GJcLpkh63WuPZUSgCDzw==} + cpu: [x64] + os: [freebsd] + + '@yuku-parser/binding-linux-arm-gnu@0.8.3': + resolution: {integrity: sha512-So61j88b9/ygDnUPlWCm1EUPw4HSxAyDjrNHKgud5N3aRDQ3kw94nW7TriXbo7GBXID9oBHCMNm1r1Fof/Df5Q==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-arm-musl@0.8.3': + resolution: {integrity: sha512-Nmnn20yJvSSKL8ZdtqReBRSGCDkSMqR5jEk/Sk/cdIdZmqVD49Z6M7w2GbMjdrxMI1MBPbsWFMMWxa93cd5t5g==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-linux-arm64-gnu@0.8.3': + resolution: {integrity: sha512-Lfgw7AXJ0rxu6BMPGgfc8HLJWEIr8BHhCzcQp/75k+NM90uCLkHlBNqIg/K42KlSvBgAvu9euOvjdswib+4qJA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-arm64-musl@0.8.3': + resolution: {integrity: sha512-cfRyu87xsJ0tFkHNsnMC4Rq6+xsFJ6i2dc4VAH52d2qLvykEJU/Mdi3ul1O2PyOApX/LoLT3uQZ0fWs3D5XE4w==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-linux-x64-gnu@0.8.3': + resolution: {integrity: sha512-GcQQCUuYxbm6P1n+io/A50rvWKDeWHutIp6rW0ycDOZuEQjOb8hDVgS88+NDyOnd9FfS0/Z6GXopcRFDyKpzOg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-x64-musl@0.8.3': + resolution: {integrity: sha512-rMkImBGZzg7GZlj8krYtdiezyjYI4igjKWMut5T65jHyNWFigMQrEpn9mDIBflloW9FKhGE3mN6yTZ/N+4HRwg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-win32-arm64@0.8.3': + resolution: {integrity: sha512-/2Pl2cAzCXWxah8FqJapEj/ikpt9cEutEZFCa0hnbfrshkn5+C+aBM3ZDq62d1jsgQjBMmqr5HVhJUA4OAG/Tg==} + cpu: [arm64] + os: [win32] + + '@yuku-parser/binding-win32-x64@0.8.3': + resolution: {integrity: sha512-Ntnvjoan9jnfLhn7Kn3h8j/bhsbVdQSVmKUqFULKtmwImLCJVHOJbLL4qbEJyrOQ7r/FBL1/c/dRvx/AQWzzXg==} + cpu: [x64] + os: [win32] + + '@yuku-toolchain/types@0.8.3': + resolution: {integrity: sha512-9LN3HYs3A9qSPVFunsxlbfwBcUgexti3TmhOzIxB/UH8zFuaHQJXTRDcN17DW6cp1GsyZtiZA7f18uIra36Jag==} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + azure-devops-node-api@12.5.0: + resolution: {integrity: sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + binaryextensions@6.11.0: + resolution: {integrity: sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==} + engines: {node: '>=4'} + + birpc@4.0.0: + resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + boundary@2.0.0: + resolution: {integrity: sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==} + + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browser-stdout@1.3.1: + resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==} + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + + cheerio@1.2.0: + resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} + engines: {node: '>=20.18.1'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + ci-info@2.0.0: + resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + cockatiel@3.2.1: + resolution: {integrity: sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==} + engines: {node: '>=16'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + commander@6.2.1: + resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} + engines: {node: '>= 6'} + + core-js-pure@3.49.0: + resolution: {integrity: sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cross-env@7.0.3: + resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} + engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} + hasBin: true + + cross-keychain@1.1.0: + resolution: {integrity: sha512-244DWNdGepLKD5vEn3reZqwzZFiE/LD4U+XV9IaXQbtIXKvQkf0VkRaOj/9vPYauPdR12PSGB3U0cE7jJi3WTQ==} + engines: {node: '>=18'} + hasBin: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decamelize@4.0.0: + resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==} + engines: {node: '>=10'} + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + diff@7.0.0: + resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==} + engines: {node: '>=0.3.1'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + editions@6.22.0: + resolution: {integrity: sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==} + engines: {ecmascript: '>= es5', node: '>=4'} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encoding-sniffer@0.2.1: + resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat@5.0.2: + resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} + hasBin: true + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs-extra@11.4.0: + resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==} + engines: {node: '>=14.14'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + globby@14.1.0: + resolution: {integrity: sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==} + engines: {node: '>=18'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + hosted-git-info@4.1.0: + resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} + engines: {node: '>=10'} + + hosted-git-info@7.0.2: + resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} + engines: {node: ^16.14.0 || >=18.0.0} + + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + is-ci@2.0.0: + resolution: {integrity: sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==} + hasBin: true + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + + is-it-type@5.1.3: + resolution: {integrity: sha512-AX2uU0HW+TxagTgQXOJY7+2fbFHemC7YFBwN1XqD8qQMKdtfbOC8OC3fUb4s5NU59a3662Dzwto8tWDdZYRXxg==} + engines: {node: '>=12'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-plain-obj@2.1.0: + resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} + engines: {node: '>=8'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istextorbinary@9.5.0: + resolution: {integrity: sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==} + engines: {node: '>=4'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + keytar@7.9.0: + resolution: {integrity: sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==} + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + markdown-it@14.3.0: + resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} + hasBin: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdurl@2.1.0: + resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} + + meow@14.1.0: + resolution: {integrity: sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==} + engines: {node: '>=20'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mocha@11.8.0: + resolution: {integrity: sha512-VyCeUdGN3A9lmCTTgG4yuvY9ixxaDk+xt2R/7/+1AP6EqNG+G9OKkzBwhVtVYoNX8YsxNSgAl8mOv3IAeOpFbw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mute-stream@0.0.8: + resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + node-abi@3.94.0: + resolution: {integrity: sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==} + engines: {node: '>=10'} + + node-addon-api@4.3.0: + resolution: {integrity: sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==} + + node-sarif-builder@3.4.0: + resolution: {integrity: sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==} + engines: {node: '>=20'} + + normalize-package-data@6.0.2: + resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} + engines: {node: ^16.14.0 || >=18.0.0} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + + ora@8.2.0: + resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} + engines: {node: '>=18'} + + ovsx@1.1.0: + resolution: {integrity: sha512-portv4pwDJTlZrvgDMpLTgA+Ctu60kRQfD/cBu4JBhakXabeHVoBAzOba8jMt7RAq+4C0PMHvANalu/S3+s6+w==} + engines: {node: '>= 20'} + hasBin: true + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-map@7.0.6: + resolution: {integrity: sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg==} + engines: {node: '>=18'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + + parse-semver@1.1.1: + resolution: {integrity: sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ==} + + parse5-htmlparser2-tree-adapter@7.1.0: + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} + + parse5-parser-stream@7.1.2: + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + path-type@6.0.0: + resolution: {integrity: sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==} + engines: {node: '>=18'} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pluralize@2.0.0: + resolution: {integrity: sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==} + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + + rc-config-loader@4.1.4: + resolution: {integrity: sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + read-pkg@9.0.1: + resolution: {integrity: sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==} + engines: {node: '>=18'} + + read@1.0.7: + resolution: {integrity: sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==} + engines: {node: '>=0.8'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rsbuild-plugin-dts@1.0.0-beta.1: + resolution: {integrity: sha512-hAEjOXhfIHR4erqjsqjRvA9Yqzc6Thry06tHheXDJUVwmR3VbN8BSMDC8kBZzyKdypDF0IJ98FmLQRiC194sMA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@microsoft/api-extractor': ^7 + '@rsbuild/core': ^2.0.0 + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + typescript: + optional: true + + rstack@0.3.2: + resolution: {integrity: sha512-xT2trYrQlxZ9SGtt4+BZzd9MCkPrVT5QJZt9B0kfJexByC37uEVryzEjkIiPFFvC6HCJMjBbdd5Cizoatwjmxg==} + engines: {node: '>=22.12.0'} + hasBin: true + peerDependencies: + '@rspress/core': ^2.0.17 + peerDependenciesMeta: + '@rspress/core': + optional: true + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + + secretlint@10.2.2: + resolution: {integrity: sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg==} + engines: {node: '>=20.0.0'} + hasBin: true + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + simple-invariant@2.0.1: + resolution: {integrity: sha512-1sbhsxqI+I2tqlmjbz99GXNmZtr6tKIyEgGGnJw/MKGblalqk/XoOYYFJlBzTKZCxx8kLaD3FD5s9BEEjx5Pyg==} + engines: {node: '>=10'} + + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + + spdx-correct@3.2.0: + resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + + stacktrace-parser@0.1.11: + resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==} + engines: {node: '>=6'} + + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + structured-source@4.0.0: + resolution: {integrity: sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-hyperlinks@3.2.0: + resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} + engines: {node: '>=14.18'} + + table@6.9.0: + resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} + engines: {node: '>=10.0.0'} + + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + terminal-link@4.0.0: + resolution: {integrity: sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==} + engines: {node: '>=18'} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + textextensions@6.11.0: + resolution: {integrity: sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==} + engines: {node: '>=4'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + tunnel@0.0.6: + resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} + engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} + + type-fest@0.7.1: + resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} + engines: {node: '>=8'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + typed-rest-client@1.8.11: + resolution: {integrity: sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + underscore@1.13.8: + resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + + unicorn-magic@0.1.0: + resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} + engines: {node: '>=18'} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + url-join@4.0.1: + resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + valibot@1.4.2: + resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + + validate-npm-package-license@3.0.4: + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + + version-range@4.15.0: + resolution: {integrity: sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==} + engines: {node: '>=4'} + + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-languageclient@9.0.1: + resolution: {integrity: sha512-JZiimVdvimEuHh5olxhxkht09m3JzUGwggb5eRUkzzJhZ2KjCN0nh55VfiED9oez9DyF8/fz1g1iBV3h+0Z2EA==} + engines: {vscode: ^1.82.0} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + workerpool@9.3.4: + resolution: {integrity: sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + + xml2js@0.5.0: + resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs-unparser@2.0.0: + resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} + engines: {node: '>=10'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yauzl-promise@4.0.0: + resolution: {integrity: sha512-/HCXpyHXJQQHvFq9noqrjfa/WpQC2XYs3vI7tBiAi4QiIU1knvYhZGaO1QPjwIVMdqflxbmwgMXtYeaRiAE0CA==} + engines: {node: '>=16'} + + yauzl@3.4.0: + resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} + engines: {node: '>=12'} + + yazl@2.5.1: + resolution: {integrity: sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + yuku-ast@0.8.3: + resolution: {integrity: sha512-8x34yU5uhHUnJXzy2Qvjvec/vE9BzS0/2khVT1MsLmSLO/P8Q1Wp8IxHv+IhD+HMYETk6kherOSvP4JPWw2joQ==} + + yuku-parser@0.8.3: + resolution: {integrity: sha512-KPQcpF9aj77ywlJBIkQWCQ9DObdxnCA8AJdUOmA5CZZx042Xt4+dvbQmPJfWxF3E+KG5dVAZ2fBKuDJ8VsKWgA==} + +snapshots: + + '@ast-grep/napi-darwin-arm64@0.37.0': + optional: true + + '@ast-grep/napi-darwin-x64@0.37.0': + optional: true + + '@ast-grep/napi-linux-arm64-gnu@0.37.0': + optional: true + + '@ast-grep/napi-linux-arm64-musl@0.37.0': + optional: true + + '@ast-grep/napi-linux-x64-gnu@0.37.0': + optional: true + + '@ast-grep/napi-linux-x64-musl@0.37.0': + optional: true + + '@ast-grep/napi-win32-arm64-msvc@0.37.0': + optional: true + + '@ast-grep/napi-win32-ia32-msvc@0.37.0': + optional: true + + '@ast-grep/napi-win32-x64-msvc@0.37.0': + optional: true + + '@ast-grep/napi@0.37.0': + optionalDependencies: + '@ast-grep/napi-darwin-arm64': 0.37.0 + '@ast-grep/napi-darwin-x64': 0.37.0 + '@ast-grep/napi-linux-arm64-gnu': 0.37.0 + '@ast-grep/napi-linux-arm64-musl': 0.37.0 + '@ast-grep/napi-linux-x64-gnu': 0.37.0 + '@ast-grep/napi-linux-x64-musl': 0.37.0 + '@ast-grep/napi-win32-arm64-msvc': 0.37.0 + '@ast-grep/napi-win32-ia32-msvc': 0.37.0 + '@ast-grep/napi-win32-x64-msvc': 0.37.0 + + '@azu/format-text@1.0.2': {} + + '@azu/style-format@1.0.1': + dependencies: + '@azu/format-text': 1.0.2 + + '@azure/abort-controller@2.2.0': + dependencies: + tslib: 2.8.1 + + '@azure/core-auth@1.11.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-util': 1.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-client@1.11.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0 + '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0 + '@azure/logger': 1.4.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-rest-pipeline@1.25.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0 + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0 + '@azure/logger': 1.4.0 + '@typespec/ts-http-runtime': 0.3.8 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-tracing@1.4.0': + dependencies: + tslib: 2.8.1 + + '@azure/core-util@1.14.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@typespec/ts-http-runtime': 0.3.8 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/identity@4.13.1': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0 + '@azure/core-client': 1.11.0 + '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0 + '@azure/logger': 1.4.0 + '@azure/msal-browser': 5.17.3 + '@azure/msal-node': 5.4.3 + open: 10.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/logger@1.4.0': + dependencies: + '@typespec/ts-http-runtime': 0.3.8 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/msal-browser@5.17.3': + dependencies: + '@azure/msal-common': 16.11.3 + + '@azure/msal-common@16.11.3': {} + + '@azure/msal-node@5.4.3': + dependencies: + '@azure/msal-common': 16.11.3 + jsonwebtoken: 9.0.3 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/core@1.11.3': + dependencies: + '@emnapi/wasi-threads': 1.2.3 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@inquirer/ansi@1.0.2': {} + + '@inquirer/checkbox@4.3.2(@types/node@22.20.1)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.20.1) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.20.1) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/confirm@5.1.21(@types/node@22.20.1)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.20.1) + '@inquirer/type': 3.0.10(@types/node@22.20.1) + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/core@10.3.2(@types/node@22.20.1)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.20.1) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/editor@4.2.23(@types/node@22.20.1)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.20.1) + '@inquirer/external-editor': 1.0.3(@types/node@22.20.1) + '@inquirer/type': 3.0.10(@types/node@22.20.1) + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/expand@4.0.23(@types/node@22.20.1)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.20.1) + '@inquirer/type': 3.0.10(@types/node@22.20.1) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/external-editor@1.0.3(@types/node@22.20.1)': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.3 + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/input@4.3.1(@types/node@22.20.1)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.20.1) + '@inquirer/type': 3.0.10(@types/node@22.20.1) + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/number@3.0.23(@types/node@22.20.1)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.20.1) + '@inquirer/type': 3.0.10(@types/node@22.20.1) + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/password@4.0.23(@types/node@22.20.1)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.20.1) + '@inquirer/type': 3.0.10(@types/node@22.20.1) + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/prompts@7.10.1(@types/node@22.20.1)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@22.20.1) + '@inquirer/confirm': 5.1.21(@types/node@22.20.1) + '@inquirer/editor': 4.2.23(@types/node@22.20.1) + '@inquirer/expand': 4.0.23(@types/node@22.20.1) + '@inquirer/input': 4.3.1(@types/node@22.20.1) + '@inquirer/number': 3.0.23(@types/node@22.20.1) + '@inquirer/password': 4.0.23(@types/node@22.20.1) + '@inquirer/rawlist': 4.1.11(@types/node@22.20.1) + '@inquirer/search': 3.2.2(@types/node@22.20.1) + '@inquirer/select': 4.4.2(@types/node@22.20.1) + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/rawlist@4.1.11(@types/node@22.20.1)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.20.1) + '@inquirer/type': 3.0.10(@types/node@22.20.1) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/search@3.2.2(@types/node@22.20.1)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.20.1) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.20.1) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/select@4.4.2(@types/node@22.20.1)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.20.1) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.20.1) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/type@3.0.10(@types/node@22.20.1)': + optionalDependencies: + '@types/node': 22.20.1 + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@napi-rs/keyring-darwin-arm64@1.3.0': + optional: true + + '@napi-rs/keyring-darwin-x64@1.3.0': + optional: true + + '@napi-rs/keyring-freebsd-x64@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm64-musl@1.3.0': + optional: true + + '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-x64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-x64-musl@1.3.0': + optional: true + + '@napi-rs/keyring-win32-arm64-msvc@1.3.0': + optional: true + + '@napi-rs/keyring-win32-ia32-msvc@1.3.0': + optional: true + + '@napi-rs/keyring-win32-x64-msvc@1.3.0': + optional: true + + '@napi-rs/keyring@1.3.0': + optionalDependencies: + '@napi-rs/keyring-darwin-arm64': 1.3.0 + '@napi-rs/keyring-darwin-x64': 1.3.0 + '@napi-rs/keyring-freebsd-x64': 1.3.0 + '@napi-rs/keyring-linux-arm-gnueabihf': 1.3.0 + '@napi-rs/keyring-linux-arm64-gnu': 1.3.0 + '@napi-rs/keyring-linux-arm64-musl': 1.3.0 + '@napi-rs/keyring-linux-riscv64-gnu': 1.3.0 + '@napi-rs/keyring-linux-x64-gnu': 1.3.0 + '@napi-rs/keyring-linux-x64-musl': 1.3.0 + '@napi-rs/keyring-win32-arm64-msvc': 1.3.0 + '@napi-rs/keyring-win32-ia32-msvc': 1.3.0 + '@napi-rs/keyring-win32-x64-msvc': 1.3.0 + optional: true + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@node-rs/crc32-android-arm-eabi@1.10.6': + optional: true + + '@node-rs/crc32-android-arm64@1.10.6': + optional: true + + '@node-rs/crc32-darwin-arm64@1.10.6': + optional: true + + '@node-rs/crc32-darwin-x64@1.10.6': + optional: true + + '@node-rs/crc32-freebsd-x64@1.10.6': + optional: true + + '@node-rs/crc32-linux-arm-gnueabihf@1.10.6': + optional: true + + '@node-rs/crc32-linux-arm64-gnu@1.10.6': + optional: true + + '@node-rs/crc32-linux-arm64-musl@1.10.6': + optional: true + + '@node-rs/crc32-linux-x64-gnu@1.10.6': + optional: true + + '@node-rs/crc32-linux-x64-musl@1.10.6': + optional: true + + '@node-rs/crc32-wasm32-wasi@1.10.6': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@node-rs/crc32-win32-arm64-msvc@1.10.6': + optional: true + + '@node-rs/crc32-win32-ia32-msvc@1.10.6': + optional: true + + '@node-rs/crc32-win32-x64-msvc@1.10.6': + optional: true + + '@node-rs/crc32@1.10.6': + optionalDependencies: + '@node-rs/crc32-android-arm-eabi': 1.10.6 + '@node-rs/crc32-android-arm64': 1.10.6 + '@node-rs/crc32-darwin-arm64': 1.10.6 + '@node-rs/crc32-darwin-x64': 1.10.6 + '@node-rs/crc32-freebsd-x64': 1.10.6 + '@node-rs/crc32-linux-arm-gnueabihf': 1.10.6 + '@node-rs/crc32-linux-arm64-gnu': 1.10.6 + '@node-rs/crc32-linux-arm64-musl': 1.10.6 + '@node-rs/crc32-linux-x64-gnu': 1.10.6 + '@node-rs/crc32-linux-x64-musl': 1.10.6 + '@node-rs/crc32-wasm32-wasi': 1.10.6 + '@node-rs/crc32-win32-arm64-msvc': 1.10.6 + '@node-rs/crc32-win32-ia32-msvc': 1.10.6 + '@node-rs/crc32-win32-x64-msvc': 1.10.6 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@rsbuild/core@2.1.9': + dependencies: + '@rspack/core': 2.1.7(@swc/helpers@0.5.23) + '@swc/helpers': 0.5.23 + transitivePeerDependencies: + - '@module-federation/runtime-tools' + + '@rslib/core@1.0.0-beta.1(typescript@5.9.3)': + dependencies: + '@rsbuild/core': 2.1.9 + rsbuild-plugin-dts: 1.0.0-beta.1(@rsbuild/core@2.1.9)(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@module-federation/runtime-tools' + - core-js + + '@rslint/core@0.7.2(jiti@2.7.0)': + dependencies: + picomatch: 4.0.4 + optionalDependencies: + '@rslint/native-darwin-arm64': 0.7.2 + '@rslint/native-darwin-x64': 0.7.2 + '@rslint/native-linux-arm64-gnu': 0.7.2 + '@rslint/native-linux-arm64-musl': 0.7.2 + '@rslint/native-linux-x64-gnu': 0.7.2 + '@rslint/native-linux-x64-musl': 0.7.2 + '@rslint/native-win32-arm64-msvc': 0.7.2 + '@rslint/native-win32-x64-msvc': 0.7.2 + jiti: 2.7.0 + + '@rslint/native-darwin-arm64@0.7.2': + optional: true + + '@rslint/native-darwin-x64@0.7.2': + optional: true + + '@rslint/native-linux-arm64-gnu@0.7.2': + optional: true + + '@rslint/native-linux-arm64-musl@0.7.2': + optional: true + + '@rslint/native-linux-x64-gnu@0.7.2': + optional: true + + '@rslint/native-linux-x64-musl@0.7.2': + optional: true + + '@rslint/native-win32-arm64-msvc@0.7.2': + optional: true + + '@rslint/native-win32-x64-msvc@0.7.2': + optional: true + + '@rspack/binding-darwin-arm64@2.1.7': + optional: true + + '@rspack/binding-darwin-x64@2.1.7': + optional: true + + '@rspack/binding-linux-arm64-gnu@2.1.7': + optional: true + + '@rspack/binding-linux-arm64-musl@2.1.7': + optional: true + + '@rspack/binding-linux-riscv64-gnu@2.1.7': + optional: true + + '@rspack/binding-linux-riscv64-musl@2.1.7': + optional: true + + '@rspack/binding-linux-x64-gnu@2.1.7': + optional: true + + '@rspack/binding-linux-x64-musl@2.1.7': + optional: true + + '@rspack/binding-wasm32-wasi@2.1.7': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + + '@rspack/binding-win32-arm64-msvc@2.1.7': + optional: true + + '@rspack/binding-win32-ia32-msvc@2.1.7': + optional: true + + '@rspack/binding-win32-x64-msvc@2.1.7': + optional: true + + '@rspack/binding@2.1.7': + optionalDependencies: + '@rspack/binding-darwin-arm64': 2.1.7 + '@rspack/binding-darwin-x64': 2.1.7 + '@rspack/binding-linux-arm64-gnu': 2.1.7 + '@rspack/binding-linux-arm64-musl': 2.1.7 + '@rspack/binding-linux-riscv64-gnu': 2.1.7 + '@rspack/binding-linux-riscv64-musl': 2.1.7 + '@rspack/binding-linux-x64-gnu': 2.1.7 + '@rspack/binding-linux-x64-musl': 2.1.7 + '@rspack/binding-wasm32-wasi': 2.1.7 + '@rspack/binding-win32-arm64-msvc': 2.1.7 + '@rspack/binding-win32-ia32-msvc': 2.1.7 + '@rspack/binding-win32-x64-msvc': 2.1.7 + + '@rspack/core@2.1.7(@swc/helpers@0.5.23)': + dependencies: + '@rspack/binding': 2.1.7 + optionalDependencies: + '@swc/helpers': 0.5.23 + + '@rstackjs/load-config@0.1.2(jiti@2.7.0)': + optionalDependencies: + jiti: 2.7.0 + + '@rstest/core@0.11.5': + dependencies: + '@rsbuild/core': 2.1.9 + '@types/chai': 5.2.3 + transitivePeerDependencies: + - '@module-federation/runtime-tools' + - core-js + + '@secretlint/config-creator@10.2.2': + dependencies: + '@secretlint/types': 10.2.2 + + '@secretlint/config-loader@10.2.2': + dependencies: + '@secretlint/profiler': 10.2.2 + '@secretlint/resolver': 10.2.2 + '@secretlint/types': 10.2.2 + ajv: 8.20.0 + debug: 4.4.3(supports-color@8.1.1) + rc-config-loader: 4.1.4 + transitivePeerDependencies: + - supports-color + + '@secretlint/core@10.2.2': + dependencies: + '@secretlint/profiler': 10.2.2 + '@secretlint/types': 10.2.2 + debug: 4.4.3(supports-color@8.1.1) + structured-source: 4.0.0 + transitivePeerDependencies: + - supports-color + + '@secretlint/formatter@10.2.2': + dependencies: + '@secretlint/resolver': 10.2.2 + '@secretlint/types': 10.2.2 + '@textlint/linter-formatter': 15.8.0 + '@textlint/module-interop': 15.8.0 + '@textlint/types': 15.8.0 + chalk: 5.6.2 + debug: 4.4.3(supports-color@8.1.1) + pluralize: 8.0.0 + strip-ansi: 7.2.0 + table: 6.9.0 + terminal-link: 4.0.0 + transitivePeerDependencies: + - supports-color + + '@secretlint/node@10.2.2': + dependencies: + '@secretlint/config-loader': 10.2.2 + '@secretlint/core': 10.2.2 + '@secretlint/formatter': 10.2.2 + '@secretlint/profiler': 10.2.2 + '@secretlint/source-creator': 10.2.2 + '@secretlint/types': 10.2.2 + debug: 4.4.3(supports-color@8.1.1) + p-map: 7.0.6 + transitivePeerDependencies: + - supports-color + + '@secretlint/profiler@10.2.2': {} + + '@secretlint/resolver@10.2.2': {} + + '@secretlint/secretlint-formatter-sarif@10.2.2': + dependencies: + node-sarif-builder: 3.4.0 + + '@secretlint/secretlint-rule-no-dotenv@10.2.2': + dependencies: + '@secretlint/types': 10.2.2 + + '@secretlint/secretlint-rule-preset-recommend@10.2.2': {} + + '@secretlint/source-creator@10.2.2': + dependencies: + '@secretlint/types': 10.2.2 + istextorbinary: 9.5.0 + + '@secretlint/types@10.2.2': {} + + '@sindresorhus/merge-streams@2.3.0': {} + + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + + '@textlint/ast-node-types@15.8.0': {} + + '@textlint/linter-formatter@15.8.0': + dependencies: + '@azu/format-text': 1.0.2 + '@azu/style-format': 1.0.1 + '@textlint/module-interop': 15.8.0 + '@textlint/resolver': 15.8.0 + '@textlint/types': 15.8.0 + debug: 4.4.3(supports-color@8.1.1) + js-yaml: 4.3.1 + lodash: 4.18.1 + pluralize: 2.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + table: 6.9.0 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + '@textlint/module-interop@15.8.0': {} + + '@textlint/resolver@15.8.0': {} + + '@textlint/types@15.8.0': + dependencies: + '@textlint/ast-node-types': 15.8.0 + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/mocha@10.0.10': {} + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@types/normalize-package-data@2.4.4': {} + + '@types/picomatch@4.0.3': {} + + '@types/sarif@2.1.7': {} + + '@types/semver@7.8.0': {} + + '@types/vscode@1.97.0': {} + + '@typespec/ts-http-runtime@0.3.8': + dependencies: + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@vscode/test-electron@3.1.0': + dependencies: + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + jszip: 3.10.1 + ora: 8.2.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + '@vscode/vsce-sign-alpine-arm64@2.0.6': + optional: true + + '@vscode/vsce-sign-alpine-x64@2.0.6': + optional: true + + '@vscode/vsce-sign-darwin-arm64@2.0.6': + optional: true + + '@vscode/vsce-sign-darwin-x64@2.0.6': + optional: true + + '@vscode/vsce-sign-linux-arm64@2.0.6': + optional: true + + '@vscode/vsce-sign-linux-arm@2.0.6': + optional: true + + '@vscode/vsce-sign-linux-x64@2.0.6': + optional: true + + '@vscode/vsce-sign-win32-arm64@2.0.6': + optional: true + + '@vscode/vsce-sign-win32-x64@2.0.6': + optional: true + + '@vscode/vsce-sign@2.0.9': + optionalDependencies: + '@vscode/vsce-sign-alpine-arm64': 2.0.6 + '@vscode/vsce-sign-alpine-x64': 2.0.6 + '@vscode/vsce-sign-darwin-arm64': 2.0.6 + '@vscode/vsce-sign-darwin-x64': 2.0.6 + '@vscode/vsce-sign-linux-arm': 2.0.6 + '@vscode/vsce-sign-linux-arm64': 2.0.6 + '@vscode/vsce-sign-linux-x64': 2.0.6 + '@vscode/vsce-sign-win32-arm64': 2.0.6 + '@vscode/vsce-sign-win32-x64': 2.0.6 + + '@vscode/vsce@3.9.2': + dependencies: + '@azure/identity': 4.13.1 + '@secretlint/node': 10.2.2 + '@secretlint/secretlint-formatter-sarif': 10.2.2 + '@secretlint/secretlint-rule-no-dotenv': 10.2.2 + '@secretlint/secretlint-rule-preset-recommend': 10.2.2 + '@vscode/vsce-sign': 2.0.9 + azure-devops-node-api: 12.5.0 + chalk: 4.1.2 + cheerio: 1.2.0 + cockatiel: 3.2.1 + commander: 12.1.0 + form-data: 4.0.6 + glob: 13.0.6 + hosted-git-info: 4.1.0 + jsonc-parser: 3.3.1 + leven: 3.1.0 + markdown-it: 14.3.0 + mime: 1.6.0 + minimatch: 10.2.6 + parse-semver: 1.1.1 + read: 1.0.7 + secretlint: 10.2.2 + semver: 7.8.5 + tmp: 0.2.7 + typed-rest-client: 1.8.11 + url-join: 4.0.1 + xml2js: 0.5.0 + yauzl: 3.4.0 + yazl: 2.5.1 + optionalDependencies: + keytar: 7.9.0 + transitivePeerDependencies: + - supports-color + + '@yuku-parser/binding-android-arm64@0.8.3': + optional: true + + '@yuku-parser/binding-darwin-arm64@0.8.3': + optional: true + + '@yuku-parser/binding-darwin-x64@0.8.3': + optional: true + + '@yuku-parser/binding-freebsd-x64@0.8.3': + optional: true + + '@yuku-parser/binding-linux-arm-gnu@0.8.3': + optional: true + + '@yuku-parser/binding-linux-arm-musl@0.8.3': + optional: true + + '@yuku-parser/binding-linux-arm64-gnu@0.8.3': + optional: true + + '@yuku-parser/binding-linux-arm64-musl@0.8.3': + optional: true + + '@yuku-parser/binding-linux-x64-gnu@0.8.3': + optional: true + + '@yuku-parser/binding-linux-x64-musl@0.8.3': + optional: true + + '@yuku-parser/binding-win32-arm64@0.8.3': + optional: true + + '@yuku-parser/binding-win32-x64@0.8.3': + optional: true + + '@yuku-toolchain/types@0.8.3': {} + + agent-base@7.1.4: {} + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.5 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + argparse@2.0.1: {} + + assertion-error@2.0.1: {} + + astral-regex@2.0.0: {} + + asynckit@0.4.0: {} + + azure-devops-node-api@12.5.0: + dependencies: + tunnel: 0.0.6 + typed-rest-client: 1.8.11 + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: + optional: true + + binaryextensions@6.11.0: + dependencies: + editions: 6.22.0 + + birpc@4.0.0: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + optional: true + + boolbase@1.0.0: {} + + boundary@2.0.0: {} + + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browser-stdout@1.3.1: {} + + buffer-crc32@0.2.13: {} + + buffer-equal-constant-time@1.0.1: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + optional: true + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + camelcase@6.3.0: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + chardet@2.2.0: {} + + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.2.2 + css-what: 6.2.2 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + + cheerio@1.2.0: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + encoding-sniffer: 0.2.1 + htmlparser2: 10.1.0 + parse5: 7.3.0 + parse5-htmlparser2-tree-adapter: 7.1.0 + parse5-parser-stream: 7.1.2 + undici: 7.29.0 + whatwg-mimetype: 4.0.0 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chownr@1.1.4: + optional: true + + ci-info@2.0.0: {} + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + cli-width@4.1.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + cockatiel@3.2.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@12.1.0: {} + + commander@6.2.1: {} + + core-js-pure@3.49.0: {} + + core-util-is@1.0.3: {} + + cross-env@7.0.3: + dependencies: + cross-spawn: 7.0.6 + + cross-keychain@1.1.0(@types/node@22.20.1): + dependencies: + '@inquirer/prompts': 7.10.1(@types/node@22.20.1) + meow: 14.1.0 + optionalDependencies: + '@napi-rs/keyring': 1.3.0 + transitivePeerDependencies: + - '@types/node' + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-what@6.2.2: {} + + debug@4.4.3(supports-color@8.1.1): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 + + decamelize@4.0.0: {} + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + optional: true + + deep-extend@0.6.0: + optional: true + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-lazy-prop@3.0.0: {} + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + delayed-stream@1.0.0: {} + + detect-libc@2.1.2: + optional: true + + diff@7.0.0: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + editions@6.22.0: + dependencies: + version-range: 4.15.0 + + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + encoding-sniffer@0.2.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding: 3.1.1 + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + optional: true + + entities@4.5.0: {} + + entities@6.0.1: {} + + entities@7.0.1: {} + + environment@1.1.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + expand-template@2.0.3: + optional: true + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-uri@3.1.5: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat@5.0.2: {} + + follow-redirects@1.16.0: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fs-constants@1.0.0: + optional: true + + fs-extra@11.4.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + function-bind@1.1.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.6.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + github-from-package@0.0.0: + optional: true + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + globby@14.1.0: + dependencies: + '@sindresorhus/merge-streams': 2.3.0 + fast-glob: 3.3.3 + ignore: 7.0.6 + path-type: 6.0.0 + slash: 5.1.0 + unicorn-magic: 0.3.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + hosted-git-info@4.1.0: + dependencies: + lru-cache: 6.0.0 + + hosted-git-info@7.0.2: + dependencies: + lru-cache: 10.4.3 + + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: + optional: true + + ignore@7.0.6: {} + + immediate@3.0.6: {} + + index-to-position@1.2.0: {} + + inherits@2.0.4: {} + + ini@1.3.8: + optional: true + + is-ci@2.0.0: + dependencies: + ci-info: 2.0.0 + + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-interactive@2.0.0: {} + + is-it-type@5.1.3: + dependencies: + globalthis: 1.0.4 + + is-number@7.0.0: {} + + is-path-inside@3.0.3: {} + + is-plain-obj@2.1.0: {} + + is-unicode-supported@0.1.0: {} + + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isarray@1.0.0: {} + + isexe@2.0.0: {} + + istextorbinary@9.5.0: + dependencies: + binaryextensions: 6.11.0 + editions: 6.22.0 + textextensions: 6.11.0 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jiti@2.7.0: + optional: true + + js-tokens@4.0.0: {} + + js-yaml@4.3.1: + dependencies: + argparse: 2.0.1 + + json-schema-traverse@1.0.0: {} + + json5@2.2.3: {} + + jsonc-parser@3.3.1: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + keytar@7.9.0: + dependencies: + node-addon-api: 4.3.0 + prebuild-install: 7.1.3 + optional: true + + leven@3.1.0: {} + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + + linkify-it@5.0.2: + dependencies: + uc.micro: 2.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} + + lodash.truncate@4.4.2: {} + + lodash@4.18.1: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + log-symbols@6.0.0: + dependencies: + chalk: 5.6.2 + is-unicode-supported: 1.3.0 + + lru-cache@10.4.3: {} + + lru-cache@11.5.2: {} + + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + + markdown-it@14.3.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.2 + mdurl: 2.1.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + math-intrinsics@1.1.0: {} + + mdurl@2.1.0: {} + + meow@14.1.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + mimic-function@5.0.1: {} + + mimic-response@3.1.0: + optional: true + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.4 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.4 + + minimist@1.2.8: + optional: true + + minipass@7.1.3: {} + + mkdirp-classic@0.5.3: + optional: true + + mocha@11.8.0: + dependencies: + browser-stdout: 1.3.1 + chokidar: 4.0.3 + debug: 4.4.3(supports-color@8.1.1) + diff: 7.0.0 + escape-string-regexp: 4.0.0 + find-up: 5.0.0 + glob: 10.5.0 + he: 1.2.0 + is-path-inside: 3.0.3 + js-yaml: 4.3.1 + log-symbols: 4.1.0 + minimatch: 9.0.9 + ms: 2.1.3 + picocolors: 1.1.1 + serialize-javascript: 6.0.2 + strip-json-comments: 3.1.1 + supports-color: 8.1.1 + workerpool: 9.3.4 + yargs: 17.7.3 + yargs-parser: 21.1.1 + yargs-unparser: 2.0.0 + + ms@2.1.3: {} + + mute-stream@0.0.8: {} + + mute-stream@2.0.0: {} + + napi-build-utils@2.0.0: + optional: true + + node-abi@3.94.0: + dependencies: + semver: 7.8.5 + optional: true + + node-addon-api@4.3.0: + optional: true + + node-sarif-builder@3.4.0: + dependencies: + '@types/sarif': 2.1.7 + fs-extra: 11.4.0 + + normalize-package-data@6.0.2: + dependencies: + hosted-git-info: 7.0.2 + semver: 7.8.5 + validate-npm-package-license: 3.0.4 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + optional: true + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + open@10.2.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + + ora@8.2.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + ovsx@1.1.0(@types/node@22.20.1): + dependencies: + '@inquirer/prompts': 7.10.1(@types/node@22.20.1) + '@vscode/vsce': 3.9.2 + commander: 6.2.1 + cross-keychain: 1.1.0(@types/node@22.20.1) + follow-redirects: 1.16.0 + is-ci: 2.0.0 + leven: 3.1.0 + semver: 7.8.5 + tmp: 0.2.7 + yauzl-promise: 4.0.0 + transitivePeerDependencies: + - '@types/node' + - debug + - supports-color + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-map@7.0.6: {} + + package-json-from-dist@1.0.1: {} + + pako@1.0.11: {} + + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.7 + index-to-position: 1.2.0 + type-fest: 4.41.0 + + parse-semver@1.1.1: + dependencies: + semver: 5.7.2 + + parse5-htmlparser2-tree-adapter@7.1.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.3.0 + + parse5-parser-stream@7.1.2: + dependencies: + parse5: 7.3.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + + path-type@6.0.0: {} + + pend@1.2.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + picomatch@4.0.5: {} + + pluralize@2.0.0: {} + + pluralize@8.0.0: {} + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.94.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.5 + tunnel-agent: 0.6.0 + optional: true + + prettier@3.9.6: {} + + process-nextick-args@2.0.1: {} + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + optional: true + + punycode.js@2.3.1: {} + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + queue-microtask@1.2.3: {} + + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + + rc-config-loader@4.1.4: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + js-yaml: 4.3.1 + json5: 2.2.3 + require-from-string: 2.0.2 + transitivePeerDependencies: + - supports-color + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + optional: true + + read-pkg@9.0.1: + dependencies: + '@types/normalize-package-data': 2.4.4 + normalize-package-data: 6.0.2 + parse-json: 8.3.0 + type-fest: 4.41.0 + unicorn-magic: 0.1.0 + + read@1.0.7: + dependencies: + mute-stream: 0.0.8 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + optional: true + + readdirp@4.1.2: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + reusify@1.1.0: {} + + rsbuild-plugin-dts@1.0.0-beta.1(@rsbuild/core@2.1.9)(typescript@5.9.3): + dependencies: + '@ast-grep/napi': 0.37.0 + '@rsbuild/core': 2.1.9 + optionalDependencies: + typescript: 5.9.3 + + rstack@0.3.2(jiti@2.7.0)(typescript@5.9.3): + dependencies: + '@rsbuild/core': 2.1.9 + '@rslib/core': 1.0.0-beta.1(typescript@5.9.3) + '@rslint/core': 0.7.2(jiti@2.7.0) + '@rstest/core': 0.11.5 + prettier: 3.9.6 + tinypool: 2.1.0 + yuku-parser: 0.8.3 + transitivePeerDependencies: + - '@microsoft/api-extractor' + - '@module-federation/runtime-tools' + - core-js + - happy-dom + - jiti + - jsdom + - typescript + + run-applescript@7.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + sax@1.6.1: {} + + secretlint@10.2.2: + dependencies: + '@secretlint/config-creator': 10.2.2 + '@secretlint/formatter': 10.2.2 + '@secretlint/node': 10.2.2 + '@secretlint/profiler': 10.2.2 + debug: 4.4.3(supports-color@8.1.1) + globby: 14.1.0 + read-pkg: 9.0.1 + transitivePeerDependencies: + - supports-color + + semver@5.7.2: {} + + semver@7.8.5: {} + + serialize-javascript@6.0.2: + dependencies: + randombytes: 2.1.0 + + setimmediate@1.0.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@4.1.0: {} + + simple-concat@1.0.1: + optional: true + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + optional: true + + simple-invariant@2.0.1: {} + + slash@5.1.0: {} + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + spdx-correct@3.2.0: + dependencies: + spdx-expression-parse: 3.0.1 + spdx-license-ids: 3.0.23 + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@3.0.1: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-license-ids@3.0.23: {} + + stacktrace-parser@0.1.11: + dependencies: + type-fest: 0.7.1 + + stdin-discarder@0.2.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + optional: true + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-json-comments@2.0.1: + optional: true + + strip-json-comments@3.1.1: {} + + structured-source@4.0.0: + dependencies: + boundary: 2.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@3.2.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + + table@6.9.0: + dependencies: + ajv: 8.20.0 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + optional: true + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + optional: true + + terminal-link@4.0.0: + dependencies: + ansi-escapes: 7.3.0 + supports-hyperlinks: 3.2.0 + + text-table@0.2.0: {} + + textextensions@6.11.0: + dependencies: + editions: 6.22.0 + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@2.1.0: {} + + tmp@0.2.7: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tslib@2.8.1: {} + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + optional: true + + tunnel@0.0.6: {} + + type-fest@0.7.1: {} + + type-fest@4.41.0: {} + + typed-rest-client@1.8.11: + dependencies: + qs: 6.15.3 + tunnel: 0.0.6 + underscore: 1.13.8 + + typescript@5.9.3: {} + + uc.micro@2.1.0: {} + + underscore@1.13.8: {} + + undici-types@6.21.0: {} + + undici@7.29.0: {} + + unicorn-magic@0.1.0: {} + + unicorn-magic@0.3.0: {} + + universalify@2.0.1: {} + + url-join@4.0.1: {} + + util-deprecate@1.0.2: {} + + valibot@1.4.2(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + validate-npm-package-license@3.0.4: + dependencies: + spdx-correct: 3.2.0 + spdx-expression-parse: 3.0.1 + + version-range@4.15.0: {} + + vscode-jsonrpc@8.2.0: {} + + vscode-languageclient@9.0.1: + dependencies: + minimatch: 5.1.9 + semver: 7.8.5 + vscode-languageserver-protocol: 3.17.5 + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-types@3.17.5: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + workerpool@9.3.4: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrappy@1.0.2: + optional: true + + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.1 + + xml2js@0.5.0: + dependencies: + sax: 1.6.1 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + + y18n@5.0.8: {} + + yallist@4.0.0: {} + + yargs-parser@21.1.1: {} + + yargs-unparser@2.0.0: + dependencies: + camelcase: 6.3.0 + decamelize: 4.0.0 + flat: 5.0.2 + is-plain-obj: 2.1.0 + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yauzl-promise@4.0.0: + dependencies: + '@node-rs/crc32': 1.10.6 + is-it-type: 5.1.3 + simple-invariant: 2.0.1 + + yauzl@3.4.0: + dependencies: + pend: 1.2.0 + + yazl@2.5.1: + dependencies: + buffer-crc32: 0.2.13 + + yocto-queue@0.1.0: {} + + yoctocolors-cjs@2.1.3: {} + + yuku-ast@0.8.3: + dependencies: + '@yuku-toolchain/types': 0.8.3 + + yuku-parser@0.8.3: + dependencies: + '@yuku-toolchain/types': 0.8.3 + yuku-ast: 0.8.3 + optionalDependencies: + '@yuku-parser/binding-android-arm64': 0.8.3 + '@yuku-parser/binding-darwin-arm64': 0.8.3 + '@yuku-parser/binding-darwin-x64': 0.8.3 + '@yuku-parser/binding-freebsd-x64': 0.8.3 + '@yuku-parser/binding-linux-arm-gnu': 0.8.3 + '@yuku-parser/binding-linux-arm-musl': 0.8.3 + '@yuku-parser/binding-linux-arm64-gnu': 0.8.3 + '@yuku-parser/binding-linux-arm64-musl': 0.8.3 + '@yuku-parser/binding-linux-x64-gnu': 0.8.3 + '@yuku-parser/binding-linux-x64-musl': 0.8.3 + '@yuku-parser/binding-win32-arm64': 0.8.3 + '@yuku-parser/binding-win32-x64': 0.8.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..5a4d660 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,17 @@ +packages: + - packages/* + +# Build-script policy: vsce-sign fetches its signing binary at install time and +# is needed by `vsce publish`; keytar (credential storage) and core-js-pure +# (funding banner) are not needed in this repo's workflows. +allowBuilds: + '@vscode/vsce-sign': true + core-js-pure: false + keytar: false + +# The whole point of this repo is tracking the freshest Rstack toolchain +# releases, so exempt them from the minimum-release-age supply-chain policy. +minimumReleaseAgeExclude: + - rstack + - '@rslint/core' + - '@rstest/core' diff --git a/rstack.config.ts b/rstack.config.ts new file mode 100644 index 0000000..43537ac --- /dev/null +++ b/rstack.config.ts @@ -0,0 +1,54 @@ +// Rstack configuration guide: https://rstack.rs/config +import { define } from 'rstack'; + +define.lint(async () => { + const { ts } = await import('rstack/lint'); + return [ + { + ignores: [ + '**/dist/**', + '**/tests-dist/**', + '**/.vscode-test/**', + // Fixture projects are user-land sample code, not extension source. + 'packages/vscode/tests/e2e/fixtures/**', + 'packages/vscode/tests/e2e/rstest/fixtures/**', + 'packages/vscode/tests/e2e/lint/fixtures/**', + ], + }, + ts.configs.recommended, + { + rules: { + // The copied upstream extension code relies on these patterns + // (`nodeRequire`, ambient anys around the VS Code test APIs). + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-require-imports': 'off', + }, + }, + { + languageOptions: { + parserOptions: { + project: ['./packages/vscode/tsconfig.json'], + }, + }, + }, + ]; +}); + +define.fmt({ + singleQuote: true, + sortPackageJson: true, + proseWrap: 'never', + ignorePatterns: [ + // E2E fixture sources are asserted on byte-for-byte (diagnostic ranges, + // autofix results, AST-collected line numbers) β€” formatting breaks them. + 'packages/vscode/tests/e2e/fixtures/**', + 'packages/vscode/tests/e2e/rstest/fixtures/**', + 'packages/vscode/tests/e2e/lint/fixtures/**', + ], +}); + +define.staged({ + '*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}': ['rs lint --fix', 'rs fmt'], + '*.{json,jsonc,md,mdx,css,html,yml,yaml}': 'rs fmt', +}); From 7622087c9c999c2d6ed5319a1c869e036cc6708f Mon Sep 17 00:00:00 2001 From: fi3ework Date: Tue, 4 Aug 2026 20:45:18 +0800 Subject: [PATCH 02/15] fix: spawn pnpm through a shell on Windows in the fixture installer Node rejects spawning .cmd shims without a shell (CVE-2024-27980 hardening), so the E2E fixture installs failed with EINVAL on the Windows CI runner. --- packages/vscode/tests/e2e/setupFixtures.mjs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/vscode/tests/e2e/setupFixtures.mjs b/packages/vscode/tests/e2e/setupFixtures.mjs index 871ce10..22acd76 100644 --- a/packages/vscode/tests/e2e/setupFixtures.mjs +++ b/packages/vscode/tests/e2e/setupFixtures.mjs @@ -33,7 +33,7 @@ export const FIXTURES = { }; export const FIXTURE_NAMES = Object.keys(FIXTURES); -const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; +const pnpmCommand = 'pnpm'; /** @param {string} name */ const install = (name) => { @@ -69,7 +69,14 @@ const install = (name) => { // build scripts as-is. '--config.dangerouslyAllowAllBuilds=true', ], - { cwd, stdio: 'inherit', env: process.env }, + { + cwd, + stdio: 'inherit', + env: process.env, + // On Windows, pnpm is a .cmd shim, and Node refuses to spawn batch + // files without a shell (CVE-2024-27980 hardening) β€” EINVAL otherwise. + shell: process.platform === 'win32', + }, ); if (result.error) { throw result.error; From 91528db4e0aaf3aaf5c691fc891edda3223c1f8b Mon Sep 17 00:00:00 2001 From: fi3ework Date: Tue, 4 Aug 2026 20:56:32 +0800 Subject: [PATCH 03/15] fix: Windows and CI-checkout E2E failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The detection E2E asserted the extensionless POSIX bin name; on Windows the probe correctly finds the rs.cmd shim, so compare without the extension. - basic/src/gitignored.ts is a test asset deliberately listed in the lint fixture's own .gitignore, which also hid it from this repo's checkout β€” CI never had the file. Force-track it. --- .../vscode/tests/e2e/lint/fixtures/basic/src/gitignored.ts | 4 ++++ packages/vscode/tests/e2e/suite/detection.test.ts | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 packages/vscode/tests/e2e/lint/fixtures/basic/src/gitignored.ts diff --git a/packages/vscode/tests/e2e/lint/fixtures/basic/src/gitignored.ts b/packages/vscode/tests/e2e/lint/fixtures/basic/src/gitignored.ts new file mode 100644 index 0000000..67a64a5 --- /dev/null +++ b/packages/vscode/tests/e2e/lint/fixtures/basic/src/gitignored.ts @@ -0,0 +1,4 @@ +const ignoredValue: any = {}; +ignoredValue.missing; + +export {}; diff --git a/packages/vscode/tests/e2e/suite/detection.test.ts b/packages/vscode/tests/e2e/suite/detection.test.ts index ee6b461..4c47900 100644 --- a/packages/vscode/tests/e2e/suite/detection.test.ts +++ b/packages/vscode/tests/e2e/suite/detection.test.ts @@ -100,7 +100,9 @@ suite('detection', () => { const binPath = detection.stacks.fmt.binPath; if (expected.fmtBin) { assert.ok(binPath, `${name}: the rs fmt bin probe found nothing`); - assert.equal(path.basename(binPath), expected.fmtBin); + // Compare without the extension: on Windows the bin is a `rs.cmd` + // (or `rs.exe`) shim, on POSIX a plain `rs` symlink. + assert.equal(path.parse(binPath).name, expected.fmtBin); assert.ok( binPath.includes( `${path.sep}node_modules${path.sep}.bin${path.sep}`, From 5b8d26799336f8003c9e202182e7c632d1325eba Mon Sep 17 00:00:00 2001 From: fi3ework Date: Tue, 4 Aug 2026 21:08:28 +0800 Subject: [PATCH 04/15] fix: retry sandbox directory removal in the JS-config E2E suite On the Windows runner the language server / VS Code watcher can still hold handles inside a nested-config directory, so an immediate recursive rmSync fails with EPERM. Use Node's built-in maxRetries/retryDelay, as the suite harness already does for the profile root. --- .../e2e/lint/suite-jsconfig/jsconfig.test.ts | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/vscode/tests/e2e/lint/suite-jsconfig/jsconfig.test.ts b/packages/vscode/tests/e2e/lint/suite-jsconfig/jsconfig.test.ts index 48fba2e..587c290 100644 --- a/packages/vscode/tests/e2e/lint/suite-jsconfig/jsconfig.test.ts +++ b/packages/vscode/tests/e2e/lint/suite-jsconfig/jsconfig.test.ts @@ -452,7 +452,15 @@ export default []; ), ); fs.writeFileSync(rootConfigPath, originalRootConfig, 'utf8'); - fs.rmSync(nestedDir, { recursive: true, force: true }); + // maxRetries: on Windows the language server / VS Code watcher can + // still hold handles inside the directory, making an immediate + // recursive delete fail with EPERM. + fs.rmSync(nestedDir, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 100, + }); assert.ok( !fs.existsSync(nestedDir), 'Broken nested-config fixtures must be deleted during cleanup', @@ -550,7 +558,12 @@ export default [{ files: ['**/*.ts'], rules: { 'no-console': 'error' } }]; diagnostic.message.includes('no-explicit-any'), ), ); - fs.rmSync(nestedDir, { recursive: true, force: true }); + fs.rmSync(nestedDir, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 100, + }); fs.writeFileSync(rootConfigPath, originalRootConfig, 'utf8'); await restored; }, @@ -614,7 +627,12 @@ export default [{ files: ['**/*.ts'], rules: { 'no-console': 'error' } }]; ), ); for (const probe of probes) { - fs.rmSync(probe, { recursive: true, force: true }); + fs.rmSync(probe, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 100, + }); } if (!gitDirectoryExisted) { try { From 4ae725ac6e913707fb73c5c876cb00f193e9512f Mon Sep 17 00:00:00 2001 From: fi3ework Date: Tue, 4 Aug 2026 21:20:16 +0800 Subject: [PATCH 05/15] fix: deflake the JS-config E2E suite on CI runners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Broken-nested-config cleanup: wait for the restored root config to take effect before deleting the nested directory β€” on Windows the server holds handles inside it until the refresh lands, so the delete hit EPERM even with short retries. Also widen the retry window. - Parent-ignore catalog test: VS Code's watcher can miss events for files created inside a just-created directory, leaving the nested config undiscovered forever (observed twice on the Linux runner). Nudge the config file and retry discovery, and compare the evaluation marker against a baseline instead of a literal count. --- .../e2e/lint/suite-jsconfig/jsconfig.test.ts | 50 +++++++++++++------ 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/packages/vscode/tests/e2e/lint/suite-jsconfig/jsconfig.test.ts b/packages/vscode/tests/e2e/lint/suite-jsconfig/jsconfig.test.ts index 587c290..d8c6552 100644 --- a/packages/vscode/tests/e2e/lint/suite-jsconfig/jsconfig.test.ts +++ b/packages/vscode/tests/e2e/lint/suite-jsconfig/jsconfig.test.ts @@ -452,20 +452,22 @@ export default []; ), ); fs.writeFileSync(rootConfigPath, originalRootConfig, 'utf8'); - // maxRetries: on Windows the language server / VS Code watcher can - // still hold handles inside the directory, making an immediate - // recursive delete fail with EPERM. + // Wait for the restored root config to take effect BEFORE deleting + // the nested directory: on Windows the server still holds handles + // inside it (the broken config's evaluator) until the refresh + // lands, making an immediate recursive delete fail with EPERM. + // The generous maxRetries covers handles released shortly after. + await rootRestored; fs.rmSync(nestedDir, { recursive: true, force: true, - maxRetries: 10, - retryDelay: 100, + maxRetries: 50, + retryDelay: 200, }); assert.ok( !fs.existsSync(nestedDir), 'Broken nested-config fixtures must be deleted during cleanup', ); - await rootRestored; }, 'Broken nested-config resource cleanup', ); @@ -510,11 +512,27 @@ export default [{ files: ['**/*.ts'], rules: { 'no-console': 'error' } }]; const nestedDoc = await vscode.workspace.openTextDocument(nestedFilePath); await vscode.window.showTextDocument(nestedDoc); - await waitForDiagnostics(nestedDoc, (diagnostics) => + // VS Code's file watcher can miss events for files created inside a + // directory that was itself just created (flaky on slow CI runners), + // leaving the new nested config undiscovered forever. If the first + // lint result does not arrive promptly, touch the config to re-fire + // the config watcher and retry. + const nestedLinted = (diagnostics: readonly vscode.Diagnostic[]) => diagnostics.some((diagnostic) => diagnostic.message.includes('Unexpected console statement'), - ), - ); + ); + for (let attempt = 0; ; attempt++) { + try { + await waitForDiagnostics(nestedDoc, nestedLinted, 10_000); + break; + } catch (error) { + if (attempt >= 5) throw error; + fs.appendFileSync(nestedConfigPath, '\n'); + } + } + // Every server-side evaluation of the nested config appends to the + // marker; the retry nudges above may legitimately cause more than one. + const evaluationsBeforeIgnore = fs.readFileSync(loadMarkerPath, 'utf8'); await vscode.window.showTextDocument(rootDoc); const parentApplied = waitForDiagnostics(rootDoc, (diagnostics) => @@ -535,7 +553,7 @@ export default [{ files: ['**/*.ts'], rules: { 'no-console': 'error' } }]; assert.strictEqual( fs.readFileSync(loadMarkerPath, 'utf8'), - 'x', + evaluationsBeforeIgnore, 'The ignored nested candidate must not be evaluated again', ); assert.ok( @@ -558,14 +576,14 @@ export default [{ files: ['**/*.ts'], rules: { 'no-console': 'error' } }]; diagnostic.message.includes('no-explicit-any'), ), ); + fs.writeFileSync(rootConfigPath, originalRootConfig, 'utf8'); + await restored; fs.rmSync(nestedDir, { recursive: true, force: true, - maxRetries: 10, - retryDelay: 100, + maxRetries: 50, + retryDelay: 200, }); - fs.writeFileSync(rootConfigPath, originalRootConfig, 'utf8'); - await restored; }, 'Parent-ignore catalog test', ); @@ -630,8 +648,8 @@ export default [{ files: ['**/*.ts'], rules: { 'no-console': 'error' } }]; fs.rmSync(probe, { recursive: true, force: true, - maxRetries: 10, - retryDelay: 100, + maxRetries: 50, + retryDelay: 200, }); } if (!gitDirectoryExisted) { From 2994ef4748873bf3b1feec59fdb2c08486c45ffb Mon Sep 17 00:00:00 2001 From: fi3ework Date: Tue, 4 Aug 2026 22:07:52 +0800 Subject: [PATCH 06/15] fix: address review findings in the port-owned adaptations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fixes to code this port owns (upstream-inherited findings are left for upstream): - migration: rstack.rslint.enable is window-scoped in the manifest, so map the legacy rslint.enable with targetScope 'window' β€” folder-level legacy values are now skipped as not-folder-scoped instead of being migrated into a layer where they have no effect. - rstack bridge: a native rstest.config.* now suppresses a bridged rstack.config.* project only at its own directory, per the documented 'native wins at the same root' contract β€” a native config in a sibling monorepo package no longer disables every bridge in the folder. - status: crash / version-mismatch reports are latched so a detection refresh cannot paint 'running' over a live failure; the latch clears when a worker actually spawns or the version check passes again. - rstack bridge: watch rstack.config.* content changes and rebuild the bridged project, so define.test() edits reach the Test Explorer without a window reload (path-only detection signatures skip these). --- packages/vscode/src/migration.test.ts | 19 +++-- packages/vscode/src/migration.ts | 5 +- packages/vscode/src/stacks/test/bridge.ts | 1 + packages/vscode/src/stacks/test/master.ts | 6 ++ packages/vscode/src/stacks/test/project.ts | 96 ++++++++++++++-------- packages/vscode/src/stacks/test/status.ts | 45 +++++++++- 6 files changed, 129 insertions(+), 43 deletions(-) diff --git a/packages/vscode/src/migration.test.ts b/packages/vscode/src/migration.test.ts index c31a74c..3fb9d91 100644 --- a/packages/vscode/src/migration.test.ts +++ b/packages/vscode/src/migration.test.ts @@ -83,11 +83,12 @@ describe('LEGACY_MAPPINGS', () => { ); }); - it('marks the window-scoped Rstest settings as such', () => { + it('marks the window-scoped settings as such', () => { const windowScoped = LEGACY_MAPPINGS.filter( (mapping) => mapping.targetScope === 'window', ).map((mapping) => mapping.from); expect(windowScoped).toEqual([ + 'rslint.enable', 'rstest.configFileGlobPattern', 'rstest.testCaseCollectMethod', 'rstest.applyDiagnostic', @@ -207,7 +208,7 @@ describe('planMigration β€” rslint.binPath', () => { describe('planMigration β€” layers', () => { it('groups writes per layer and orders them user, workspace, folder', () => { const plan = planMigration([ - folderReading('file:///w/app', 'app', 'rslint.enable', false), + folderReading('file:///w/app', 'app', 'rslint.customBinPath', '/x/bin'), reading({ scopeId: 'workspace', layer: 'workspace', @@ -226,16 +227,16 @@ describe('planMigration β€” layers', () => { it('keeps same-named folders of a multi-root workspace apart', () => { const plan = planMigration([ - folderReading('file:///a/app', 'app', 'rslint.enable', true), - folderReading('file:///b/app', 'app', 'rslint.enable', false), + folderReading('file:///a/app', 'app', 'rslint.customBinPath', '/a/bin'), + folderReading('file:///b/app', 'app', 'rslint.customBinPath', '/b/bin'), ]); expect(plan.scopes.map((scope) => scope.scopeId)).toEqual([ 'file:///a/app', 'file:///b/app', ]); expect(plan.scopes.map((scope) => scope.writes[0]?.value)).toEqual([ - true, - false, + '/a/bin', + '/b/bin', ]); }); @@ -345,14 +346,16 @@ describe('formatPreview', () => { const preview = formatPreview( planMigration([ reading({ key: 'rslint.binPath', value: 'built-in' }), - folderReading('file:///w/app', 'app', 'rslint.enable', false), + folderReading('file:///w/app', 'app', 'rslint.customBinPath', '/x'), ]), ); expect(preview).toContain('User Settings'); expect(preview).toContain('rslint.binPath -> rstack.rslint.binPath'); expect(preview).toContain('"built-in" -> "local"'); expect(preview).toContain('Folder Settings β€” app'); - expect(preview).toContain('rslint.enable -> rstack.rslint.enable'); + expect(preview).toContain( + 'rslint.customBinPath -> rstack.rslint.customBinPath', + ); }); it('lists what was left untouched and why', () => { diff --git a/packages/vscode/src/migration.ts b/packages/vscode/src/migration.ts index a84e7ac..b89aad8 100644 --- a/packages/vscode/src/migration.ts +++ b/packages/vscode/src/migration.ts @@ -119,8 +119,11 @@ const RSTEST_KEYS: readonly (readonly [string, 'resource' | 'window'])[] = [ export const LEGACY_MAPPINGS: readonly LegacyMapping[] = [ { from: 'rslint.enable', + // The manifest declares `rstack.rslint.enable` as window-scoped (the + // shell reads it without a resource URI), so a folder-level legacy value + // cannot be preserved and must be skipped as `not-folder-scoped`. to: 'rstack.rslint.enable', - targetScope: 'resource', + targetScope: 'window', }, { from: 'rslint.binPath', diff --git a/packages/vscode/src/stacks/test/bridge.ts b/packages/vscode/src/stacks/test/bridge.ts index 1b13261..eb5d12e 100644 --- a/packages/vscode/src/stacks/test/bridge.ts +++ b/packages/vscode/src/stacks/test/bridge.ts @@ -126,5 +126,6 @@ export function resolveRstackShim( configFilePath, version, }); + status.versionOk(); return { configFilePath, version }; } diff --git a/packages/vscode/src/stacks/test/master.ts b/packages/vscode/src/stacks/test/master.ts index 71e9ef5..4e3d74d 100644 --- a/packages/vscode/src/stacks/test/master.ts +++ b/packages/vscode/src/stacks/test/master.ts @@ -195,6 +195,8 @@ export class RstestApi { logger.error( `Unsupported @rstest/core version ${coreVersion ?? 'unknown'} resolved from ${this.cwd}`, ); + } else { + status.versionOk(); } return nodeExport; @@ -499,6 +501,10 @@ export class RstestApi { configFilePath: this.configFilePath, }); + rstestProcess.on('spawn', () => { + status.workerSpawned(); + }); + rstestProcess.on('error', (error) => { logger.error('Worker process error', error); // The status-aggregation adaptation: a worker that never came up is the diff --git a/packages/vscode/src/stacks/test/project.ts b/packages/vscode/src/stacks/test/project.ts index 1e3ba72..24af05e 100644 --- a/packages/vscode/src/stacks/test/project.ts +++ b/packages/vscode/src/stacks/test/project.ts @@ -3,6 +3,7 @@ import type { TestInfo } from '@rstest/core'; import picomatch from 'picomatch'; import { glob } from 'tinyglobby'; import vscode from 'vscode'; +import { RSTACK_CONFIG_NAMES } from '../../detection'; import { resolveRstackShim } from './bridge'; import { watchConfigValue } from './config'; import { logger } from './logger'; @@ -186,6 +187,31 @@ export class WorkspaceManager implements vscode.Disposable { this.refreshAllProject(); }); } + + // Content edits to a `rstack.config.*` change neither the detection + // snapshot (it records paths only) nor the shim file Rstest actually + // loads, so without this watcher nothing would re-evaluate + // `define.test()` for a bridged project until a reload. Rebuild the + // project on change; create/delete arrive through the shell's + // detection events (`setRstackConfigFiles`) instead. + const rstackWatcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern( + this.workspaceFolder, + `**/{${RSTACK_CONFIG_NAMES.join(',')}}`, + ), + true, + false, + true, + ); + token.onCancellationRequested(() => rstackWatcher.dispose()); + rstackWatcher.onDidChange((file) => { + const key = file.toString(); + if (!this.projects.get(key)?.isBridge) return; + this.handleRemoveConfigFile(file); + // `refreshAllProject` β†’ `syncBridgeProjects` re-resolves the shim + // and recreates the project with a fresh config evaluation. + this.refreshAllProject(); + }); }, ); } @@ -223,47 +249,51 @@ export class WorkspaceManager implements vscode.Disposable { * per `rstack.config.*` whose config file is rstack's shipped Rstest shim and * whose cwd is the config's own directory. * - * A native `rstest.config.*` always wins: rstack's own `rs test` would load - * the shim, which reads `define.test()`, but a project that ships both has - * deliberately opted into the tool-native config, and running the same tests - * through two projects would duplicate the whole tree. + * A native `rstest.config.*` wins **at its own root**: rstack's own + * `rs test` would load the shim, which reads `define.test()`, but a + * directory that ships both configs has deliberately opted into the + * tool-native one, and running the same tests through two projects would + * duplicate the tree. A native config elsewhere in the workspace folder + * (e.g. a sibling monorepo package) says nothing about this root and must + * not suppress its bridge. */ private syncBridgeProjects() { if (!this.didScanConfigFiles) return; - const hasNativeProject = [...this.projects.values()].some( - (project) => !project.isBridge, + const nativeProjectDirs = new Set( + [...this.projects.values()] + .filter((project) => !project.isBridge) + .map((project) => path.dirname(project.sourceUri.fsPath)), ); const wanted = new Map(); - if (!hasNativeProject) { - for (const rstackConfig of this.rstackConfigFiles) { - const key = rstackConfig.toString(); - // Keep an existing bridged project as-is; re-resolving the shim on - // every tree refresh would churn its worker for nothing. - if (this.projects.get(key)?.isBridge) { - wanted.set(key, { sourceUri: rstackConfig }); - continue; - } - const cwd = path.dirname(rstackConfig.fsPath); - const shim = resolveRstackShim(cwd, { - silent: this.reportedShimFailures.has(cwd), - }); - if (!shim) { - this.reportedShimFailures.add(cwd); - continue; - } - this.reportedShimFailures.delete(cwd); - wanted.set(key, { - sourceUri: rstackConfig, - configFileUri: vscode.Uri.file(shim.configFilePath), - cwd, - isBridge: true, - }); - logger.info( - `Driving Rstest from ${rstackConfig.fsPath} through the rstack config shim`, - ); + for (const rstackConfig of this.rstackConfigFiles) { + const key = rstackConfig.toString(); + const cwd = path.dirname(rstackConfig.fsPath); + if (nativeProjectDirs.has(cwd)) continue; + // Keep an existing bridged project as-is; re-resolving the shim on + // every tree refresh would churn its worker for nothing. + if (this.projects.get(key)?.isBridge) { + wanted.set(key, { sourceUri: rstackConfig }); + continue; + } + const shim = resolveRstackShim(cwd, { + silent: this.reportedShimFailures.has(cwd), + }); + if (!shim) { + this.reportedShimFailures.add(cwd); + continue; } + this.reportedShimFailures.delete(cwd); + wanted.set(key, { + sourceUri: rstackConfig, + configFileUri: vscode.Uri.file(shim.configFilePath), + cwd, + isBridge: true, + }); + logger.info( + `Driving Rstest from ${rstackConfig.fsPath} through the rstack config shim`, + ); } for (const [key, project] of this.projects) { diff --git a/packages/vscode/src/stacks/test/status.ts b/packages/vscode/src/stacks/test/status.ts index 53e5ded..886e032 100644 --- a/packages/vscode/src/stacks/test/status.ts +++ b/packages/vscode/src/stacks/test/status.ts @@ -13,6 +13,14 @@ import type { StackState, StatusReporter } from '../../types'; */ class StatusHolder implements StatusReporter { #reporter: StatusReporter | undefined; + // Failure latches. Detection refreshes re-derive `running` from the folder + // count alone (`reportStatus`), which must not paint over a live failure + // that was neither recovered nor retried. Each latch is cleared by the + // code path that observes the corresponding recovery: a worker process + // that actually spawned, or a version check that passed. + #crash: string | undefined; + #mismatch: string | undefined; + #lastRunningDetail: string | undefined; get stack() { return this.#reporter?.stack ?? ('rstest' as const); @@ -20,30 +28,65 @@ class StatusHolder implements StatusReporter { public bind(reporter: StatusReporter) { this.#reporter = reporter; + this.#crash = undefined; + this.#mismatch = undefined; + this.#lastRunningDetail = undefined; } public unbind() { this.#reporter = undefined; } + /** Worst live state first: a crash outranks a version mismatch. */ + #paintOrRun(): void { + if (this.#crash !== undefined) { + this.#reporter?.crashed(this.#crash); + } else if (this.#mismatch !== undefined) { + this.#reporter?.versionMismatch(this.#mismatch); + } else { + this.#reporter?.running(this.#lastRunningDetail); + } + } + report(state: StackState): void { this.#reporter?.report(state); } starting(detail?: string): void { + if (this.#crash !== undefined || this.#mismatch !== undefined) return; this.#reporter?.starting(detail); } running(detail?: string): void { + this.#lastRunningDetail = detail; + if (this.#crash !== undefined || this.#mismatch !== undefined) return; this.#reporter?.running(detail); } crashed(detail: string): void { + this.#crash = detail; this.#reporter?.crashed(detail); } versionMismatch(detail: string): void { - this.#reporter?.versionMismatch(detail); + this.#mismatch = detail; + if (this.#crash === undefined) { + this.#reporter?.versionMismatch(detail); + } + } + + /** A worker process came up: the previous spawn failure is over. */ + workerSpawned(): void { + if (this.#crash === undefined) return; + this.#crash = undefined; + this.#paintOrRun(); + } + + /** A package version check passed: the previous mismatch is resolved. */ + versionOk(): void { + if (this.#mismatch === undefined) return; + this.#mismatch = undefined; + this.#paintOrRun(); } } From 738892429bf2c64232ed5b8662b914d419e39123 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Tue, 4 Aug 2026 23:01:52 +0800 Subject: [PATCH 07/15] fix(vscode): lockfile-driven retries and per-root status latches Two fixes from the second Codex review round: - A lockfile-only change (e.g. pnpm install) used to produce a detection pass whose signature was unchanged, so no event fired and stacks never retried failed resolutions until a window reload. Lockfile-triggered passes now always notify, and the lint stack explicitly re-attempts roots whose last start failed (a failed slot is generation-pinned and reconcile alone never retries it). - The rstest status failure latches were single-slot: in a multi-root workspace one root's successful version check or worker spawn cleared another root's live mismatch/crash. Latches are now keyed by the resolution root that reported them and only that root's recovery clears its entry. --- packages/vscode/src/detection.ts | 23 +++++- packages/vscode/src/shared/versionCheck.ts | 9 ++- .../stacks/lint/WorkspaceRslintCoordinator.ts | 25 ++++++ packages/vscode/src/stacks/lint/index.ts | 4 + packages/vscode/src/stacks/test/bridge.ts | 4 +- packages/vscode/src/stacks/test/master.ts | 8 +- .../vscode/src/stacks/test/status.test.ts | 79 +++++++++++++++++++ packages/vscode/src/stacks/test/status.ts | 75 ++++++++++-------- 8 files changed, 184 insertions(+), 43 deletions(-) create mode 100644 packages/vscode/src/stacks/test/status.test.ts diff --git a/packages/vscode/src/detection.ts b/packages/vscode/src/detection.ts index eabe0e7..44b0253 100644 --- a/packages/vscode/src/detection.ts +++ b/packages/vscode/src/detection.ts @@ -49,6 +49,11 @@ export const LOCKFILE_NAMES = [ 'yarn.lock', ] as const; +const LOCKFILE_NAME_SET: ReadonlySet = new Set(LOCKFILE_NAMES); + +const isLockfile = (uri: vscode.Uri): boolean => + LOCKFILE_NAME_SET.has(uri.path.slice(uri.path.lastIndexOf('/') + 1)); + /** Both bins of the `rstack` package point at the same launcher. */ export const FMT_BIN_NAMES = ['rs', 'rstack'] as const; @@ -224,6 +229,13 @@ const signatureOf = (snapshot: DetectionSnapshot): string => export class DetectionService implements vscode.Disposable { #snapshot: DetectionSnapshot = emptySnapshot(); #signature = ''; + // A lockfile change means dependencies changed without necessarily moving + // any config file or the fmt bin probe, so the discovery signature can come + // out identical while every project-resolved package (Rslint binary, Rstest + // core, the rstack shim) may now resolve differently. Such a pass must + // notify subscribers even when the signature is unchanged, or failed + // resolutions are never retried until a window reload. + #notifyUnchanged = false; #watchers: vscode.Disposable[] = []; #debounce: ReturnType | undefined; #running: Promise | undefined; @@ -287,7 +299,9 @@ export class DetectionService implements vscode.Disposable { const snapshot = new Snapshot(detections); const signature = signatureOf(snapshot); this.#snapshot = snapshot; - if (signature !== this.#signature) { + const notifyUnchanged = this.#notifyUnchanged; + this.#notifyUnchanged = false; + if (signature !== this.#signature || notifyUnchanged) { this.#signature = signature; this.log(snapshot); if (!this.#disposed) { @@ -333,7 +347,12 @@ export class DetectionService implements vscode.Disposable { if (folder.uri.scheme !== 'file') { continue; } - const onEvent = () => this.schedule(); + const onEvent = (uri: vscode.Uri) => { + if (isLockfile(uri)) { + this.#notifyUnchanged = true; + } + this.schedule(); + }; for (const pattern of detectionWatchPatterns(readRstestGlobs(folder))) { const watcher = vscode.workspace.createFileSystemWatcher( new vscode.RelativePattern(folder, pattern), diff --git a/packages/vscode/src/shared/versionCheck.ts b/packages/vscode/src/shared/versionCheck.ts index a1ad97d..db6e985 100644 --- a/packages/vscode/src/shared/versionCheck.ts +++ b/packages/vscode/src/shared/versionCheck.ts @@ -1,5 +1,4 @@ import semver from 'semver'; -import type { StatusReporter } from '../types'; /** * The version-compatibility contract. A VSIX has no npm install step, @@ -56,15 +55,19 @@ export const formatVersionMismatch = ( /** * Checks one project-resolved package and reports a mismatch through the * shared status reporter. Returns `true` when the stack may keep going. + * + * `source` identifies the resolution root on reporters that latch mismatches + * per root (`stacks/test/status.ts`); plain `StatusReporter`s ignore it. */ export const reportVersionCheck = ( - status: StatusReporter, + status: { versionMismatch(detail: string, source?: string): void }, packageName: SupportedPackage, version: string | undefined, + source?: string, ): boolean => { const result = checkPackageVersion(packageName, version); if (result.kind === 'mismatch') { - status.versionMismatch(formatVersionMismatch(packageName, result)); + status.versionMismatch(formatVersionMismatch(packageName, result), source); return false; } return true; diff --git a/packages/vscode/src/stacks/lint/WorkspaceRslintCoordinator.ts b/packages/vscode/src/stacks/lint/WorkspaceRslintCoordinator.ts index ce51996..9db870b 100644 --- a/packages/vscode/src/stacks/lint/WorkspaceRslintCoordinator.ts +++ b/packages/vscode/src/stacks/lint/WorkspaceRslintCoordinator.ts @@ -167,6 +167,31 @@ export class WorkspaceRslintCoordinator { this.reconcile(folders, forceReplace); } + /** + * Re-attempts every root whose last start failed. A failed slot is pinned to + * its `failedGeneration` so `reconcile` never spins on it, and folder + * identity β€” the only thing `reconcile` keys on β€” does not change when the + * failure cause goes away (e.g. `pnpm install` materializes the Rslint + * binary, observed as a lockfile-driven detection pass). The retry therefore + * has to bump the generation explicitly. `failedGeneration` matching the + * desired generation is the precise "currently failed" predicate β€” a live or + * in-flight runtime always carries a newer generation β€” so healthy roots are + * left alone. + */ + public retryFailedRoots(): void { + if (this.closing) return; + const changedKeys = new Set(); + for (const [key, desired] of this.desiredRoots) { + if (this.slots.get(key)?.failedGeneration !== desired.generation) { + continue; + } + this.desiredRoots.set(key, this.createDesiredRoot(desired.folder)); + changedKeys.add(key); + } + for (const key of changedKeys) this.kick(key); + if (changedKeys.size > 0) this.signalTopologyChanged(); + } + public async close(): Promise { await (this.closePromise ??= this.closeImpl()); } diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index eeecfba..235f028 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -131,6 +131,10 @@ class RslintController implements StackController { context.onDidChangeDetection((snapshot) => { this.#snapshot = snapshot; this.reconcileFolders({ added: [], removed: [] }); + // A detection pass fires on config topology and lockfile changes β€” + // exactly the moments a previously failed root (missing binary, + // uninstalled dependencies) may have become startable. + this.#coordinator?.retryFailedRoots(); }), vscode.workspace.onDidChangeWorkspaceFolders((event) => { this.reconcileFolders(event); diff --git a/packages/vscode/src/stacks/test/bridge.ts b/packages/vscode/src/stacks/test/bridge.ts index eb5d12e..16da1ba 100644 --- a/packages/vscode/src/stacks/test/bridge.ts +++ b/packages/vscode/src/stacks/test/bridge.ts @@ -118,7 +118,7 @@ export function resolveRstackShim( if (!silent) { logger.error(message); } - status.versionMismatch(message); + status.versionMismatch(message, configDir); return undefined; } @@ -126,6 +126,6 @@ export function resolveRstackShim( configFilePath, version, }); - status.versionOk(); + status.versionOk(configDir); return { configFilePath, version }; } diff --git a/packages/vscode/src/stacks/test/master.ts b/packages/vscode/src/stacks/test/master.ts index 4e3d74d..b83d72d 100644 --- a/packages/vscode/src/stacks/test/master.ts +++ b/packages/vscode/src/stacks/test/master.ts @@ -191,12 +191,12 @@ export class RstestApi { // The status-aggregation adaptation: the one-shot `showWarningMessage` // becomes the shared `version mismatch` status bar state with actual vs // required versions. The floor is the same `>= 0.6.0`. - if (!reportVersionCheck(status, '@rstest/core', coreVersion)) { + if (!reportVersionCheck(status, '@rstest/core', coreVersion, this.cwd)) { logger.error( `Unsupported @rstest/core version ${coreVersion ?? 'unknown'} resolved from ${this.cwd}`, ); } else { - status.versionOk(); + status.versionOk(this.cwd); } return nodeExport; @@ -502,7 +502,7 @@ export class RstestApi { }); rstestProcess.on('spawn', () => { - status.workerSpawned(); + status.workerSpawned(this.cwd); }); rstestProcess.on('error', (error) => { @@ -511,7 +511,7 @@ export class RstestApi { // `crashed` state of the shared status bar. The notification is kept because // a failed spawn is almost always a wrong `nodeExecutable` the user has // to fix, and the status bar alone is easy to miss mid-run. - status.crashed(`worker process failed: ${error.message}`); + status.crashed(`worker process failed: ${error.message}`, this.cwd); vscode.window.showErrorMessage( `Rstest worker process failed: ${error.message}`, ); diff --git a/packages/vscode/src/stacks/test/status.test.ts b/packages/vscode/src/stacks/test/status.test.ts new file mode 100644 index 0000000..6cb0715 --- /dev/null +++ b/packages/vscode/src/stacks/test/status.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from '@rstest/core'; +import type { StatusReporter } from '../../types'; +import { status } from './status'; + +// `status` is a module singleton; `bind()` resets the latches, so each test +// binds its own recorder and starts from a clean slate. +const bindRecorder = () => { + const calls: string[] = []; + const reporter: StatusReporter = { + stack: 'rstest', + report: (state) => calls.push(`report:${state.kind}`), + starting: (detail) => calls.push(`starting:${detail ?? ''}`), + running: (detail) => calls.push(`running:${detail ?? ''}`), + crashed: (detail) => calls.push(`crashed:${detail}`), + versionMismatch: (detail) => calls.push(`mismatch:${detail}`), + }; + status.bind(reporter); + return calls; +}; + +describe('StatusHolder failure latches', () => { + it('keeps a mismatch latched across detection-driven running repaints', () => { + const calls = bindRecorder(); + status.versionMismatch('core too old', '/a'); + status.running('2 folders'); + status.starting(); + expect(calls).toEqual(['mismatch:core too old']); + }); + + it('does not clear one root’s mismatch when another root passes its check', () => { + const calls = bindRecorder(); + status.versionMismatch('core too old', '/a'); + status.versionOk('/b'); + status.running('2 folders'); + expect(calls).toEqual(['mismatch:core too old']); + + status.versionOk('/a'); + expect(calls).toEqual(['mismatch:core too old', 'running:2 folders']); + }); + + it('does not clear one root’s crash when another root’s worker spawns', () => { + const calls = bindRecorder(); + status.crashed('spawn ENOENT', '/a'); + status.workerSpawned('/b'); + status.running(); + expect(calls).toEqual(['crashed:spawn ENOENT']); + + status.workerSpawned('/a'); + expect(calls).toEqual(['crashed:spawn ENOENT', 'running:']); + }); + + it('outranks a mismatch with a crash and falls back on recovery', () => { + const calls = bindRecorder(); + status.versionMismatch('core too old', '/a'); + status.crashed('spawn ENOENT', '/b'); + status.workerSpawned('/b'); + expect(calls).toEqual([ + 'mismatch:core too old', + 'crashed:spawn ENOENT', + 'mismatch:core too old', + ]); + }); + + it('replays the latest running detail once every latch clears', () => { + const calls = bindRecorder(); + status.crashed('spawn ENOENT', '/a'); + status.running('3 folders'); + status.workerSpawned('/a'); + expect(calls).toEqual(['crashed:spawn ENOENT', 'running:3 folders']); + }); + + it('drops stale latches on bind', () => { + bindRecorder(); + status.versionMismatch('core too old', '/a'); + const calls = bindRecorder(); + status.running(); + expect(calls).toEqual(['running:']); + }); +}); diff --git a/packages/vscode/src/stacks/test/status.ts b/packages/vscode/src/stacks/test/status.ts index 886e032..d315e3e 100644 --- a/packages/vscode/src/stacks/test/status.ts +++ b/packages/vscode/src/stacks/test/status.ts @@ -15,11 +15,14 @@ class StatusHolder implements StatusReporter { #reporter: StatusReporter | undefined; // Failure latches. Detection refreshes re-derive `running` from the folder // count alone (`reportStatus`), which must not paint over a live failure - // that was neither recovered nor retried. Each latch is cleared by the - // code path that observes the corresponding recovery: a worker process - // that actually spawned, or a version check that passed. - #crash: string | undefined; - #mismatch: string | undefined; + // that was neither recovered nor retried. Each latch is keyed by the + // resolution root that reported it β€” a multi-project workspace runs one + // master (and one bridge resolution) per root, and a recovery observed by + // one root must not clear another root's live failure. A latch entry is + // cleared by the code path that observes the corresponding recovery: a + // worker process that actually spawned, or a version check that passed. + #crashes = new Map(); + #mismatches = new Map(); #lastRunningDetail: string | undefined; get stack() { @@ -28,8 +31,8 @@ class StatusHolder implements StatusReporter { public bind(reporter: StatusReporter) { this.#reporter = reporter; - this.#crash = undefined; - this.#mismatch = undefined; + this.#crashes.clear(); + this.#mismatches.clear(); this.#lastRunningDetail = undefined; } @@ -37,15 +40,27 @@ class StatusHolder implements StatusReporter { this.#reporter = undefined; } - /** Worst live state first: a crash outranks a version mismatch. */ + get #latched(): boolean { + return this.#crashes.size > 0 || this.#mismatches.size > 0; + } + + /** + * Worst live state first: a crash outranks a version mismatch. Within one + * severity the oldest unrecovered entry wins, keeping the display stable + * while other roots come and go. + */ #paintOrRun(): void { - if (this.#crash !== undefined) { - this.#reporter?.crashed(this.#crash); - } else if (this.#mismatch !== undefined) { - this.#reporter?.versionMismatch(this.#mismatch); - } else { - this.#reporter?.running(this.#lastRunningDetail); + const [crash] = this.#crashes.values(); + if (crash !== undefined) { + this.#reporter?.crashed(crash); + return; + } + const [mismatch] = this.#mismatches.values(); + if (mismatch !== undefined) { + this.#reporter?.versionMismatch(mismatch); + return; } + this.#reporter?.running(this.#lastRunningDetail); } report(state: StackState): void { @@ -53,39 +68,35 @@ class StatusHolder implements StatusReporter { } starting(detail?: string): void { - if (this.#crash !== undefined || this.#mismatch !== undefined) return; + if (this.#latched) return; this.#reporter?.starting(detail); } running(detail?: string): void { this.#lastRunningDetail = detail; - if (this.#crash !== undefined || this.#mismatch !== undefined) return; + if (this.#latched) return; this.#reporter?.running(detail); } - crashed(detail: string): void { - this.#crash = detail; - this.#reporter?.crashed(detail); + crashed(detail: string, source = ''): void { + this.#crashes.set(source, detail); + this.#paintOrRun(); } - versionMismatch(detail: string): void { - this.#mismatch = detail; - if (this.#crash === undefined) { - this.#reporter?.versionMismatch(detail); - } + versionMismatch(detail: string, source = ''): void { + this.#mismatches.set(source, detail); + this.#paintOrRun(); } - /** A worker process came up: the previous spawn failure is over. */ - workerSpawned(): void { - if (this.#crash === undefined) return; - this.#crash = undefined; + /** A worker process came up: that root's previous spawn failure is over. */ + workerSpawned(source = ''): void { + if (!this.#crashes.delete(source)) return; this.#paintOrRun(); } - /** A package version check passed: the previous mismatch is resolved. */ - versionOk(): void { - if (this.#mismatch === undefined) return; - this.#mismatch = undefined; + /** A package version check passed: that root's previous mismatch is resolved. */ + versionOk(source = ''): void { + if (!this.#mismatches.delete(source)) return; this.#paintOrRun(); } } From 1965f8e5e117d4b3da050eb80e2d04f23f15e327 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Tue, 4 Aug 2026 23:13:05 +0800 Subject: [PATCH 08/15] fix(vscode): re-resolve live bridges and drop latches for removed roots Third Codex review round, both on the previous round's fixes: - syncBridgeProjects kept a live bridge without re-resolving the shim, so a dependency upgrade could leave the project pinned to a pruned pnpm store path. The shim is now re-resolved on every sync; a bridge is kept (worker stays warm) only while the resolution lands on the same file, and is rebuilt when it moved or stopped resolving. - A latched crash/mismatch now dies with its root: Project.dispose forgets its cwd's entries, and dangling mismatches from failed shim resolutions (which never create a project) are cleared when the directory stops being a bridge candidate or the folder is disposed. --- packages/vscode/src/stacks/test/project.ts | 51 ++++++++++++++++--- .../vscode/src/stacks/test/status.test.ts | 20 ++++++++ packages/vscode/src/stacks/test/status.ts | 11 ++++ 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/packages/vscode/src/stacks/test/project.ts b/packages/vscode/src/stacks/test/project.ts index 24af05e..5615b39 100644 --- a/packages/vscode/src/stacks/test/project.ts +++ b/packages/vscode/src/stacks/test/project.ts @@ -9,6 +9,7 @@ import { watchConfigValue } from './config'; import { logger } from './logger'; import { RstestApi } from './master'; import { type ChildProjectRef, computeCoveredConfigs } from './projectCoverage'; +import { status } from './status'; import { ProjectFolder, TestFile, TestFolder, testData } from './testTree'; // The default config file name at the workspace root. A lone project using it @@ -123,6 +124,11 @@ export class WorkspaceManager implements vscode.Disposable { for (const project of this.projects.values()) { project.dispose(); } + // A failed shim resolution latches a mismatch without ever creating a + // project, so no `Project.dispose` will clear it when the folder goes. + for (const cwd of this.reportedShimFailures) { + status.forget(cwd); + } this.configValueWatcher.dispose(); } private startWatchingWorkspace() { @@ -256,6 +262,13 @@ export class WorkspaceManager implements vscode.Disposable { * duplicate the tree. A native config elsewhere in the workspace folder * (e.g. a sibling monorepo package) says nothing about this root and must * not suppress its bridge. + * + * The shim is re-resolved on **every** sync, including for live bridges: a + * dependency change (observed as a lockfile-driven detection pass) can move + * the version-pinned store path the project was created with, or swap the + * installed version entirely. A bridge whose shim still resolves to the + * same file is kept as-is β€” its warm worker is not churned β€” and rebuilt + * only when the resolution actually moved or stopped resolving. */ private syncBridgeProjects() { if (!this.didScanConfigFiles) return; @@ -266,17 +279,13 @@ export class WorkspaceManager implements vscode.Disposable { .map((project) => path.dirname(project.sourceUri.fsPath)), ); + const candidateDirs = new Set(); const wanted = new Map(); for (const rstackConfig of this.rstackConfigFiles) { const key = rstackConfig.toString(); const cwd = path.dirname(rstackConfig.fsPath); if (nativeProjectDirs.has(cwd)) continue; - // Keep an existing bridged project as-is; re-resolving the shim on - // every tree refresh would churn its worker for nothing. - if (this.projects.get(key)?.isBridge) { - wanted.set(key, { sourceUri: rstackConfig }); - continue; - } + candidateDirs.add(cwd); const shim = resolveRstackShim(cwd, { silent: this.reportedShimFailures.has(cwd), }); @@ -285,9 +294,24 @@ export class WorkspaceManager implements vscode.Disposable { continue; } this.reportedShimFailures.delete(cwd); + const configFileUri = vscode.Uri.file(shim.configFilePath); + const existing = this.projects.get(key); + if ( + existing?.isBridge && + existing.configFileUri.toString() === configFileUri.toString() + ) { + wanted.set(key, { sourceUri: rstackConfig }); + continue; + } + if (existing?.isBridge) { + // The shim moved: drop the stale project so the add loop below + // recreates it against the fresh resolution. + existing.dispose(); + this.projects.delete(key); + } wanted.set(key, { sourceUri: rstackConfig, - configFileUri: vscode.Uri.file(shim.configFilePath), + configFileUri, cwd, isBridge: true, }); @@ -296,6 +320,15 @@ export class WorkspaceManager implements vscode.Disposable { ); } + // A directory that stopped being a bridge candidate (its config was + // removed or a native config took over) can leave a latched version + // mismatch behind with no project whose disposal would clear it. + for (const cwd of [...this.reportedShimFailures]) { + if (candidateDirs.has(cwd)) continue; + this.reportedShimFailures.delete(cwd); + status.forget(cwd); + } + for (const [key, project] of this.projects) { if (project.isBridge && !wanted.has(key)) { project.dispose(); @@ -578,6 +611,10 @@ export class Project implements vscode.Disposable { this.#watch?.dispose(); this.api.dispose(); this.cancellationSource.cancel(); + // This root's failures must not outlive its project (config removed, + // folder closed, bridge rebuilt); the master and bridge report keyed by + // this same cwd. + status.forget(this.cwd); } get collection() { return this.testItem?.children || this.parentCollection; diff --git a/packages/vscode/src/stacks/test/status.test.ts b/packages/vscode/src/stacks/test/status.test.ts index 6cb0715..f2ccecf 100644 --- a/packages/vscode/src/stacks/test/status.test.ts +++ b/packages/vscode/src/stacks/test/status.test.ts @@ -69,6 +69,26 @@ describe('StatusHolder failure latches', () => { expect(calls).toEqual(['crashed:spawn ENOENT', 'running:3 folders']); }); + it('forgets a removed root’s failures and repaints', () => { + const calls = bindRecorder(); + status.versionMismatch('core too old', '/a'); + status.crashed('spawn ENOENT', '/a'); + status.running('1 folder'); + status.forget('/a'); + expect(calls).toEqual([ + 'mismatch:core too old', + 'crashed:spawn ENOENT', + 'running:1 folder', + ]); + }); + + it('forget of an unknown root does not repaint', () => { + const calls = bindRecorder(); + status.versionMismatch('core too old', '/a'); + status.forget('/b'); + expect(calls).toEqual(['mismatch:core too old']); + }); + it('drops stale latches on bind', () => { bindRecorder(); status.versionMismatch('core too old', '/a'); diff --git a/packages/vscode/src/stacks/test/status.ts b/packages/vscode/src/stacks/test/status.ts index d315e3e..70bed42 100644 --- a/packages/vscode/src/stacks/test/status.ts +++ b/packages/vscode/src/stacks/test/status.ts @@ -99,6 +99,17 @@ class StatusHolder implements StatusReporter { if (!this.#mismatches.delete(source)) return; this.#paintOrRun(); } + + /** + * A resolution root went away (its config file or workspace folder was + * removed) without recovering: its failures must not outlive it and keep + * suppressing the surviving roots' status. + */ + forget(source: string): void { + const hadCrash = this.#crashes.delete(source); + const hadMismatch = this.#mismatches.delete(source); + if (hadCrash || hadMismatch) this.#paintOrRun(); + } } export const status = new StatusHolder(); From 9cf8bb01785a90bf4680dbb971c3b3f99314deb3 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Tue, 4 Aug 2026 23:27:05 +0800 Subject: [PATCH 09/15] fix(vscode): retry failed native projects, key latches per project Fourth Codex review round: - A native project whose one-shot config evaluation rejected (deps not installed yet) stayed an empty tree forever. Projects now record the failure and are recreated on the next detection pass (lockfile-driven installs included). Only detection events trigger the retry, so a persistently broken config cannot loop. - Master status reports now latch under the project source URI instead of cwd: two configs in one directory no longer share a latch key, so disposing or recovering one cannot clear its sibling's live failure. This also unshadows bridge resolution latches (keyed by config dir) from bridge project disposal -- previously a sync that latched a fresh mismatch and then dropped the dead bridge wiped its own report. --- packages/vscode/src/stacks/test/index.ts | 3 ++ packages/vscode/src/stacks/test/master.ts | 28 +++++++++++++--- packages/vscode/src/stacks/test/project.ts | 38 +++++++++++++++++++--- packages/vscode/src/stacks/test/status.ts | 15 +++++---- 4 files changed, 70 insertions(+), 14 deletions(-) diff --git a/packages/vscode/src/stacks/test/index.ts b/packages/vscode/src/stacks/test/index.ts index 3b5fe3c..2692c66 100644 --- a/packages/vscode/src/stacks/test/index.ts +++ b/packages/vscode/src/stacks/test/index.ts @@ -322,6 +322,9 @@ class Rstest implements vscode.Disposable { const existing = this.workspaces.get(key); if (existing) { existing.setRstackConfigFiles(this.rstackConfigFilesOf(folder)); + // The detection pass may have been lockfile-driven: dependencies that + // were missing when a project's config first evaluated may exist now. + existing.retryFailedProjects(); } else { this.handleAddWorkspace(folder); } diff --git a/packages/vscode/src/stacks/test/master.ts b/packages/vscode/src/stacks/test/master.ts index b83d72d..10570c6 100644 --- a/packages/vscode/src/stacks/test/master.ts +++ b/packages/vscode/src/stacks/test/master.ts @@ -67,6 +67,16 @@ export class RstestApi { private project: Project, ) {} + /** + * The failure-latch key for this master's status reports. The project's + * source URI is unique (the projects map is keyed by it), unlike `cwd`, + * which two configs in one directory share β€” a shared key would let one + * project's recovery or disposal clear its sibling's live failure. + */ + private get statusSource(): string { + return this.project.sourceUri.toString(); + } + private expandWorkspaceFolder(value: string): string { return value.replaceAll('${workspaceFolder}', this.workspace.uri.fsPath); } @@ -191,12 +201,19 @@ export class RstestApi { // The status-aggregation adaptation: the one-shot `showWarningMessage` // becomes the shared `version mismatch` status bar state with actual vs // required versions. The floor is the same `>= 0.6.0`. - if (!reportVersionCheck(status, '@rstest/core', coreVersion, this.cwd)) { + if ( + !reportVersionCheck( + status, + '@rstest/core', + coreVersion, + this.statusSource, + ) + ) { logger.error( `Unsupported @rstest/core version ${coreVersion ?? 'unknown'} resolved from ${this.cwd}`, ); } else { - status.versionOk(this.cwd); + status.versionOk(this.statusSource); } return nodeExport; @@ -502,7 +519,7 @@ export class RstestApi { }); rstestProcess.on('spawn', () => { - status.workerSpawned(this.cwd); + status.workerSpawned(this.statusSource); }); rstestProcess.on('error', (error) => { @@ -511,7 +528,10 @@ export class RstestApi { // `crashed` state of the shared status bar. The notification is kept because // a failed spawn is almost always a wrong `nodeExecutable` the user has // to fix, and the status bar alone is easy to miss mid-run. - status.crashed(`worker process failed: ${error.message}`, this.cwd); + status.crashed( + `worker process failed: ${error.message}`, + this.statusSource, + ); vscode.window.showErrorMessage( `Rstest worker process failed: ${error.message}`, ); diff --git a/packages/vscode/src/stacks/test/project.ts b/packages/vscode/src/stacks/test/project.ts index 5615b39..72ee65f 100644 --- a/packages/vscode/src/stacks/test/project.ts +++ b/packages/vscode/src/stacks/test/project.ts @@ -221,6 +221,29 @@ export class WorkspaceManager implements vscode.Disposable { }, ); } + /** + * Recreates projects whose one-shot config evaluation failed β€” dependencies + * may have been installed since (observed as a lockfile-driven detection + * pass). Only ever called from a detection event, never from a project + * callback, so a persistently failing config cannot recreate itself in a + * loop; it is simply re-attempted once per detection pass. + */ + public retryFailedProjects() { + for (const [key, project] of [...this.projects]) { + if (!project.configLoadFailed) continue; + project.dispose(); + this.projects.set( + key, + this.createProject({ + sourceUri: project.sourceUri, + configFileUri: project.configFileUri, + cwd: project.cwd, + isBridge: project.isBridge, + }), + ); + } + } + private handleAddConfigFile(configFileUri: vscode.Uri) { const configFilePath = configFileUri.toString(); if (this.projects.has(configFilePath)) return; @@ -505,6 +528,10 @@ export class Project implements vscode.Disposable { // project is suppressed: it neither watches files nor renders test items, so // the same tests are not shown twice. suppressed = false; + // The one-shot config evaluation in the constructor rejected (typically: + // dependencies not installed yet). `retryFailedProjects` recreates such + // projects on the next detection pass. + configLoadFailed = false; // See `ProjectSource`. readonly sourceUri: vscode.Uri; readonly configFileUri: vscode.Uri; @@ -546,6 +573,7 @@ export class Project implements vscode.Disposable { }) .catch((error) => { if (this.cancellationSource.token.isCancellationRequested) return; + this.configLoadFailed = true; logger.error('Failed to initialize project config', error); // Let the manager settle its tree even when a config fails to load. this.onConfigResolved?.(); @@ -611,10 +639,12 @@ export class Project implements vscode.Disposable { this.#watch?.dispose(); this.api.dispose(); this.cancellationSource.cancel(); - // This root's failures must not outlive its project (config removed, - // folder closed, bridge rebuilt); the master and bridge report keyed by - // this same cwd. - status.forget(this.cwd); + // This project's failures must not outlive it (config removed, folder + // closed, bridge rebuilt). The master latches under the source URI β€” + // unique to this project, so a sibling config in the same directory keeps + // its own entries. Bridge *resolution* failures latch under the config + // directory instead and are reconciled by `syncBridgeProjects`, not here. + status.forget(this.sourceUri.toString()); } get collection() { return this.testItem?.children || this.parentCollection; diff --git a/packages/vscode/src/stacks/test/status.ts b/packages/vscode/src/stacks/test/status.ts index 70bed42..58c9fb2 100644 --- a/packages/vscode/src/stacks/test/status.ts +++ b/packages/vscode/src/stacks/test/status.ts @@ -15,12 +15,15 @@ class StatusHolder implements StatusReporter { #reporter: StatusReporter | undefined; // Failure latches. Detection refreshes re-derive `running` from the folder // count alone (`reportStatus`), which must not paint over a live failure - // that was neither recovered nor retried. Each latch is keyed by the - // resolution root that reported it β€” a multi-project workspace runs one - // master (and one bridge resolution) per root, and a recovery observed by - // one root must not clear another root's live failure. A latch entry is - // cleared by the code path that observes the corresponding recovery: a - // worker process that actually spawned, or a version check that passed. + // that was neither recovered nor retried. Each latch is keyed by its + // reporting site's identity β€” a master reports under its project's source + // URI (unique per project, so sibling configs in one directory stay + // independent), a bridge shim resolution under its config directory; the + // two namespaces (URI string vs filesystem path) never collide. A recovery + // observed under one key must not clear another key's live failure. An + // entry is cleared by the code path that observes the corresponding + // recovery (a worker that actually spawned, a version check that passed) or + // by `forget` when its reporter goes away. #crashes = new Map(); #mismatches = new Map(); #lastRunningDetail: string | undefined; From 15a751fcb80a2c4f1e9f685a193df10c864ec0e8 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Tue, 4 Aug 2026 23:36:19 +0800 Subject: [PATCH 10/15] fix(vscode): gate restart on a live controller, one bridge per directory Fifth Codex review round: - The status bar offered Restart for a detected-but-disabled stack (enable-setting off, Restricted Mode), whose controller never registered the command; selecting it failed with command-not-found. Restart now shows only for states a registered controller produces. - A directory shipping several rstack config names (e.g. .ts next to .js mid-migration) produced one bridge project per file, but the shim probes the default names in its cwd itself, so every sibling loaded the same winning config and duplicated its tests. One bridge per directory now, keyed to the file rstack's own probe order picks. --- packages/vscode/src/stacks/test/project.ts | 22 ++++++++++++++++++++-- packages/vscode/src/statusBar.ts | 13 +++++++++++-- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/vscode/src/stacks/test/project.ts b/packages/vscode/src/stacks/test/project.ts index 72ee65f..817735e 100644 --- a/packages/vscode/src/stacks/test/project.ts +++ b/packages/vscode/src/stacks/test/project.ts @@ -302,11 +302,29 @@ export class WorkspaceManager implements vscode.Disposable { .map((project) => path.dirname(project.sourceUri.fsPath)), ); + // One bridge per directory, not per file: the shim probes the default + // config names in its cwd itself and never receives the source path, so + // sibling names (e.g. `rstack.config.ts` next to `rstack.config.js` + // mid-migration) would all load the same winning config and duplicate its + // tests. Keep the file the shim will actually pick β€” RSTACK_CONFIG_NAMES + // mirrors rstack's own probe order. + const precedenceOf = (uri: vscode.Uri): number => + (RSTACK_CONFIG_NAMES as readonly string[]).indexOf( + path.basename(uri.fsPath), + ); + const winnerByDir = new Map(); + for (const rstackConfig of this.rstackConfigFiles) { + const dir = path.dirname(rstackConfig.fsPath); + const current = winnerByDir.get(dir); + if (!current || precedenceOf(rstackConfig) < precedenceOf(current)) { + winnerByDir.set(dir, rstackConfig); + } + } + const candidateDirs = new Set(); const wanted = new Map(); - for (const rstackConfig of this.rstackConfigFiles) { + for (const [cwd, rstackConfig] of winnerByDir) { const key = rstackConfig.toString(); - const cwd = path.dirname(rstackConfig.fsPath); if (nativeProjectDirs.has(cwd)) continue; candidateDirs.add(cwd); const shim = resolveRstackShim(cwd, { diff --git a/packages/vscode/src/statusBar.ts b/packages/vscode/src/statusBar.ts index 917692c..c680f36 100644 --- a/packages/vscode/src/statusBar.ts +++ b/packages/vscode/src/statusBar.ts @@ -27,6 +27,15 @@ const STATE_ICONS: Readonly> = { 'version-mismatch': '$(warning)', }; +/** + * The restart command handler only exists while the stack's controller is + * registered. A detected-but-disabled stack (enable-setting off, Restricted + * Mode) never registered it, so offering the action would die with a + * command-not-found error. + */ +const canRestart = (state: StackState): boolean => + state.kind !== 'not-detected' && state.kind !== 'disabled'; + const stateText = (state: StackState): string => { switch (state.kind) { case 'not-detected': @@ -101,7 +110,7 @@ export class StatusBar implements vscode.Disposable { command: OUTPUT_COMMANDS[stack], }); const restart = RESTART_COMMANDS[stack]; - if (restart && state.kind !== 'not-detected') { + if (restart && canRestart(state)) { items.push({ label: `$(refresh) Restart ${STACK_LABELS[stack]}`, command: restart, @@ -172,7 +181,7 @@ export class StatusBar implements vscode.Disposable { const state = this.stateOf(stack); const links = [`[Output](command:${OUTPUT_COMMANDS[stack]})`]; const restart = RESTART_COMMANDS[stack]; - if (restart && state.kind !== 'not-detected') { + if (restart && canRestart(state)) { links.push(`[Restart](command:${restart})`); } tooltip.appendMarkdown( From d9da99b21f65824a694c1e0e19832b24cda4f66f Mon Sep 17 00:00:00 2001 From: fi3ework Date: Tue, 4 Aug 2026 23:53:01 +0800 Subject: [PATCH 11/15] fix(vscode): uncached version reads, restart tracks controller liveness Sixth Codex review round: - Version checks read package.json through nodeRequire, whose module cache pinned the first-seen version for the extension host lifetime; an in-place upgrade (npm/yarn reuse the path) kept the old verdict. readPackageVersion() now does a plain filesystem read; used by both the @rstest/core check (master) and the rstack shim check (bridge). - Restart availability now tracks controller registration explicitly (StatusBar.setActive, reported by the shell) instead of being inferred from the state kind: a crashed state can mean either a live controller whose worker died (restart valid) or a failed registration whose command was disposed with the controller (restart dead). --- packages/vscode/src/extension.ts | 3 ++ packages/vscode/src/shared/versionCheck.ts | 23 +++++++++++++++ packages/vscode/src/stacks/test/bridge.ts | 14 ++++----- packages/vscode/src/stacks/test/master.ts | 10 +++---- packages/vscode/src/statusBar.ts | 33 ++++++++++++++-------- 5 files changed, 58 insertions(+), 25 deletions(-) diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 972a8aa..867b310 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -200,6 +200,7 @@ class ExtensionShell { this.#controllers.delete(stack); await this.disposeController(stack, existing); await this.setContextKey(`rstack.${stack}.active`, false); + this.#statusBar.setActive(stack, false); } this.#statusBar.setState(stack, gate.state); return; @@ -226,11 +227,13 @@ class ExtensionShell { this.publishStackExports(stack, stackExports); } await this.setContextKey(`rstack.${stack}.active`, true); + this.#statusBar.setActive(stack, true); this.#channels.shell.info(`${STACK_LABELS[stack]} registered`); } catch (error) { this.#controllers.delete(stack); await this.disposeController(stack, controller); await this.setContextKey(`rstack.${stack}.active`, false); + this.#statusBar.setActive(stack, false); const message = errorMessage(error); this.#channels.shell.error( `${STACK_LABELS[stack]} failed to register: ${message}`, diff --git a/packages/vscode/src/shared/versionCheck.ts b/packages/vscode/src/shared/versionCheck.ts index db6e985..368cb99 100644 --- a/packages/vscode/src/shared/versionCheck.ts +++ b/packages/vscode/src/shared/versionCheck.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs'; import semver from 'semver'; /** @@ -30,6 +31,28 @@ export type VersionCheckResult = readonly required: string; }; +/** + * Reads a package.json `version` with a plain filesystem read. `require`-ing + * the file would cache the parsed module by path, so an in-place upgrade + * (npm and yarn reuse the same node_modules path; pnpm's store path changes) + * would keep reporting the pre-upgrade version until the extension host + * reloads. Returns `undefined` when unreadable β€” version checks treat an + * unknown version as soft-pass, so an unreadable package.json only costs the + * check, never the feature. + */ +export const readPackageVersion = ( + packageJsonPath: string, +): string | undefined => { + try { + const parsed = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { + version?: unknown; + }; + return typeof parsed.version === 'string' ? parsed.version : undefined; + } catch { + return undefined; + } +}; + export const checkPackageVersion = ( packageName: SupportedPackage, version: string | undefined, diff --git a/packages/vscode/src/stacks/test/bridge.ts b/packages/vscode/src/stacks/test/bridge.ts index 16da1ba..f1b2ab4 100644 --- a/packages/vscode/src/stacks/test/bridge.ts +++ b/packages/vscode/src/stacks/test/bridge.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import { checkPackageVersion, formatVersionMismatch, + readPackageVersion, } from '../../shared/versionCheck'; import { logger } from './logger'; import { nodeRequire } from './nodeRequire'; @@ -44,8 +45,6 @@ export type RstackShim = { readonly version?: string; }; -type RstackPackageJson = { version?: string }; - /** * Resolves the rstack shim for a directory containing an `rstack.config.*`. * @@ -104,13 +103,10 @@ export function resolveRstackShim( return undefined; } - let version: string | undefined; - try { - version = (nodeRequire(packageJsonPath) as RstackPackageJson).version; - } catch { - // A readable `dist/rstestConfig.js` is what the bridge actually needs; an - // unreadable package.json only costs the version check. - } + // An uncached filesystem read: `nodeRequire` would pin the version seen by + // the first resolution for the lifetime of the extension host, defeating + // the re-resolution that every `syncBridgeProjects` pass performs. + const version = readPackageVersion(packageJsonPath); const result = checkPackageVersion('rstack', version); if (result.kind === 'mismatch') { diff --git a/packages/vscode/src/stacks/test/master.ts b/packages/vscode/src/stacks/test/master.ts index 10570c6..8d15666 100644 --- a/packages/vscode/src/stacks/test/master.ts +++ b/packages/vscode/src/stacks/test/master.ts @@ -4,7 +4,10 @@ import path, { dirname } from 'node:path'; import { type BirpcReturn, createBirpc } from 'birpc'; import regexpEscape from 'core-js-pure/actual/regexp/escape'; import vscode from 'vscode'; -import { reportVersionCheck } from '../../shared/versionCheck'; +import { + readPackageVersion, + reportVersionCheck, +} from '../../shared/versionCheck'; import { CONFIG_SECTION, getConfigValue } from './config'; import { formatConfiguredCoreNotFoundMessage, @@ -188,10 +191,7 @@ export class RstestApi { logger.error('Failed to resolve @rstest/core/package.json', e); return ''; } - const corePackageJson = nodeRequire(corePackageJsonPath) as { - version?: string; - }; - const coreVersion = corePackageJson.version; + const coreVersion = readPackageVersion(corePackageJsonPath); // Upstream also compared the core version against the extension's own // version, because they were released from one monorepo in lockstep. This diff --git a/packages/vscode/src/statusBar.ts b/packages/vscode/src/statusBar.ts index c680f36..2e48559 100644 --- a/packages/vscode/src/statusBar.ts +++ b/packages/vscode/src/statusBar.ts @@ -27,15 +27,6 @@ const STATE_ICONS: Readonly> = { 'version-mismatch': '$(warning)', }; -/** - * The restart command handler only exists while the stack's controller is - * registered. A detected-but-disabled stack (enable-setting off, Restricted - * Mode) never registered it, so offering the action would die with a - * command-not-found error. - */ -const canRestart = (state: StackState): boolean => - state.kind !== 'not-detected' && state.kind !== 'disabled'; - const stateText = (state: StackState): string => { switch (state.kind) { case 'not-detected': @@ -63,6 +54,12 @@ export class StatusBar implements vscode.Disposable { readonly #states = new Map( STACK_IDS.map((stack) => [stack, { kind: 'not-detected' }]), ); + // Stacks whose controller is currently registered. Restart availability + // tracks this, not the state kind: a state cannot distinguish "crashed + // while running" (controller alive, its restart command exists) from + // "failed to register" (controller disposed, the command with it), and a + // disabled stack never registered the command at all. + readonly #active = new Set(); constructor() { this.#item = vscode.window.createStatusBarItem( @@ -97,6 +94,20 @@ export class StatusBar implements vscode.Disposable { return this.#states.get(stack) ?? { kind: 'not-detected' }; } + /** The shell reports controller registration and disposal here. */ + setActive(stack: StackId, active: boolean): void { + if (active) { + this.#active.add(stack); + } else { + this.#active.delete(stack); + } + this.render(); + } + + #canRestart(stack: StackId): boolean { + return RESTART_COMMANDS[stack] !== undefined && this.#active.has(stack); + } + /** The QuickPick behind the status bar item. */ async showMenu(): Promise { type Item = vscode.QuickPickItem & { readonly command?: string }; @@ -110,7 +121,7 @@ export class StatusBar implements vscode.Disposable { command: OUTPUT_COMMANDS[stack], }); const restart = RESTART_COMMANDS[stack]; - if (restart && canRestart(state)) { + if (restart && this.#canRestart(stack)) { items.push({ label: `$(refresh) Restart ${STACK_LABELS[stack]}`, command: restart, @@ -181,7 +192,7 @@ export class StatusBar implements vscode.Disposable { const state = this.stateOf(stack); const links = [`[Output](command:${OUTPUT_COMMANDS[stack]})`]; const restart = RESTART_COMMANDS[stack]; - if (restart && canRestart(state)) { + if (restart && this.#canRestart(stack)) { links.push(`[Restart](command:${restart})`); } tooltip.appendMarkdown( From 710a0220f25ccc3ebb7b0d808673d599ae70a9f6 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Wed, 5 Aug 2026 00:09:29 +0800 Subject: [PATCH 12/15] fix(vscode): bypass require.resolve's path cache for re-resolutions Seventh Codex review round, one root cause in three places: Node caches every successful require.resolve() for the process lifetime (request + paths in Module._pathCache, symlink targets in the CJS realpath cache), so once a package resolved, re-resolving after pnpm retargets the node_modules symlink replayed the pre-upgrade store path. Failed lookups are never cached, which is why resolve-after-install always worked; this closes the upgrade/downgrade half. findPackageJsonUncached() walks node_modules up with plain fs calls and an uncached realpathSync. It now anchors: - the Rslint core location (locateCore), whose cooperating pieces already resolve from the returned path (one-resolution-root rule); - the @rstest/core worker/bin resolution, with the package entry resolved from the realpath'd package dir so the cache key is version-pinned on pnpm and the exports map stays honored (a configured rstestPackagePath keeps its explicit-pin behavior); - the rstack shim resolution, replacing the previous resolve plus node_modules containment check (the walk-up only yields node_modules candidates). --- packages/vscode/src/shared/packageResolve.ts | 66 ++++++++++++++ packages/vscode/src/shared/versionCheck.ts | 23 ++--- packages/vscode/src/stacks/lint/resolution.ts | 15 ++-- packages/vscode/src/stacks/test/bridge.ts | 30 ++----- packages/vscode/src/stacks/test/master.ts | 86 ++++++++++++++----- 5 files changed, 153 insertions(+), 67 deletions(-) create mode 100644 packages/vscode/src/shared/packageResolve.ts diff --git a/packages/vscode/src/shared/packageResolve.ts b/packages/vscode/src/shared/packageResolve.ts new file mode 100644 index 0000000..e2423a3 --- /dev/null +++ b/packages/vscode/src/shared/packageResolve.ts @@ -0,0 +1,66 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Locates `/package.json` by walking `node_modules` up from + * `fromDir`, with plain fs calls and a final `fs.realpathSync`. + * + * `require.resolve` is unusable for a *re*-resolution: Node caches every + * successful lookup for the process lifetime (`Module._pathCache` keyed by + * request + paths, plus the CJS loader's realpath cache for symlink targets), + * so once a package resolved, later calls replay the pre-upgrade answer after + * pnpm retargets the `node_modules` symlink. Failed lookups are not cached β€” + * which is why resolve-after-install always worked; this helper closes the + * upgrade/downgrade half. `fs.realpathSync` uses no persistent cache, so the + * returned path always reflects the current link target (and is the + * version-pinned store path on pnpm). + * + * Only the walk-up half of Node's algorithm is implemented: a `package.json` + * lookup needs no exports-map logic. Callers that need entry points resolve + * them anchored at the returned path, which keys Node's cache by a + * version-pinned location. + */ +export const findPackageJsonUncached = ( + packageName: string, + fromDir: string, +): string | undefined => { + let dir = path.resolve(fromDir); + for (;;) { + const candidate = path.join( + dir, + 'node_modules', + ...packageName.split('/'), + 'package.json', + ); + try { + if (fs.statSync(candidate).isFile()) { + return fs.realpathSync(candidate); + } + } catch { + // Keep walking up. + } + const parent = path.dirname(dir); + if (parent === dir) { + return undefined; + } + dir = parent; + } +}; + +/** + * Reads and parses a package.json with a plain filesystem read β€” never + * `require`, whose module cache would pin the first-seen contents until the + * extension host reloads. Returns `undefined` when unreadable. + */ +export const readPackageJson = ( + packageJsonPath: string, +): Record | undefined => { + try { + return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as Record< + string, + unknown + >; + } catch { + return undefined; + } +}; diff --git a/packages/vscode/src/shared/versionCheck.ts b/packages/vscode/src/shared/versionCheck.ts index 368cb99..5dcf73b 100644 --- a/packages/vscode/src/shared/versionCheck.ts +++ b/packages/vscode/src/shared/versionCheck.ts @@ -1,5 +1,5 @@ -import fs from 'node:fs'; import semver from 'semver'; +import { readPackageJson } from './packageResolve'; /** * The version-compatibility contract. A VSIX has no npm install step, @@ -32,25 +32,16 @@ export type VersionCheckResult = }; /** - * Reads a package.json `version` with a plain filesystem read. `require`-ing - * the file would cache the parsed module by path, so an in-place upgrade - * (npm and yarn reuse the same node_modules path; pnpm's store path changes) - * would keep reporting the pre-upgrade version until the extension host - * reloads. Returns `undefined` when unreadable β€” version checks treat an - * unknown version as soft-pass, so an unreadable package.json only costs the - * check, never the feature. + * Reads a package.json `version` uncached (see `readPackageJson`). Returns + * `undefined` when unreadable β€” version checks treat an unknown version as + * soft-pass, so an unreadable package.json only costs the check, never the + * feature. */ export const readPackageVersion = ( packageJsonPath: string, ): string | undefined => { - try { - const parsed = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { - version?: unknown; - }; - return typeof parsed.version === 'string' ? parsed.version : undefined; - } catch { - return undefined; - } + const version = readPackageJson(packageJsonPath)?.version; + return typeof version === 'string' ? version : undefined; }; export const checkPackageVersion = ( diff --git a/packages/vscode/src/stacks/lint/resolution.ts b/packages/vscode/src/stacks/lint/resolution.ts index d63c099..b114946 100644 --- a/packages/vscode/src/stacks/lint/resolution.ts +++ b/packages/vscode/src/stacks/lint/resolution.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import { createRequire } from 'node:module'; import path from 'node:path'; import { Uri, workspace, type WorkspaceFolder } from 'vscode'; +import { findPackageJsonUncached } from '../../shared/packageResolve'; import type { Logger } from './logger'; import { fileExists, @@ -124,19 +125,21 @@ const locateCore = async ( logger: Logger, ): Promise => { const searchRoot = folder.uri.fsPath; - try { - const packageJsonPath = nodeRequire.resolve('@rslint/core/package.json', { - paths: [searchRoot], - }); + // Uncached on purpose: a failed root is retried after dependency changes + // (the lockfile-driven detection pass), and `require.resolve` would replay + // its process-lifetime cache instead of seeing a retargeted install. Every + // cooperating piece resolves from the returned path afterwards, so the + // whole root follows the fresh location (the one-resolution-root rule). + const packageJsonPath = findPackageJsonUncached('@rslint/core', searchRoot); + if (packageJsonPath !== undefined) { logger.debug(`Found @rslint/core in node_modules: ${packageJsonPath}`); return { kind: 'node-modules', packageJsonPath, coreDir: path.dirname(packageJsonPath), }; - } catch { - logger.debug('No @rslint/core in node_modules, trying Yarn PnP'); } + logger.debug('No @rslint/core in node_modules, trying Yarn PnP'); const pnpApi = await loadPnpApi(folder); if (pnpApi) { diff --git a/packages/vscode/src/stacks/test/bridge.ts b/packages/vscode/src/stacks/test/bridge.ts index f1b2ab4..083695f 100644 --- a/packages/vscode/src/stacks/test/bridge.ts +++ b/packages/vscode/src/stacks/test/bridge.ts @@ -1,12 +1,12 @@ import { existsSync } from 'node:fs'; import path from 'node:path'; +import { findPackageJsonUncached } from '../../shared/packageResolve'; import { checkPackageVersion, formatVersionMismatch, readPackageVersion, } from '../../shared/versionCheck'; import { logger } from './logger'; -import { nodeRequire } from './nodeRequire'; import { status } from './status'; /** @@ -50,8 +50,12 @@ export type RstackShim = { * * `rstack`'s exports map has no `./rstestConfig`, no `./config` and no wildcard * subpath, so the shim is unreachable by bare specifier - * (`ERR_PACKAGE_PATH_NOT_EXPORTED`). `./package.json` *is* exported, which makes - * it the resolution anchor; the shim is then addressed as a filesystem path. + * (`ERR_PACKAGE_PATH_NOT_EXPORTED`). The package.json is the resolution anchor + * instead β€” located by an uncached `node_modules` walk-up (`require.resolve` + * would replay its process-lifetime cache instead of seeing a retargeted + * install; see `findPackageJsonUncached`) β€” and the shim is then addressed as + * a filesystem path. The walk-up only ever yields `node_modules` candidates, + * which also rules out self-resolving a workspace package named "rstack". * * Resolution is anchored on the config directory rather than the workspace * folder so a monorepo package with its own `rstack` install wins over the root. @@ -62,25 +66,7 @@ export function resolveRstackShim( // be picked up without a reload), so a persistent failure must not re-log. { silent = false }: { silent?: boolean } = {}, ): RstackShim | undefined { - let packageJsonPath: string | undefined; - try { - packageJsonPath = nodeRequire.resolve('rstack/package.json', { - paths: [configDir], - }); - } catch { - packageJsonPath = undefined; - } - // The shim lives in the project's installed `rstack` package - // (`node_modules/rstack/dist/...`), so anything resolved from - // outside a node_modules tree is not it. This also shields against resolvers - // that self-resolve the enclosing workspace package named "rstack" (this - // extension's own manifest) despite the explicit `paths` override. - if ( - packageJsonPath !== undefined && - !packageJsonPath.includes(`${path.sep}node_modules${path.sep}`) - ) { - packageJsonPath = undefined; - } + const packageJsonPath = findPackageJsonUncached('rstack', configDir); if (packageJsonPath === undefined) { if (!silent) { logger.warn( diff --git a/packages/vscode/src/stacks/test/master.ts b/packages/vscode/src/stacks/test/master.ts index 8d15666..e1ebfe1 100644 --- a/packages/vscode/src/stacks/test/master.ts +++ b/packages/vscode/src/stacks/test/master.ts @@ -4,6 +4,10 @@ import path, { dirname } from 'node:path'; import { type BirpcReturn, createBirpc } from 'birpc'; import regexpEscape from 'core-js-pure/actual/regexp/escape'; import vscode from 'vscode'; +import { + findPackageJsonUncached, + readPackageJson, +} from '../../shared/packageResolve'; import { readPackageVersion, reportVersionCheck, @@ -149,9 +153,13 @@ export class RstestApi { private resolveFromCwd( specifier: string, configuredPackagePath?: string, + // Default-resolution callers pass the freshly located package directory + // so the lookup is keyed by that (version-pinned on pnpm) path instead of + // replaying `require.resolve`'s process-lifetime cache for `this.cwd`. + fromDir: string = this.cwd, ): string | undefined { try { - return nodeRequire.resolve(specifier, { paths: [this.cwd] }); + return nodeRequire.resolve(specifier, { paths: [fromDir] }); } catch (e) { if (!isModuleNotFoundError(e, specifier)) throw e; if (configuredPackagePath) { @@ -170,27 +178,52 @@ export class RstestApi { private resolveRstestPath(): string { try { const configured = this.resolveConfiguredPackageJson(); - const packageJson = configured ?? CORE_PACKAGE_JSON; - if (configured) { - logger.debug('Using configured rstestPackagePath:', configured); - } - - // `dirname` turns either package.json specifier into its package entry. - const nodeExport = this.resolveFromCwd(dirname(packageJson), configured); - if (!nodeExport) return ''; let corePackageJsonPath: string; - try { - corePackageJsonPath = nodeRequire.resolve(packageJson, { - paths: [this.cwd], - }); - } catch (e) { - vscode.window.showErrorMessage( - 'Failed to resolve @rstest/core/package.json. Please upgrade @rstest/core to the latest version.', + let nodeExport: string | undefined; + if (configured) { + logger.debug('Using configured rstestPackagePath:', configured); + // An explicit pin, resolved exactly as before β€” the setting names one + // fixed path, so cache staleness is moot. + // `dirname` turns the package.json specifier into its package entry. + nodeExport = this.resolveFromCwd(dirname(configured), configured); + if (!nodeExport) return ''; + try { + corePackageJsonPath = nodeRequire.resolve(configured, { + paths: [this.cwd], + }); + } catch (e) { + vscode.window.showErrorMessage( + 'Failed to resolve @rstest/core/package.json. Please upgrade @rstest/core to the latest version.', + ); + logger.error('Failed to resolve @rstest/core/package.json', e); + return ''; + } + } else { + // The uncached walk-up runs first (see `findPackageJsonUncached`); + // the entry resolution is then anchored at the realpath'd package + // directory, so a re-resolution after a dependency change lands on + // the retargeted install instead of `require.resolve`'s cached + // answer, while the bare specifier keeps the exports map honored. + const found = findPackageJsonUncached( + dirname(CORE_PACKAGE_JSON), + this.cwd, + ); + if (!found) { + // The normal state of a repository whose dependencies are not + // installed yet: output channel only, never a notification. + logger.error(formatCoreNotFoundMessage(this.cwd)); + return ''; + } + corePackageJsonPath = found; + nodeExport = this.resolveFromCwd( + dirname(CORE_PACKAGE_JSON), + undefined, + dirname(corePackageJsonPath), ); - logger.error('Failed to resolve @rstest/core/package.json', e); - return ''; + if (!nodeExport) return ''; } + const coreVersion = readPackageVersion(corePackageJsonPath); // Upstream also compared the core version against the extension's own @@ -228,12 +261,19 @@ export class RstestApi { // resolution above. private resolveRstestBin(): string | undefined { const configured = this.resolveConfiguredPackageJson(); - const pkgJsonPath = this.resolveFromCwd( - configured ?? CORE_PACKAGE_JSON, - configured, - ); + let pkgJsonPath: string | undefined; + if (configured) { + pkgJsonPath = this.resolveFromCwd(configured, configured); + } else { + // Same uncached lookup as the worker resolution above. + pkgJsonPath = findPackageJsonUncached( + dirname(CORE_PACKAGE_JSON), + this.cwd, + ); + if (!pkgJsonPath) logger.error(formatCoreNotFoundMessage(this.cwd)); + } if (!pkgJsonPath) return undefined; - const pkg = nodeRequire(pkgJsonPath) as { + const pkg = (readPackageJson(pkgJsonPath) ?? {}) as { bin?: string | Record; }; const binRel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.rstest; From da2918cff974c5e8fc6a8f9c493ca79b22ae8d54 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Wed, 5 Aug 2026 00:28:10 +0800 Subject: [PATCH 13/15] fix(vscode): reload PnP on retry, report unexpected worker exits Eighth Codex review round: - The Yarn PnP fallback required .pnp.cjs through the module cache, so a dependency-driven retry kept resolving against the map seen by the first load. The cache entry is evicted before each load -- the PnP flavor of the resolution staleness findPackageJsonUncached avoids. - A worker that spawned successfully but exited before being closed (e.g. an invalid nodeExecArgs option) cleared the crash latch on spawn and then vanished silently: birpc was unblocked but no state was reported, leaving the status on running over an empty explorer. Unexpected exits now report crashed; deliberate teardowns are recognized either by $close having run first or by an explicit expected-exit mark set by dispose and the failed-debug-attach path, so project rebuilds and disposals do not misreport. --- packages/vscode/src/stacks/lint/resolution.ts | 6 ++++++ packages/vscode/src/stacks/test/master.ts | 19 ++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/vscode/src/stacks/lint/resolution.ts b/packages/vscode/src/stacks/lint/resolution.ts index b114946..53c15f2 100644 --- a/packages/vscode/src/stacks/lint/resolution.ts +++ b/packages/vscode/src/stacks/lint/resolution.ts @@ -105,6 +105,12 @@ const loadPnpApi = async (folder: WorkspaceFolder): Promise => { continue; } try { + // `yarn install` rewrites `.pnp.cjs` in place, and the module cache + // would pin the dependency map seen by the first load β€” the PnP flavor + // of the resolution staleness `findPackageJsonUncached` avoids β€” so a + // dependency-driven retry must load the current file, not the cached + // module. + delete nodeRequire.cache[nodeRequire.resolve(pnpFile.fsPath)]; return nodeRequire(pnpFile.fsPath) as PnpApi; } catch { // Try the next candidate; a broken PnP file is not fatal on its own. diff --git a/packages/vscode/src/stacks/test/master.ts b/packages/vscode/src/stacks/test/master.ts index e1ebfe1..c24982d 100644 --- a/packages/vscode/src/stacks/test/master.ts +++ b/packages/vscode/src/stacks/test/master.ts @@ -55,6 +55,9 @@ const isPortAvailable = (port: number, host?: string): Promise => export class RstestApi { private childProcesses = new Set(); + // Processes killed on purpose outside the `$close` β†’ `off` path (dispose, + // failed debugger attach). Their `exit` events are not crashes. + private readonly expectedExits = new WeakSet(); constructor( private workspace: vscode.WorkspaceFolder, @@ -582,8 +585,20 @@ export class RstestApi { rstestProcess.on('exit', (code, signal) => { logger.debug('Worker process exited', { code, signal }); + if (worker.$closed || this.expectedExits.has(rstestProcess)) return; + // An exit nobody asked for: every deliberate teardown either runs + // `$close` first (its `off` handler kills after `$closed` flips) or + // marks the process in `expectedExits` before killing. The process + // *did* spawn β€” which cleared the crash latch β€” and nothing else will + // report; e.g. an invalid `nodeExecArgs` option makes Node exit right + // after a successful spawn, and without this the status keeps saying + // running over an empty Test Explorer. + status.crashed( + `worker process exited unexpectedly (code: ${String(code)}, signal: ${String(signal)})`, + this.statusSource, + ); // Unblock pending calls when the worker exits before we closed it. - if (!worker.$closed) worker.$close(); + worker.$close(); }); // Attach the debugger only after the error/exit handlers are wired, so a @@ -609,6 +624,7 @@ export class RstestApi { { testRun }, ); if (!startedDebugging) { + this.expectedExits.add(rstestProcess); rstestProcess.kill(); throw new Error( `Failed to attach debugger to test worker process (PID: ${rstestProcess.pid})`, @@ -621,6 +637,7 @@ export class RstestApi { public dispose() { for (const child of this.childProcesses) { + this.expectedExits.add(child); child.kill(); } this.childProcesses.clear(); From 6fb5deb9ff6dff4a0ba615eaf4bd1b0fe1a31d17 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Wed, 5 Aug 2026 00:33:32 +0800 Subject: [PATCH 14/15] fix(vscode): close birpc for expected worker exits too Ninth Codex review round, on the previous round's fix: the expected-exit early return also skipped worker.$close(), so a disposed or rebuilt project's pending RPC calls stayed alive until timeout and the worker lingered in the tracking set. Expected exits now skip only the crash report; birpc is always closed when the process exits before $close. --- packages/vscode/src/stacks/test/master.ts | 30 +++++++++++++---------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/vscode/src/stacks/test/master.ts b/packages/vscode/src/stacks/test/master.ts index c24982d..f1b5c91 100644 --- a/packages/vscode/src/stacks/test/master.ts +++ b/packages/vscode/src/stacks/test/master.ts @@ -585,19 +585,23 @@ export class RstestApi { rstestProcess.on('exit', (code, signal) => { logger.debug('Worker process exited', { code, signal }); - if (worker.$closed || this.expectedExits.has(rstestProcess)) return; - // An exit nobody asked for: every deliberate teardown either runs - // `$close` first (its `off` handler kills after `$closed` flips) or - // marks the process in `expectedExits` before killing. The process - // *did* spawn β€” which cleared the crash latch β€” and nothing else will - // report; e.g. an invalid `nodeExecArgs` option makes Node exit right - // after a successful spawn, and without this the status keeps saying - // running over an empty Test Explorer. - status.crashed( - `worker process exited unexpectedly (code: ${String(code)}, signal: ${String(signal)})`, - this.statusSource, - ); - // Unblock pending calls when the worker exits before we closed it. + if (worker.$closed) return; + if (!this.expectedExits.has(rstestProcess)) { + // An exit nobody asked for: every deliberate teardown either runs + // `$close` first (its `off` handler kills after `$closed` flips) or + // marks the process in `expectedExits` before killing. The process + // *did* spawn β€” which cleared the crash latch β€” and nothing else + // will report; e.g. an invalid `nodeExecArgs` option makes Node exit + // right after a successful spawn, and without this the status keeps + // saying running over an empty Test Explorer. + status.crashed( + `worker process exited unexpectedly (code: ${String(code)}, signal: ${String(signal)})`, + this.statusSource, + ); + } + // Always unblock pending calls (and drop the worker from the tracking + // set via `off`) when the worker exits before we closed it β€” expected + // or not. worker.$close(); }); From 1ecada9e6fa402b61fc83063e1aace780bb57edd Mon Sep 17 00:00:00 2001 From: fi3ework Date: Wed, 5 Aug 2026 00:41:09 +0800 Subject: [PATCH 15/15] fix(vscode): unbounded rstack config discovery Tenth Codex review round: the 100-file findFiles cap is fine for rows that are mere detection signals (the stacks rescan their own configs), but the rstack config list is consumed as-is by the test stack's bridge sync, so a monorepo with more than 100 rstack configs silently lost the overflow's bridged projects. The rstack row is now unbounded; the cap stays for the signal-only rows. --- packages/vscode/src/detection.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/vscode/src/detection.ts b/packages/vscode/src/detection.ts index 44b0253..fc07227 100644 --- a/packages/vscode/src/detection.ts +++ b/packages/vscode/src/detection.ts @@ -129,11 +129,17 @@ const readRstestGlobs = (folder: vscode.WorkspaceFolder): readonly string[] => { const findFiles = async ( folder: vscode.WorkspaceFolder, glob: string, + // The default cap bounds detection cost for rows that are mere *signals* β€” + // the stacks rescan their own configs, so a truncated list still lights + // them up. Rows whose URI list is consumed as-is pass `undefined` + // (unbounded): the rstack configs are the sole input to the test stack's + // bridge sync, where silent truncation would drop whole projects. + maxResults: number | undefined = MAX_CONFIG_FILES, ): Promise => vscode.workspace.findFiles( new vscode.RelativePattern(folder, glob), NODE_MODULES_EXCLUDE, - MAX_CONFIG_FILES, + maxResults, ); const fileExists = async (uri: vscode.Uri): Promise => { @@ -173,7 +179,7 @@ export const detectFolder = async ( const rstestGlobs = readRstestGlobs(folder); const [rstackConfigFiles, rslintConfigFiles, binPath, rstestConfigFiles] = await Promise.all([ - findFiles(folder, RSTACK_CONFIG_GLOB), + findFiles(folder, RSTACK_CONFIG_GLOB, undefined), findFiles(folder, RSLINT_CONFIG_GLOB), probeFmtBin(folder), Promise.all(rstestGlobs.map((glob) => findFiles(folder, glob))).then(