diff --git a/.dockerignore b/.dockerignore index 70cc06d..0ad6055 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,13 @@ node_modules +fixtures/*/.venv +fixtures/*/.code-quality-tmp +fixtures/*/.import_linter_cache +fixtures/*/.complexipy_cache +fixtures/*/complexipy-results.json dist launcher/dist artifacts fixtures/*/artifacts +fixtures/*/node_modules fixtures/php-project/vendor .git diff --git a/.gitignore b/.gitignore index 1773c70..4420494 100644 --- a/.gitignore +++ b/.gitignore @@ -5,12 +5,12 @@ coverage/ .code-quality-tmp/ fixtures/*/artifacts/ fixtures/*/.code-quality-tmp/ -fixtures/ts-project/node_modules/ -fixtures/ts-project/package-lock.json +fixtures/*/node_modules/ +fixtures/*/package-lock.json fixtures/php-project/vendor/ -fixtures/python-project/.venv/ -fixtures/python-project/.import_linter_cache/ -fixtures/python-project/complexipy-results.json +fixtures/*/.venv/ +fixtures/*/.import_linter_cache/ +fixtures/*/complexipy-results.json *.log .pnpm-store/ .deptrac.cache diff --git a/Dockerfile b/Dockerfile index defc896..a0ac856 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,8 +42,19 @@ RUN for t in phpcs phpstan deptrac; do composer install --working-dir=/opt/php/$ && curl -fsSL -o /opt/php/bin/composer-require-checker https://github.com/maglnet/ComposerRequireChecker/releases/download/4.24.0/composer-require-checker.phar \ && echo "35d1dfdd7aa94a750f4b4c9b0b22dd5e76b5e9e35fb910c8901f3dafd2ad2ab9 /opt/php/bin/composer-require-checker" | sha256sum -c - \ && chmod +x /opt/php/bin/* && rm -rf /opt/composer-home/cache +ENV COREPACK_HOME=/opt/corepack COREPACK_DEFAULT_TO_LATEST=0 UV_PYTHON_INSTALL_DIR=/opt/python UV_NO_MODIFY_PATH=1 +RUN corepack enable \ + && corepack prepare pnpm@10.17.1 yarn@1.22.22 --activate \ + && corepack prepare pnpm@11.5.2 # Python tools COPY docker/python/requirements.txt /opt/requirements.txt +RUN python3 -m venv /opt/uv && /opt/uv/bin/pip install --no-cache-dir uv==0.12.14 \ + && ln -s /opt/uv/bin/uv /usr/local/bin/uv \ + && uv python install 3.14.7 \ + && ln -s "$(uv python find 3.14.7)" /usr/local/bin/python3.14 \ + && uv venv /opt/venv314 --python 3.14.7 \ + && uv pip install --python /opt/venv314/bin/python --no-cache -r /opt/requirements.txt \ + && rm -rf /root/.cache RUN python3 -m venv /opt/venv && /opt/venv/bin/pip install --no-cache-dir -r /opt/requirements.txt # CLI bundle + grammar assets COPY --from=builder /build/dist /opt/code-quality/dist diff --git a/README.md b/README.md index 7e2c327..11eda56 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ checks: reason: "Architecture rules are not defined yet" ``` -See [Configuration](docs/configuration.md) for detection, defaults, validation, Drupal behavior, and every key. +See [Configuration](docs/configuration.md) for detection, defaults, validation, framework profiles (Drupal, Payload/Next), and every key. ## Documentation diff --git a/docs/checks.md b/docs/checks.md index 646239a..bde0b86 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -29,7 +29,7 @@ Built-in test, generated-file, and framework exclusions apply before these check ## v1 tool pins -`versions` prints the 15 binary pins and two library pins; `doctor` verifies them. +`versions` prints the 15 check-tool binary pins, two library pins, and the image Runtime group; `doctor` verifies the pinned tools and runtime availability. | Family | Binary or package | Pin | | --- | --- | --- | @@ -53,6 +53,24 @@ Built-in test, generated-file, and framework exclusions apply before these check The two Composer library pins come from installed package metadata. Although they are not standalone binary probes, they remain part of the version contract. +## Runtime toolchain + +| Verified Runtime pin | Version | +| --- | --- | +| uv | 0.12.14 | +| CPython (`python3.14`) | 3.14.7 | +| `/opt/venv314/bin/ruff` | 0.16.6 | +| `/opt/venv314/bin/complexipy` | 8.0.1 | +| `/opt/venv314/bin/vulture` | 2.16 | +| `/opt/venv314/bin/deptry` | 0.25.1 | +| `/opt/venv314/bin/lint-imports` | 2.15 | + +| Presence-only Runtime | Provisioning | +| --- | --- | +| corepack | pnpm 10.17.1 by default; pnpm 11.5.2 and yarn 1.22.22 cached; other `packageManager` pins download during setup. | + +Python targets at or above 3.14 run the Python tools from `/opt/venv314` with a `Notice:`; other targets use the default CPython 3.13 environment, and Ruff receives the matching `target-version`. See [Runtime targets](configuration.md#language-detection-and-source-roots) for how the target is read. Both environments install `docker/python/requirements.txt`, and the version gate probes each tool through the environment its check uses. + ## Baseline semantics Each concrete adapter owns `quality/-baseline.json`. A schema-version-1 snapshot contains its exact `tool@version` stamp, a SHA-256 configuration hash, and a sorted map of positive finding counts. @@ -93,7 +111,7 @@ Each unused-code tool has its own adapter, version stamp, and baseline: Knip requires `node_modules/` only when `package.json` declares any dependency field (`dependencies`, `devDependencies`, `peerDependencies`, or `optionalDependencies`). Install with the repository's package manager before checking; the gate reports a missing install directly. -Knip disables configuration-executing plugins, so dependencies referenced only by tool configuration can appear unused and be baselined. Entry heuristics include conventional `index`, `main`, and `cli` files, files below `bin` directories in each source root, repository-level tests and test patterns, and scripts referenced from `package.json`, such as `node scripts/build.ts`. +Knip disables every plugin in the pinned Knip registry by generating a `false` toggle, so dependencies referenced only by tool configuration can appear unused and be baselined. Entry heuristics include conventional `index`, `main`, and `cli` files, files below `bin` directories in each source root, repository-level tests and test patterns, and scripts referenced from `package.json`, such as `node scripts/build.ts`. Next routes, middleware, instrumentation, and Payload, Vite, Vitest, and Playwright configs are added as static entries; monorepos get one Knip workspace per source-root owner. PHP checks require a usable `vendor/` directory containing application dependencies and PHPStan context. Run `composer install` before checking; the gate reports a missing install directly. diff --git a/docs/ci.md b/docs/ci.md index c4583f1..27ffc18 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -48,6 +48,15 @@ jobs: For npm, use `setup: npm ci`. For Composer, use `setup: composer install` or a suitable non-interactive variant such as `setup: composer install --no-interaction`. +The image supports these dependency setup recipes: + +- `setup: pnpm install --frozen-lockfile` — Corepack honors the repository's `packageManager`; pnpm 10.17.1 and 11.5.2 are pre-cached, and other pins download on demand. +- `setup: yarn install --frozen-lockfile` — yarn 1.22.22 is pre-cached. +- `setup: uv sync --frozen` — uv uses the image's CPython 3.14.7 or 3.13; `.python-version` drives the choice. +- `setup: composer install --ignore-platform-req=php` — use this for repositories pinned to `php: ~8.3.0`, because the image runs PHP 8.4. + +When possible, widen PHP dependency templates to `^8.3` so both PHP 8.3 and 8.4 satisfy the declared platform requirement. + A complete customized caller can select checks, install PHP dependencies, pin the image major, select a consumer subdirectory, and retain reports: ```yaml diff --git a/docs/configuration.md b/docs/configuration.md index 30b34a0..15a1aa3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -53,7 +53,11 @@ Without an explicit `languages` list, code-quality combines detected languages w | Python | `pyproject.toml` or `setup.py` | Existing `src/` | | Web | `.twig`, `.html`, `.css`, `.scss`, or `.less` below `templates/` or `assets/` | Existing `templates/` and `assets/` directories | -When a manifest detects a language but its default roots contain no source files, configuration loading discovers eligible source directories at depth 1 on every command. It ignores hidden, test, build, dependency, generated, and other conventional non-source directories; `init` records the discovered directories in the generated `paths.`. +Conventional roots with sources are kept and workspace roots from manifests are added: TS/JS from `pnpm-workspace.yaml`, `package.json` `workspaces`, and depth-1 directories with their own `package.json`; PHP from `composer.json` `autoload` directories; Python from `[tool.uv.workspace]` members and `[tool.setuptools.packages.find]` `where`. A TS member maps to `/src` only when no source exists elsewhere in the member. Member patterns support literal segments, `*`, `**`, one `*` inside a segment, and `!` negations; other patterns are ignored. Without conventional roots, depth-1 discovery is unioned with workspace roots. A `Notice:` lists added roots and `init` writes them. Drupal keeps its curated PHP roots. + +The Python target comes from the repository-root `.python-version`, then the root `project.requires-python`; if neither defines it, the highest target among `[tool.uv.workspace]` members is used. Targets newer than the image still use CPython 3.14 tools with a notice. A Python target is part of the configuration hash only when Python resolves. PHPStan infers the PHP version from `composer.json` `config.platform.php`. + +A directory without a manifest inside a workspace container, such as `packages/scripts`, is not analysed unless listed in `paths.`. If discovery finds no roots, configuration loading omits the language and prints a `Notice:` explaining how to add `paths.`. An explicitly listed language or explicit path still fails when it contains no source files. @@ -75,6 +79,7 @@ Dependency, virtual-environment, distribution, and artifact directories named `n When `composer.json` requires `drupal/core`, `drupal/core-recommended`, or another `drupal/core-*` package, code-quality detects custom code automatically: - PHP uses existing custom modules, themes, and profiles under `web/` or `docroot/`. +- PHP checks also scan `.module`, `.theme`, `.install`, `.inc`, `.profile`, and `.engine` files with structural anchors. - Web uses custom theme directories. - TS/JS adds custom theme directories that contain JavaScript. - Ordinary source-root discovery remains the fallback when no custom directory exists. @@ -91,11 +96,27 @@ exclude: - "web/modules/custom/site/generated/**" ``` +## Payload and Next profiles + +Profiles are detected separately for the repository root and each workspace owner of a resolved TS root. `next.config.{js,mjs,cjs,ts,mts}` activates the Next profile, which excludes the owner's `.next/**` and `next-env.d.ts` only. + +`payload.config.{ts,js,mjs,mts}` in the owner root or `src/`, or a `payload` dependency or dev dependency in that owner's `package.json`, activates the Payload profile. It excludes these paths under every TS root owned by that package: + +- `/**/payload-types.ts` +- `/**/importMap.js` +- `/**/app/[(]payload[)]/**` +- `/**/migrations/**` +- `/**/migrations-*/**` +- `/**/seed/**` +A TS root named `app` also excludes `/[(]payload[)]/**`. One `Notice:` identifies each active profile kind and lists non-root owners. Dependency-cruiser does not apply exclude globs. + +Payload exclusions scoped to a TS root also apply to other languages under the same directory. + ## What `init` writes Run `npx @runroom/code-quality init` once the first source and manifest files exist. It writes or updates: -- `.code-quality.yml` with detected languages and paths, unless the file already exists; +- `.code-quality.yml` with detected languages and resolved paths, including workspace roots, unless the file already exists; - missing `quality/-baseline.json` files while keeping existing snapshots and reporting each as `● Kept existing quality/-baseline.json`; - `.github/workflows/quality.yml`, unless it already exists; - a Makefile or an appended target block when safe; and diff --git a/docs/development.md b/docs/development.md index eddee26..9bd62df 100644 --- a/docs/development.md +++ b/docs/development.md @@ -2,6 +2,8 @@ The repository uses Node 24 and pnpm. Do not update generated policy output without reviewing the native tool behavior that produced it. +The runtime image has a default CPython 3.13 tool virtual environment and a second CPython 3.14 environment at `/opt/venv314`. Both are installed from `docker/python/requirements.txt`. + ## Verification commands ```sh @@ -17,7 +19,7 @@ pnpm capture | `pnpm verify` | Runs type checking, linting, tests, and the bundle build. | | `pnpm build:launcher` | Builds the npm launcher package. | | `pnpm docker:build` | Builds the local analysis image. | -| `pnpm integration` | Exercises the TS, PHP, Python, and web fixture repositories plus deliberate mutations against a locally built image. | +| `pnpm integration` | Exercises the TS, PHP, Python (`python-project` and `python314-project`), web, Drupal, Payload/Next, and monorepo fixture repositories plus deliberate mutations against a locally built image. | | `pnpm capture …` | Captures a selected adapter's native output from a pinned image tool for parser tests. | On its first run, integration installs fixture dependencies through the image. Native-output fixtures preserve the actual supported tool format and feed adapter parser tests. @@ -26,7 +28,7 @@ On its first run, integration installs fixture dependencies through the image. N A tool bump changes policy: -1. Change the pin in `src/registry.ts` and the image build definition. +1. Change the pin in `src/registry.ts` and the image build definition. A Python tool bump changes `docker/python/requirements.txt`, which feeds both virtual environments. 2. Build an image containing exactly that tool and capture fresh native-output fixtures. 3. Run unit tests, integration tests, and `doctor`. 4. Build and release the new image tags. @@ -34,6 +36,8 @@ A tool bump changes policy: Ordinary `--update` does not cross a tool-version gate. The project does not use Renovate for v1, and `doctor` is the final authority for versions in a built image. +Generated-configuration changes that alter tool settings or scan scope, including plugin toggles, extensions, and profiles, must bump `POLICY_VERSION` like a tool bump so consumers re-initialize affected baselines. + ## Version changes A version bump updates these three locations together: diff --git a/docs/security.md b/docs/security.md index a134c26..265afa8 100644 --- a/docs/security.md +++ b/docs/security.md @@ -6,7 +6,7 @@ code-quality analyzes untrusted repository content with tools that may execute c - dependency-cruiser configuration and import-linter imports can execute consumer code inside the container. - A reusable-workflow `setup` command, such as `composer install`, executes consumer code inside the job container. -- Knip runs with configuration-executing plugins disabled, but other supported tools can still load repository configuration where their operation requires it. +- Knip runs with configuration-executing plugins disabled; its plugin toggles are generated from the complete plugin registry of the pinned Knip version and verified by a test. Framework entry points are matched as static file patterns and parsed, never executed, but other supported tools can still load repository configuration where their operation requires it. - Local launcher containers bind-mount the current repository at `/work` read-write. ## Container users @@ -17,6 +17,7 @@ code-quality analyzes untrusted repository content with tools that may execute c ## Environment handling +- `/opt/corepack`, `/opt/python`, and `/opt/venv314` are root-owned and read-only for the `node` user. Under the reusable workflow (`--user root`), a setup command can write them, and anything cached there remains in scope for the rest of the job; the consumer `packageManager` field selects what corepack resolves. - Tool subprocesses receive an allow-list plus constant and invocation-specific values, not the complete parent environment. - The npm launcher forwards `CI`, `GITHUB_ACTIONS`, `NO_COLOR`, and `FORCE_COLOR` into the analysis container. - When host stdout is a TTY and neither `NO_COLOR` nor `FORCE_COLOR` is set, the launcher injects `FORCE_COLOR=1`; it invokes `docker run` without `-t`, so container stdout is never a TTY. @@ -48,4 +49,7 @@ code-quality analyzes untrusted repository content with tools that may execute c - Global npm packages' transitive dependencies and Python packages are pinned only at the top level. - Composer dependencies are fully locked. - Downloaded phars are pinned and checksum-verified. +- uv is installed from PyPI by version pin without a hash. +- CPython 3.14.7 integrity relies on uv's bundled download metadata. +- `corepack prepare` specifications carry no integrity hash. - These are accepted v1 limits; a tool-version change is a reviewed policy change, not an automatic baseline update. diff --git a/fixtures/drupal-project/.code-quality.yml b/fixtures/drupal-project/.code-quality.yml new file mode 100644 index 0000000..63a8883 --- /dev/null +++ b/fixtures/drupal-project/.code-quality.yml @@ -0,0 +1,4 @@ +checks: + disabled: + - id: unused + reason: "fixture has no installed Composer dependencies" diff --git a/fixtures/drupal-project/.github/workflows/quality.yml b/fixtures/drupal-project/.github/workflows/quality.yml new file mode 100644 index 0000000..8a37475 --- /dev/null +++ b/fixtures/drupal-project/.github/workflows/quality.yml @@ -0,0 +1,6 @@ +name: quality +on: [pull_request, push] +permissions: { contents: read, packages: read } +jobs: + quality: + uses: Runroom/code-quality/.github/workflows/quality.yml@v1 diff --git a/fixtures/drupal-project/Makefile b/fixtures/drupal-project/Makefile new file mode 100644 index 0000000..d5fda54 --- /dev/null +++ b/fixtures/drupal-project/Makefile @@ -0,0 +1,21 @@ +# code-quality — run these through make; `$$PWD` is Make escaping for `$PWD` +CODE_QUALITY_IMAGE ?= ghcr.io/runroom/code-quality:v1 +# Must be a single image reference; it is interpolated into docker run unquoted. +CODE_QUALITY = docker run --rm -v "$$PWD:/work" $(CODE_QUALITY_IMAGE) + +.PHONY: quality quality-all quality-baseline quality-report quality-doctor + +quality: + $(CODE_QUALITY) check + +quality-all: + $(CODE_QUALITY) check --all + +quality-baseline: + $(CODE_QUALITY) baseline + +quality-report: + $(CODE_QUALITY) report + +quality-doctor: + $(CODE_QUALITY) doctor diff --git a/fixtures/drupal-project/composer.json b/fixtures/drupal-project/composer.json new file mode 100644 index 0000000..b978596 --- /dev/null +++ b/fixtures/drupal-project/composer.json @@ -0,0 +1 @@ +{"name":"fixture/drupal","require":{"drupal/core-recommended":"^11"}} diff --git a/fixtures/drupal-project/quality/php-cognitive-baseline.json b/fixtures/drupal-project/quality/php-cognitive-baseline.json new file mode 100644 index 0000000..9ab7c8b --- /dev/null +++ b/fixtures/drupal-project/quality/php-cognitive-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "phpcs@4.0.4", + "configHash": "788bbffbbd6d43bd4fcab5854d279bebdf4215471f87f6d6b60cd83bacc49260", + "findings": {} +} diff --git a/fixtures/drupal-project/quality/php-complexity-baseline.json b/fixtures/drupal-project/quality/php-complexity-baseline.json new file mode 100644 index 0000000..9ab7c8b --- /dev/null +++ b/fixtures/drupal-project/quality/php-complexity-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "phpcs@4.0.4", + "configHash": "788bbffbbd6d43bd4fcab5854d279bebdf4215471f87f6d6b60cd83bacc49260", + "findings": {} +} diff --git a/fixtures/drupal-project/quality/php-duplication-baseline.json b/fixtures/drupal-project/quality/php-duplication-baseline.json new file mode 100644 index 0000000..dfc2ad3 --- /dev/null +++ b/fixtures/drupal-project/quality/php-duplication-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "jscpd@5.2.0", + "configHash": "788bbffbbd6d43bd4fcab5854d279bebdf4215471f87f6d6b60cd83bacc49260", + "findings": {} +} diff --git a/fixtures/drupal-project/quality/web-duplication-baseline.json b/fixtures/drupal-project/quality/web-duplication-baseline.json new file mode 100644 index 0000000..dfc2ad3 --- /dev/null +++ b/fixtures/drupal-project/quality/web-duplication-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "jscpd@5.2.0", + "configHash": "788bbffbbd6d43bd4fcab5854d279bebdf4215471f87f6d6b60cd83bacc49260", + "findings": {} +} diff --git a/fixtures/drupal-project/web/modules/custom/demo/demo.install b/fixtures/drupal-project/web/modules/custom/demo/demo.install new file mode 100644 index 0000000..cf03458 --- /dev/null +++ b/fixtures/drupal-project/web/modules/custom/demo/demo.install @@ -0,0 +1,7 @@ + +

{{ page.title }}

+
+ {{ page.content }} +
+ diff --git a/fixtures/monorepo-project/.code-quality.yml b/fixtures/monorepo-project/.code-quality.yml new file mode 100644 index 0000000..f236e25 --- /dev/null +++ b/fixtures/monorepo-project/.code-quality.yml @@ -0,0 +1 @@ +languages: [ts] diff --git a/fixtures/monorepo-project/.github/workflows/quality.yml b/fixtures/monorepo-project/.github/workflows/quality.yml new file mode 100644 index 0000000..8a37475 --- /dev/null +++ b/fixtures/monorepo-project/.github/workflows/quality.yml @@ -0,0 +1,6 @@ +name: quality +on: [pull_request, push] +permissions: { contents: read, packages: read } +jobs: + quality: + uses: Runroom/code-quality/.github/workflows/quality.yml@v1 diff --git a/fixtures/monorepo-project/Makefile b/fixtures/monorepo-project/Makefile new file mode 100644 index 0000000..d5fda54 --- /dev/null +++ b/fixtures/monorepo-project/Makefile @@ -0,0 +1,21 @@ +# code-quality — run these through make; `$$PWD` is Make escaping for `$PWD` +CODE_QUALITY_IMAGE ?= ghcr.io/runroom/code-quality:v1 +# Must be a single image reference; it is interpolated into docker run unquoted. +CODE_QUALITY = docker run --rm -v "$$PWD:/work" $(CODE_QUALITY_IMAGE) + +.PHONY: quality quality-all quality-baseline quality-report quality-doctor + +quality: + $(CODE_QUALITY) check + +quality-all: + $(CODE_QUALITY) check --all + +quality-baseline: + $(CODE_QUALITY) baseline + +quality-report: + $(CODE_QUALITY) report + +quality-doctor: + $(CODE_QUALITY) doctor diff --git a/fixtures/monorepo-project/apps/web/package.json b/fixtures/monorepo-project/apps/web/package.json new file mode 100644 index 0000000..fdfafac --- /dev/null +++ b/fixtures/monorepo-project/apps/web/package.json @@ -0,0 +1 @@ +{"name":"@fixture/web","private":true,"type":"module"} diff --git a/fixtures/monorepo-project/apps/web/src/main.ts b/fixtures/monorepo-project/apps/web/src/main.ts new file mode 100644 index 0000000..7d9efd2 --- /dev/null +++ b/fixtures/monorepo-project/apps/web/src/main.ts @@ -0,0 +1,3 @@ +export function webValue(): string { + return "web"; +} diff --git a/fixtures/monorepo-project/package.json b/fixtures/monorepo-project/package.json new file mode 100644 index 0000000..4796ffe --- /dev/null +++ b/fixtures/monorepo-project/package.json @@ -0,0 +1 @@ +{"name":"monorepo-fixture","private":true,"type":"module","packageManager":"pnpm@10.17.1"} diff --git a/fixtures/monorepo-project/packages/core/package.json b/fixtures/monorepo-project/packages/core/package.json new file mode 100644 index 0000000..9a4fe74 --- /dev/null +++ b/fixtures/monorepo-project/packages/core/package.json @@ -0,0 +1 @@ +{"name":"@fixture/core","private":true,"type":"module"} diff --git a/fixtures/monorepo-project/packages/core/src/index.ts b/fixtures/monorepo-project/packages/core/src/index.ts new file mode 100644 index 0000000..2cf038e --- /dev/null +++ b/fixtures/monorepo-project/packages/core/src/index.ts @@ -0,0 +1,3 @@ +export function coreValue(): string { + return "core"; +} diff --git a/fixtures/monorepo-project/pnpm-lock.yaml b/fixtures/monorepo-project/pnpm-lock.yaml new file mode 100644 index 0000000..e565e11 --- /dev/null +++ b/fixtures/monorepo-project/pnpm-lock.yaml @@ -0,0 +1,13 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} + + apps/web: {} + + packages/core: {} diff --git a/fixtures/monorepo-project/pnpm-workspace.yaml b/fixtures/monorepo-project/pnpm-workspace.yaml new file mode 100644 index 0000000..2602a6f --- /dev/null +++ b/fixtures/monorepo-project/pnpm-workspace.yaml @@ -0,0 +1 @@ +packages: ["packages/*", "apps/*"] diff --git a/fixtures/monorepo-project/quality/ts-cognitive-baseline.json b/fixtures/monorepo-project/quality/ts-cognitive-baseline.json new file mode 100644 index 0000000..69c7fba --- /dev/null +++ b/fixtures/monorepo-project/quality/ts-cognitive-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "fallow@3.23.0", + "configHash": "88b87f9ea62ee2e3991852bd7460e0a27c882032757df6cc05de9aca45a891f5", + "findings": {} +} diff --git a/fixtures/monorepo-project/quality/ts-complexity-baseline.json b/fixtures/monorepo-project/quality/ts-complexity-baseline.json new file mode 100644 index 0000000..9b72f40 --- /dev/null +++ b/fixtures/monorepo-project/quality/ts-complexity-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "oxlint@1.82.0", + "configHash": "88b87f9ea62ee2e3991852bd7460e0a27c882032757df6cc05de9aca45a891f5", + "findings": {} +} diff --git a/fixtures/monorepo-project/quality/ts-duplication-baseline.json b/fixtures/monorepo-project/quality/ts-duplication-baseline.json new file mode 100644 index 0000000..bc1b2d2 --- /dev/null +++ b/fixtures/monorepo-project/quality/ts-duplication-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "jscpd@5.2.0", + "configHash": "88b87f9ea62ee2e3991852bd7460e0a27c882032757df6cc05de9aca45a891f5", + "findings": {} +} diff --git a/fixtures/monorepo-project/quality/ts-unused-baseline.json b/fixtures/monorepo-project/quality/ts-unused-baseline.json new file mode 100644 index 0000000..d666c26 --- /dev/null +++ b/fixtures/monorepo-project/quality/ts-unused-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "knip@6.35.1", + "configHash": "88b87f9ea62ee2e3991852bd7460e0a27c882032757df6cc05de9aca45a891f5", + "findings": {} +} diff --git a/fixtures/monorepo-project/tsconfig.json b/fixtures/monorepo-project/tsconfig.json new file mode 100644 index 0000000..4c867b4 --- /dev/null +++ b/fixtures/monorepo-project/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/fixtures/payload-project/.code-quality.yml b/fixtures/payload-project/.code-quality.yml new file mode 100644 index 0000000..f236e25 --- /dev/null +++ b/fixtures/payload-project/.code-quality.yml @@ -0,0 +1 @@ +languages: [ts] diff --git a/fixtures/payload-project/.github/workflows/quality.yml b/fixtures/payload-project/.github/workflows/quality.yml new file mode 100644 index 0000000..8a37475 --- /dev/null +++ b/fixtures/payload-project/.github/workflows/quality.yml @@ -0,0 +1,6 @@ +name: quality +on: [pull_request, push] +permissions: { contents: read, packages: read } +jobs: + quality: + uses: Runroom/code-quality/.github/workflows/quality.yml@v1 diff --git a/fixtures/payload-project/Makefile b/fixtures/payload-project/Makefile new file mode 100644 index 0000000..d5fda54 --- /dev/null +++ b/fixtures/payload-project/Makefile @@ -0,0 +1,21 @@ +# code-quality — run these through make; `$$PWD` is Make escaping for `$PWD` +CODE_QUALITY_IMAGE ?= ghcr.io/runroom/code-quality:v1 +# Must be a single image reference; it is interpolated into docker run unquoted. +CODE_QUALITY = docker run --rm -v "$$PWD:/work" $(CODE_QUALITY_IMAGE) + +.PHONY: quality quality-all quality-baseline quality-report quality-doctor + +quality: + $(CODE_QUALITY) check + +quality-all: + $(CODE_QUALITY) check --all + +quality-baseline: + $(CODE_QUALITY) baseline + +quality-report: + $(CODE_QUALITY) report + +quality-doctor: + $(CODE_QUALITY) doctor diff --git a/fixtures/payload-project/next.config.mjs b/fixtures/payload-project/next.config.mjs new file mode 100644 index 0000000..d2b5cdb --- /dev/null +++ b/fixtures/payload-project/next.config.mjs @@ -0,0 +1,3 @@ +/** @type {import("next").NextConfig} */ +const config = {}; +export default config; diff --git a/fixtures/payload-project/package.json b/fixtures/payload-project/package.json new file mode 100644 index 0000000..1479335 --- /dev/null +++ b/fixtures/payload-project/package.json @@ -0,0 +1 @@ +{"name":"payload-fixture","private":true,"type":"module","dependencies":{"next":"file:stubs/next","payload":"file:stubs/payload"},"devDependencies":{"vitest":"file:stubs/vitest","@playwright/test":"file:stubs/playwright-test"}} diff --git a/fixtures/payload-project/playwright.config.ts b/fixtures/payload-project/playwright.config.ts new file mode 100644 index 0000000..142a177 --- /dev/null +++ b/fixtures/payload-project/playwright.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({}); diff --git a/fixtures/payload-project/quality/ts-cognitive-baseline.json b/fixtures/payload-project/quality/ts-cognitive-baseline.json new file mode 100644 index 0000000..a5489eb --- /dev/null +++ b/fixtures/payload-project/quality/ts-cognitive-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "fallow@3.23.0", + "configHash": "beaa41f62a18fac8e6869e8c035c9dfc258c4aa2e57aaf93f0db204cf6ff9f00", + "findings": {} +} diff --git a/fixtures/payload-project/quality/ts-complexity-baseline.json b/fixtures/payload-project/quality/ts-complexity-baseline.json new file mode 100644 index 0000000..bc45494 --- /dev/null +++ b/fixtures/payload-project/quality/ts-complexity-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "oxlint@1.82.0", + "configHash": "beaa41f62a18fac8e6869e8c035c9dfc258c4aa2e57aaf93f0db204cf6ff9f00", + "findings": {} +} diff --git a/fixtures/payload-project/quality/ts-duplication-baseline.json b/fixtures/payload-project/quality/ts-duplication-baseline.json new file mode 100644 index 0000000..462a5b8 --- /dev/null +++ b/fixtures/payload-project/quality/ts-duplication-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "jscpd@5.2.0", + "configHash": "beaa41f62a18fac8e6869e8c035c9dfc258c4aa2e57aaf93f0db204cf6ff9f00", + "findings": {} +} diff --git a/fixtures/payload-project/quality/ts-unused-baseline.json b/fixtures/payload-project/quality/ts-unused-baseline.json new file mode 100644 index 0000000..1d76605 --- /dev/null +++ b/fixtures/payload-project/quality/ts-unused-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "knip@6.35.1", + "configHash": "beaa41f62a18fac8e6869e8c035c9dfc258c4aa2e57aaf93f0db204cf6ff9f00", + "findings": {} +} diff --git a/fixtures/payload-project/src/app/(payload)/backoffice/importMap.js b/fixtures/payload-project/src/app/(payload)/backoffice/importMap.js new file mode 100644 index 0000000..6830e73 --- /dev/null +++ b/fixtures/payload-project/src/app/(payload)/backoffice/importMap.js @@ -0,0 +1,2 @@ +// Generated by Payload. Do not edit. +export const importMap = {}; diff --git a/fixtures/payload-project/src/app/(payload)/layout.tsx b/fixtures/payload-project/src/app/(payload)/layout.tsx new file mode 100644 index 0000000..2732cd2 --- /dev/null +++ b/fixtures/payload-project/src/app/(payload)/layout.tsx @@ -0,0 +1,4 @@ +// Generated by Payload. Do not edit. +export default function PayloadLayout(): string { + return "payload"; +} diff --git a/fixtures/payload-project/src/app/layout.tsx b/fixtures/payload-project/src/app/layout.tsx new file mode 100644 index 0000000..abfd531 --- /dev/null +++ b/fixtures/payload-project/src/app/layout.tsx @@ -0,0 +1,3 @@ +export default function Page(): string { + return "page"; +} diff --git a/fixtures/payload-project/src/app/page.tsx b/fixtures/payload-project/src/app/page.tsx new file mode 100644 index 0000000..abfd531 --- /dev/null +++ b/fixtures/payload-project/src/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page(): string { + return "page"; +} diff --git a/fixtures/payload-project/src/collections/Users.ts b/fixtures/payload-project/src/collections/Users.ts new file mode 100644 index 0000000..12c7290 --- /dev/null +++ b/fixtures/payload-project/src/collections/Users.ts @@ -0,0 +1 @@ +export const Users = { slug: "users", fields: [] }; diff --git a/fixtures/payload-project/src/migrations/20260101_000000.ts b/fixtures/payload-project/src/migrations/20260101_000000.ts new file mode 100644 index 0000000..c5e60fa --- /dev/null +++ b/fixtures/payload-project/src/migrations/20260101_000000.ts @@ -0,0 +1 @@ +export async function up(): Promise {} diff --git a/fixtures/payload-project/src/payload-types.ts b/fixtures/payload-project/src/payload-types.ts new file mode 100644 index 0000000..65b0671 --- /dev/null +++ b/fixtures/payload-project/src/payload-types.ts @@ -0,0 +1,3 @@ +export interface User { + id: string; +} diff --git a/fixtures/payload-project/src/payload.config.ts b/fixtures/payload-project/src/payload.config.ts new file mode 100644 index 0000000..381ea8e --- /dev/null +++ b/fixtures/payload-project/src/payload.config.ts @@ -0,0 +1,4 @@ +import { buildConfig } from "payload"; +import { Users } from "./collections/Users.ts"; + +export default buildConfig({ collections: [Users] }); diff --git a/fixtures/payload-project/src/seed/seed.ts b/fixtures/payload-project/src/seed/seed.ts new file mode 100644 index 0000000..232ee66 --- /dev/null +++ b/fixtures/payload-project/src/seed/seed.ts @@ -0,0 +1 @@ +export async function seed(): Promise {} diff --git a/fixtures/payload-project/stubs/next/index.d.ts b/fixtures/payload-project/stubs/next/index.d.ts new file mode 100644 index 0000000..45bf247 --- /dev/null +++ b/fixtures/payload-project/stubs/next/index.d.ts @@ -0,0 +1 @@ +export type NextConfig = Record; diff --git a/fixtures/payload-project/stubs/next/index.js b/fixtures/payload-project/stubs/next/index.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/fixtures/payload-project/stubs/next/index.js @@ -0,0 +1 @@ +export {}; diff --git a/fixtures/payload-project/stubs/next/package.json b/fixtures/payload-project/stubs/next/package.json new file mode 100644 index 0000000..354fae2 --- /dev/null +++ b/fixtures/payload-project/stubs/next/package.json @@ -0,0 +1 @@ +{"name":"next","version":"0.0.0","main":"index.js","types":"index.d.ts"} diff --git a/fixtures/payload-project/stubs/payload/index.d.ts b/fixtures/payload-project/stubs/payload/index.d.ts new file mode 100644 index 0000000..2e50a93 --- /dev/null +++ b/fixtures/payload-project/stubs/payload/index.d.ts @@ -0,0 +1 @@ +export function buildConfig(config: unknown): unknown; diff --git a/fixtures/payload-project/stubs/payload/index.js b/fixtures/payload-project/stubs/payload/index.js new file mode 100644 index 0000000..2a2d5d0 --- /dev/null +++ b/fixtures/payload-project/stubs/payload/index.js @@ -0,0 +1,3 @@ +export function buildConfig(config) { + return config; +} diff --git a/fixtures/payload-project/stubs/payload/package.json b/fixtures/payload-project/stubs/payload/package.json new file mode 100644 index 0000000..9733809 --- /dev/null +++ b/fixtures/payload-project/stubs/payload/package.json @@ -0,0 +1 @@ +{"name":"payload","version":"0.0.0","main":"index.js","types":"index.d.ts"} diff --git a/fixtures/payload-project/stubs/playwright-test/index.d.ts b/fixtures/payload-project/stubs/playwright-test/index.d.ts new file mode 100644 index 0000000..a1bd19d --- /dev/null +++ b/fixtures/payload-project/stubs/playwright-test/index.d.ts @@ -0,0 +1 @@ +export function defineConfig(config: unknown): unknown; diff --git a/fixtures/payload-project/stubs/playwright-test/index.js b/fixtures/payload-project/stubs/playwright-test/index.js new file mode 100644 index 0000000..64a4c49 --- /dev/null +++ b/fixtures/payload-project/stubs/playwright-test/index.js @@ -0,0 +1,3 @@ +export function defineConfig(config) { + return config; +} diff --git a/fixtures/payload-project/stubs/playwright-test/package.json b/fixtures/payload-project/stubs/playwright-test/package.json new file mode 100644 index 0000000..4727f67 --- /dev/null +++ b/fixtures/payload-project/stubs/playwright-test/package.json @@ -0,0 +1 @@ +{"name":"@playwright/test","version":"0.0.0","main":"index.js","types":"index.d.ts"} diff --git a/fixtures/payload-project/stubs/vitest/index.d.ts b/fixtures/payload-project/stubs/vitest/index.d.ts new file mode 100644 index 0000000..5cc7d8e --- /dev/null +++ b/fixtures/payload-project/stubs/vitest/index.d.ts @@ -0,0 +1,2 @@ +export function defineConfig(config: unknown): unknown; +export const describe: unknown; diff --git a/fixtures/payload-project/stubs/vitest/index.js b/fixtures/payload-project/stubs/vitest/index.js new file mode 100644 index 0000000..de0b214 --- /dev/null +++ b/fixtures/payload-project/stubs/vitest/index.js @@ -0,0 +1,5 @@ +export function defineConfig(config) { + return config; +} + +export const describe = undefined; diff --git a/fixtures/payload-project/stubs/vitest/package.json b/fixtures/payload-project/stubs/vitest/package.json new file mode 100644 index 0000000..29e9141 --- /dev/null +++ b/fixtures/payload-project/stubs/vitest/package.json @@ -0,0 +1 @@ +{"name":"vitest","version":"0.0.0","main":"index.js","types":"index.d.ts"} diff --git a/fixtures/payload-project/tsconfig.json b/fixtures/payload-project/tsconfig.json new file mode 100644 index 0000000..a8a6a62 --- /dev/null +++ b/fixtures/payload-project/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "jsx": "preserve" + }, + "include": ["src"] +} diff --git a/fixtures/payload-project/vitest.config.ts b/fixtures/payload-project/vitest.config.ts new file mode 100644 index 0000000..0e98ec4 --- /dev/null +++ b/fixtures/payload-project/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from "vitest"; + +export default defineConfig({}); diff --git a/fixtures/php-project/quality/php-architecture-baseline.json b/fixtures/php-project/quality/php-architecture-baseline.json index c1d422b..3bfa68c 100644 --- a/fixtures/php-project/quality/php-architecture-baseline.json +++ b/fixtures/php-project/quality/php-architecture-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "deptrac@4.7.1", - "configHash": "e66bb9f2f4190d9dcc46e99eafc97dd48253112dc22634482abab9182d9af259", + "configHash": "289388ed5f404165811c82fc643dc408cd8010a59eff9dc59e1c78db8c0a1a88", "findings": { "src/Domain/Entity.php | deptrac:Domain-on-Infrastructure | /class:Entity/method:save": 1, "src/Infrastructure/Repo.php | deptrac:Infrastructure-on-Domain | /class:Repo/method:store": 1 diff --git a/fixtures/php-project/quality/php-cognitive-baseline.json b/fixtures/php-project/quality/php-cognitive-baseline.json index 9723c58..343a782 100644 --- a/fixtures/php-project/quality/php-cognitive-baseline.json +++ b/fixtures/php-project/quality/php-cognitive-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "phpcs@4.0.4", - "configHash": "e66bb9f2f4190d9dcc46e99eafc97dd48253112dc22634482abab9182d9af259", + "configHash": "289388ed5f404165811c82fc643dc408cd8010a59eff9dc59e1c78db8c0a1a88", "findings": { "src/Service/Complex.php | cognitive-complexity | /class:Complex/method:run": 17 } diff --git a/fixtures/php-project/quality/php-complexity-baseline.json b/fixtures/php-project/quality/php-complexity-baseline.json index c30323f..dcbfb4e 100644 --- a/fixtures/php-project/quality/php-complexity-baseline.json +++ b/fixtures/php-project/quality/php-complexity-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "phpcs@4.0.4", - "configHash": "e66bb9f2f4190d9dcc46e99eafc97dd48253112dc22634482abab9182d9af259", + "configHash": "289388ed5f404165811c82fc643dc408cd8010a59eff9dc59e1c78db8c0a1a88", "findings": { "src/Service/Complex.php | Generic.Metrics.CyclomaticComplexity | /class:Complex/method:run": 12, "src/Service/Complex.php | Generic.Metrics.NestingLevel | /class:Complex/method:run": 4, diff --git a/fixtures/php-project/quality/php-duplication-baseline.json b/fixtures/php-project/quality/php-duplication-baseline.json index 75057d1..a3800ed 100644 --- a/fixtures/php-project/quality/php-duplication-baseline.json +++ b/fixtures/php-project/quality/php-duplication-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "jscpd@5.2.0", - "configHash": "e66bb9f2f4190d9dcc46e99eafc97dd48253112dc22634482abab9182d9af259", + "configHash": "289388ed5f404165811c82fc643dc408cd8010a59eff9dc59e1c78db8c0a1a88", "findings": { "4e1f30eae51b9473": 1 } diff --git a/fixtures/php-project/quality/php-unused-composer-unused-baseline.json b/fixtures/php-project/quality/php-unused-composer-unused-baseline.json index 02df32a..e8583d6 100644 --- a/fixtures/php-project/quality/php-unused-composer-unused-baseline.json +++ b/fixtures/php-project/quality/php-unused-composer-unused-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "composer-unused@0.9.6", - "configHash": "e66bb9f2f4190d9dcc46e99eafc97dd48253112dc22634482abab9182d9af259", + "configHash": "289388ed5f404165811c82fc643dc408cd8010a59eff9dc59e1c78db8c0a1a88", "findings": { "composer.json | unused-package | psr/log": 1, "composer.json | unused-package | symfony/yaml": 1 diff --git a/fixtures/php-project/quality/php-unused-phpstan-baseline.json b/fixtures/php-project/quality/php-unused-phpstan-baseline.json index 05a236d..b56b051 100644 --- a/fixtures/php-project/quality/php-unused-phpstan-baseline.json +++ b/fixtures/php-project/quality/php-unused-phpstan-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "phpstan@2.2.13", - "configHash": "e66bb9f2f4190d9dcc46e99eafc97dd48253112dc22634482abab9182d9af259", + "configHash": "289388ed5f404165811c82fc643dc408cd8010a59eff9dc59e1c78db8c0a1a88", "findings": { "src/Domain/Entity.php | dead-code | /class:Entity/method:save#shipmonk.deadMethod#save": 1, "src/Service/Complex.php | dead-code | /class:Complex/method:run#shipmonk.deadMethod#run": 1, diff --git a/fixtures/php-project/quality/php-unused-require-checker-baseline.json b/fixtures/php-project/quality/php-unused-require-checker-baseline.json index 3779495..d638ad0 100644 --- a/fixtures/php-project/quality/php-unused-require-checker-baseline.json +++ b/fixtures/php-project/quality/php-unused-require-checker-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "composer-require-checker@4.24.0", - "configHash": "e66bb9f2f4190d9dcc46e99eafc97dd48253112dc22634482abab9182d9af259", + "configHash": "289388ed5f404165811c82fc643dc408cd8010a59eff9dc59e1c78db8c0a1a88", "findings": { "composer.json | unknown-symbol | Composer\\InstalledVersions": 1 } diff --git a/fixtures/python-project/quality/python-architecture-baseline.json b/fixtures/python-project/quality/python-architecture-baseline.json index 3e57c97..7272d2b 100644 --- a/fixtures/python-project/quality/python-architecture-baseline.json +++ b/fixtures/python-project/quality/python-architecture-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "lint-imports@2.15", - "configHash": "2b7ec686151a6782ee8a58da019f29b963f1ccfac954a4de036a6fc91cf30f78", + "configHash": "1864e3ffa415a8a465e63df9e079b80fa7770548a924223fd029429878f9b2f0", "findings": { "src/demo_app/domain/model.py | import-linter:Layers | demo_app.infra.db": 1 } diff --git a/fixtures/python-project/quality/python-cognitive-baseline.json b/fixtures/python-project/quality/python-cognitive-baseline.json index 32a55a1..90cccd4 100644 --- a/fixtures/python-project/quality/python-cognitive-baseline.json +++ b/fixtures/python-project/quality/python-cognitive-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "complexipy@8.0.1", - "configHash": "2b7ec686151a6782ee8a58da019f29b963f1ccfac954a4de036a6fc91cf30f78", + "configHash": "1864e3ffa415a8a465e63df9e079b80fa7770548a924223fd029429878f9b2f0", "findings": { "src/demo_app/complex.py | cognitive-complexity | /function:busy": 20 } diff --git a/fixtures/python-project/quality/python-complexity-baseline.json b/fixtures/python-project/quality/python-complexity-baseline.json index e0e9dd7..a62a8a4 100644 --- a/fixtures/python-project/quality/python-complexity-baseline.json +++ b/fixtures/python-project/quality/python-complexity-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "ruff@0.16.6", - "configHash": "2b7ec686151a6782ee8a58da019f29b963f1ccfac954a4de036a6fc91cf30f78", + "configHash": "1864e3ffa415a8a465e63df9e079b80fa7770548a924223fd029429878f9b2f0", "findings": { "src/demo_app/complex.py | C901 | /function:busy": 12, "src/demo_app/complex.py | PLR0913 | /function:busy": 5 diff --git a/fixtures/python-project/quality/python-duplication-baseline.json b/fixtures/python-project/quality/python-duplication-baseline.json index 8216251..762a62a 100644 --- a/fixtures/python-project/quality/python-duplication-baseline.json +++ b/fixtures/python-project/quality/python-duplication-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "jscpd@5.2.0", - "configHash": "2b7ec686151a6782ee8a58da019f29b963f1ccfac954a4de036a6fc91cf30f78", + "configHash": "1864e3ffa415a8a465e63df9e079b80fa7770548a924223fd029429878f9b2f0", "findings": { "534ad38acbaa6ef4": 1 } diff --git a/fixtures/python-project/quality/python-unused-deptry-baseline.json b/fixtures/python-project/quality/python-unused-deptry-baseline.json index ba5cd6d..3f9c418 100644 --- a/fixtures/python-project/quality/python-unused-deptry-baseline.json +++ b/fixtures/python-project/quality/python-unused-deptry-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "deptry@0.25.1", - "configHash": "2b7ec686151a6782ee8a58da019f29b963f1ccfac954a4de036a6fc91cf30f78", + "configHash": "1864e3ffa415a8a465e63df9e079b80fa7770548a924223fd029429878f9b2f0", "findings": { "pyproject.toml | deptry-DEP002 | requests": 1 } diff --git a/fixtures/python-project/quality/python-unused-vulture-baseline.json b/fixtures/python-project/quality/python-unused-vulture-baseline.json index 8b182c9..e74aa83 100644 --- a/fixtures/python-project/quality/python-unused-vulture-baseline.json +++ b/fixtures/python-project/quality/python-unused-vulture-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "vulture@2.16", - "configHash": "2b7ec686151a6782ee8a58da019f29b963f1ccfac954a4de036a6fc91cf30f78", + "configHash": "1864e3ffa415a8a465e63df9e079b80fa7770548a924223fd029429878f9b2f0", "findings": { "src/demo_app/client.py | vulture-function | /function:get_client#get_client": 1, "src/demo_app/complex.py | vulture-function | /function:busy#busy": 1, diff --git a/fixtures/python314-project/.code-quality.yml b/fixtures/python314-project/.code-quality.yml new file mode 100644 index 0000000..d55415f --- /dev/null +++ b/fixtures/python314-project/.code-quality.yml @@ -0,0 +1,5 @@ +languages: [python] +paths: + python: [src] +exclude: + - "**/.venv/**" diff --git a/fixtures/python314-project/.github/workflows/quality.yml b/fixtures/python314-project/.github/workflows/quality.yml new file mode 100644 index 0000000..8a37475 --- /dev/null +++ b/fixtures/python314-project/.github/workflows/quality.yml @@ -0,0 +1,6 @@ +name: quality +on: [pull_request, push] +permissions: { contents: read, packages: read } +jobs: + quality: + uses: Runroom/code-quality/.github/workflows/quality.yml@v1 diff --git a/fixtures/python314-project/.importlinter b/fixtures/python314-project/.importlinter new file mode 100644 index 0000000..c85d545 --- /dev/null +++ b/fixtures/python314-project/.importlinter @@ -0,0 +1,9 @@ +[importlinter] +root_package = demo314 + +[importlinter:contract:layers] +name = Layers +type = layers +layers = + demo314.app + demo314.util diff --git a/fixtures/python314-project/.python-version b/fixtures/python314-project/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/fixtures/python314-project/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/fixtures/python314-project/Makefile b/fixtures/python314-project/Makefile new file mode 100644 index 0000000..d5fda54 --- /dev/null +++ b/fixtures/python314-project/Makefile @@ -0,0 +1,21 @@ +# code-quality — run these through make; `$$PWD` is Make escaping for `$PWD` +CODE_QUALITY_IMAGE ?= ghcr.io/runroom/code-quality:v1 +# Must be a single image reference; it is interpolated into docker run unquoted. +CODE_QUALITY = docker run --rm -v "$$PWD:/work" $(CODE_QUALITY_IMAGE) + +.PHONY: quality quality-all quality-baseline quality-report quality-doctor + +quality: + $(CODE_QUALITY) check + +quality-all: + $(CODE_QUALITY) check --all + +quality-baseline: + $(CODE_QUALITY) baseline + +quality-report: + $(CODE_QUALITY) report + +quality-doctor: + $(CODE_QUALITY) doctor diff --git a/fixtures/python314-project/pyproject.toml b/fixtures/python314-project/pyproject.toml new file mode 100644 index 0000000..0e690e9 --- /dev/null +++ b/fixtures/python314-project/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "demo314" +version = "0.1.0" +requires-python = ">=3.14" +dependencies = [] + +[tool.uv] +package = false diff --git a/fixtures/python314-project/quality/python-architecture-baseline.json b/fixtures/python314-project/quality/python-architecture-baseline.json new file mode 100644 index 0000000..e0b076a --- /dev/null +++ b/fixtures/python314-project/quality/python-architecture-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "lint-imports@2.15", + "configHash": "d6504752db11a714a47a836e496cdf929c1a2b3c6f601a789f7ef0679aedc2bb", + "findings": {} +} diff --git a/fixtures/python314-project/quality/python-cognitive-baseline.json b/fixtures/python314-project/quality/python-cognitive-baseline.json new file mode 100644 index 0000000..9a01a14 --- /dev/null +++ b/fixtures/python314-project/quality/python-cognitive-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "complexipy@8.0.1", + "configHash": "d6504752db11a714a47a836e496cdf929c1a2b3c6f601a789f7ef0679aedc2bb", + "findings": {} +} diff --git a/fixtures/python314-project/quality/python-complexity-baseline.json b/fixtures/python314-project/quality/python-complexity-baseline.json new file mode 100644 index 0000000..0befa9e --- /dev/null +++ b/fixtures/python314-project/quality/python-complexity-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "ruff@0.16.6", + "configHash": "d6504752db11a714a47a836e496cdf929c1a2b3c6f601a789f7ef0679aedc2bb", + "findings": {} +} diff --git a/fixtures/python314-project/quality/python-duplication-baseline.json b/fixtures/python314-project/quality/python-duplication-baseline.json new file mode 100644 index 0000000..21749ed --- /dev/null +++ b/fixtures/python314-project/quality/python-duplication-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "jscpd@5.2.0", + "configHash": "d6504752db11a714a47a836e496cdf929c1a2b3c6f601a789f7ef0679aedc2bb", + "findings": {} +} diff --git a/fixtures/python314-project/quality/python-unused-deptry-baseline.json b/fixtures/python314-project/quality/python-unused-deptry-baseline.json new file mode 100644 index 0000000..2c44e8c --- /dev/null +++ b/fixtures/python314-project/quality/python-unused-deptry-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "deptry@0.25.1", + "configHash": "d6504752db11a714a47a836e496cdf929c1a2b3c6f601a789f7ef0679aedc2bb", + "findings": {} +} diff --git a/fixtures/python314-project/quality/python-unused-vulture-baseline.json b/fixtures/python314-project/quality/python-unused-vulture-baseline.json new file mode 100644 index 0000000..5c2f667 --- /dev/null +++ b/fixtures/python314-project/quality/python-unused-vulture-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "vulture@2.16", + "configHash": "d6504752db11a714a47a836e496cdf929c1a2b3c6f601a789f7ef0679aedc2bb", + "findings": {} +} diff --git a/fixtures/python314-project/src/demo314/__init__.py b/fixtures/python314-project/src/demo314/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/python314-project/src/demo314/app.py b/fixtures/python314-project/src/demo314/app.py new file mode 100644 index 0000000..6ebdf3a --- /dev/null +++ b/fixtures/python314-project/src/demo314/app.py @@ -0,0 +1,14 @@ +from .util import fallback_name + + +def render(name: str) -> str: + safe_name = fallback_name(name) + try: + template = t"hello {safe_name}" + return str(template) + except ValueError, TypeError: + return "hello world" + + +if __name__ == "__main__": + print(render("world")) diff --git a/fixtures/python314-project/src/demo314/util.py b/fixtures/python314-project/src/demo314/util.py new file mode 100644 index 0000000..cc433aa --- /dev/null +++ b/fixtures/python314-project/src/demo314/util.py @@ -0,0 +1,2 @@ +def fallback_name(name: str) -> str: + return name.strip() or "world" diff --git a/fixtures/python314-project/uv.lock b/fixtures/python314-project/uv.lock new file mode 100644 index 0000000..4d5a29c --- /dev/null +++ b/fixtures/python314-project/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "demo314" +version = "0.1.0" +source = { virtual = "." } diff --git a/fixtures/ts-project/quality/ts-architecture-baseline.json b/fixtures/ts-project/quality/ts-architecture-baseline.json index 20444f0..cfae244 100644 --- a/fixtures/ts-project/quality/ts-architecture-baseline.json +++ b/fixtures/ts-project/quality/ts-architecture-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "depcruise@18.2.0", - "configHash": "30e5965bab93b756d73c3815d4e68fbd5b0e19b797c6b32a80dce524f7fb52b5", + "configHash": "09cea93df07b517f2cc1af3418a1bcca1128f0b056897ba948a03afad4aa1609", "findings": { "src/ui/view.ts | no-ui-to-db | src/db/repo.ts": 1 } diff --git a/fixtures/ts-project/quality/ts-cognitive-baseline.json b/fixtures/ts-project/quality/ts-cognitive-baseline.json index 120ca8d..cbf5e86 100644 --- a/fixtures/ts-project/quality/ts-cognitive-baseline.json +++ b/fixtures/ts-project/quality/ts-cognitive-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "fallow@3.23.0", - "configHash": "30e5965bab93b756d73c3815d4e68fbd5b0e19b797c6b32a80dce524f7fb52b5", + "configHash": "09cea93df07b517f2cc1af3418a1bcca1128f0b056897ba948a03afad4aa1609", "findings": { "src/cognitive.ts | cognitive-complexity | /function:nested": 21, "src/complexity.ts | cognitive-complexity | /function:busy": 21 diff --git a/fixtures/ts-project/quality/ts-complexity-baseline.json b/fixtures/ts-project/quality/ts-complexity-baseline.json index 3073ffc..1483af5 100644 --- a/fixtures/ts-project/quality/ts-complexity-baseline.json +++ b/fixtures/ts-project/quality/ts-complexity-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "oxlint@1.82.0", - "configHash": "30e5965bab93b756d73c3815d4e68fbd5b0e19b797c6b32a80dce524f7fb52b5", + "configHash": "09cea93df07b517f2cc1af3418a1bcca1128f0b056897ba948a03afad4aa1609", "findings": { "src/cognitive.ts | eslint(max-depth) | /function:nested/if/block/if/block/if/block/if": 4, "src/cognitive.ts | eslint(max-depth) | /function:nested/if/block/if/block/if/block/if/block/if": 5, diff --git a/fixtures/ts-project/quality/ts-duplication-baseline.json b/fixtures/ts-project/quality/ts-duplication-baseline.json index f7f7e7c..05e9609 100644 --- a/fixtures/ts-project/quality/ts-duplication-baseline.json +++ b/fixtures/ts-project/quality/ts-duplication-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "jscpd@5.2.0", - "configHash": "30e5965bab93b756d73c3815d4e68fbd5b0e19b797c6b32a80dce524f7fb52b5", + "configHash": "09cea93df07b517f2cc1af3418a1bcca1128f0b056897ba948a03afad4aa1609", "findings": { "52345a7776aa96cf": 1, "f8c7fd6b65e1d3fa": 1 diff --git a/fixtures/ts-project/quality/ts-unused-baseline.json b/fixtures/ts-project/quality/ts-unused-baseline.json index aec01d7..407fb2f 100644 --- a/fixtures/ts-project/quality/ts-unused-baseline.json +++ b/fixtures/ts-project/quality/ts-unused-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "knip@6.35.1", - "configHash": "30e5965bab93b756d73c3815d4e68fbd5b0e19b797c6b32a80dce524f7fb52b5", + "configHash": "09cea93df07b517f2cc1af3418a1bcca1128f0b056897ba948a03afad4aa1609", "findings": { "package.json | unused-dependency | left-pad": 1, "src/cognitive.ts | unused-file | src/cognitive.ts": 1, diff --git a/fixtures/web-project/quality/web-duplication-baseline.json b/fixtures/web-project/quality/web-duplication-baseline.json index cd4567a..cd56842 100644 --- a/fixtures/web-project/quality/web-duplication-baseline.json +++ b/fixtures/web-project/quality/web-duplication-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "jscpd@5.2.0", - "configHash": "69302b9cc817fc2cb7ea00d5e9b0074cc766cefb27338ed8f509a76cf5f51d5f", + "configHash": "81a07efba6c46a7fab23adec58c898034dab35b276ff98d48bad64dba1395541", "findings": { "49b1e091be111932": 1, "bf23efa9a8a4e58b": 1 diff --git a/package.json b/package.json index 29f7349..da42a79 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dependencies": { "commander": "15.0.0", "picomatch": "4.0.7", + "smol-toml": "1.8.0", "tree-sitter-javascript": "0.25.0", "tree-sitter-php": "0.24.2", "tree-sitter-python": "0.25.0", @@ -33,6 +34,7 @@ "@types/node": "24.13.3", "@types/picomatch": "4.0.3", "esbuild": "0.28.2", + "knip": "6.35.1", "oxlint": "1.81.0", "typescript": "5.9.3", "vitest": "4.1.11" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb2f399..008f537 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: picomatch: specifier: 4.0.7 version: 4.0.7 + smol-toml: + specifier: 1.8.0 + version: 1.8.0 tree-sitter-javascript: specifier: 0.25.0 version: 0.25.0 @@ -45,6 +48,9 @@ importers: esbuild: specifier: 0.28.2 version: 0.28.2 + knip: + specifier: 6.35.1 + version: 6.35.1 oxlint: specifier: 1.81.0 version: 1.81.0 @@ -53,10 +59,19 @@ importers: version: 5.9.3 vitest: specifier: 4.1.11 - version: 4.1.11(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(yaml@2.9.0)) + version: 4.1.11(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) packages: + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} @@ -216,9 +231,241 @@ packages: '@jridgewell/sourcemap-codec@1.6.0': resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + '@napi-rs/wasm-runtime@1.2.4': + resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + + '@oxc-parser/binding-android-arm-eabi@0.148.0': + resolution: {integrity: sha512-pHASv9g5pASxb7akHERZNSkrEqPhFaUix98o7d9hbTpolnnFWl7UiRrcMhCsV1+iVO4/cJwKsbKRJTFNs2tdBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.148.0': + resolution: {integrity: sha512-sg/6Ez0KdAygsu0POELux9wN1Po2CP93WY8eNl4DBKIGprsd4QSHBXOb471Pu9i2OCD5sLkISSb2agZEhVn2Zw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.148.0': + resolution: {integrity: sha512-yiSJmzGUvCUaJT8X3j40gVcX+ckuHQMuiOtF8DvzTs5+JtB/7XuHFPp4M+vv5u+HlBtDUd4Ks5pyHpWz8mfnkg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.148.0': + resolution: {integrity: sha512-6ZeklaamrMy4H2JmhvcJg6iip59tYILtuLaILxyAHT3l5FDxnI5ihVievAft5ZmAbqtlWHErOi1OpJK8gy1wcA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.148.0': + resolution: {integrity: sha512-vFsPx+a/qFECPnz/H8nC6x6MDvnWscLTCo/5muojEF54ERUq1kdgbvnWo95YnkhjF9sTIcG/uDxQBh1gffaufQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.148.0': + resolution: {integrity: sha512-eOr3M+6iGbbxNL4PSS0VtsyQ2eOUxSBh00BqO22SbolDimPSYsBuLr/LCrZBkiqW2BoabhR6V4R8jrRAay7hjg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.148.0': + resolution: {integrity: sha512-58ZKDw0mQRbCNfrd2IDyV4o8T7enzGERJn41BH2tjrZVGyiKiFzcfDicuB7Zcpb/1xIOrObovr8Dja6lZi8dLw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.148.0': + resolution: {integrity: sha512-Fnu95O4eZ5i++GPvIzBEZ8y4ddTLR+D9paYa8JRaRk6ZK7nHQiWP5xtrhcPQsXqgat1d7sU/d5rbbI0p1FTHSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-arm64-musl@0.148.0': + resolution: {integrity: sha512-3CQy/BMdx7N7H3qrcPxUL+a2CwUZodUcf6oq8iJuNZ9C6Ol1aq3mcWzsgySJ7CHFLvpX21ZDPp1r1X0QLbu/AQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-ppc64-gnu@0.148.0': + resolution: {integrity: sha512-9LkaYvfiF8hMOw900csAvkf1oxE8XlmMeGowu5BcastSSwV8mKvKRMNU7HsV+ycyj1dQD8pX5qgOw8ja6SJacg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-gnu@0.148.0': + resolution: {integrity: sha512-2GBiM9h26dR4WJfhoMvnFMnFLf7m/kYs4UMqjvrOfQG4BV1nuTJDH22Zc2MQr3INZF7nSKYQ6xlhD3hQ7A6gug==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-musl@0.148.0': + resolution: {integrity: sha512-uPqZexvKJmEgq4mAu36qe2xTfXZE7oyik1R7KtZ5tl8qKlq1U1fIqTFRUEBZqRGvforoTrGIpatRzcoPKO66RA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-s390x-gnu@0.148.0': + resolution: {integrity: sha512-9oUHvnTbp7ZraFsTC8PN6XhdhPSSxZumYvixWl7Smi353gEULvK6yV0sXNVrdFMHQeaDKFCi8TgDhNK7/A+Y+Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-gnu@0.148.0': + resolution: {integrity: sha512-2qhDSJwKzbSZzF7lDqqk8sr/yXsmwr3PeUa4/nazIF+zFAYz1gVPEfC34GQtGxzJUUmklaYAL63368LEfrMeyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-musl@0.148.0': + resolution: {integrity: sha512-qQoPDZUFV0bh9xA09XydmkjMBpgc1ukJuhMvzQ9QeVmFaHTS9W5TE5CoLmSl3QQyUP9OuHO3x/WPZTIIZPWR3Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-openharmony-arm64@0.148.0': + resolution: {integrity: sha512-1UGbaQWEXUCLqAmaR5kwRDjx/R4S5LQKZkM9CHmaHkuKhriOF32aRLfS0jCRNE2yGQJLMEA1z9UucbBVqjXnDw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-win32-arm64-msvc@0.148.0': + resolution: {integrity: sha512-pWKdzRDNG2+NK4h/V6U/CYERcfYD6u28h5IB/VJVsrZaD3muvE58tUj22lieL5vLZ+XFi1GPv9YXckZbJZ9BLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.148.0': + resolution: {integrity: sha512-i3p4x+mvwtjcE1J5HM6V7ggsbXiznExN/4MkNyOy3dfXrVV3bnkSfmZxvo6/84qCVX4ShkpNE1SKt9biIF31GQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.148.0': + resolution: {integrity: sha512-Ye6vB7VQulghWYkYkECOBYFRVEizz4XyRTUAv+t8BuyurhKU7uD0P9eowL+mKG5Mf8MSYx+DI3Cm8SKZvYG7bQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxc-project/types@0.148.0': resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==} + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.24.2': + resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@11.24.2': + resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==} + cpu: [arm64] + os: [openharmony] + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==} + cpu: [x64] + os: [win32] + '@oxlint/binding-android-arm-eabi@1.81.0': resolution: {integrity: sha512-IcCRsXiedJoJopY6mpZUBEeVFsUrutmrG7dZ87zMuKJlhg70Ora9bBl1WcCxZQtyI10YpnVdEso5oCg7YcfSHw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -443,6 +690,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tybys/wasm-util@0.10.4': + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -521,6 +771,9 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + fd-package-json@2.0.0: + resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -530,11 +783,28 @@ packages: picomatch: optional: true + formatly@0.7.0: + resolution: {integrity: sha512-7CXJtIIA0zy/u12StsYk25qVKxvdLA2ep2sTNxK3ov0mGNIIDqIvAXDSgTnAfDJFsPfWjuz0WjfYSdpvnLA5Tg==} + engines: {node: '>=18.3.0'} + hasBin: true + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-tsconfig@4.14.3: + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + knip@6.35.1: + resolution: {integrity: sha512-22wnEnv4do2fvoeJsxpFCG/MBxReNxoCVBccaCl7suUJsG+F0UvxI9ycxZ9YgtLHSSjrZl5tOas0JZ/R1vJ93g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} @@ -629,6 +899,13 @@ packages: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} + oxc-parser@0.148.0: + resolution: {integrity: sha512-syxUKHeUll89RIABQADcI7sikYrwyssvA6gj4phSSIPezKVM8yMaLAiLLSc7fmzVvrwybfFGFbW5zme9sX87rg==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-resolver@11.24.2: + resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} + oxlint@1.81.0: resolution: {integrity: sha512-HyrJYqeoOCL0iqaLEzGewGT48ZX99P3hxYh8udAF9RGGIghSamkXE4ClUyBpEDNqasamThgmlPbuMOe7SAZmHg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -642,6 +919,9 @@ packages: vite-plus: optional: true + package-manager-detector@1.8.0: + resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -656,6 +936,9 @@ packages: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + rolldown@1.2.7: resolution: {integrity: sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==} engines: {node: ^20.19.0 || >=22.12.0} @@ -664,6 +947,10 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + smol-toml@1.8.0: + resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} + engines: {node: '>= 18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -674,6 +961,10 @@ packages: std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -729,11 +1020,18 @@ packages: tree-sitter: optional: true + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true + unbash@4.0.11: + resolution: {integrity: sha512-FoSOKV7NEofQSkAefMVHam4ZPKYMxjAydxiV72UFEDNV/YofxjGfiZ2A9pZjdL/lRJzTjcu4PABo1JYJX8N5iQ==} + engines: {node: '>=14'} + undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} @@ -821,6 +1119,10 @@ packages: jsdom: optional: true + walk-up-path@4.0.0: + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} + engines: {node: 20 || >=22} + web-tree-sitter@0.27.0: resolution: {integrity: sha512-XK08gj6RwTMQatAG7uVRP8MunqotL/XC19vHgkSPKmELgbGPBj4ECvB8haHOUnyj6ls2B8t42UTro14zxGgAHg==} @@ -839,6 +1141,22 @@ packages: snapshots: + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.28.2': optional: true @@ -919,8 +1237,133 @@ snapshots: '@jridgewell/sourcemap-codec@1.6.0': {} + '@napi-rs/wasm-runtime@1.2.4(@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.4 + optional: true + + '@oxc-parser/binding-android-arm-eabi@0.148.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.148.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.148.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.148.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.148.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.148.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.148.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.148.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.148.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.148.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.148.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.148.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.148.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.148.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.148.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.148.0': + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.148.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.148.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.148.0': + optional: true + '@oxc-project/types@0.148.0': {} + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + optional: true + + '@oxc-resolver/binding-android-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + optional: true + '@oxlint/binding-android-arm-eabi@1.81.0': optional: true @@ -1027,6 +1470,11 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@tybys/wasm-util@0.10.4': + dependencies: + tslib: 2.8.1 + optional: true + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -1051,13 +1499,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(yaml@2.9.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.11': dependencies: @@ -1130,13 +1578,44 @@ snapshots: expect-type@1.4.0: {} + fd-package-json@2.0.0: + dependencies: + walk-up-path: 4.0.0 + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 + formatly@0.7.0: + dependencies: + fd-package-json: 2.0.0 + package-manager-detector: 1.8.0 + fsevents@2.3.3: optional: true + get-tsconfig@4.14.3: + dependencies: + resolve-pkg-maps: 1.0.0 + + jiti@2.7.0: {} + + knip@6.35.1: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + formatly: 0.7.0 + get-tsconfig: 4.14.3 + jiti: 2.7.0 + oxc-parser: 0.148.0 + oxc-resolver: 11.24.2 + picomatch: 4.0.7 + smol-toml: 1.8.0 + strip-json-comments: 5.0.3 + tinyglobby: 0.2.17 + unbash: 4.0.11 + yaml: 2.9.0 + zod: 4.5.4 + lightningcss-android-arm64@1.33.0: optional: true @@ -1198,6 +1677,52 @@ snapshots: obug@2.1.4: {} + oxc-parser@0.148.0: + dependencies: + '@oxc-project/types': 0.148.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.148.0 + '@oxc-parser/binding-android-arm64': 0.148.0 + '@oxc-parser/binding-darwin-arm64': 0.148.0 + '@oxc-parser/binding-darwin-x64': 0.148.0 + '@oxc-parser/binding-freebsd-x64': 0.148.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.148.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.148.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.148.0 + '@oxc-parser/binding-linux-arm64-musl': 0.148.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.148.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.148.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.148.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.148.0 + '@oxc-parser/binding-linux-x64-gnu': 0.148.0 + '@oxc-parser/binding-linux-x64-musl': 0.148.0 + '@oxc-parser/binding-openharmony-arm64': 0.148.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.148.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.148.0 + '@oxc-parser/binding-win32-x64-msvc': 0.148.0 + + oxc-resolver@11.24.2: + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.24.2 + '@oxc-resolver/binding-android-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-x64': 11.24.2 + '@oxc-resolver/binding-freebsd-x64': 11.24.2 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-arm64-musl': 11.24.2 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-musl': 11.24.2 + '@oxc-resolver/binding-linux-s390x-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-musl': 11.24.2 + '@oxc-resolver/binding-openharmony-arm64': 11.24.2 + '@oxc-resolver/binding-wasm32-wasi': 11.24.2 + '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 + '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 + oxlint@1.81.0: optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.81.0 @@ -1220,6 +1745,8 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.81.0 '@oxlint/binding-win32-x64-msvc': 1.81.0 + package-manager-detector@1.8.0: {} + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -1232,6 +1759,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + resolve-pkg-maps@1.0.0: {} + rolldown@1.2.7: dependencies: '@oxc-project/types': 0.148.0 @@ -1255,12 +1784,16 @@ snapshots: siginfo@2.0.0: {} + smol-toml@1.8.0: {} + source-map-js@1.2.1: {} stackback@0.0.2: {} std-env@4.2.0: {} + strip-json-comments@5.0.3: {} + tinybench@2.9.0: {} tinyexec@1.3.0: {} @@ -1298,11 +1831,16 @@ snapshots: node-gyp-build: 4.8.4 tree-sitter-javascript: 0.23.1 + tslib@2.8.1: + optional: true + typescript@5.9.3: {} + unbash@4.0.11: {} + undici-types@7.18.2: {} - vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(yaml@2.9.0): + vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -1313,12 +1851,13 @@ snapshots: '@types/node': 24.13.3 esbuild: 0.28.2 fsevents: 2.3.3 + jiti: 2.7.0 yaml: 2.9.0 - vitest@4.1.11(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(yaml@2.9.0)): + vitest@4.1.11(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(yaml@2.9.0)) + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -1335,13 +1874,15 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(yaml@2.9.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.3 transitivePeerDependencies: - msw + walk-up-path@4.0.0: {} + web-tree-sitter@0.27.0: {} why-is-node-running@2.3.0: diff --git a/quality/ts-architecture-baseline.json b/quality/ts-architecture-baseline.json index 4b7acb0..1a4a76a 100644 --- a/quality/ts-architecture-baseline.json +++ b/quality/ts-architecture-baseline.json @@ -1,6 +1,6 @@ { "version": 1, "tool": "depcruise@18.2.0", - "configHash": "22f4a18e604b10488c376219de2a32dec314621ccbd2a2263c9414ba70ff2301", + "configHash": "5efc8f54a48ed2847d32f8e54797f7fbecb80d40283bfa6a5b922fd4503610db", "findings": {} } diff --git a/quality/ts-cognitive-baseline.json b/quality/ts-cognitive-baseline.json index 5e14e29..a80bec9 100644 --- a/quality/ts-cognitive-baseline.json +++ b/quality/ts-cognitive-baseline.json @@ -1,6 +1,6 @@ { "version": 1, "tool": "fallow@3.23.0", - "configHash": "22f4a18e604b10488c376219de2a32dec314621ccbd2a2263c9414ba70ff2301", + "configHash": "5efc8f54a48ed2847d32f8e54797f7fbecb80d40283bfa6a5b922fd4503610db", "findings": {} } diff --git a/quality/ts-complexity-baseline.json b/quality/ts-complexity-baseline.json index 2c221a8..544cb0e 100644 --- a/quality/ts-complexity-baseline.json +++ b/quality/ts-complexity-baseline.json @@ -1,6 +1,6 @@ { "version": 1, "tool": "oxlint@1.82.0", - "configHash": "22f4a18e604b10488c376219de2a32dec314621ccbd2a2263c9414ba70ff2301", + "configHash": "5efc8f54a48ed2847d32f8e54797f7fbecb80d40283bfa6a5b922fd4503610db", "findings": {} } diff --git a/quality/ts-duplication-baseline.json b/quality/ts-duplication-baseline.json index 76f40bb..19bdf78 100644 --- a/quality/ts-duplication-baseline.json +++ b/quality/ts-duplication-baseline.json @@ -1,6 +1,6 @@ { "version": 1, "tool": "jscpd@5.2.0", - "configHash": "22f4a18e604b10488c376219de2a32dec314621ccbd2a2263c9414ba70ff2301", + "configHash": "5efc8f54a48ed2847d32f8e54797f7fbecb80d40283bfa6a5b922fd4503610db", "findings": {} } diff --git a/quality/ts-unused-baseline.json b/quality/ts-unused-baseline.json index 58236c6..dd880bc 100644 --- a/quality/ts-unused-baseline.json +++ b/quality/ts-unused-baseline.json @@ -1,6 +1,11 @@ { "version": 1, "tool": "knip@6.35.1", - "configHash": "22f4a18e604b10488c376219de2a32dec314621ccbd2a2263c9414ba70ff2301", - "findings": {} + "configHash": "5efc8f54a48ed2847d32f8e54797f7fbecb80d40283bfa6a5b922fd4503610db", + "findings": { + "src/core/config/detect.ts | unused-export | /#NEXT_CONFIGS": 1, + "src/core/config/detect.ts | unused-export | /#PAYLOAD_CONFIGS": 1, + "src/core/config/exclusions.ts | unused-export | /#NEXT_EXCLUSIONS": 1, + "src/core/config/exclusions.ts | unused-export | /#PAYLOAD_EXCLUSIONS": 1 + } } diff --git a/scripts/integration.ts b/scripts/integration.ts index e89741c..75d498a 100644 --- a/scripts/integration.ts +++ b/scripts/integration.ts @@ -22,7 +22,8 @@ export interface Mutation { append?: string; copyFrom?: string; content?: string; - expect: string; + expect?: string; + exitCode?: 0 | 1; } export const MUTATIONS = [ @@ -56,12 +57,114 @@ export const MUTATIONS = [ content: " 0) { + $result += $a; + if ($b > 0) { + $result += $b; + if ($c > 0) { + $result += $c; + if ($d > 0) { + $result += $d; + if ($e > 0) { + $result += $e; + } + } + } + } + } + if ($a > $b) $result += 1; + if ($b > $c) $result += 2; + if ($c > $d) $result += 3; + if ($d > $e) $result += 4; + if ($e > $a) $result += 5; + if ($a === $e) $result += 6; + return $result; +} +`, + expect: "regressions", + }, + { + fixture: "drupal-project", + file: "web/modules/custom/demo/demo_copy.module", + copyFrom: "web/modules/custom/demo/demo.module", + expect: "regressions", + }, + { + fixture: "payload-project", + file: "src/payload-types.ts", + content: ` +export function generatedComplex( + a: number, + b: number, + c: number, + d: number, + e: number, +): number { + let result = 0; + if (a > 0) { + result += a; + if (b > 0) { + result += b; + if (c > 0) { + result += c; + if (d > 0) { + result += d; + if (e > 0) result += e; + } + } + } + } + if (a > b) result += 1; + if (b > c) result += 2; + if (c > d) result += 3; + if (d > e) result += 4; + if (e > a) result += 5; + if (a === e) result += 6; + return result; +} +`, + expect: "regressions", + exitCode: 0, + }, + { + fixture: "monorepo-project", + file: "packages/core/src/orphan.ts", + content: "export function orphan(): string { return \"orphan\"; }\n", + expect: "regressions", + }, { fixture: "python-project", file: "src/demo_app/extra.py", content: "def extra(a, b, c, d, e):\n return [a, b, c, d, e]\n", expect: "regressions", }, + { + fixture: "python314-project", + file: "src/demo314/extra.py", + content: `def extra(value: int) -> int: + if value > 0: + if value > 1: + if value > 2: + if value > 3: + if value > 4: + if value > 5: + if value > 6: + if value > 7: + if value > 8: + if value > 9: + if value > 10: + return value + return 0 +`, + expect: "regressions", + }, { fixture: "web-project", file: "templates/page-c.twig", @@ -88,15 +191,54 @@ interface DockerResult { interface FixtureDependencyState { tsNodeModules: boolean; phpVendor: boolean; + payloadNodeModules: boolean; + monorepoNodeModules: boolean; + python314Venv: boolean; } interface DependencyInstallPlan { - fixture: "ts-project" | "php-project"; - entrypoint: "npm" | "composer"; + fixture: "ts-project" | "php-project" | "payload-project" | "monorepo-project" | "python314-project"; + entrypoint: "npm" | "composer" | "corepack" | "uv"; command: readonly string[]; } -const FIXTURES = ["ts-project", "php-project", "python-project", "web-project"] as const; +const DEPENDENCY_INSTALLS = [ + { + state: "tsNodeModules", + fixture: "ts-project", + entrypoint: "npm", + command: ["install", "--ignore-scripts", "--no-audit", "--no-fund"], + }, + { + state: "phpVendor", + fixture: "php-project", + entrypoint: "composer", + command: ["install", "--no-interaction"], + }, + { + state: "payloadNodeModules", + fixture: "payload-project", + entrypoint: "npm", + command: ["install", "--ignore-scripts", "--no-audit", "--no-fund"], + }, + { + state: "monorepoNodeModules", + fixture: "monorepo-project", + entrypoint: "corepack", + command: ["pnpm", "install", "--frozen-lockfile", "--ignore-scripts"], + }, + { + state: "python314Venv", + fixture: "python314-project", + entrypoint: "uv", + command: ["sync", "--frozen"], + }, +] as const satisfies readonly (DependencyInstallPlan & { state: keyof FixtureDependencyState })[]; + +const FIXTURES = [ + "ts-project", "php-project", "python-project", "python314-project", "web-project", + "drupal-project", "payload-project", "monorepo-project", +] as const; // web-project selects no advisory report. const REPORT_FIXTURES = ["ts-project", "php-project", "python-project"] as const; const MAX_BUFFER = 256 * 1024 * 1024; @@ -162,6 +304,11 @@ export function applyMutation(root: string, mutation: Mutation): void { writeMutation(target, sourceMutation); } +export function mutationEvidenceOk(mutation: Mutation, stderr: string): boolean { + const expected = mutation.expect ?? "regressions"; + return (mutation.exitCode ?? 1) === 1 ? stderr.includes(expected) : !stderr.includes(expected); +} + function hostUserArgs(): string[] { const uid = process.getuid?.(); const gid = process.getgid?.(); @@ -172,7 +319,7 @@ export function dockerArgs( image: string, command: readonly string[], mountRoot?: string, - entrypoint?: "npm" | "composer", + entrypoint?: "npm" | "composer" | "corepack" | "uv", ): string[] { const mountArgs = mountRoot === undefined ? [] @@ -180,7 +327,12 @@ export function dockerArgs( // Installers run as the host user, who has no home inside the image. const entrypointArgs = entrypoint === undefined ? [] - : ["-e", "HOME=/tmp", "-e", "COMPOSER_HOME=/tmp/composer", "--entrypoint", entrypoint]; + : [ + "-e", "HOME=/tmp", + "-e", "COMPOSER_HOME=/tmp/composer", + "-e", "UV_CACHE_DIR=/tmp/uv-cache", + "--entrypoint", entrypoint, + ]; return [ "run", "--rm", "-e", "GITHUB_ACTIONS=true", ...hostUserArgs(), ...mountArgs, ...entrypointArgs, image, ...command, @@ -190,29 +342,16 @@ export function dockerArgs( export function plannedDependencyInstalls( state: FixtureDependencyState, ): DependencyInstallPlan[] { - const plans: DependencyInstallPlan[] = []; - if (!state.tsNodeModules) { - plans.push({ - fixture: "ts-project", - entrypoint: "npm", - command: ["install", "--no-audit", "--no-fund"], - }); - } - if (!state.phpVendor) { - plans.push({ - fixture: "php-project", - entrypoint: "composer", - command: ["install", "--no-interaction"], - }); - } - return plans; + return DEPENDENCY_INSTALLS + .filter((plan) => !state[plan.state]) + .map(({ state: _state, ...plan }) => plan); } function runDocker( image: string, command: readonly string[], mountRoot?: string, - entrypoint?: "npm" | "composer", + entrypoint?: "npm" | "composer" | "corepack" | "uv", ): DockerResult { const result = spawnSync("docker", dockerArgs(image, command, mountRoot, entrypoint), { encoding: "utf8", @@ -276,6 +415,9 @@ function installMissingFixtureDependencies(repositoryRoot: string, image: string const state: FixtureDependencyState = { tsNodeModules: existsSync(join(fixtureRoot(repositoryRoot, "ts-project"), "node_modules")), phpVendor: existsSync(join(fixtureRoot(repositoryRoot, "php-project"), "vendor")), + payloadNodeModules: existsSync(join(fixtureRoot(repositoryRoot, "payload-project"), "node_modules")), + monorepoNodeModules: existsSync(join(fixtureRoot(repositoryRoot, "monorepo-project"), "node_modules")), + python314Venv: existsSync(join(fixtureRoot(repositoryRoot, "python314-project"), ".venv")), }; for (const plan of plannedDependencyInstalls(state)) { const root = fixtureRoot(repositoryRoot, plan.fixture); @@ -290,7 +432,11 @@ function temporaryFixture(repositoryRoot: string, fixture: string): string { const sourceRoot = fixtureRoot(repositoryRoot, fixture); const destinationRoot = mkdtempSync(join(tmpdir(), "code-quality-integration-")); chmodSync(destinationRoot, 0o755); - copyFixture(sourceRoot, destinationRoot, fixture !== "python-project"); + copyFixture( + sourceRoot, + destinationRoot, + fixture !== "python-project" && fixture !== "python314-project", + ); return destinationRoot; } @@ -331,7 +477,15 @@ function mutationRow(repositoryRoot: string, image: string, mutation: Mutation): try { applyMutation(root, mutation); const result = runDocker(image, ["check"], root); - return resultRow(`${mutation.fixture} ${mutation.file}`, result, 1, mutation.expect); + const expectedExitCode = mutation.exitCode ?? 1; + const expectedText = mutation.expect ?? "regressions"; + const row = resultRow( + `${mutation.fixture} ${mutation.file}`, + result, + expectedExitCode, + expectedExitCode === 1 ? expectedText : undefined, + ); + return { ...row, evidenceOk: mutationEvidenceOk(mutation, result.stderr) }; } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/src/checks/php/phpcs-cognitive.ts b/src/checks/php/phpcs-cognitive.ts index 87898d8..6e00f8d 100644 --- a/src/checks/php/phpcs-cognitive.ts +++ b/src/checks/php/phpcs-cognitive.ts @@ -1,8 +1,9 @@ import { join } from "node:path"; import { parseJsonOutput, POLICY, xml } from "../shared/kit.ts"; -import { phpcsCommand, phpcsFindings, phpcsRuleset } from "./phpcs-shared.ts"; +import { phpcsCommand, phpcsExtensions, phpcsFindings, phpcsRuleset } from "./phpcs-shared.ts"; import type { CheckAdapter } from "../shared/kit.ts"; +import type { ResolvedConfig } from "../../core/config/types.ts"; const COGNITIVE_RULE = { sources: { "SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh": "ERROR" }, @@ -11,20 +12,20 @@ const COGNITIVE_RULE = { minimumExclusive: POLICY.cognitive, }; -export function cognitiveRuleset(): string { +export function cognitiveRuleset(config: Pick): string { const properties = xml("properties", {}, [ xml("property", { name: "maxComplexity", value: String(POLICY.cognitive) }), ]); return phpcsRuleset("code-quality-cognitive", xml( "rule", { ref: "SlevomatCodingStandard.Complexity.Cognitive" }, [properties], - )); + ), phpcsExtensions(config)); } export const phpcsCognitiveAdapter: CheckAdapter = { id: "php-cognitive", check: "cognitive", language: "php", tool: { bin: "phpcs", version: "4.0.4" }, applicability: () => ({ kind: "run" }), - configFiles: () => [{ path: "phpcs-cognitive.xml", content: cognitiveRuleset() }], + configFiles: (ctx) => [{ path: "phpcs-cognitive.xml", content: cognitiveRuleset(ctx.config) }], command: (ctx) => phpcsCommand(ctx, join(ctx.tempDir, "phpcs-cognitive.xml")), parse: (ctx, result) => phpcsFindings( ctx, diff --git a/src/checks/php/phpcs-complexity.ts b/src/checks/php/phpcs-complexity.ts index ff658f3..452f6d9 100644 --- a/src/checks/php/phpcs-complexity.ts +++ b/src/checks/php/phpcs-complexity.ts @@ -4,6 +4,8 @@ import { parseJsonOutput, POLICY, xml } from "../shared/kit.ts"; import { phpcsCommand, phpcsFindings, phpcsRuleset } from "./phpcs-shared.ts"; import type { CheckAdapter } from "../shared/kit.ts"; import type { PhpcsRule } from "./phpcs-shared.ts"; +import { phpcsExtensions } from "./phpcs-shared.ts"; +import type { ResolvedConfig } from "../../core/config/types.ts"; const COMPLEXITY_RULES: readonly PhpcsRule[] = [ { @@ -49,7 +51,7 @@ function configuredRule(ref: string, properties: string[]): string { return xml("rule", { ref }, [xml("properties", {}, properties)]); } -export function complexityRuleset(): string { +export function complexityRuleset(config: Pick): string { const rules = [ configuredRule("Generic.Metrics.CyclomaticComplexity", [ property("complexity", POLICY.complexity), property("absoluteComplexity", 1000), @@ -64,14 +66,14 @@ export function complexityRuleset(): string { property("maxParameters", POLICY.maxParams), ]), ]; - return phpcsRuleset("code-quality-complexity", rules.join("")); + return phpcsRuleset("code-quality-complexity", rules.join(""), phpcsExtensions(config)); } export const phpcsComplexityAdapter: CheckAdapter = { id: "php-complexity", check: "complexity", language: "php", tool: { bin: "phpcs", version: "4.0.4" }, applicability: () => ({ kind: "run" }), - configFiles: () => [{ path: "phpcs-complexity.xml", content: complexityRuleset() }], + configFiles: (ctx) => [{ path: "phpcs-complexity.xml", content: complexityRuleset(ctx.config) }], command: (ctx) => phpcsCommand(ctx, join(ctx.tempDir, "phpcs-complexity.xml")), parse: (ctx, result) => phpcsFindings( ctx, parseJsonOutput(result.stdout, "phpcs", result.stderr), COMPLEXITY_RULES, diff --git a/src/checks/php/phpcs-shared.ts b/src/checks/php/phpcs-shared.ts index 3955bc1..35d6f3e 100644 --- a/src/checks/php/phpcs-shared.ts +++ b/src/checks/php/phpcs-shared.ts @@ -2,6 +2,8 @@ import { z } from "zod"; import { addAnchoredFinding, excludeGlobs, extractMeasurement, fail, FindingsBuilder, relativizeFrom, xml } from "../shared/kit.ts"; import type { CheckContext, ParsedFindings, ToolInvocation } from "../shared/kit.ts"; +import { phpToolExtensions } from "../../core/config/sources.ts"; +import type { ResolvedConfig } from "../../core/config/types.ts"; const phpcsSchema = z.looseObject({ totals: z.looseObject({ @@ -25,13 +27,17 @@ export interface PhpcsRule { minimumExclusive?: number; } -export function phpcsRuleset(name: string, rules: string): string { +export function phpcsRuleset(name: string, rules: string, extensions: readonly string[]): string { const installed = xml("config", { name: "installed_paths", value: "/opt/php/phpcs/vendor/slevomat/coding-standard,/opt/php/phpcs-standard", }); - const extensions = xml("arg", { name: "extensions", value: "php" }); - return `\n${xml("ruleset", { name }, [installed, extensions, rules])}\n`; + const extensionArg = xml("arg", { name: "extensions", value: extensions.join(",") }); + return `\n${xml("ruleset", { name }, [installed, extensionArg, rules])}\n`; +} + +export function phpcsExtensions(config: Pick): string[] { + return phpToolExtensions(config.isDrupal); } export function phpcsCommand(ctx: CheckContext, rulesetFile: string): ToolInvocation { diff --git a/src/checks/python/complexipy.ts b/src/checks/python/complexipy.ts index 9600136..d9b28e2 100644 --- a/src/checks/python/complexipy.ts +++ b/src/checks/python/complexipy.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { addAnchoredFinding, excludeGlobs, extractMeasurement, fail, FindingsBuilder, POLICY, relativizeFrom, toolOutput } from "../shared/kit.ts"; import type { CheckAdapter, CheckContext, ParsedFindings } from "../shared/kit.ts"; +import { pythonInvocation } from "./interpreter.ts"; const RULE_ID = "CC001"; const MESSAGE = /has a cognitive complexity of (\d+),/u; @@ -56,7 +57,7 @@ export const complexipyAdapter: CheckAdapter = { tool: { bin: "complexipy", version: "8.0.1" }, applicability: () => ({ kind: "run" }), configFiles: (ctx) => toolOutput(ctx, "complexipy.sarif").clear(), - command: (ctx) => ({ + command: (ctx) => pythonInvocation(ctx, { bin: "complexipy", args: ["--max-complexity-allowed", String(POLICY.cognitive), "--quiet", "--output-format", "sarif", "--output", toolOutput(ctx, "complexipy.sarif").path, ...excludeGlobs(ctx.config, true) diff --git a/src/checks/python/deptry.ts b/src/checks/python/deptry.ts index 3e60dc7..81f2e05 100644 --- a/src/checks/python/deptry.ts +++ b/src/checks/python/deptry.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { assertInScope, excludeGlobs, FindingsBuilder, relativizeFrom, toolOutput } from "../shared/kit.ts"; import type { CheckAdapter, CheckContext, ParsedFindings } from "../shared/kit.ts"; +import { pythonInvocation } from "./interpreter.ts"; const reportSchema = z.array(z.looseObject({ error: z.looseObject({ @@ -63,7 +64,7 @@ export const deptryAdapter: CheckAdapter = { tool: { bin: "deptry", version: "0.25.1" }, applicability: () => ({ kind: "run" }), configFiles: (ctx) => toolOutput(ctx, "deptry.json").clear(), - command: (ctx) => ({ + command: (ctx) => pythonInvocation(ctx, { bin: "deptry", args: [...ctx.paths, "--json-output", toolOutput(ctx, "deptry.json").path, "--no-ansi", ...excludeGlobs(ctx.config, true).flatMap((glob) => [ diff --git a/src/checks/python/import-linter.ts b/src/checks/python/import-linter.ts index 9760535..d5515c4 100644 --- a/src/checks/python/import-linter.ts +++ b/src/checks/python/import-linter.ts @@ -1,6 +1,7 @@ import { assertInScope, fail, FindingsBuilder } from "../shared/kit.ts"; import { resolveModuleFile } from "./modules.ts"; import type { CheckAdapter, CheckContext, ParsedFindings } from "../shared/kit.ts"; +import { pythonInvocation } from "./interpreter.ts"; const RESULT = /^(?.+?) (?KEPT|BROKEN)( \([^)]*\))?$/u; const TOTALS = /^Contracts: (?\d+) kept, (?\d+) broken\.$/u; @@ -185,8 +186,13 @@ export const importLinterAdapter: CheckAdapter = { const selection = ctx.config.architecture.python; if (selection?.kind !== "file") throw new Error("import-linter rules file is unavailable"); const pythonPath = [...ctx.paths.map((path) => `${ctx.root}/${path}`), ctx.root].join(":"); - return { bin: "lint-imports", args: ["--config", selection.rulesFile, "--no-logo", "--no-cache"], - cwd: ctx.root, env: { PYTHONPATH: pythonPath }, exitCodes: [0, 1] }; + return pythonInvocation(ctx, { + bin: "lint-imports", + args: ["--config", selection.rulesFile, "--no-logo", "--no-cache"], + cwd: ctx.root, + env: { PYTHONPATH: pythonPath }, + exitCodes: [0, 1], + }); }, parse: (ctx, result) => Promise.resolve(importLinterFindings(ctx, result.stdout)), }; diff --git a/src/checks/python/interpreter.ts b/src/checks/python/interpreter.ts new file mode 100644 index 0000000..023fd38 --- /dev/null +++ b/src/checks/python/interpreter.ts @@ -0,0 +1,21 @@ +import { isAtLeast, PYTHON_314_BIN, pythonTarget } from "../../core/config/runtime.ts"; +import type { CheckContext, ToolInvocation } from "../../core/types.ts"; + +export const PYTHON_VENV_TOOLS = [ + "ruff", "complexipy", "vulture", "deptry", "lint-imports", +] as const; + +export function pythonInvocation( + ctx: CheckContext, + invocation: ToolInvocation, +): ToolInvocation { + const target = pythonTarget(ctx.root); + if (target === undefined || !isAtLeast(target.version, "3.14")) return invocation; + return { + ...invocation, + env: { + ...invocation.env, + PATH: [PYTHON_314_BIN, process.env.PATH].filter(Boolean).join(":"), + }, + }; +} diff --git a/src/checks/python/ruff.ts b/src/checks/python/ruff.ts index ea8c7cf..48c31c1 100644 --- a/src/checks/python/ruff.ts +++ b/src/checks/python/ruff.ts @@ -4,6 +4,8 @@ import { z } from "zod"; import { addAnchoredFinding, excludeGlobs, extractMeasurement, fail, FindingsBuilder, parseJsonOutput, POLICY, relativizeFrom } from "../shared/kit.ts"; import type { CheckAdapter, CheckContext, ParsedFindings } from "../shared/kit.ts"; +import { compareMajorMinor, PYTHON_314_VERSION, pythonTarget } from "../../core/config/runtime.ts"; +import { pythonInvocation } from "./interpreter.ts"; const METRICS: Record = { C901: new RegExp(`is too complex \\((\\d+) > ${POLICY.complexity}\\)`, "u"), @@ -12,8 +14,11 @@ const METRICS: Record = { PLR1702: new RegExp(`\\((\\d+) > ${POLICY.maxDepth}\\)`, "u"), }; +const MINIMUM_TARGET = "3.10"; +const MAXIMUM_TARGET = PYTHON_314_VERSION.split(".").slice(0, 2).join("."); + const reportSchema = z.array(z.looseObject({ - code: z.string(), + code: z.string().nullable(), message: z.string(), filename: z.string(), location: z.looseObject({ @@ -22,8 +27,13 @@ const reportSchema = z.array(z.looseObject({ }), })); -export function ruffConfig(): string { - return `[lint] +export function ruffConfig(target?: string): string { + const supported = target !== undefined && compareMajorMinor(target, MINIMUM_TARGET) >= 0 + && compareMajorMinor(target, MAXIMUM_TARGET) <= 0; + const targetVersion = supported + ? `target-version = "py${target.replace(".", "")}"\n` + : ""; + return `${targetVersion}[lint] select = ["C901", "PLR0913", "PLR0915", "PLR1702"] [lint.mccabe] max-complexity = ${POLICY.complexity} @@ -36,16 +46,27 @@ max-nested-blocks = ${POLICY.maxDepth} export async function ruffFindings(ctx: CheckContext, input: unknown): Promise { const report = reportSchema.parse(input); + const syntaxErrors = report.filter((diagnostic) => diagnostic.code === null); + if (syntaxErrors.length > 0) { + const examples = syntaxErrors.slice(0, 3).map((diagnostic) => + `${relativizeFrom(ctx.root, diagnostic.filename)}:${diagnostic.location.row} ${diagnostic.message}` + ); + return fail( + `Ruff could not parse Python source: ${examples.join("; ")}. ` + + "Hint: set .python-version or requires-python so Ruff targets the right Python version", + ); + } const findings = new FindingsBuilder(); for (const diagnostic of report) { - const pattern = METRICS[diagnostic.code]; - if (!pattern) return fail(`Unbaselined Ruff code ${diagnostic.code}`); + const code = diagnostic.code!; + const pattern = METRICS[code]; + if (!pattern) return fail(`Unbaselined Ruff code ${code}`); const file = relativizeFrom(ctx.root, diagnostic.filename); - const value = extractMeasurement(pattern, diagnostic.message, diagnostic.code); + const value = extractMeasurement(pattern, diagnostic.message, code); await addAnchoredFinding(ctx, findings, { - file, rule: diagnostic.code, value, line: diagnostic.location.row, + file, rule: code, value, line: diagnostic.location.row, column: diagnostic.location.column, message: diagnostic.message, - blockMode: diagnostic.code === "PLR1702", + blockMode: code === "PLR1702", }); } return findings.build(); @@ -55,8 +76,8 @@ export const ruffAdapter: CheckAdapter = { id: "python-complexity", check: "complexity", language: "python", tool: { bin: "ruff", version: "0.16.6" }, applicability: () => ({ kind: "run" }), - configFiles: () => [{ path: "ruff.toml", content: ruffConfig() }], - command: (ctx) => ({ + configFiles: (ctx) => [{ path: "ruff.toml", content: ruffConfig(pythonTarget(ctx.root)?.version) }], + command: (ctx) => pythonInvocation(ctx, { bin: "ruff", args: ["check", "--config", join(ctx.tempDir, "ruff.toml"), "--output-format", "json", "--no-cache", "--exit-zero", "--exclude", excludeGlobs(ctx.config, true).join(","), diff --git a/src/checks/python/vulture.ts b/src/checks/python/vulture.ts index ad8352e..532206e 100644 --- a/src/checks/python/vulture.ts +++ b/src/checks/python/vulture.ts @@ -1,5 +1,6 @@ import { addAnchoredFinding, excludeGlobs, fail, FindingsBuilder, relativizeFrom } from "../shared/kit.ts"; import type { CheckAdapter, CheckContext, ParsedFindings } from "../shared/kit.ts"; +import { pythonInvocation } from "./interpreter.ts"; const LINE = /^(?[^:]+):(?\d+): (?unused (?attribute|class|function|import|method|property|variable) '(?[^']+)'|unreachable code after '(?[^']+)'|unsatisfiable '(?[^']+)' condition) \((?\d+)% confidence\)$/u; @@ -48,7 +49,7 @@ export const vultureAdapter: CheckAdapter = { tool: { bin: "vulture", version: "2.16" }, applicability: () => ({ kind: "run" }), configFiles: () => [], - command: (ctx) => ({ + command: (ctx) => pythonInvocation(ctx, { bin: "vulture", args: [...ctx.paths, "--exclude", excludeGlobs(ctx.config, true).join(","), "--min-confidence", "0"], diff --git a/src/checks/shared/jscpd.ts b/src/checks/shared/jscpd.ts index cc6f180..15aedc1 100644 --- a/src/checks/shared/jscpd.ts +++ b/src/checks/shared/jscpd.ts @@ -5,6 +5,8 @@ import { z } from "zod"; import { findingsSchema, readSnapshot } from "../../core/snapshot.ts"; import { baselineFile } from "../../core/types.ts"; +import { phpToolExtensions } from "../../core/config/sources.ts"; +import type { ResolvedConfig } from "../../core/config/types.ts"; import { excludeGlobs, FindingsBuilder, POLICY, relativize } from "./kit.ts"; import type { CheckAdapter, CheckContext, GeneratedFile, ParsedFindings } from "./kit.ts"; import type { DuplicateDetail, Findings, Language } from "../../core/types.ts"; @@ -45,12 +47,21 @@ function outputPaths(ctx: CheckContext): [string, string] { ]; } +export function jscpdPhpExtensions(resolved: Pick): string[] | undefined { + if (!resolved.isDrupal) return undefined; + return phpToolExtensions(true); +} + function config(ctx: CheckContext, language: Language): string { + const phpExtensions = jscpdPhpExtensions(ctx.config); return JSON.stringify({ mode: POLICY.duplication.mode, minTokens: POLICY.duplication.minTokens, minLines: POLICY.duplication.minLines, format: FORMATS[language], + ...(language === "php" && phpExtensions !== undefined + ? { formatsExts: { php: phpExtensions } } + : {}), ignore: excludeGlobs(ctx.config, true), reporters: ["json"], output: join(ctx.artifactDir, "jscpd"), diff --git a/src/checks/ts/knip-plugins.ts b/src/checks/ts/knip-plugins.ts new file mode 100644 index 0000000..8937930 --- /dev/null +++ b/src/checks/ts/knip-plugins.ts @@ -0,0 +1,31 @@ +// Generated from node_modules/knip/dist/types/PluginNames.js; schema.json can lag the runtime registry. +export const KNIP_PLUGIN_NAMES = [ + "angular", "astro", "astro-db", "astro-markdoc", "astro-og-canvas", "ava", "babel", "biome", "borp", + "bumpp", "bun", + "c8", "capacitor", "catalyst", "changelogen", "changelogithub", "changesets", + "commitizen", "commitlint", "convex", "create-typescript-app", "cspell", "cucumber", "cypress", + "danger", "dependency-cruiser", "docusaurus", "dotenv", "drizzle", "electron-vite", "eleventy", + "esbuild", "eslint", "eve", "execa", "expo", "expressive-code", "fast", "fumadocs", "gatsby", + "github-action", "github-actions", "glob", "graphql-codegen", "hardhat", "husky", "i18next-parser", "jest", + "karma", "knex", "ladle", "laravel-vite-plugin", "lefthook", "lint-staged", "linthtml", "lit", + "lockfile-lint", "lost-pixel", "lunaria", "markdownlint", "marko", "mdx", "mdxlint", "metro", "mise", + "mocha", "moonrepo", "msw", "nano-spawn", + "nano-staged", "nest", "netlify", "next", "next-intl", "next-mdx", "nitro", "node", + "node-modules-inspector", "nodemon", "npm-package-json-lint", "nuxt", "nuxtjs-i18n", + "nx", "nyc", "oclif", "openapi-ts", "openclaw", "orval", "oxfmt", "oxlint", "panda-css", "parcel", + "payload", "pino", "playwright", "playwright-ct", "playwright-test", "plop", "pm2", "pnpm", "postcss", + "pre-commit", + "preconstruct", "prettier", "prisma", "quasar", "qwik", "raycast", "react-cosmos", "react-email", "react-native", + "react-router", "relay", + "release-it", "remark", "remix", "rolldown", "rollup", "rsbuild", "rslib", "rspack", "rstest", + "sanity", "semantic-release", "sentry", "serverless-framework", "simple-git-hooks", "size-limit", "sst", + "starlight", "stencil", "storybook", "stryker", "stylelint", "svelte", "sveltejs-package", "sveltekit", + "svgo", "svgr", "swc", "syncpack", "tailwind", "tanstack-router", "taskfile", "tauri", + "temporal", "travis", "ts-node", "tsd", "tsdown", "tsup", "tsx", "typedoc", "typescript", "unbuild", + "unocss", "unplugin-auto-import", "unplugin-icons", "unplugin-vue-components", "unplugin-vue-i18n", + "unplugin-vue-markdown", "unplugin-vue-router", "vercel", "vercel-og", "vike", "vite", + "vite-plugin-pages", "vite-plugin-pwa", "vite-plugin-vue-layouts-next", "vite-plus", + "vite-pwa-assets-generator", "vitepress", "vitest", "vue", "webdriver-io", "webpack", "wireit", + "wrangler", "wxt", "xo", + "yarn", "yorkie", "zx", +] as const satisfies readonly string[]; diff --git a/src/checks/ts/knip.ts b/src/checks/ts/knip.ts index 02211c7..c068513 100644 --- a/src/checks/ts/knip.ts +++ b/src/checks/ts/knip.ts @@ -1,9 +1,11 @@ -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { z } from "zod"; +import { PAYLOAD_NEXT_CONFIGS } from "../../core/config/detect.ts"; import type { ResolvedConfig } from "../../core/config/types.ts"; +import { ownerOf, workspaceMemberDirectories } from "../../core/config/workspaces.ts"; import type { Applicability } from "../../core/types.ts"; import { assertInScope, @@ -15,6 +17,7 @@ import { relativize, } from "../shared/kit.ts"; import type { CheckAdapter, CheckContext, GeneratedFile, ParsedFindings } from "../shared/kit.ts"; +import { KNIP_PLUGIN_NAMES } from "./knip-plugins.ts"; const itemSchema = z.looseObject({ name: z.string(), @@ -44,6 +47,47 @@ const DEPENDENCY_FIELDS = [ "peerDependencies", "optionalDependencies", ] as const; +const NEXT_APP_ENTRIES = [ + "page", "layout", "template", "loading", "error", "global-error", "not-found", "default", "route", + "icon", "apple-icon", "opengraph-image", "twitter-image", "sitemap", "robots", "manifest", "forbidden", + "unauthorized", +] as const; + +const PLUGIN_FLAGS = Object.fromEntries(KNIP_PLUGIN_NAMES.map((plugin) => [plugin, false])); + +const FRAMEWORK_CONFIGS = [ + ...PAYLOAD_NEXT_CONFIGS, + "vite.config.ts", "vite.config.js", "vite.config.mts", "vite.config.mjs", + "vitest.config.ts", "vitest.config.js", "vitest.config.mts", "vitest.config.mjs", + "playwright.config.ts", "playwright.config.js", "playwright.config.mts", "playwright.config.mjs", +] as const; + +interface FrameworkDetection { + configs: string[]; + nextLike: boolean; +} + +interface WorkspaceConfig { + entry: string[]; + project: string[]; + ignore: string[]; +} + +function isFile(path: string): boolean { + try { + return lstatSync(path).isFile(); + } catch { + return false; + } +} + +function isDirectory(path: string): boolean { + try { + return lstatSync(path).isDirectory(); + } catch { + return false; + } +} function declaresDependencies(packageFile: string): boolean { const parsed: unknown = JSON.parse(readFileSync(packageFile, "utf8")); @@ -72,41 +116,135 @@ function requireNodeModules(config: ResolvedConfig): Applicability { }; } -function knipConfig(ctx: CheckContext): string { - const entry = ctx.paths.flatMap((path) => [ - `${path}/**/{index,main,cli}.{ts,tsx,js,mjs,cjs}`, - `${path}/**/bin/**/*.{ts,js,mjs,cjs}`, +function prefix(base: string, pattern: string): string { + return base === "." ? pattern : `${base}/${pattern}`; +} + +function sourceEntries(paths: readonly string[]): string[] { + return paths.flatMap((path) => [ + prefix(path, "**/{index,main,cli}.{ts,tsx,js,mjs,cjs}"), + prefix(path, "**/bin/**/*.{ts,js,mjs,cjs}"), ]); +} + +function projectEntries(paths: readonly string[]): string[] { + return paths.map((path) => prefix(path, "**/*.{ts,tsx,js,jsx,mjs,cjs}")); +} + +function detectFrameworks(root: string): FrameworkDetection { + if (!isDirectory(root)) return { configs: [], nextLike: false }; + const configs = FRAMEWORK_CONFIGS.filter((file) => isFile(join(root, file))); + const nextLike = configs.some((file) => + file.startsWith("next.config.") || file.includes("payload.config.") + ); + return { configs, nextLike }; +} + +function nextEntries(root: string, paths: readonly string[]): string[] { + const entries: string[] = []; + for (const base of new Set([".", ...paths])) { + if (isDirectory(join(root, base, "app"))) { + entries.push(prefix( + base, + `app/**/{${NEXT_APP_ENTRIES.join(",")}}.{ts,tsx,js,jsx}`, + )); + } + if (isDirectory(join(root, base, "pages"))) { + entries.push(prefix(base, "pages/**/*.{ts,tsx,js,jsx}")); + } + for (const file of ["middleware", "instrumentation", "proxy", "instrumentation-client"]) { + if (["ts", "js"].some((extension) => isFile(join(root, base, `${file}.${extension}`)))) { + entries.push(prefix(base, `${file}.{ts,js}`)); + } + } + } + return entries; +} + +function frameworkEntries( + root: string, + paths: readonly string[], + detection: FrameworkDetection, +): string[] { + const applications = detection.nextLike ? nextEntries(root, paths) : []; + return [...detection.configs, ...applications]; +} + +function relativeToOwner(path: string, owner: string): string { + if (owner === ".") return path; + if (path === owner) return "."; + return path.slice(owner.length + 1); +} + +function workspaceIgnore(patterns: readonly string[], owner: string): string[] { + return patterns.flatMap((pattern) => { + if (pattern.startsWith("**/") || owner === ".") return [pattern]; + const ownerPrefix = `${owner}/`; + return pattern.startsWith(ownerPrefix) ? [pattern.slice(ownerPrefix.length)] : []; + }); +} + +function workspaceFrameworkEntries(root: string, owner: string, paths: readonly string[]): string[] { + const ownerRoot = owner === "." ? root : join(root, owner); + return frameworkEntries(ownerRoot, paths, detectFrameworks(ownerRoot)); +} + +function workspaceBlock( + ctx: CheckContext, + owner: string, + paths: readonly string[], +): WorkspaceConfig { + const entries = workspaceFrameworkEntries(ctx.root, owner, paths); + return { + entry: [...sourceEntries(paths), ...entries, ...TEST_ENTRIES], + project: [...projectEntries(paths), ...entries, ...TEST_ENTRIES], + ignore: workspaceIgnore(excludeGlobs(ctx.config, true), owner), + }; +} + +function safeWorkspaceName(name: string): boolean { + return name === "." || name.split("/").every((segment) => /^[A-Za-z0-9._@-]+$/u.test(segment)); +} + +function groupedPaths(ctx: CheckContext): Map { + const groups = new Map(); + for (const path of ctx.paths) { + const owner = ownerOf(ctx.root, path); + if (!safeWorkspaceName(owner)) return fail(`ts-unused: unsafe workspace owner ${owner}`); + const paths = groups.get(owner) ?? []; + paths.push(relativeToOwner(path, owner)); + groups.set(owner, paths); + } + return groups; +} + +function knipConfig(ctx: CheckContext): string { + const members = workspaceMemberDirectories(ctx.root); + const groups = groupedPaths(ctx); + const workspaceMode = members.length > 0 || [...groups.keys()].some((owner) => owner !== "."); + if (!workspaceMode) { + const frameworks = frameworkEntries(ctx.root, ctx.paths, detectFrameworks(ctx.root)); + return JSON.stringify({ + entry: [...sourceEntries(ctx.paths), ...frameworks, ...TEST_ENTRIES], + project: [...projectEntries(ctx.paths), ...frameworks, ...TEST_ENTRIES], + ignore: excludeGlobs(ctx.config, true), + ...PLUGIN_FLAGS, + }, null, 2); + } + const workspaceGroups = new Map([[".", groups.get(".") ?? []]]); + for (const [owner, paths] of groups) workspaceGroups.set(owner, paths); + const workspaces = Object.fromEntries([...workspaceGroups].map(([owner, paths]) => [ + owner, workspaceBlock(ctx, owner, paths), + ])); + const ignoredMembers = members.filter((member) => !groups.has(member)).filter((member) => { + if (safeWorkspaceName(member)) return true; + ctx.notice(`ts-unused: ignored unsafe workspace member ${member}`); + return false; + }); return JSON.stringify({ - entry: [...entry, ...TEST_ENTRIES], - project: [ - ...ctx.paths.map((path) => `${path}/**/*.{ts,tsx,js,jsx,mjs,cjs}`), - ...TEST_ENTRIES, - ], - ignore: excludeGlobs(ctx.config, true), - ignoreDependencies: [], - webpack: false, - vite: false, - vitest: false, - jest: false, - eslint: false, - babel: false, - postcss: false, - prettier: false, - stylelint: false, - rollup: false, - next: false, - nuxt: false, - storybook: false, - playwright: false, - cypress: false, - tailwind: false, - commitlint: false, - husky: false, - "lint-staged": false, - tsup: false, - typedoc: false, - payload: false, + workspaces, + ignoreWorkspaces: ignoredMembers, + ...PLUGIN_FLAGS, }, null, 2); } diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts index 22ab99a..e959cc4 100644 --- a/src/cli/commands/doctor.ts +++ b/src/cli/commands/doctor.ts @@ -2,7 +2,13 @@ import { join } from "node:path"; import { createAnchorService, type AnchorService } from "../../core/anchor/service.ts"; import { parseVersion } from "../../core/runner/verify.ts"; -import { GRAMMAR_ASSETS, LIBRARY_PINS, TOOL_PINS } from "../../registry.ts"; +import { + GRAMMAR_ASSETS, + LIBRARY_PINS, + RUNTIME_PINS, + RUNTIME_PRESENCE, + TOOL_PINS, +} from "../../registry.ts"; import { GLYPH, type Style } from "../style.ts"; import { sanitizeLine } from "../render.ts"; @@ -50,15 +56,23 @@ function versionResult(name: string, expected: string, actual: string): DoctorPr return { name, ok: false, detail: `expected ${expected}, received ${actual}` }; } +function probe(name: string, operation: () => DoctorProbe): DoctorProbe { + try { + return operation(); + } catch (error) { + return { name, ok: false, detail: errorDetail(error) }; + } +} + function versionProbe( pin: { bin: string; version: string }, deps: DoctorDeps, ): DoctorProbe { - try { - return versionResult(pin.bin, pin.version, parseVersion(deps.probe(pin.bin))); - } catch (error) { - return { name: pin.bin, ok: false, detail: errorDetail(error) }; - } + return probe(pin.bin, () => versionResult(pin.bin, pin.version, parseVersion(deps.probe(pin.bin)))); +} + +function presenceProbe(bin: string, deps: DoctorDeps): DoctorProbe { + return probe(bin, () => ({ name: bin, ok: true, detail: parseVersion(deps.probe(bin)) })); } function asInstalledData(value: unknown): InstalledData { @@ -80,12 +94,10 @@ function libraryProbe( pin: (typeof LIBRARY_PINS)[number], deps: DoctorDeps, ): DoctorProbe { - try { + return probe(pin.name, () => { const actual = packageVersion(deps.readInstalled(pin.installed), pin.name) ?? "unknown"; return versionResult(pin.name, pin.version, actual); - } catch (error) { - return { name: pin.name, ok: false, detail: errorDetail(error) }; - } + }); } function assetProbe(asset: string, deps: DoctorDeps): DoctorProbe { @@ -98,8 +110,10 @@ function assetProbe(asset: string, deps: DoctorDeps): DoctorProbe { export function runDoctor(deps: DoctorDeps): DoctorProbe[] { const tools = TOOL_PINS.map((pin) => versionProbe(pin, deps)); const libraries = LIBRARY_PINS.map((pin) => libraryProbe(pin, deps)); + const runtime = RUNTIME_PINS.map((pin) => versionProbe(pin, deps)); + const presence = RUNTIME_PRESENCE.map((bin) => presenceProbe(bin, deps)); const assets = GRAMMAR_ASSETS.map((asset) => assetProbe(asset, deps)); - return [...tools, ...libraries, ...assets]; + return [...tools, ...libraries, ...runtime, ...presence, ...assets]; } async function grammarProbe(service: AnchorService, input: GrammarSource): Promise { @@ -119,9 +133,9 @@ export async function grammarProbes(assets: string): Promise { return probes; } -export function doctorLine(probe: DoctorProbe, style: Style): string { - const glyph = probe.ok ? style.green(GLYPH.pass) : style.red(GLYPH.fail); - const rawDetail = sanitizeLine(probe.detail); - const detail = probe.ok ? style.dim(rawDetail) : style.red(rawDetail); - return ` ${glyph} ${style.bold(sanitizeLine(probe.name))} ${detail}`; +export function doctorLine(item: DoctorProbe, style: Style): string { + const glyph = item.ok ? style.green(GLYPH.pass) : style.red(GLYPH.fail); + const rawDetail = sanitizeLine(item.detail); + const detail = item.ok ? style.dim(rawDetail) : style.red(rawDetail); + return ` ${glyph} ${style.bold(sanitizeLine(item.name))} ${detail}`; } diff --git a/src/cli/commands/versions.ts b/src/cli/commands/versions.ts index dc45958..02aa1fd 100644 --- a/src/cli/commands/versions.ts +++ b/src/cli/commands/versions.ts @@ -1,8 +1,16 @@ import type { ToolPin } from "../../core/types.ts"; -import { ADAPTERS, LIBRARY_PINS, TOOL_PINS } from "../../registry.ts"; +import { + ADAPTERS, + LIBRARY_PINS, + RUNTIME_PINS, + RUNTIME_PRESENCE, + TOOL_PINS, +} from "../../registry.ts"; import { createStyle, type Style } from "../style.ts"; interface LibraryPin { name: string; version: string; } +interface RenderContext { lines: string[]; style: Style; } +interface SectionEntry { name: string; value: string; } const LANGUAGE_GROUPS = [ { language: "ts", label: "TS/JS" }, @@ -14,36 +22,68 @@ function pinLine(name: string, version: string, width: number, style: Style): st return ` ${name.padEnd(width)} ${style.dim(version)}`; } -export function versionsText( - pins: readonly ToolPin[] = TOOL_PINS, - libraries: readonly LibraryPin[] = LIBRARY_PINS, - style: Style = createStyle(false), -): string[] { - const lines = [` ${style.bold("Tools")}`]; - const remaining = new Set(pins); - const toolWidth = Math.max(...pins.map((pin) => pin.bin.length), 0); +function appendSection( + lines: string[], + label: string, + entries: readonly SectionEntry[], + style: Style, +): void { + if (entries.length === 0) return; + lines.push(` ${style.bold(label)}`); + const width = Math.max(...entries.map((entry) => entry.name.length), 0); + for (const entry of entries) lines.push(pinLine(entry.name, entry.value, width, style)); +} + +function appendLanguageGroups( + context: RenderContext, + pins: readonly ToolPin[], + remaining: Set, +): void { for (const group of LANGUAGE_GROUPS) { const bins = new Set(ADAPTERS .filter((adapter) => adapter.language === group.language) .map((adapter) => adapter.tool.bin)); const selected = pins.filter((pin) => remaining.has(pin) && bins.has(pin.bin)); if (selected.length === 0) continue; - lines.push(` ${style.bold(group.label)}`); - for (const pin of selected) { - lines.push(pinLine(pin.bin, pin.version, toolWidth, style)); - remaining.delete(pin); - } - } - if (remaining.size > 0) { - lines.push(` ${style.bold("Other")}`); - for (const pin of remaining) { - lines.push(pinLine(pin.bin, pin.version, toolWidth, style)); - } - } - lines.push(` ${style.bold("Libraries")}`); - const libraryWidth = Math.max(...libraries.map((pin) => pin.name.length), 0); - for (const pin of libraries) { - lines.push(pinLine(pin.name, pin.version, libraryWidth, style)); + appendSection(context.lines, group.label, selected.map((pin) => ({ + name: pin.bin, value: pin.version, + })), context.style); + for (const pin of selected) remaining.delete(pin); } +} + +function appendOther( + context: RenderContext, + remaining: Set, +): void { + appendSection(context.lines, "Other", [...remaining].map((pin) => ({ + name: pin.bin, value: pin.version, + })), context.style); +} + +interface VersionsOptions { + pins?: readonly ToolPin[]; + libraries?: readonly LibraryPin[]; + runtime?: readonly ToolPin[]; + style?: Style; +} + +export function versionsText(options: VersionsOptions = {}): string[] { + const pins = options.pins ?? TOOL_PINS; + const libraries = options.libraries ?? LIBRARY_PINS; + const runtime = options.runtime ?? RUNTIME_PINS; + const style = options.style ?? createStyle(false); + const lines = [` ${style.bold("Tools")}`]; + const remaining = new Set(pins); + const context = { lines, style }; + appendLanguageGroups(context, pins, remaining); + appendOther(context, remaining); + appendSection(lines, "Runtime", [ + ...runtime.map((pin) => ({ name: pin.bin, value: pin.version })), + ...RUNTIME_PRESENCE.map((bin) => ({ name: bin, value: "presence (verified by doctor)" })), + ], style); + appendSection(lines, "Libraries", libraries.map((pin) => ({ + name: pin.name, value: pin.version, + })), style); return lines; } diff --git a/src/cli/program.ts b/src/cli/program.ts index 4c4ab10..5c7eccf 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -44,7 +44,7 @@ function doctorDependencies(deps: CliDeps): { assets: string; probe: (bin: strin } async function versionsCommand(deps: CliDeps): Promise { - deps.stdout(`${versionsText(undefined, undefined, deps.style).join("\n")}\n`); + deps.stdout(`${versionsText({ style: deps.style }).join("\n")}\n`); return 0; } diff --git a/src/core/anchor/kinds.ts b/src/core/anchor/kinds.ts index 71f5f80..e1447dd 100644 --- a/src/core/anchor/kinds.ts +++ b/src/core/anchor/kinds.ts @@ -118,6 +118,12 @@ const EXTENSIONS: Readonly> = { ".cjs": "javascript", ".jsx": "javascript", ".php": "php", + ".module": "php", + ".theme": "php", + ".install": "php", + ".inc": "php", + ".profile": "php", + ".engine": "php", ".py": "python", }; diff --git a/src/core/config/detect.ts b/src/core/config/detect.ts index e67e64d..1c56639 100644 --- a/src/core/config/detect.ts +++ b/src/core/config/detect.ts @@ -3,7 +3,8 @@ import { join } from "node:path"; import { BUILTIN_EXCLUSIONS, TEST_EXCLUSIONS } from "./exclusions.ts"; import { LANGUAGES, type Language } from "./schema.ts"; -import { findSourceFiles } from "./sources.ts"; +import { findSourceFiles, languageExtensions } from "./sources.ts"; +import type { SourceSelection } from "./sources.ts"; import type { ArchitectureSelection } from "./types.ts"; const MANIFESTS: Partial> = { @@ -33,6 +34,32 @@ const DRUPAL_PATHS: Record<"php" | "web", string[]> = { web: ["web/themes/custom", "docroot/themes/custom"], }; +export const NEXT_CONFIGS = [ + "next.config.js", + "next.config.mjs", + "next.config.cjs", + "next.config.ts", + "next.config.mts", +] as const; + +export const PAYLOAD_CONFIGS = [ + "payload.config.ts", + "payload.config.js", + "payload.config.mjs", + "payload.config.mts", + "src/payload.config.ts", + "src/payload.config.js", + "src/payload.config.mjs", + "src/payload.config.mts", +] as const; + +export const PAYLOAD_NEXT_CONFIGS = [...NEXT_CONFIGS, ...PAYLOAD_CONFIGS] as const; + +export interface ProjectProfiles { + next: boolean; + payload: boolean; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -49,9 +76,34 @@ export function isDrupalProject(root: string): boolean { } } +function hasPayloadDependency(root: string): boolean { + try { + const parsed: unknown = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + if (!isRecord(parsed)) return false; + return [parsed.dependencies, parsed.devDependencies] + .some((dependencies) => isRecord(dependencies) && "payload" in dependencies); + } catch { + return false; + } +} + +export function projectProfiles(root: string): ProjectProfiles { + return { + next: NEXT_CONFIGS.some((file) => existsSync(join(root, file))), + payload: PAYLOAD_CONFIGS.some((file) => existsSync(join(root, file))) + || hasPayloadDependency(root), + }; +} + +export function isPayloadNextProject(root: string): boolean { + const profiles = projectProfiles(root); + return profiles.next || profiles.payload; +} + function containsSources(root: string, paths: string[], language: Language): boolean { const excludes = [...BUILTIN_EXCLUSIONS, ...TEST_EXCLUSIONS]; - return paths.length > 0 && findSourceFiles(root, paths, language, excludes).length > 0; + const selection: SourceSelection = { excludes, extensions: languageExtensions(language, false) }; + return paths.length > 0 && findSourceFiles(root, paths, language, selection).length > 0; } function drupalPaths(root: string, language: Language): string[] { @@ -73,7 +125,10 @@ export function detectLanguages(root: string): Language[] { function webSourcesExist(root: string): boolean { const paths = defaultPaths(root, "web"); const excludes = [...BUILTIN_EXCLUSIONS, ...TEST_EXCLUSIONS]; - return paths.length > 0 && findSourceFiles(root, paths, "web", excludes).length > 0; + return paths.length > 0 && findSourceFiles(root, paths, "web", { + excludes, + extensions: languageExtensions("web", false), + }).length > 0; } export function defaultPaths(root: string, language: Language): string[] { diff --git a/src/core/config/exclusions.ts b/src/core/config/exclusions.ts index cc61518..ac87097 100644 --- a/src/core/config/exclusions.ts +++ b/src/core/config/exclusions.ts @@ -33,6 +33,37 @@ export const BUILTIN_EXCLUSIONS = [ "**/var/**", ] as const; +export const PAYLOAD_EXCLUSIONS = [ + "**/payload-types.ts", + "**/importMap.js", + "**/app/[(]payload[)]/**", + "**/migrations/**", + "**/migrations-*/**", + "**/seed/**", +] as const; + +export const NEXT_EXCLUSIONS = [".next/**", "next-env.d.ts"] as const; +export const PAYLOAD_NEXT_EXCLUSIONS = [...PAYLOAD_EXCLUSIONS, ...NEXT_EXCLUSIONS] as const; + +function prefix(owner: string, pattern: string): string { + return owner === "." ? pattern : `${owner}/${pattern}`; +} + +export function nextExclusions(owner: string): string[] { + return NEXT_EXCLUSIONS.map((pattern) => prefix(owner, pattern)); +} + +export function payloadExclusions(tsRoots: readonly string[]): string[] { + return tsRoots.flatMap((root) => [ + ...PAYLOAD_EXCLUSIONS.map((pattern) => `${root}/${pattern}`), + ...(root.split("/").at(-1) === "app" ? [`${root}/[(]payload[)]/**`] : []), + ]); +} + +export function payloadNextExclusions(tsRoots: readonly string[]): string[] { + return [...payloadExclusions(tsRoots), ...nextExclusions(".")]; +} + export function isExcluded(file: string, patterns: readonly string[]): boolean { return patterns.some((pattern) => picomatch(pattern, { dot: true })(file)); } diff --git a/src/core/config/hash.ts b/src/core/config/hash.ts index fd7f044..c8b9ddb 100644 --- a/src/core/config/hash.ts +++ b/src/core/config/hash.ts @@ -62,7 +62,8 @@ function serializeArchitecture( } type ConfigHashInput = Omit - & Partial>; + & Partial> + & { runtime?: { python?: string } }; export function configHash(resolved: ConfigHashInput): string { const hashable = Object.fromEntries( diff --git a/src/core/config/load.ts b/src/core/config/load.ts index 59aa799..862eaa1 100644 --- a/src/core/config/load.ts +++ b/src/core/config/load.ts @@ -5,15 +5,28 @@ import { parse } from "yaml"; import { ZodError } from "zod"; import { fail } from "../errors.ts"; -import { BUILTIN_EXCLUSIONS, TEST_EXCLUSIONS } from "./exclusions.ts"; +import { + BUILTIN_EXCLUSIONS, + nextExclusions, + payloadExclusions, + TEST_EXCLUSIONS, +} from "./exclusions.ts"; import { configHash } from "./hash.ts"; -import { defaultPaths, detectLanguages, isDrupalProject, selectArchitecture } from "./detect.ts"; +import { + defaultPaths, + detectLanguages, + isDrupalProject, + projectProfiles, + selectArchitecture, +} from "./detect.ts"; import { discoverSourceRoots, findMinifiedFiles, findSourceFiles, - LANGUAGE_EXTENSIONS, + languageExtensions, } from "./sources.ts"; +import type { SourceSelection } from "./sources.ts"; +import { ownerOf, workspaceRoots } from "./workspaces.ts"; import { LANGUAGES, consumerConfigSchema, @@ -21,6 +34,7 @@ import { type Language, } from "./schema.ts"; import type { ArchitectureSelection, ResolvedConfig } from "./types.ts"; +import { isAtLeast, PYTHON_314_VERSION, pythonTarget } from "./runtime.ts"; const DETECTION_MANIFESTS: Partial> = { ts: ["package.json"], @@ -63,7 +77,6 @@ function readYaml(root: string): ConsumerConfig { interface LanguageResolution { languages: Language[]; - explicit: Set; } function configuredPathLanguages(config: ConsumerConfig): Language[] { @@ -74,10 +87,7 @@ function resolveLanguages(root: string, config: ConsumerConfig): LanguageResolut const configuredPaths = configuredPathLanguages(config); const detected = detectLanguages(root); const languages = config.languages ?? [...new Set([...detected, ...configuredPaths])]; - return { - languages: [...languages], - explicit: new Set([...config.languages ?? [], ...configuredPaths]), - }; + return { languages: [...languages] }; } function ensureConfiguredPaths(root: string, language: Language, paths: string[]): string[] { @@ -90,12 +100,12 @@ function ensureSourceFiles( root: string, language: Language, paths: string[], - excludes: readonly string[], + selection: SourceSelection, ): void { - if (findSourceFiles(root, paths, language, excludes).length > 0) return; + if (findSourceFiles(root, paths, language, selection).length > 0) return; fail( `${language}: no ${language} source files found under ${paths.join(", ")} ` + - `(extensions: ${LANGUAGE_EXTENSIONS[language].join(" ")})`, + `(extensions: ${selection.extensions.join(" ")})`, ); } @@ -136,29 +146,58 @@ interface LanguagePathContext { language: Language; explicit: ReadonlySet; config: ConsumerConfig; - excludes: readonly string[]; + selection: SourceSelection; + isDrupal: boolean; +} + +function conventionalPaths(context: LanguagePathContext, defaults: string[]): string[] { + const { root, language, selection } = context; + if (defaults.length === 0) return []; + return findSourceFiles(root, defaults, language, selection).length > 0 ? defaults : []; +} + +function automaticPaths( + context: LanguagePathContext, + conventional: string[], +): LanguagePathResolution | undefined { + const { root, language, explicit, selection, isDrupal } = context; + const candidates = language === "php" && isDrupal ? [] : workspaceRoots(root, language, selection); + const workspace = candidates.filter((candidate) => + !conventional.some((path) => candidate === path || candidate.startsWith(`${path}/`)) + ); + if (conventional.length + workspace.length === 0) return undefined; + const discovered = conventional.length === 0 && !explicit.has(language) + ? discoverSourceRoots(root, language, selection).filter((candidate) => + !workspace.some((rootPath) => rootPath === candidate || rootPath.startsWith(`${candidate}/`)) + ) + : []; + const additional = [...new Set([...workspace, ...discovered])].toSorted(); + const path = [...conventional, ...additional]; + return workspace.length > 0 + ? { path, notice: `${language}: added workspace roots ${workspace.join(", ")}` } + : { path }; } function resolveLanguagePath(context: LanguagePathContext): LanguagePathResolution { - const { root, language, explicit, config, excludes } = context; + const { root, language, explicit, config, selection } = context; const configured = config.paths?.[language]; if (configured !== undefined) { const paths = ensureConfiguredPaths(root, language, configured); - ensureSourceFiles(root, language, paths, excludes); + ensureSourceFiles(root, language, paths, selection); return { path: paths }; } const defaults = defaultPaths(root, language); + const conventional = conventionalPaths(context, defaults); + const automatic = automaticPaths(context, conventional); + if (automatic !== undefined) return automatic; if (explicit.has(language) && defaults.length === 0) return { path: noDefaultPath(language) }; - if (defaults.length > 0 && findSourceFiles(root, defaults, language, excludes).length > 0) { - return { path: defaults }; - } if (explicit.has(language)) { - ensureSourceFiles(root, language, defaults, excludes); + ensureSourceFiles(root, language, defaults, selection); return { path: defaults }; } - const discovered = discoverSourceRoots(root, language, excludes); + const discovered = discoverSourceRoots(root, language, selection); if (discovered.length > 0) { return { path: discovered, notice: discoveredRootsNotice(language, discovered) }; } @@ -167,19 +206,32 @@ function resolveLanguagePath(context: LanguagePathContext): LanguagePathResoluti function resolvePaths( root: string, - languages: Language[], - explicit: ReadonlySet, config: ConsumerConfig, + languages: Language[], + options: { isDrupal: boolean; profileExclusions: readonly string[] }, ): { paths: Partial>; notices: string[] } { + const { isDrupal, profileExclusions } = options; const paths: Partial> = {}; const notices: string[] = []; - const excludes = [...BUILTIN_EXCLUSIONS, ...config.exclude ?? [], ...TEST_EXCLUSIONS]; + const baseExcludes = [...BUILTIN_EXCLUSIONS, ...config.exclude ?? [], ...TEST_EXCLUSIONS]; + const explicit = new Set([...config.languages ?? [], ...configuredPathLanguages(config)]); for (const language of languages) { - const resolution = resolveLanguagePath({ root, language, explicit, config, excludes }); + const selection: SourceSelection = { + excludes: language === "ts" ? [...baseExcludes, ...profileExclusions] : baseExcludes, + extensions: languageExtensions(language, isDrupal), + }; + const resolution = resolveLanguagePath({ + root, + language, + explicit, + config, + selection, + isDrupal, + }); if (resolution.path !== undefined) paths[language] = resolution.path; if (resolution.notice !== undefined) notices.push(resolution.notice); } - if (isDrupalProject(root)) { + if (isDrupal) { notices.push( "Drupal profile: using custom module, theme, and profile roots while excluding core, " + "contrib, generated, and runtime paths", @@ -198,9 +250,15 @@ function resolveMinifiedFiles( root: string, paths: Partial>, consumerExcludes: readonly string[], + profileExclusions: readonly string[], ): string[] { const roots = [...new Set(Object.values(paths).flatMap((entries) => entries ?? []))]; - const excludes = [...BUILTIN_EXCLUSIONS, ...consumerExcludes, ...TEST_EXCLUSIONS]; + const excludes = [ + ...BUILTIN_EXCLUSIONS, + ...consumerExcludes, + ...profileExclusions, + ...TEST_EXCLUSIONS, + ]; return findMinifiedFiles(root, roots, excludes); } @@ -227,28 +285,114 @@ function resolveArchitecture( return architecture; } +interface ProfileResolution { + exclusions: string[]; + nextOwners: string[]; + payloadOwners: string[]; +} + +function resolveProfiles(root: string, tsRoots: readonly string[]): ProfileResolution { + const rootsByOwner = new Map(); + for (const path of tsRoots) { + const owner = ownerOf(root, path); + const paths = rootsByOwner.get(owner) ?? []; + paths.push(path); + rootsByOwner.set(owner, paths); + } + const resolution: ProfileResolution = { exclusions: [], nextOwners: [], payloadOwners: [] }; + for (const [owner, paths] of rootsByOwner) { + const ownerRoot = owner === "." ? root : join(root, owner); + const profiles = projectProfiles(ownerRoot); + if (profiles.next) { + resolution.nextOwners.push(owner); + resolution.exclusions.push(...nextExclusions(owner)); + } + if (profiles.payload) { + resolution.payloadOwners.push(owner); + resolution.exclusions.push(...payloadExclusions(paths)); + } + } + resolution.exclusions = [...new Set(resolution.exclusions)]; + return resolution; +} + +function profileNotice(message: string, owners: readonly string[]): string { + const nested = owners.filter((owner) => owner !== "."); + return nested.length === owners.length ? `${message} (${nested.join(", ")})` : message; +} + +function appendProfileNotices( + notices: string[], + minified: string[], + profiles: ProfileResolution, +): void { + if (minified.length > 0) notices.push(minifiedNotice(minified)); + if (profiles.nextOwners.length > 0) { + notices.push(profileNotice( + "Next profile: excluding Next build output", profiles.nextOwners, + )); + } + if (profiles.payloadOwners.length > 0) { + notices.push(profileNotice( + "Payload profile: excluding generated Payload types, import map, admin route group, " + + "migrations, and seed data", + profiles.payloadOwners, + )); + } +} + +function appendRuntimeNotice(root: string, languages: readonly Language[], notices: string[]): void { + if (!languages.includes("python")) return; + const target = pythonTarget(root); + if (target === undefined || !isAtLeast(target.version, "3.14")) return; + const newer = isAtLeast(target.version, PYTHON_314_VERSION) + && target.version !== PYTHON_314_VERSION.slice(0, PYTHON_314_VERSION.lastIndexOf(".")); + const qualifier = newer ? ", newer than the image" : ""; + notices.push(`python: targeting ${target.version} (from ${target.source})${qualifier}; ` + + `tools run on CPython ${PYTHON_314_VERSION}`); +} + +function resolvedExclusions( + consumer: ConsumerConfig, + minified: string[], + profileExclusions: readonly string[], +): string[] { + return [...consumer.exclude ?? [], ...minified, ...profileExclusions]; +} + export function loadConfig(root: string): ResolvedConfig { const consumer = readYaml(root); const isDrupal = isDrupalProject(root); const languageResolution = resolveLanguages(root, consumer); - const pathResolution = resolvePaths( + const initialPaths = resolvePaths( root, + consumer, languageResolution.languages, - languageResolution.explicit, + { isDrupal, profileExclusions: [] }, + ); + const initialProfiles = resolveProfiles(root, initialPaths.paths.ts ?? []); + const pathResolution = initialProfiles.exclusions.length === 0 ? initialPaths : resolvePaths( + root, consumer, + languageResolution.languages, + { isDrupal, profileExclusions: initialProfiles.exclusions }, ); const languages = languageResolution.languages.filter( (language) => pathResolution.paths[language] !== undefined, ); const paths = pathResolution.paths; - const minified = resolveMinifiedFiles(root, paths, consumer.exclude ?? []); - if (minified.length > 0) pathResolution.notices.push(minifiedNotice(minified)); + const profiles = resolveProfiles(root, paths.ts ?? []); + const resolvedProfileExclusions = profiles.exclusions; + const minified = resolveMinifiedFiles(root, paths, consumer.exclude ?? [], resolvedProfileExclusions); + appendProfileNotices(pathResolution.notices, minified, profiles); + appendRuntimeNotice(root, languages, pathResolution.notices); const architecture = resolveArchitecture(root, languages, consumer); + const target = pythonTarget(root); const resolved = { isDrupal, languages, paths, - exclude: [...consumer.exclude ?? [], ...minified], + exclude: resolvedExclusions(consumer, minified, resolvedProfileExclusions), disabled: consumer.checks?.disabled ?? [], architecture, notices: pathResolution.notices, @@ -257,6 +401,8 @@ export function loadConfig(root: string): ResolvedConfig { root, ...resolved, ...(consumer.report ? { report: consumer.report } : {}), - configHash: configHash(resolved), + configHash: configHash(target === undefined || !languages.includes("python") + ? resolved + : { ...resolved, runtime: { python: target.version } }), }; } diff --git a/src/core/config/policy.ts b/src/core/config/policy.ts index 4c6d21e..9960c39 100644 --- a/src/core/config/policy.ts +++ b/src/core/config/policy.ts @@ -1,4 +1,4 @@ -export const POLICY_VERSION = "2026-09-12.1"; +export const POLICY_VERSION = "2026-09-23.1"; export const POLICY = { complexity: 10, diff --git a/src/core/config/runtime.ts b/src/core/config/runtime.ts new file mode 100644 index 0000000..d277de0 --- /dev/null +++ b/src/core/config/runtime.ts @@ -0,0 +1,114 @@ +import { readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; + +import { parse as parseToml } from "smol-toml"; + +import { readRecord, uvWorkspaceMemberDirectories } from "./workspaces.ts"; + +export const PYTHON_314_BIN = "/opt/venv314/bin"; +export const PYTHON_314_VERSION = "3.14.7"; + +export interface PythonTarget { + version: string; + source: string; +} + +function majorMinor(value: string): string | undefined { + const match = /^(?\d+)\.(?\d+)/u.exec(value); + return match?.groups ? `${match.groups.major}.${match.groups.minor}` : undefined; +} + +export function compareMajorMinor(left: string, right: string): number { + const [leftMajor, leftMinor] = left.split(".").map(Number); + const [rightMajor, rightMinor] = right.split(".").map(Number); + return leftMajor! - rightMajor! || leftMinor! - rightMinor!; +} + +function maximumVersion(versions: readonly string[]): string | undefined { + return versions.toSorted(compareMajorMinor).at(-1); +} + +type LowerPythonBound = + | { kind: "invalid" } + | { kind: "bound"; version: string } + | { kind: "none" }; + +const MAX_PYTHON_VERSION_FILE_SIZE = 4 * 1024; + +function pinnedPythonVersion(value: string): string | undefined { + if (/^(?:pypy|graalpy)/iu.test(value)) return undefined; + const versionPattern = "\\d+\\.\\d+(?:\\.\\d+)?(?:a\\d+|b\\d+|rc\\d+)?"; + const exact = new RegExp(`^(?${versionPattern})$`, "u"); + const uvPin = new RegExp(`^(?${versionPattern})(?:-[\\w.-]+)?$`, "u"); + const candidate = value.startsWith("cpython@") + ? exact.exec(value.slice("cpython@".length)) + : value.startsWith("cpython-") + ? uvPin.exec(value.slice("cpython-".length)) + : exact.exec(value); + const version = candidate?.groups?.version; + return version ? majorMinor(version) : undefined; +} + +function pythonVersionFile(root: string): PythonTarget | undefined { + const path = join(root, ".python-version"); + try { + const stat = statSync(path); + if (!stat.isFile() || stat.size > MAX_PYTHON_VERSION_FILE_SIZE) return undefined; + const value = readFileSync(path, "utf8").split(/\r?\n/u) + .map((line) => line.trim()) + .find((line) => line !== "" && !line.startsWith("#")); + const version = value === undefined ? undefined : pinnedPythonVersion(value); + return version === undefined ? undefined : { version, source: ".python-version" }; + } catch { + return undefined; + } +} + +function lowerPythonBound(clause: string): LowerPythonBound { + const match = /^(?>=|<=|~=|==|!=|>|<)\s*(?\d+(?:\.\d+){0,2}(?:\.\*)?)$/u.exec(clause); + if (!match?.groups) return { kind: "invalid" }; + if (![">=", ">", "~=", "=="].includes(match.groups.operator!)) return { kind: "none" }; + const version = majorMinor(match.groups.version!); + return version === undefined ? { kind: "invalid" } : { kind: "bound", version }; +} + +function requiresPythonField(root: string): string | undefined { + const project = readRecord(join(root, "pyproject.toml"), parseToml)?.project; + if (typeof project !== "object" || project === null || Array.isArray(project)) return undefined; + const field = (project as Record)["requires-python"]; + return typeof field === "string" ? field : undefined; +} + +function requiresPython(root: string): PythonTarget | undefined { + const field = requiresPythonField(root); + if (field === undefined) return undefined; + const bounds: string[] = []; + for (const clause of field.split(",").map((value) => value.trim())) { + const bound = lowerPythonBound(clause); + if (bound.kind === "invalid") return undefined; + if (bound.kind === "bound") bounds.push(bound.version); + } + const version = maximumVersion(bounds); + return version === undefined ? undefined : { version, source: "requires-python" }; +} + +function localPythonTarget(root: string): PythonTarget | undefined { + return pythonVersionFile(root) ?? requiresPython(root); +} + +export function pythonTarget(root: string): PythonTarget | undefined { + const local = localPythonTarget(root); + if (local !== undefined) return local; + const members = uvWorkspaceMemberDirectories(root).flatMap((member) => { + const target = localPythonTarget(join(root, member)); + return target === undefined ? [] : [{ + version: target.version, + source: `${member}/${target.source}`, + }]; + }); + return members.toSorted((left, right) => compareMajorMinor(right.version, left.version))[0]; +} + +export function isAtLeast(version: string, minimum: string): boolean { + return compareMajorMinor(version, minimum) >= 0; +} diff --git a/src/core/config/sources.ts b/src/core/config/sources.ts index 52d81c8..c950f06 100644 --- a/src/core/config/sources.ts +++ b/src/core/config/sources.ts @@ -4,7 +4,7 @@ import { basename, extname, join, relative, sep } from "node:path"; import { isExcluded } from "./exclusions.ts"; import type { Language } from "./schema.ts"; -const DISCOVERY_EXCLUDED_ROOTS = new Set([ +export const DISCOVERY_EXCLUDED_ROOTS = new Set([ "node_modules", "vendor", "dist", @@ -23,13 +23,30 @@ const DISCOVERY_EXCLUDED_ROOTS = new Set([ "tmp", ]); -export const LANGUAGE_EXTENSIONS: Readonly> = { +const LANGUAGE_EXTENSIONS: Readonly> = { ts: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"], php: [".php"], python: [".py"], web: [".twig", ".html", ".css", ".scss", ".less"], }; +const DRUPAL_PHP_EXTENSIONS = [".module", ".theme", ".install", ".inc", ".profile", ".engine"] as const; + +export interface SourceSelection { + excludes: readonly string[]; + extensions: readonly string[]; +} + +interface MinifiedSelection { + excludes: readonly string[]; +} + +export function languageExtensions(language: Language, isDrupal: boolean): readonly string[] { + return language === "php" && isDrupal + ? [".php", ...DRUPAL_PHP_EXTENSIONS] + : LANGUAGE_EXTENSIONS[language]; +} + const MINIFIED_NAME = /(?:[.-]min\.(?:js|css)$|^[a-z-]+-\d+(?:\.\d+)*\.js$)/u; const MINIFIED_GLOBS = new Set(["**/*.min.js", "**/*.min.css"]); const SOURCE_EXTENSIONS = new Set(Object.values(LANGUAGE_EXTENSIONS).flat()); @@ -60,16 +77,16 @@ function* walkSourceFiles( root: string, paths: readonly string[], language: Language | undefined, - excludes: readonly string[], + selection: SourceSelection | MinifiedSelection, ): Generator { const pending = paths.map((path) => join(root, path)); - const extensions = language === undefined + const extensionSet = language === undefined ? SOURCE_EXTENSIONS - : new Set(LANGUAGE_EXTENSIONS[language]); + : new Set((selection as SourceSelection).extensions); while (pending.length > 0) { const candidate = pending.pop()!; const relativePath = portable(relative(root, candidate)); - if (isExcluded(relativePath, excludes)) continue; + if (isExcluded(relativePath, selection.excludes)) continue; const stats = lstatSync(candidate); if (stats.isDirectory()) { for (const entry of readdirSync(candidate, { withFileTypes: true })) { @@ -77,20 +94,24 @@ function* walkSourceFiles( } continue; } - if (stats.isFile() && extensions.has(extname(candidate))) { + if (stats.isFile() && extensionSet.has(extname(candidate))) { yield { absolute: candidate, relative: relativePath }; } } } +export function phpToolExtensions(isDrupal: boolean): string[] { + return languageExtensions("php", isDrupal).map((extension) => extension.slice(1)); +} + export function findSourceFiles( root: string, paths: readonly string[], language: Language, - excludes: readonly string[], + selection: SourceSelection, ): string[] { const files: string[] = []; - for (const file of walkSourceFiles(root, paths, language, excludes)) { + for (const file of walkSourceFiles(root, paths, language, selection)) { if (!isMinifiedFile(file.absolute)) files.push(file.relative); } return files.toSorted(); @@ -103,7 +124,7 @@ export function findMinifiedFiles( ): string[] { const effectiveExcludes = excludes.filter((pattern) => !MINIFIED_GLOBS.has(pattern)); const files: string[] = []; - for (const file of walkSourceFiles(root, paths, undefined, effectiveExcludes)) { + for (const file of walkSourceFiles(root, paths, undefined, { excludes: effectiveExcludes })) { if (isMinifiedFile(file.absolute)) { files.push(file.relative); } @@ -114,12 +135,12 @@ export function findMinifiedFiles( export function discoverSourceRoots( root: string, language: Language, - excludes: readonly string[], + selection: SourceSelection, ): string[] { return readdirSync(root, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .filter((name) => !name.startsWith(".") && !DISCOVERY_EXCLUDED_ROOTS.has(name)) - .filter((name) => findSourceFiles(root, [name], language, excludes).length > 0) + .filter((name) => findSourceFiles(root, [name], language, selection).length > 0) .toSorted(); } diff --git a/src/core/config/workspaces.ts b/src/core/config/workspaces.ts new file mode 100644 index 0000000..10b38e0 --- /dev/null +++ b/src/core/config/workspaces.ts @@ -0,0 +1,271 @@ +import { existsSync, lstatSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { parse as parseToml } from "smol-toml"; +import { parse as parseYaml } from "yaml"; + +import { isExcluded } from "./exclusions.ts"; +import { isRelativePath, type Language } from "./schema.ts"; +import { DISCOVERY_EXCLUDED_ROOTS, findSourceFiles, type SourceSelection } from "./sources.ts"; + +const MAX_MANIFEST_SIZE = 1024 * 1024; +const MAX_PATTERNS = 200; +const MAX_PATTERN_LENGTH = 200; +const INVALID_CHARACTERS = new Set(["?", "[", "]", "{", "}", "(", ")", "!", "+", "@"]); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function strings(value: unknown): string[] { + if (typeof value === "string") return [value]; + return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : []; +} + +export function readRecord( + file: string, + parse: (content: string) => unknown, +): Record | undefined { + try { + const stat = statSync(file); + if (!stat.isFile() || stat.size > MAX_MANIFEST_SIZE) return undefined; + const value = parse(readFileSync(file, "utf8")); + return isRecord(value) ? value : undefined; + } catch { + return undefined; + } +} + +function normalize(candidate: string): string | undefined { + if (candidate.startsWith("/")) return undefined; + const normalized = candidate.split("/").filter((segment) => segment.length > 0 && segment !== ".").join("/"); + return isRelativePath(normalized) ? normalized : undefined; +} + +function normalizePattern(pattern: string): string | undefined { + const negation = pattern.startsWith("!"); + const normalized = normalize(negation ? pattern.slice(1) : pattern); + return normalized === undefined ? undefined : `${negation ? "!" : ""}${normalized}`; +} + +function safeDirectories(root: string): string[] { + try { + return readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")) + .map((entry) => entry.name); + } catch { + return []; + } +} + +function childDirectories(root: string): string[] { + const directories: string[] = []; + let frontier = [""]; + for (let depth = 1; depth <= 3; depth += 1) { + const next: string[] = []; + for (const parent of frontier) { + for (const name of safeDirectories(join(root, parent))) { + const directory = parent === "" ? name : `${parent}/${name}`; + directories.push(directory); + next.push(directory); + } + } + frontier = next; + } + return directories; +} + +function validSegment(segment: string): boolean { + if (segment === "**" || segment === "*") return true; + return ![...segment].some((character) => INVALID_CHARACTERS.has(character)) && + (segment.match(/\*/gu)?.length ?? 0) <= 1; +} + +function validPattern(pattern: string): boolean { + return pattern.length > 0 && pattern.length <= MAX_PATTERN_LENGTH && + pattern.split("/").every(validSegment); +} + +function segmentMatches(pattern: string, candidate: string): boolean { + if (pattern === "*") return true; + const star = pattern.indexOf("*"); + return star < 0 + ? pattern === candidate + : candidate.startsWith(pattern.slice(0, star)) && candidate.endsWith(pattern.slice(star + 1)); +} + +function matchSegments(pattern: readonly string[], candidate: readonly string[]): boolean { + const memo = new Map(); + function matches(patternIndex: number, candidateIndex: number): boolean { + const key = `${patternIndex}:${candidateIndex}`; + const cached = memo.get(key); + if (cached !== undefined) return cached; + const head = pattern[patternIndex]; + let result = candidateIndex === candidate.length; + if (head === "**") { + result = matches(patternIndex + 1, candidateIndex) || + (candidateIndex < candidate.length && matches(patternIndex, candidateIndex + 1)); + } else if (head !== undefined) { + result = candidateIndex < candidate.length && segmentMatches(head, candidate[candidateIndex]!) && + matches(patternIndex + 1, candidateIndex + 1); + } + memo.set(key, result); + return result; + } + return matches(0, 0); +} + +function memberMatches(pattern: string, directory: string): boolean { + return validPattern(pattern) && matchSegments(pattern.split("/"), directory.split("/")); +} + +function expandMemberGlobs(root: string, patterns: readonly string[]): string[] { + const normalized = patterns.flatMap((pattern) => { + const result = normalizePattern(pattern); + return result === undefined ? [] : [result]; + }); + const included = normalized.filter((pattern) => !pattern.startsWith("!")).slice(0, MAX_PATTERNS); + const excluded = normalized.filter((pattern) => pattern.startsWith("!")).slice(0, MAX_PATTERNS) + .map((pattern) => pattern.slice(1)); + if (included.length === 0) return []; + return childDirectories(root).filter((directory) => + included.some((pattern) => memberMatches(pattern, directory)) && + !excluded.some((pattern) => memberMatches(pattern, directory)) + ); +} + +function packageWorkspacePatterns(manifest: Record | undefined): string[] { + const workspaces = manifest?.workspaces; + if (Array.isArray(workspaces)) return strings(workspaces); + return isRecord(workspaces) ? strings(workspaces.packages) : []; +} + +function hasManifest(root: string, member: string, manifest: string): boolean { + return existsSync(join(root, member, manifest)); +} + +export function workspaceMemberDirectories(root: string): string[] { + const pnpm = readRecord(join(root, "pnpm-workspace.yaml"), parseYaml); + const packageJson = readRecord(join(root, "package.json"), JSON.parse); + const patterns = [...strings(pnpm?.packages), ...packageWorkspacePatterns(packageJson)]; + return [...new Set(expandMemberGlobs(root, patterns))] + .filter((member) => hasManifest(root, member, "package.json")) + .toSorted(); +} + +function isConventionalName(name: string): boolean { + return name.startsWith(".") || DISCOVERY_EXCLUDED_ROOTS.has(name); +} + +function depthOnePackages(root: string): string[] { + return safeDirectories(root) + .filter((name) => !isConventionalName(name)) + .filter((name) => hasManifest(root, name, "package.json")); +} + +function preferSrcSubdir(root: string, member: string, selection?: SourceSelection): string { + const source = `${member}/src`; + if (!existsSync(join(root, source))) return member; + if (selection === undefined) return source; + const outsideSrc = findSourceFiles(root, [member], "ts", selection) + .some((file) => !file.startsWith(`${source}/`)); + return outsideSrc ? member : source; +} + +function typeScriptCandidates(root: string, selection: SourceSelection): string[] { + return [...workspaceMemberDirectories(root), ...depthOnePackages(root)] + .map((member) => preferSrcSubdir(root, member, selection)); +} + +function composerMapDirectories(value: unknown): string[] { + return isRecord(value) ? Object.values(value).flatMap(strings) : []; +} + +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +function phpCandidates(root: string): string[] { + const autoload = readRecord(join(root, "composer.json"), JSON.parse)?.autoload; + if (!isRecord(autoload)) return []; + const candidates = [ + ...composerMapDirectories(autoload["psr-4"]), + ...composerMapDirectories(autoload["psr-0"]), + ]; + for (const entry of strings(autoload.classmap)) { + const directory = normalize(entry); + if (directory !== undefined && isDirectory(join(root, directory))) candidates.push(directory); + } + return candidates; +} + +function nestedRecord(value: unknown, keys: readonly string[]): Record | undefined { + let current = value; + for (const key of keys) { + if (!isRecord(current)) return undefined; + current = current[key]; + } + return isRecord(current) ? current : undefined; +} + +export function uvWorkspaceMemberDirectories(root: string): string[] { + const project = readRecord(join(root, "pyproject.toml"), parseToml); + const workspace = nestedRecord(project, ["tool", "uv", "workspace"]); + const patterns = [ + ...strings(workspace?.members), + ...strings(workspace?.exclude).map((pattern) => `!${pattern}`), + ]; + return expandMemberGlobs(root, patterns) + .filter((member) => hasManifest(root, member, "pyproject.toml")); +} + +function pythonCandidates(root: string): string[] { + const project = readRecord(join(root, "pyproject.toml"), parseToml); + const setuptools = nestedRecord(project, ["tool", "setuptools", "packages", "find"]); + const members = uvWorkspaceMemberDirectories(root) + .map((member) => preferSrcSubdir(root, member)); + return [...members, ...strings(setuptools?.where)]; +} + +function isFile(path: string): boolean { + try { + return lstatSync(path).isFile(); + } catch { + return false; + } +} + +export function ownerOf(root: string, path: string): string { + let candidate = path.replace(/\/+$/u, "") || "."; + while (candidate !== ".") { + if (isFile(join(root, candidate, "package.json"))) return candidate; + const parent = dirname(candidate); + candidate = parent === candidate ? "." : parent; + } + return "."; +} + +function reduceRoots(candidates: readonly string[]): string[] { + const unique = [...new Set(candidates)].toSorted(); + return unique.filter((candidate) => + !unique.some((parent) => parent !== candidate && candidate.startsWith(`${parent}/`)) + ); +} + +export function workspaceRoots(root: string, language: Language, selection: SourceSelection): string[] { + if (language === "web") return []; + const candidates = language === "ts" + ? typeScriptCandidates(root, selection) + : language === "php" ? phpCandidates(root) : pythonCandidates(root); + const valid = candidates.flatMap((candidate) => { + const directory = normalize(candidate); + if (directory === undefined || isExcluded(directory, selection.excludes)) return []; + if (!isDirectory(join(root, directory))) return []; + return findSourceFiles(root, [directory], language, selection).length > 0 ? [directory] : []; + }); + return reduceRoots(valid); +} diff --git a/src/core/runner/run-adapter.ts b/src/core/runner/run-adapter.ts index 49a94c8..49f8420 100644 --- a/src/core/runner/run-adapter.ts +++ b/src/core/runner/run-adapter.ts @@ -108,8 +108,15 @@ async function runInTemp( }); const generated: GeneratedFile[] = adapter.configFiles(context); writeGenerated(tempDir, generated); - deps.verify(adapter.tool); - const result: ToolResult = deps.spawn(adapter.command(context), config.root); + const invocation = adapter.command(context); + deps.verify(adapter.tool, (bin) => deps.spawn({ + bin, + args: ["--version"], + ...(invocation.env === undefined ? {} : { env: invocation.env }), + ...(invocation.cwd === undefined ? {} : { cwd: invocation.cwd }), + exitCodes: [0], + }, config.root)); + const result: ToolResult = deps.spawn(invocation, config.root); if (deps.artifactsRoot !== undefined) { writeArtifact(artifacts, "stdout.log", result.stdout); writeArtifact(artifacts, "stderr.log", result.stderr); diff --git a/src/registry.ts b/src/registry.ts index 0b0ec22..e9dfb4f 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -16,6 +16,8 @@ import { deptryAdapter } from "./checks/python/deptry.ts"; import { importLinterAdapter } from "./checks/python/import-linter.ts"; import { ruffAdapter } from "./checks/python/ruff.ts"; import { vultureAdapter } from "./checks/python/vulture.ts"; +import { PYTHON_VENV_TOOLS } from "./checks/python/interpreter.ts"; +import { PYTHON_314_BIN, PYTHON_314_VERSION } from "./core/config/runtime.ts"; import type { CheckAdapter, ToolPin } from "./core/types.ts"; export const TOOL_PINS: readonly ToolPin[] = [ @@ -36,6 +38,16 @@ export const TOOL_PINS: readonly ToolPin[] = [ { bin: "lint-imports", version: "2.15" }, ]; +export const RUNTIME_PINS: readonly ToolPin[] = [ + { bin: "uv", version: "0.12.14" }, + { bin: "python3.14", version: PYTHON_314_VERSION }, + ...TOOL_PINS + .filter((pin) => (PYTHON_VENV_TOOLS as readonly string[]).includes(pin.bin)) + .map((pin) => ({ bin: `${PYTHON_314_BIN}/${pin.bin}`, version: pin.version })), +]; + +export const RUNTIME_PRESENCE = ["corepack"] as const; + export const LIBRARY_PINS = [ { name: "slevomat/coding-standard", diff --git a/src/report/advisory.ts b/src/report/advisory.ts index 6e9d8b5..773979b 100644 --- a/src/report/advisory.ts +++ b/src/report/advisory.ts @@ -4,6 +4,7 @@ import { dirname, join, relative } from "node:path"; import { z } from "zod"; import { isInScope, relativize } from "../checks/shared/paths.ts"; +import { jscpdPhpExtensions } from "../checks/shared/jscpd.ts"; import { fail } from "../core/errors.ts"; import { loadConfig } from "../core/config/load.ts"; import { artifactDir } from "../core/runner/artifacts.ts"; @@ -194,11 +195,15 @@ function fallowDupesValidate(ctx: CheckContext, result: ToolResult): void { } function jscpdHtmlCommand(ctx: CheckContext): ToolInvocation { + const phpExtensions = jscpdPhpExtensions(ctx.config); return { bin: "jscpd", args: [ "--mode", POLICY.duplication.mode, "--min-tokens", String(POLICY.duplication.minTokens), "--min-lines", String(POLICY.duplication.minLines), "--reporters", "html", + ...(phpExtensions === undefined + ? [] + : ["--formats-exts", `php:${phpExtensions.join(",")}`]), "--output", outputPath(ctx, "jscpd-html"), ...reportPaths(ctx), ], exitCodes: [0], diff --git a/tests/checks/php/phpcs.test.ts b/tests/checks/php/phpcs.test.ts index 4247e54..72d9657 100644 --- a/tests/checks/php/phpcs.test.ts +++ b/tests/checks/php/phpcs.test.ts @@ -3,7 +3,10 @@ import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; -import { cognitiveRuleset } from "../../../src/checks/php/phpcs-cognitive.ts"; +import { + cognitiveRuleset, + phpcsCognitiveAdapter, +} from "../../../src/checks/php/phpcs-cognitive.ts"; import { complexityRuleset, phpcsComplexityAdapter, @@ -46,13 +49,26 @@ function complexityReport(): unknown { describe("phpcs config and synthetic parser", () => { it("generates all PHP complexity thresholds and cognitive max 15", () => { - const ruleset = complexityRuleset(); + const ruleset = complexityRuleset({ isDrupal: false }); expect(ruleset).toContain(`name="complexity" value="${POLICY.complexity}"`); expect(ruleset).toContain(`name="nestingLevel" value="${POLICY.maxDepth}"`); expect(ruleset).toContain(`name="maxLinesLength" value="${POLICY.maxLinesPerFunction}"`); expect(ruleset).toContain(`name="maxParameters" value="${POLICY.maxParams}"`); expect(ruleset).toContain("/opt/php/phpcs-standard"); - expect(cognitiveRuleset()).toContain(`name="maxComplexity" value="${POLICY.cognitive}"`); + expect(ruleset).toContain('name="extensions" value="php"'); + expect(cognitiveRuleset({ isDrupal: false })).toContain(`name="maxComplexity" value="${POLICY.cognitive}"`); + expect(cognitiveRuleset({ isDrupal: false })).toContain('name="extensions" value="php"'); + + const drupalRuleset = complexityRuleset({ isDrupal: true }); + const drupalCognitiveRuleset = cognitiveRuleset({ isDrupal: true }); + const extensions = 'name="extensions" value="php,module,theme,install,inc,profile,engine"'; + expect(drupalRuleset).toContain(extensions); + expect(drupalCognitiveRuleset).toContain(extensions); + + const context = checkContext("/r", "php", { "src/a.php": source }); + context.config.isDrupal = true; + expect(phpcsComplexityAdapter.configFiles(context)[0]!.content).toContain(extensions); + expect(phpcsCognitiveAdapter.configFiles(context)[0]!.content).toContain(extensions); }); it("rejects an unknown source", async () => { diff --git a/tests/checks/php/phpstan-dead-code.test.ts b/tests/checks/php/phpstan-dead-code.test.ts index 4790170..5ce7a38 100644 --- a/tests/checks/php/phpstan-dead-code.test.ts +++ b/tests/checks/php/phpstan-dead-code.test.ts @@ -33,6 +33,13 @@ function messagesReport(messages: Array<{ message: string; line: number; identif }; } +describe("phpstan dead-code configuration", () => { + it("lets PHPStan infer the PHP version from Composer", () => { + const context = checkContext(root, "php"); + expect(phpstanDeadCodeAdapter.configFiles(context)[0]!.content).not.toContain("phpVersion:"); + }); +}); + describe("phpstan dead-code synthetic parser", () => { it("accepts dotted ShipMonk dead-code identifiers", async () => { expect((await phpstanFindings( diff --git a/tests/checks/python/interpreter.test.ts b/tests/checks/python/interpreter.test.ts new file mode 100644 index 0000000..2f04590 --- /dev/null +++ b/tests/checks/python/interpreter.test.ts @@ -0,0 +1,104 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { complexipyAdapter } from "../../../src/checks/python/complexipy.ts"; +import { deptryAdapter } from "../../../src/checks/python/deptry.ts"; +import { importLinterAdapter } from "../../../src/checks/python/import-linter.ts"; +import { PYTHON_VENV_TOOLS, pythonInvocation } from "../../../src/checks/python/interpreter.ts"; +import { ruffAdapter } from "../../../src/checks/python/ruff.ts"; +import { vultureAdapter } from "../../../src/checks/python/vulture.ts"; +import { ADAPTERS } from "../../../src/registry.ts"; +import type { CheckAdapter, ToolInvocation } from "../../../src/core/types.ts"; +import { checkContext } from "../../helpers/check-context.ts"; + +const invocation: ToolInvocation = { bin: "tool", args: [], env: { KEEP: "yes" }, exitCodes: [0] }; + +function withRoot(version: string | undefined, run: (root: string) => void): void { + const root = mkdtempSync(join(tmpdir(), "code-quality-interpreter-")); + try { + if (version !== undefined) writeFileSync(join(root, ".python-version"), `${version}\n`); + run(root); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +function pythonAdapters(): CheckAdapter[] { + return ADAPTERS.filter((adapter) => adapter.language === "python"); +} + +function venvAdapters(adapters: readonly CheckAdapter[]): CheckAdapter[] { + return adapters.filter((adapter) => + (PYTHON_VENV_TOOLS as readonly string[]).includes(adapter.tool.bin) + ); +} + +function adapterWithBin(adapters: readonly CheckAdapter[], bin: string): CheckAdapter { + return adapters.find((adapter) => adapter.tool.bin === bin)!; +} + +describe("pythonInvocation", () => { + it.each([undefined, "3.13"])("returns the invocation unchanged for target %s", (version) => { + withRoot(version, (root) => expect( + pythonInvocation(checkContext(root, "python"), invocation), + ).toBe(invocation)); + }); + + it.each(["3.14", "3.15"])("prefixes PATH and preserves other environment keys for Python %s", (version) => { + withRoot(version, (root) => { + const result = pythonInvocation(checkContext(root, "python"), invocation); + expect(result.env).toMatchObject({ KEEP: "yes" }); + expect(result.env?.PATH).toBe(`/opt/venv314/bin:${process.env.PATH ?? ""}`); + }); + }); + + it("does not append an empty PATH component", () => { + const original = process.env.PATH; + delete process.env.PATH; + try { + withRoot("3.14", (root) => { + expect(pythonInvocation(checkContext(root, "python"), invocation).env?.PATH) + .toBe("/opt/venv314/bin"); + }); + } finally { + if (original === undefined) delete process.env.PATH; + else process.env.PATH = original; + } + }); +}); + +describe("Python adapter interpreter selection", () => { + it.each([ + ["ruff", ruffAdapter], + ["complexipy", complexipyAdapter], + ["vulture", vultureAdapter], + ["deptry", deptryAdapter], + ["import-linter", importLinterAdapter], + ] satisfies Array<[string, CheckAdapter]>)("prefixes PATH for %s", (_name, adapter) => { + withRoot("3.14", (root) => { + const context = checkContext(root, "python"); + context.config.architecture.python = { kind: "file", rulesFile: ".importlinter" }; + expect(adapter.command(context).env?.PATH).toMatch(/^\/opt\/venv314\/bin:/u); + }); + }); + + it("wraps every registered venv tool but not Python jscpd", () => { + withRoot("3.14", (root) => { + const context = checkContext(root, "python"); + context.config.architecture.python = { kind: "file", rulesFile: ".importlinter" }; + const adapters = pythonAdapters(); + const wrapped = venvAdapters(adapters); + const wrappedBins: string[] = []; + for (const adapter of wrapped) { + wrappedBins.push(adapter.tool.bin); + expect(adapter.command(context).env?.PATH).toMatch(/^\/opt\/venv314\/bin(?::|$)/u); + } + expect(wrappedBins.toSorted()).toEqual([...PYTHON_VENV_TOOLS].toSorted()); + const jscpd = adapterWithBin(adapters, "jscpd"); + expect(jscpd.command(context).env?.PATH).toBeUndefined(); + }); + }); +}); diff --git a/tests/checks/python/ruff.test.ts b/tests/checks/python/ruff.test.ts index d04730a..d2f9520 100644 --- a/tests/checks/python/ruff.test.ts +++ b/tests/checks/python/ruff.test.ts @@ -11,7 +11,7 @@ const root = resolve("fixtures/python-project"); const nativeFile = resolve("tests/fixtures/native/python-complexity/stdout.json"); const source = "def busy(a, b, c, d, e):\n return 1\n"; -function report(code: string, message: string): unknown { +function report(code: string | null, message: string): unknown { return [{ code, message, filename: "src/a.py", location: { row: 1, column: 5 } }]; } @@ -23,6 +23,30 @@ describe("ruff config and synthetic parser", () => { expect(ruffConfig()).toContain(`max-nested-blocks = ${POLICY.maxDepth}`); }); + it.each(["3.10", "3.11", "3.12", "3.13", "3.14"])( + "adds supported Python target version %s", + (version) => expect(ruffConfig(version)).toContain(`target-version = "py${version.replace(".", "")}"`), + ); + + it("omits unsupported and absent Python target versions", () => { + expect(ruffConfig()).not.toContain("target-version"); + expect(ruffConfig("3.9")).not.toContain("target-version"); + expect(ruffConfig("3.15")).not.toContain("target-version"); + }); + + it("reports null-code syntax errors with actionable examples", async () => { + await expect(ruffFindings( + checkContext("/r", "python", { "src/a.py": source }), + [ + ...(report(null, "Expected an expression") as unknown[]), + { code: null, message: "Unexpected indentation", filename: "src/b.py", + location: { row: 2, column: 1 } }, + ], + )).rejects.toThrow( + "src/a.py:1 Expected an expression; src/b.py:2 Unexpected indentation. Hint: set .python-version or requires-python so Ruff targets the right Python version", + ); + }); + it("rejects unknown E501 diagnostics", async () => { await expect(ruffFindings( checkContext("/r", "python", { "src/a.py": source }), report("E501", "too long"), diff --git a/tests/checks/shared/jscpd.test.ts b/tests/checks/shared/jscpd.test.ts index 76a3a45..9d5e02c 100644 --- a/tests/checks/shared/jscpd.test.ts +++ b/tests/checks/shared/jscpd.test.ts @@ -59,14 +59,36 @@ describe("jscpd adapter", () => { absolute: boolean; format: string[]; ignore: string[]; + formatsExts?: Record; }; expect(adapter.id).toBe(`${language}-duplication`); expect(config.format).toEqual(formats[language]); expect(config.ignore).toEqual(expect.arrayContaining([...TEST_EXCLUSIONS])); expect(config.absolute).toBe(true); + expect(config).not.toHaveProperty("formatsExts"); } }); + it("maps Drupal PHP extensions for jscpd without changing other languages", () => { + const context = checkContext("/r", "php"); + context.config.isDrupal = true; + const config = JSON.parse(jscpdAdapter("php").configFiles(context)[0]!.content) as { + format: string[]; + formatsExts?: Record; + }; + expect(config.format).toEqual(["php"]); + expect(config.formatsExts?.php).toEqual([ + "php", "module", "theme", "install", "inc", "profile", "engine", + ]); + + const tsContext = checkContext("/r", "ts"); + tsContext.config.isDrupal = true; + const tsConfig = JSON.parse(jscpdAdapter("ts").configFiles(tsContext)[0]!.content) as { + formatsExts?: Record; + }; + expect(tsConfig).not.toHaveProperty("formatsExts"); + }); + it("uses the temp baseline for commands, parsing, and captured artifacts", () => { const ctx = checkContext("/r"); const adapter = jscpdAdapter("ts"); diff --git a/tests/checks/ts/knip-plugins.test.ts b/tests/checks/ts/knip-plugins.test.ts new file mode 100644 index 0000000..943da1f --- /dev/null +++ b/tests/checks/ts/knip-plugins.test.ts @@ -0,0 +1,51 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { KNIP_PLUGIN_NAMES } from "../../../src/checks/ts/knip-plugins.ts"; +import { knipAdapter } from "../../../src/checks/ts/knip.ts"; +import { checkContext } from "../../helpers/check-context.ts"; + +const pluginNamesFile = resolve("node_modules/knip/dist/types/PluginNames.js"); + +async function installedPluginNames(): Promise { + const module = await import(pathToFileURL(pluginNamesFile).href) as { pluginNames: string[] }; + return [...module.pluginNames].toSorted(); +} + +const knipManifest = resolve("node_modules/knip/package.json"); + +describe.skipIf(!existsSync(knipManifest))("pinned Knip plugin list", () => { + it("still finds the runtime plugin registry of the installed knip", () => { + expect(existsSync(pluginNamesFile), `update pluginNamesFile after a knip upgrade: ${pluginNamesFile}`).toBe(true); + }); + + it("matches every unique plugin in the knip 6.35.1 runtime registry", async () => { + expect(KNIP_PLUGIN_NAMES).toEqual(await installedPluginNames()); + expect(new Set(KNIP_PLUGIN_NAMES)).toHaveLength(KNIP_PLUGIN_NAMES.length); + }); +}); + +describe("generated Knip plugin flags", () => { + it("sets every plugin false in flat and workspace shapes", () => { + const flat = JSON.parse(knipAdapter.configFiles(checkContext("/r", "ts"))[0]!.content) as Record; + const root = mkdtempSync(join(tmpdir(), "knip-plugins-")); + try { + mkdirSync(join(root, "server/src"), { recursive: true }); + writeFileSync(join(root, "server/package.json"), "{}"); + const workspaceContext = checkContext(root, "ts"); + workspaceContext.paths = ["server/src"]; + const workspace = JSON.parse(knipAdapter.configFiles(workspaceContext)[0]!.content) as Record; + expect(workspace).toHaveProperty("workspaces"); + for (const plugin of KNIP_PLUGIN_NAMES) { + expect(flat[plugin], plugin).toBe(false); + expect(workspace[plugin], plugin).toBe(false); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/checks/ts/knip.test.ts b/tests/checks/ts/knip.test.ts index a922913..b3991aa 100644 --- a/tests/checks/ts/knip.test.ts +++ b/tests/checks/ts/knip.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -16,6 +16,22 @@ function generatedConfig(paths = ["src"]): Record { return JSON.parse(knipAdapter.configFiles(ctx)[0]!.content) as Record; } +function generatedConfigAt(files: Record, paths: string[]): Record { + const tempRoot = mkdtempSync(join(tmpdir(), "knip-config-")); + try { + for (const [file, content] of Object.entries(files)) { + const path = join(tempRoot, file); + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, content); + } + const ctx = checkContext(tempRoot, "ts"); + ctx.paths = paths; + return JSON.parse(knipAdapter.configFiles(ctx)[0]!.content) as Record; + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } +} + describe("knip synthetic parser", () => { const ctx = checkContext("/r", "ts", { "src/exports.ts": "export const orphan = 1;\n" }); @@ -174,3 +190,191 @@ describe("knip generated config", () => { expect(generated).not.toHaveProperty("includeEntryExports"); }); }); + +describe("knip static framework entries", () => { + it("adds static framework config and existing Next application entries", () => { + const generated = generatedConfigAt({ + "next.config.mjs": "export default {};\n", + "src/payload.config.ts": "export default {};\n", + "vitest.config.ts": "export default {};\n", + "playwright.config.ts": "export default {};\n", + "src/app/page.tsx": "export default function Page() { return null; }\n", + "src/middleware.ts": "export function middleware() {}\n", + "src/proxy.ts": "export function proxy() {}\n", + "src/instrumentation-client.js": "export {};\n", + }, ["src"]); + const application = "src/app/**/{page,layout,template,loading,error,global-error,not-found,default,route,icon,apple-icon,opengraph-image,twitter-image,sitemap,robots,manifest,forbidden,unauthorized}.{ts,tsx,js,jsx}"; + expect(generated.entry).toEqual(expect.arrayContaining([ + "next.config.mjs", "src/payload.config.ts", "vitest.config.ts", + "playwright.config.ts", application, "src/middleware.{ts,js}", "src/proxy.{ts,js}", + "src/instrumentation-client.{ts,js}", + ])); + expect(generated.project).toEqual(expect.arrayContaining([ + "next.config.mjs", "src/payload.config.ts", "vitest.config.ts", + "playwright.config.ts", application, "src/middleware.{ts,js}", "src/proxy.{ts,js}", + "src/instrumentation-client.{ts,js}", + ])); + expect(generated.entry).not.toContain("src/pages/**/*.{ts,tsx,js,jsx}"); + }); +}); + +describe("knip workspace config", () => { + it("creates per-member configs and ignores unused manifest workspaces", () => { + const generated = generatedConfigAt({ + "pnpm-workspace.yaml": "packages: ['packages/*', 'apps/*']\n", + "packages/core/package.json": "{}", + "packages/core/src/index.ts": "", + "apps/web/package.json": "{}", + "apps/web/src/main.ts": "", + "packages/unused/package.json": "{}", + }, ["apps/web/src", "packages/core/src"]); + const workspaces = generated.workspaces as Record>; + expect(Object.keys(workspaces)).toEqual([".", "apps/web", "packages/core"]); + for (const workspace of [workspaces["apps/web"]!, workspaces["packages/core"]!]) { + expect(workspace.entry).toEqual(expect.arrayContaining([ + "src/**/{index,main,cli}.{ts,tsx,js,mjs,cjs}", + ])); + expect(workspace.project).toEqual([ + "src/**/*.{ts,tsx,js,jsx,mjs,cjs}", + "tests/**/*.{ts,tsx,js,mjs,cjs}", + "test/**/*.{ts,tsx,js,mjs,cjs}", + "**/__tests__/**/*.{ts,tsx,js,mjs,cjs}", + "**/*.{test,spec}.{ts,tsx,js,mjs,cjs}", + ]); + expect(workspace.ignore).toEqual(expect.arrayContaining([ + "**/*.test.*", "**/__tests__/**", "**/tests/**", + ])); + expect(workspace).not.toHaveProperty("ignoreDependencies"); + } + expect(generated.ignoreWorkspaces).toEqual(["packages/unused"]); + expect(generated).not.toHaveProperty("entry"); + expect(generated).not.toHaveProperty("project"); + expect(generated).not.toHaveProperty("ignore"); + expect(generated.next).toBe(false); + expect(generated.payload).toBe(false); + }); + + it("creates root and nested workspace configs without manifest workspaces", () => { + const generated = generatedConfigAt({ + "package.json": "{}", + "src/index.ts": "", + "server/package.json": "{}", + "server/main.ts": "", + }, ["src", "server"]); + expect(Object.keys(generated.workspaces as object)).toEqual([".", "server"]); + expect(generated.ignoreWorkspaces).toEqual([]); + expect(generated).not.toHaveProperty("entry"); + expect(generated).not.toHaveProperty("project"); + }); +}); + +describe("knip workspace framework config", () => { + it("always emits a root workspace block when declared members own every source path", () => { + const generated = generatedConfigAt({ + "package.json": "{}", + "pnpm-workspace.yaml": "packages: ['packages/*']\n", + "vitest.config.ts": "export default {};\n", + "packages/core/package.json": "{}", + "packages/core/src/index.ts": "", + }, ["packages/core/src"]); + const workspaces = generated.workspaces as Record>; + expect(Object.keys(workspaces)).toEqual([".", "packages/core"]); + expect(workspaces["."]?.project).toEqual(expect.arrayContaining(["vitest.config.ts"])); + expect(workspaces["."]?.project).not.toContain("**/*.{ts,tsx,js,jsx,mjs,cjs}"); + }); + + it("detects framework entries independently for each workspace owner", () => { + const generated = generatedConfigAt({ + "package.json": "{}", + "pnpm-workspace.yaml": "packages: ['apps/*']\n", + "apps/web/package.json": "{}", + "apps/web/next.config.mjs": "export default {};\n", + "apps/web/src/app/page.tsx": "export default function Page() { return null; }\n", + "apps/api/package.json": "{}", + "apps/api/src/index.ts": "", + }, ["apps/api/src", "apps/web/src"]); + const workspaces = generated.workspaces as Record>; + const appPattern = "src/app/**/{page,layout,template,loading,error,global-error,not-found,default,route,icon,apple-icon,opengraph-image,twitter-image,sitemap,robots,manifest,forbidden,unauthorized}.{ts,tsx,js,jsx}"; + expect(workspaces["apps/web"]?.entry).toEqual(expect.arrayContaining(["next.config.mjs", appPattern])); + expect(workspaces["apps/api"]?.entry).not.toContain(appPattern); + }); +}); + +describe("knip workspace safety config", () => { + it("rebases consumer ignores to their owning workspace", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "knip-config-")); + try { + for (const file of ["package.json", "packages/core/package.json", "packages/core/src/index.ts"]) { + const path = join(tempRoot, file); + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, "{}"); + } + const ctx = checkContext(tempRoot, "ts"); + ctx.paths = ["packages/core/src"]; + ctx.config.exclude = ["packages/core/legacy/**", "docs/**", "**/x/**"]; + const generated = JSON.parse(knipAdapter.configFiles(ctx)[0]!.content) as { + workspaces: Record; + }; + expect(generated.workspaces["packages/core"]?.ignore).toEqual(expect.arrayContaining([ + "legacy/**", "**/x/**", + ])); + expect(generated.workspaces["packages/core"]?.ignore).not.toContain("docs/**"); + expect(generated.workspaces["."]?.ignore).toEqual(expect.arrayContaining(["docs/**", "**/x/**"])); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("keeps root and server workspace keys for the hr-platform shape", () => { + const generated = generatedConfigAt({ + "package.json": "{}", "src/index.ts": "", "server/package.json": "{}", "server/src/index.ts": "", + }, ["src", "server/src"]); + expect(Object.keys(generated.workspaces as object)).toEqual([".", "server"]); + }); + + it("drops unsafe ignored member names and records a notice", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "knip-config-")); + try { + for (const file of ["package.json", "safe/package.json", "safe/src/index.ts", "bad*/package.json"]) { + const path = join(tempRoot, file); + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, "{}"); + } + writeFileSync(join(tempRoot, "package.json"), JSON.stringify({ workspaces: ["*"] })); + const ctx = checkContext(tempRoot, "ts"); + ctx.paths = ["safe/src"]; + const generated = JSON.parse(knipAdapter.configFiles(ctx)[0]!.content) as { ignoreWorkspaces: string[] }; + expect(generated.ignoreWorkspaces).not.toContain("bad*"); + expect(ctx.config.notices).toContain("ts-unused: ignored unsafe workspace member bad*"); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("fails loudly when a resolved path has an unsafe owner", () => { + expect(() => generatedConfigAt({ + "bad*/package.json": "{}", "bad*/src/index.ts": "", + }, ["bad*/src"])).toThrow("ts-unused: unsafe workspace owner bad*"); + }); + +}); + +describe("knip workspace manifest ownership", () => { + it("does not treat a symlinked package manifest as a workspace owner", () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "knip-config-")); + try { + mkdirSync(join(workspaceRoot, "server/src"), { recursive: true }); + writeFileSync(join(workspaceRoot, "package.json"), "{}"); + writeFileSync(join(workspaceRoot, "manifest.json"), "{}"); + symlinkSync("../manifest.json", join(workspaceRoot, "server/package.json")); + const ctx = checkContext(workspaceRoot, "ts"); + ctx.paths = ["server/src"]; + const generated = JSON.parse(knipAdapter.configFiles(ctx)[0]!.content) as { + workspaces?: Record; + }; + expect(generated).not.toHaveProperty("workspaces"); + } finally { + rmSync(workspaceRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/cli/doctor.test.ts b/tests/cli/doctor.test.ts index 9175892..b81dbf2 100644 --- a/tests/cli/doctor.test.ts +++ b/tests/cli/doctor.test.ts @@ -8,12 +8,22 @@ import { runDoctor, type DoctorDeps, } from "../../src/cli/commands/doctor.ts"; -import { LIBRARY_PINS, TOOL_PINS } from "../../src/registry.ts"; +import { + LIBRARY_PINS, + RUNTIME_PINS, + RUNTIME_PRESENCE, + TOOL_PINS, +} from "../../src/registry.ts"; import { createStyle } from "../../src/cli/style.ts"; function exactDependencies(): DoctorDeps { + const versions = new Map([ + ...TOOL_PINS.map((pin) => [pin.bin, pin.version] as const), + ...RUNTIME_PINS.map((pin) => [pin.bin, pin.version] as const), + ...RUNTIME_PRESENCE.map((bin) => [bin, "99.0.0"] as const), + ]); return { - probe: (bin) => `${bin} ${TOOL_PINS.find((pin) => pin.bin === bin)?.version}`, + probe: (bin) => versions.get(bin) ?? "", exists: () => true, readInstalled: (path) => { const name = path.includes("phpcs") ? "slevomat/coding-standard" : "shipmonk/dead-code-detector"; @@ -46,6 +56,48 @@ describe("runDoctor", () => { }); }); + it("reports a venv314 tool mismatch", () => { + const bin = "/opt/venv314/bin/ruff"; + const probes = runDoctor({ + ...exactDependencies(), + probe: (candidate) => candidate === bin ? "ruff 0.16.5" : exactDependencies().probe(candidate), + }); + expect(probes.find((probe) => probe.name === bin)).toEqual({ + name: bin, + ok: false, + detail: "expected 0.16.6, received 0.16.5", + }); + }); +}); + +describe("runtime and metadata probes", () => { + it("accepts any parsable version for runtime presence probes", () => { + const probes = runDoctor({ + ...exactDependencies(), + probe: (bin) => bin === "corepack" ? "Corepack 123.45.6" : exactDependencies().probe(bin), + }); + expect(probes.find((probe) => probe.name === "corepack")).toEqual({ + name: "corepack", + ok: true, + detail: "123.45.6", + }); + }); + + it("reports a runtime presence probe failure", () => { + const probes = runDoctor({ + ...exactDependencies(), + probe: (bin) => { + if (bin === "corepack") throw new Error("command not found"); + return exactDependencies().probe(bin); + }, + }); + expect(probes.find((probe) => probe.name === "corepack")).toEqual({ + name: "corepack", + ok: false, + detail: "command not found", + }); + }); + it("reports a missing grammar asset", () => { const probes = runDoctor({ ...exactDependencies(), exists: (path) => !path.endsWith("tree-sitter-php.wasm") }); expect(probes.find((probe) => probe.name === "tree-sitter-php.wasm")?.ok).toBe(false); diff --git a/tests/cli/init.test.ts b/tests/cli/init.test.ts index 2b6cd47..7323084 100644 --- a/tests/cli/init.test.ts +++ b/tests/cli/init.test.ts @@ -208,6 +208,24 @@ describe("init with excluded sources", () => { }); describe("init discovered configuration", () => { + it("writes conventional and pnpm workspace roots to the generated configuration", async () => { + const root = mkdtempSync(join(tmpdir(), "code-quality-init-workspace-")); + roots.push(root); + mkdirSync(join(root, "src"), { recursive: true }); + mkdirSync(join(root, "packages/core/src"), { recursive: true }); + writeFileSync(join(root, "package.json"), "{}", "utf8"); + writeFileSync(join(root, "pnpm-workspace.yaml"), "packages: ['packages/*']\n", "utf8"); + writeFileSync(join(root, "packages/core/package.json"), "{}", "utf8"); + writeFileSync(join(root, "src/index.ts"), "", "utf8"); + writeFileSync(join(root, "packages/core/src/index.ts"), "", "utf8"); + + expect(await runCli(["node", "code-quality", "init"], command(root, []))).toBe(0); + const config = parse(readFileSync(join(root, ".code-quality.yml"), "utf8")) as { + paths: Record; + }; + expect(config.paths.ts).toEqual(["src", "packages/core/src"]); + }); + it("writes discovered TypeScript roots to the generated configuration", async () => { const root = mkdtempSync(join(tmpdir(), "code-quality-init-discovery-")); roots.push(root); diff --git a/tests/cli/versions.test.ts b/tests/cli/versions.test.ts index 05b0f43..55530d7 100644 --- a/tests/cli/versions.test.ts +++ b/tests/cli/versions.test.ts @@ -1,7 +1,15 @@ import { describe, expect, it } from "vitest"; +import { PYTHON_VENV_TOOLS } from "../../src/checks/python/interpreter.ts"; +import { PYTHON_314_BIN } from "../../src/core/config/runtime.ts"; import { versionsText } from "../../src/cli/commands/versions.ts"; -import { ADAPTERS, LIBRARY_PINS, TOOL_PINS } from "../../src/registry.ts"; +import { + ADAPTERS, + LIBRARY_PINS, + RUNTIME_PINS, + RUNTIME_PRESENCE, + TOOL_PINS, +} from "../../src/registry.ts"; describe("versionsText", () => { it("prints tools and libraries in registry order", () => { @@ -10,7 +18,9 @@ describe("versionsText", () => { expect(lines).toContain(" TS/JS"); expect(lines).toContain(" PHP"); expect(lines).toContain(" Python"); + expect(lines).toContain(" Runtime"); expect(lines).toContain(" Libraries"); + expect(lines).not.toContain(" Other"); for (const { bin, version } of TOOL_PINS) { expect(lines.filter((line) => line.trimStart().startsWith(`${bin} `) && line.endsWith(version))).toHaveLength(1); @@ -19,6 +29,29 @@ describe("versionsText", () => { expect(lines.filter((line) => line.trimStart().startsWith(`${name} `) && line.endsWith(version))).toHaveLength(1); } + for (const { bin, version } of RUNTIME_PINS) { + expect(lines.filter((line) => line.trimStart().startsWith(`${bin} `) + && line.endsWith(version))).toHaveLength(1); + } + for (const bin of RUNTIME_PRESENCE) { + expect(lines.filter((line) => line.trimStart().startsWith(`${bin} `) + && line.endsWith("presence (verified by doctor)"))).toHaveLength(1); + } + }); + + it("renders uncategorized pins in Other", () => { + const other = [{ bin: "other-only", version: "1.2.3" }]; + const lines = versionsText({ pins: other, libraries: [], runtime: [] }); + expect(lines).toContain(" Runtime"); + expect(lines).toContain(" Other"); + expect(lines.filter((line) => line.trimStart().startsWith("other-only "))).toHaveLength(1); + }); + + it("derives one venv314 runtime pin per wrapped Python tool", () => { + const venvPins = RUNTIME_PINS + .filter((pin) => pin.bin.startsWith(`${PYTHON_314_BIN}/`)) + .map((pin) => pin.bin.slice(PYTHON_314_BIN.length + 1)); + expect(venvPins).toEqual([...PYTHON_VENV_TOOLS]); }); it("declares every adapter tool in the registry", () => { diff --git a/tests/core/anchor/service.test.ts b/tests/core/anchor/service.test.ts index db836f4..6476179 100644 --- a/tests/core/anchor/service.test.ts +++ b/tests/core/anchor/service.test.ts @@ -84,6 +84,15 @@ describe("other grammar anchors", () => { describe("PHP anchors", () => { const source = fixture("sample.php"); + it.each(["module", "theme", "install", "inc", "profile", "engine"])( + "anchors Drupal .%s files as PHP", + async (extension) => { + const module = " { }); }); +describe("isPayloadNextProject", () => { + it.each([ + "next.config.js", + "next.config.mjs", + "next.config.cjs", + "next.config.ts", + "next.config.mts", + "payload.config.ts", + "payload.config.js", + "payload.config.mjs", + "payload.config.mts", + "src/payload.config.ts", + "src/payload.config.js", + "src/payload.config.mjs", + "src/payload.config.mts", + ])("detects %s", (file) => { + withRoot([file], [], (root) => expect(isPayloadNextProject(root)).toBe(true)); + }); + + it("returns false when no Payload or Next config exists", () => { + withRoot(["src/index.ts"], [], (root) => expect(isPayloadNextProject(root)).toBe(false)); + }); +}); + describe("defaultPaths", () => { it("returns existing PHP source roots in order", () => { withRoot([], ["src", "lib"], (root) => { diff --git a/tests/core/config/exclusions.test.ts b/tests/core/config/exclusions.test.ts index 4ea41e7..85fe7e2 100644 --- a/tests/core/config/exclusions.test.ts +++ b/tests/core/config/exclusions.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { BUILTIN_EXCLUSIONS, + PAYLOAD_NEXT_EXCLUSIONS, + payloadNextExclusions, TEST_EXCLUSIONS, isExcluded, } from "../../../src/core/config/exclusions.ts"; @@ -32,4 +34,34 @@ describe("isExcluded", () => { it("matches dot directories", () => { expect(isExcluded(".hidden/x.ts", ["**/.hidden/**"])).toBe(true); }); + + it.each([ + "src/app/(payload)/admin/page.tsx", + "src/app/(payload)/backoffice/importMap.js", + "src/migrations-2024/x.ts", + "src/payload-types.ts", + "src/seed/seed.ts", + ])("excludes Payload/Next generated path %s", (file) => { + expect(isExcluded(file, payloadNextExclusions(["src"]))).toBe(true); + }); + + it.each([ + "src/app/(frontend)/page.tsx", + "src/app/api/payload/route.ts", + ])("keeps non-generated Payload/Next path %s", (file) => { + expect(isExcluded(file, payloadNextExclusions(["src"]))).toBe(false); + }); + + it("scopes generated exclusions to TypeScript roots except root Next files", () => { + expect(payloadNextExclusions(["src"])).toEqual([ + "src/**/payload-types.ts", "src/**/importMap.js", "src/**/app/[(]payload[)]/**", + "src/**/migrations/**", "src/**/migrations-*/**", "src/**/seed/**", + ".next/**", "next-env.d.ts", + ]); + expect(PAYLOAD_NEXT_EXCLUSIONS).toHaveLength(8); + }); + + it("excludes the Payload route group when app itself is the TypeScript root", () => { + expect(isExcluded("app/(payload)/admin/page.tsx", payloadNextExclusions(["app"]))).toBe(true); + }); }); diff --git a/tests/core/config/hash.test.ts b/tests/core/config/hash.test.ts index acf74c1..3211e04 100644 --- a/tests/core/config/hash.test.ts +++ b/tests/core/config/hash.test.ts @@ -42,6 +42,16 @@ describe("configHash", () => { expect(configHash(baseConfig)).toMatch(/^[0-9a-f]{64}$/); }); + it("matches the current policy hash when no runtime target is supplied", () => { + expect(configHash(baseConfig)).toBe("5725438a068191a110bfba12477cad782ab11bb21f0077cb9c781244d80c9906"); + }); + + it("changes when the derived Python runtime target changes", () => { + expect(configHash({ ...baseConfig, runtime: { python: "3.12" } })).not.toBe( + configHash({ ...baseConfig, runtime: { python: "3.14" } }), + ); + }); + it("matches for equivalent YAML-shaped configurations", () => { const first = parse(` # The same values in flow notation. diff --git a/tests/core/config/load.test.ts b/tests/core/config/load.test.ts index f23daaf..f3276ec 100644 --- a/tests/core/config/load.test.ts +++ b/tests/core/config/load.test.ts @@ -11,6 +11,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { loadConfig } from "../../../src/core/config/load.ts"; +import { isExcluded, nextExclusions, payloadExclusions } from "../../../src/core/config/exclusions.ts"; const fixtureRoot = join(process.cwd(), "tests/fixtures/config"); @@ -50,6 +51,10 @@ function expectLoadFailure(root: string, message: string | RegExp): void { expect(() => loadConfig(root)).toThrow(message); } +function pythonRuntimeNotices(notices: readonly string[]): string[] { + return notices.filter((notice) => notice.startsWith("python: targeting")); +} + describe("loadConfig", () => { it("detects TypeScript and defaults to src without YAML", () => { withRoot({ "package.json": "{}", "src/index.ts": "" }, ["src"], (root) => { @@ -205,6 +210,154 @@ describe("loadConfig auto-detection notices", () => { }); }); +describe("loadConfig workspace roots", () => { + it("adds Composer autoload roots to conventional PHP roots", () => { + const packageRoots = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"] + .map((name) => `packages/${name}/src`); + const psr4 = Object.fromEntries( + packageRoots.map((directory) => [`Package${directory.slice(9, 10).toUpperCase()}\\\\`, `${directory}/`]), + ); + const packageFiles = Object.fromEntries( + Object.values(psr4).map((directory) => [`${directory}Package.php`, " { + const config = loadConfig(root); + expect(config.paths.php).toEqual(["src", ...packageRoots]); + expect(config.notices).toContain( + `php: added workspace roots ${packageRoots.join(", ")}`, + ); + }); + }); + + it("resolves pnpm members instead of their container directories", () => { + withRoot({ + "package.json": "{}", + "pnpm-workspace.yaml": "packages: ['packages/*', 'apps/*']\n", + "apps/web/src/index.ts": "", + "apps/web/package.json": "{}", + "apps/web/tests/index.ts": "", + "packages/core/src/index.ts": "", + "packages/core/package.json": "{}", + "packages/core/tests/index.ts": "", + }, [], (root) => { + const config = loadConfig(root); + expect(config.paths.ts).toEqual(["apps/web/src", "packages/core/src"]); + expect(config.notices).toContain( + "ts: added workspace roots apps/web/src, packages/core/src", + ); + }); + }); +}); + +describe("loadConfig workspace discovery union", () => { + it("unions workspace roots with discovered roots when conventional roots are empty", () => { + withRoot({ + "package.json": "{}", + "pnpm-workspace.yaml": "packages: ['packages/*']\n", + "packages/core/package.json": "{}", + "packages/core/src/index.ts": "", + "scripts/tool.ts": "", + }, [], (root) => { + const config = loadConfig(root); + expect(config.paths.ts).toEqual(["packages/core/src", "scripts"]); + expect(config.notices).toContain("ts: added workspace roots packages/core/src"); + }); + }); +}); + +describe("loadConfig conventional and workspace roots", () => { + it("combines a conventional root with a depth-1 package root", () => { + withRoot({ + "package.json": "{}", + "src/index.ts": "", + "server/package.json": "{}", + "server/index.ts": "", + }, [], (root) => { + const config = loadConfig(root); + expect(config.paths.ts).toEqual(["src", "server"]); + expect(config.notices).toContain("ts: added workspace roots server"); + }); + }); +}); + +describe("loadConfig workspace root overrides", () => { + it("does not augment explicitly configured paths", () => { + withRoot({ + ".code-quality.yml": "paths:\n ts: [custom]\n", + "package.json": JSON.stringify({ workspaces: ["packages/*"] }), + "custom/index.ts": "", + "packages/core/src/index.ts": "", + }, [], (root) => expect(loadConfig(root).paths.ts).toEqual(["custom"])); + }); + + it("resolves an explicit language from workspace roots without src", () => { + withRoot({ + ".code-quality.yml": "languages: [ts]\n", + "package.json": JSON.stringify({ workspaces: ["packages/*"] }), + "packages/core/package.json": "{}", + "packages/core/src/index.ts": "", + }, [], (root) => expect(loadConfig(root).paths.ts).toEqual(["packages/core/src"])); + }); +}); + +describe("Drupal extension source detection", () => { + it("detects Drupal module files as PHP sources", () => { + withRoot( + { + "composer.json": JSON.stringify({ require: { "drupal/core-recommended": "^11" } }), + "web/modules/custom/demo/demo.module": " { + const config = loadConfig(root); + expect(config.paths.php).toContain("web/modules/custom/demo"); + expect(config.languages).toContain("php"); + }, + ); + }); + + it("does not treat Drupal module extensions as PHP outside Drupal", () => { + withRoot( + { + "composer.json": JSON.stringify({ require: { "vendor/package": "^1" } }), + "web/modules/custom/demo/demo.module": " { + const config = loadConfig(root); + expect(config.isDrupal).toBe(false); + expect(config.languages).not.toContain("php"); + expect(config.paths.php).toBeUndefined(); + }, + ); + }); + + it("allows explicit Drupal module paths but rejects them outside Drupal", () => { + const files = { + ".code-quality.yml": "paths:\n php: [custom]\n", + "custom/demo.module": " expect(loadConfig(root).paths.php).toEqual(["custom"]), + ); + withRoot( + { ...files, "composer.json": JSON.stringify({ require: { "vendor/package": "^1" } }) }, + [], + (root) => expectLoadFailure(root, "php: no php source files found under custom (extensions: .php)"), + ); + }); +}); + describe("Drupal and vendored-source profiles", () => { it("uses Drupal custom-code roots and reports the profile", () => { withRoot( @@ -247,6 +400,137 @@ describe("Drupal and vendored-source profiles", () => { (root) => expect(loadConfig(root).paths.php).toEqual(["domain"]), ); }); + + it("does not augment curated Drupal PHP roots from Composer autoload", () => { + withRoot({ + "composer.json": JSON.stringify({ + require: { "drupal/core": "^11" }, + autoload: { "psr-4": { "Domain\\\\": "domain/" } }, + }), + "web/modules/custom/demo/demo.module": " { + expect(loadConfig(root).paths.php).toEqual(["web/modules/custom"]); + }); + }); +}); + +describe("Payload and Next profiles", () => { + it("a plain Next app excludes only Next output and keeps migrations and seed in scope", () => { + const sourceFiles = { + "package.json": "{}", + "src/app/page.tsx": "export default function Page() { return null; }\n", + ".code-quality.yml": "exclude:\n - '**/consumer-generated/**'\n", + }; + let ordinaryHash = ""; + withRoot(sourceFiles, [], (root) => { + ordinaryHash = loadConfig(root).configHash; + }); + withRoot({ ...sourceFiles, "next.config.mjs": "export default {};\n" }, [], (root) => { + const config = loadConfig(root); + expect(config.exclude).toEqual([ + "**/consumer-generated/**", + ...nextExclusions("."), + ]); + expect(config.notices).toContain( + "Next profile: excluding Next build output", + ); + expect(isExcluded("src/migrations/x.ts", config.exclude)).toBe(false); + expect(isExcluded("src/seed/x.ts", config.exclude)).toBe(false); + expect(config.configHash).not.toBe(ordinaryHash); + }); + }); + + it("activates Payload exclusions from the package dependency", () => { + withRoot({ + "package.json": JSON.stringify({ dependencies: { payload: "3.0.0" } }), + "src/index.ts": "", + "src/payload-types.ts": "", + }, [], (root) => { + const config = loadConfig(root); + expect(config.exclude).toEqual(payloadExclusions(["src"])); + expect(config.notices).toContain( + "Payload profile: excluding generated Payload types, import map, admin route group, " + + "migrations, and seed data", + ); + }); + }); +}); + +describe("Payload/Next scoped exclusions", () => { + it("records Payload exclusions only under resolved TypeScript roots", () => { + withRoot({ + ".code-quality.yml": "paths:\n ts: [assets]\n", + "package.json": "{}", + "payload.config.ts": "export default {};\n", + "assets/index.ts": "", + "assets/migrations/x.ts": "", + "src/migrations/Version1.php": " { + const config = loadConfig(root); + expect(config.exclude).toContain("assets/**/migrations/**"); + expect(config.exclude).not.toContain("**/migrations/**"); + expect(config.exclude.join("\n")).not.toContain("src/migrations"); + expect(isExcluded("src/migrations/Version1.php", config.exclude)).toBe(false); + expect(isExcluded("assets/migrations/x.ts", config.exclude)).toBe(true); + }); + }); + + it("does not resolve TypeScript when Payload-generated files are the only sources", () => { + withRoot({ + "package.json": "{}", + "payload.config.ts": "export default {};\n", + "src/migrations/x.ts": "", + "src/payload-types.ts": "", + }, [], (root) => { + const config = loadConfig(root); + expect(config.languages).toEqual([]); + expect(config.paths.ts).toBeUndefined(); + expect(config.exclude).toEqual([]); + expect(config.notices).toEqual([ + "ts: package.json detected but no ts source files under src; " + + "add paths.ts to .code-quality.yml to enable TS checks", + ]); + }); + }); + + it("does not apply the profile when TypeScript does not resolve", () => { + withRoot({ + "composer.json": "{}", + "payload.config.ts": "export default {};\n", + "src/index.php": " { + const config = loadConfig(root); + expect(config.languages).toEqual(["php"]); + expect(config.exclude).toEqual([]); + expect(config.notices).not.toContain( + "Payload profile: excluding generated Payload types, import map, admin route group, " + + "migrations, and seed data", + ); + }); + }); +}); + +describe("workspace Payload and Next profiles", () => { + it("scopes Payload exclusions and notices to the owning workspace", () => { + withRoot({ + "package.json": JSON.stringify({ workspaces: ["apps/*"] }), + "apps/cms/package.json": "{}", + "apps/cms/payload.config.ts": "export default {};\n", + "apps/cms/src/index.ts": "", + "apps/cms/src/types/payload-types.ts": "", + "apps/web/package.json": "{}", + "apps/web/next.config.mjs": "export default {};\n", + "apps/web/src/index.ts": "", + "apps/web/src/migrations/keep.ts": "", + }, [], (root) => { + const config = loadConfig(root); + expect(isExcluded("apps/cms/src/types/payload-types.ts", config.exclude)).toBe(true); + expect(isExcluded("apps/web/src/migrations/keep.ts", config.exclude)).toBe(false); + expect(config.notices).toContain("Payload profile: excluding generated Payload types, import map, admin route group, migrations, and seed data (apps/cms)"); + expect(config.notices).toContain("Next profile: excluding Next build output (apps/web)"); + }); + }); }); describe("minified source discovery", () => { @@ -295,6 +579,76 @@ it.each([ ); }); +describe("Python runtime configuration", () => { + it("adds one Python 3.14 runtime notice", () => { + withRoot({ + "pyproject.toml": "[project]\nrequires-python = \">=3.14\"\n", + "src/index.py": "", + }, ["src"], (root) => { + expect(pythonRuntimeNotices(loadConfig(root).notices)) + .toEqual([ + "python: targeting 3.14 (from requires-python); tools run on CPython 3.14.7", + ]); + }); + }); + + it("omits the runtime notice without a resolved Python language", () => { + withRoot({ ".python-version": "3.14\n", "package.json": "{}", "src/index.ts": "" }, ["src"], (root) => { + expect(pythonRuntimeNotices(loadConfig(root).notices)).toEqual([]); + }); + }); + + it("omits the runtime notice below Python 3.14", () => { + withRoot({ ".python-version": "3.12\n", "pyproject.toml": "[project]\n", "src/index.py": "" }, ["src"], (root) => { + expect(pythonRuntimeNotices(loadConfig(root).notices)).toEqual([]); + }); + }); + + it("includes an added Python target in the config hash", () => { + const files = { "pyproject.toml": "[project]\n", "src/index.py": "" }; + let hashWithout = ""; + withRoot(files, ["src"], (root) => { hashWithout = loadConfig(root).configHash; }); + withRoot({ ...files, ".python-version": "3.14\n" }, ["src"], (root) => { + expect(loadConfig(root).configHash).not.toBe(hashWithout); + }); + }); + + it("changes the config hash when the Python target changes", () => { + const files = { "pyproject.toml": "[project]\n", "src/index.py": "" }; + let hash312 = ""; + withRoot({ ...files, ".python-version": "3.12\n" }, ["src"], (root) => { + hash312 = loadConfig(root).configHash; + }); + withRoot({ ...files, ".python-version": "3.14\n" }, ["src"], (root) => { + expect(loadConfig(root).configHash).not.toBe(hash312); + }); + }); + + it("ignores a Python target in the hash when Python is not a resolved language", () => { + const files = { "package.json": "{}", "src/index.ts": "" }; + let hashWithout = ""; + withRoot(files, ["src"], (root) => { hashWithout = loadConfig(root).configHash; }); + withRoot({ ...files, ".python-version": "3.14\n" }, ["src"], (root) => { + expect(loadConfig(root).configHash).toBe(hashWithout); + expect(loadConfig(root).languages).toEqual(["ts"]); + }); + }); +}); + +describe("Python image compatibility notice", () => { + it("reports when a target is newer than the image", () => { + withRoot({ + ".python-version": "3.15\n", + "pyproject.toml": "[project]\n", + "src/index.py": "", + }, ["src"], (root) => { + expect(pythonRuntimeNotices(loadConfig(root).notices)).toEqual([ + "python: targeting 3.15 (from .python-version), newer than the image; tools run on CPython 3.14.7", + ]); + }); + }); +}); + describe("resolved consumer config", () => { it("copies disabled checks and consumer exclusions", () => { withFixture("disabled-exclude.yml", { "package.json": "{}", "src/index.ts": "" }, ["src"], (root) => { diff --git a/tests/core/config/runtime.test.ts b/tests/core/config/runtime.test.ts new file mode 100644 index 0000000..c0fbebc --- /dev/null +++ b/tests/core/config/runtime.test.ts @@ -0,0 +1,138 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { compareMajorMinor, isAtLeast, pythonTarget } from "../../../src/core/config/runtime.ts"; + +function withRoot(files: Record, run: (root: string) => void): void { + const root = mkdtempSync(join(tmpdir(), "code-quality-runtime-")); + try { + for (const [file, content] of Object.entries(files)) { + const path = join(root, file); + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, content); + } + run(root); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +describe("pythonTarget", () => { + it.each([ + ["3.14", "3.14"], + ["3.14.2", "3.14"], + ["cpython@3.14", "3.14"], + ["cpython-3.14", "3.14"], + ["cpython-3.14.7-linux-x86_64-gnu", "3.14"], + ["3.14.0b1", "3.14"], + ["3.14.0a2", "3.14"], + ["3.14rc1", "3.14"], + ])("reads .python-version form %s", (value, version) => { + withRoot({ ".python-version": `\n${value}\n` }, (root) => { + expect(pythonTarget(root)).toEqual({ version, source: ".python-version" }); + }); + }); + + it("skips blank and comment lines with CRLF endings", () => { + withRoot({ ".python-version": "\r\n# managed by uv\r\ncpython@3.14\r\n" }, (root) => { + expect(pythonTarget(root)?.version).toBe("3.14"); + }); + }); + + it.each([ + [">=3.12", "3.12"], + [">3.11", "3.11"], + ["~=3.10.4", "3.10"], + ["==3.13.*", "3.13"], + ["==3.14.1", "3.14"], + [">=3.12, !=3.13.*, <4", "3.12"], + [">=3.12, >=3.10", "3.12"], + [">=3.9, ~=3.14", "3.14"], + [">= 3.14", "3.14"], + ])("reads requires-python constraint %s", (constraint, version) => { + withRoot({ "pyproject.toml": `[project]\nrequires-python = "${constraint}"\n` }, (root) => { + expect(pythonTarget(root)).toEqual({ version, source: "requires-python" }); + }); + }); + + it.each(["<4", "!=3.12.*", "^3.12", "3.12", ">=3.12, nope"])( + "rejects requires-python constraint %s", + (constraint) => { + withRoot({ "pyproject.toml": `[project]\nrequires-python = "${constraint}"\n` }, (root) => { + expect(pythonTarget(root)).toBeUndefined(); + }); + }, + ); + + it("uses the highest target from uv workspace members when the root has none", () => { + withRoot({ + "pyproject.toml": "[tool.uv.workspace]\nmembers = [\"packages/*\"]\n", + "packages/api/pyproject.toml": "[project]\nrequires-python = \">=3.13\"\n", + "packages/svc/pyproject.toml": "[project]\nrequires-python = \">=3.14\"\n", + "packages/svc/.python-version": "3.15\n", + }, (root) => expect(pythonTarget(root)).toEqual({ + version: "3.15", source: "packages/svc/.python-version", + })); + }); +}); + +describe("pythonTarget fallback and safe reads", () => { + it("returns undefined for malformed TOML", () => { + withRoot({ "pyproject.toml": "[project\n" }, (root) => expect(pythonTarget(root)).toBeUndefined()); + }); + + it("prefers .python-version over requires-python", () => { + withRoot({ + ".python-version": "3.14\n", + "pyproject.toml": "[project]\nrequires-python = \">=3.10\"\n", + }, (root) => expect(pythonTarget(root)?.version).toBe("3.14")); + }); + + it.each(["pypy@3.10", "pypy3.10", "graalpy@3.11"])( + "ignores %s and falls back to requires-python", + (value) => { + withRoot({ + ".python-version": `${value}\n`, + "pyproject.toml": "[project]\nrequires-python = \">=3.12\"\n", + }, (root) => expect(pythonTarget(root)?.version).toBe("3.12")); + }, + ); + + it("returns undefined when no target exists", () => { + withRoot({}, (root) => expect(pythonTarget(root)).toBeUndefined()); + }); + + it.each([".python-version", "pyproject.toml"])("ignores a directory named %s", (name) => { + withRoot({}, (root) => { + mkdirSync(join(root, name)); + expect(pythonTarget(root)).toBeUndefined(); + }); + }); + + it("ignores an oversized .python-version", () => { + withRoot({ ".python-version": `3.14\n${"#".repeat(4096)}` }, (root) => { + expect(pythonTarget(root)).toBeUndefined(); + }); + }); + + it("ignores an oversized pyproject.toml", () => { + withRoot({ + "pyproject.toml": `[project]\nrequires-python = ">=3.14"\n#${"x".repeat(1024 * 1024)}`, + }, (root) => expect(pythonTarget(root)).toBeUndefined()); + }); +}); + +describe("major/minor comparison", () => { + it("compares major and minor versions numerically", () => { + expect(compareMajorMinor("3.14", "3.14")).toBe(0); + expect(compareMajorMinor("3.15", "3.14")).toBeGreaterThan(0); + expect(compareMajorMinor("3.9", "3.14")).toBeLessThan(0); + expect(isAtLeast("3.14", "3.14")).toBe(true); + expect(isAtLeast("3.15", "3.14")).toBe(true); + expect(isAtLeast("4.0", "3.14")).toBe(true); + expect(isAtLeast("3.9", "3.14")).toBe(false); + }); +}); diff --git a/tests/core/config/workspaces.test.ts b/tests/core/config/workspaces.test.ts new file mode 100644 index 0000000..b2681be --- /dev/null +++ b/tests/core/config/workspaces.test.ts @@ -0,0 +1,263 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { BUILTIN_EXCLUSIONS, TEST_EXCLUSIONS } from "../../../src/core/config/exclusions.ts"; +import type { Language } from "../../../src/core/config/schema.ts"; +import type { SourceSelection } from "../../../src/core/config/sources.ts"; +import { workspaceRoots } from "../../../src/core/config/workspaces.ts"; +import { workspaceMemberDirectories } from "../../../src/core/config/workspaces.ts"; + +function withRoot(files: Record, run: (root: string) => void): void { + const root = mkdtempSync(join(tmpdir(), "code-quality-workspaces-")); + try { + for (const [file, content] of Object.entries(files)) { + const path = join(root, file); + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, content, "utf8"); + } + run(root); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +const selection: SourceSelection = { + excludes: [...BUILTIN_EXCLUSIONS, ...TEST_EXCLUSIONS], + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"], +}; + +function roots(root: string, language: Language): string[] { + return workspaceRoots(root, language, { + ...selection, + extensions: language === "php" ? [".php"] : language === "python" ? [".py"] : selection.extensions, + }); +} + +describe("workspaceRoots TypeScript manifests", () => { + it("expands pnpm member globs and maps members to their src directories", () => { + withRoot({ + "pnpm-workspace.yaml": "packages:\n - packages/*\n - apps/*\n", + "packages/core/package.json": "{}", + "packages/core/src/index.ts": "", + "apps/web/package.json": "{}", + "apps/web/src/index.tsx": "", + }, (root) => expect(roots(root, "ts")).toEqual([ + "apps/web/src", "packages/core/src", + ])); + }); + + it.each([ + JSON.stringify({ workspaces: ["packages/*"] }), + JSON.stringify({ workspaces: { packages: ["packages/*"] } }), + ])("supports package.json workspace shapes", (manifest) => { + withRoot({ + "package.json": manifest, + "packages/core/package.json": "{}", + "packages/core/index.ts": "", + }, (root) => expect(roots(root, "ts")).toEqual(["packages/core"])); + }); + + it("applies negated globs and skips a dot member", () => { + withRoot({ + "pnpm-workspace.yaml": "packages: ['.', 'packages/*', '!packages/private-*']\n", + "index.ts": "", + "packages/shared/package.json": "{}", + "packages/shared/src/index.ts": "", + "packages/private-core/package.json": "{}", + "packages/private-core/src/index.ts": "", + }, (root) => expect(roots(root, "ts")).toEqual(["packages/shared/src"])); + }); + + it("adds depth-1 directories containing package.json", () => { + withRoot({ + "server/package.json": "{}", + "server/main.ts": "", + "plain/package.json": "{}", + "plain/readme.md": "", + "nested/child/package.json": "{}", + "nested/child/main.ts": "", + }, (root) => expect(roots(root, "ts")).toEqual(["server"])); + }); +}); + +describe("workspace TypeScript root layout", () => { + it("keeps a member root when it has source files outside src", () => { + withRoot({ + "package.json": JSON.stringify({ workspaces: ["packages/*"] }), + "packages/core/package.json": "{}", + "packages/core/src/index.ts": "", + "packages/cli/package.json": "{}", + "packages/cli/src/lib.ts": "", + "packages/cli/cli.ts": "", + }, (root) => expect(roots(root, "ts")).toEqual([ + "packages/cli", "packages/core/src", + ])); + }); +}); + +describe("workspace TypeScript exclusions", () => { + it("drops members excluded as tests", () => { + withRoot({ + "pnpm-workspace.yaml": "packages: ['packages/*', 'packages/core/plugins/*', 'tests/*']\n", + "packages/core/package.json": "{}", + "packages/core/index.ts": "", + "packages/core/plugins/demo/package.json": "{}", + "packages/core/plugins/demo/index.ts": "", + "tests/demo/package.json": "{}", + "tests/demo/src/index.ts": "", + }, (root) => expect(roots(root, "ts")).toEqual(["packages/core"])); + }); +}); + +describe("workspace pattern normalization", () => { + it.each(["./packages/*", "packages//*"])("normalizes %s and its negation", (pattern) => { + withRoot({ + "pnpm-workspace.yaml": `packages: ['${pattern}', '!./packages/legacy']\n`, + "packages/core/package.json": "{}", + "packages/core/src/index.ts": "", + "packages/legacy/package.json": "{}", + "packages/legacy/src/index.ts": "", + }, (root) => { + expect(workspaceMemberDirectories(root)).toEqual(["packages/core"]); + expect(roots(root, "ts")).toEqual(["packages/core/src"]); + }); + }); +}); + +describe("workspace member validation", () => { + it("matches recursive members to depth three and requires package manifests", () => { + withRoot({ + "package.json": JSON.stringify({ workspaces: ["packages/**"] }), + "packages/container/src/index.ts": "", + "packages/group/sub/package.json": "{}", + "packages/group/sub/src/index.ts": "", + }, (root) => { + expect(workspaceMemberDirectories(root)).toEqual(["packages/group/sub"]); + expect(roots(root, "ts")).toEqual(["packages/group/sub/src"]); + }); + }); + + it("keeps explicitly declared members with conventional names", () => { + withRoot({ + "package.json": JSON.stringify({ workspaces: ["packages/*"] }), + "packages/docs/package.json": "{}", + }, (root) => { + expect(workspaceMemberDirectories(root)).toEqual(["packages/docs"]); + expect(roots(root, "ts")).toEqual([]); + }); + }); + + it("ignores invalid and pathological workspace patterns", () => { + const longPattern = `packages/${"a".repeat(201)}`; + withRoot({ + "package.json": JSON.stringify({ workspaces: [ + "packages/?", "packages/[abc]", "packages/*a*a*b", longPattern, "packages/*", + ] }), + "packages/core/package.json": "{}", + "packages/core/src/index.ts": "", + }, (root) => expect(workspaceMemberDirectories(root)).toEqual(["packages/core"])); + }); + + it("ignores manifests larger than one MiB", () => { + withRoot({ + "package.json": `${" ".repeat(1024 * 1024)}{}`, + "packages/core/package.json": "{}", + "packages/core/src/index.ts": "", + }, (root) => expect(workspaceMemberDirectories(root)).toEqual([])); + }); + + it("caps includes and excludes independently", () => { + const includes = ["packages/*", ...Array.from({ length: 200 }, (_, index) => `packages/unused-${index}`)]; + withRoot({ + "pnpm-workspace.yaml": `packages: [${[...includes.map((pattern) => `'${pattern}'`), "'!packages/legacy'"].join(", ")}]\n`, + "packages/core/package.json": "{}", + "packages/core/src/index.ts": "", + "packages/legacy/package.json": "{}", + "packages/legacy/src/index.ts": "", + }, (root) => { + expect(workspaceMemberDirectories(root)).toEqual(["packages/core"]); + }); + }); +}); + +describe("workspaceRoots Composer manifests", () => { + it("reads autoload directory values and ignores unsupported entries", () => { + withRoot({ + "composer.json": JSON.stringify({ + autoload: { + "psr-4": { "App\\\\": "./src/", "": ["packages/a/src/", "packages/b/src"] }, + "psr-0": { "Legacy\\\\": ["legacy/", "fallback/"] }, + classmap: ["classmap/", "bootstrap.php"], + files: ["helpers.php"], + }, + "autoload-dev": { "psr-4": { "Tests\\\\": "tests/" } }, + }), + "src/App.php": " expect(roots(root, "php")).toEqual([ + "classmap", "fallback", "legacy", "packages/a/src", "packages/b/src", "src", + ])); + }); + + it("drops a source root nested under another selected root", () => { + withRoot({ + "composer.json": JSON.stringify({ + autoload: { "psr-4": { "Domain\\\\": "domain/", "Nested\\\\": "domain/nested/" } }, + }), + "domain/nested/Entity.php": " expect(roots(root, "php")).toEqual(["domain"])); + }); +}); + +describe("workspaceRoots Python manifests", () => { + it("expands uv members minus excludes and maps members to src", () => { + withRoot({ + "pyproject.toml": [ + "[tool.uv.workspace]", + "members = [\"packages/*\"]", + "exclude = [\"packages/private-*\"]", + ].join("\n"), + "packages/core/src/core.py": "", + "packages/core/pyproject.toml": "", + "packages/private-api/src/api.py": "", + "packages/private-api/pyproject.toml": "", + }, (root) => expect(roots(root, "python")).toEqual(["packages/core/src"])); + }); + + it("reads setuptools package find where directories", () => { + withRoot({ + "pyproject.toml": [ + "[tool.setuptools.packages.find]", + "where = [\"python/\", \"lib\", \"missing\"]", + ].join("\n"), + "python/app.py": "", + "lib/pkg.py": "", + }, (root) => expect(roots(root, "python")).toEqual(["lib", "python"])); + }); +}); + +describe("workspaceRoots validation", () => { + it("returns no roots for web", () => { + withRoot({ "package.json": "{}", "app/src/index.ts": "" }, (root) => { + expect(roots(root, "web")).toEqual([]); + }); + }); + + it.each([ + ["ts", { "package.json": "{", "pnpm-workspace.yaml": "packages: [" }], + ["php", { "composer.json": "{" }], + ["python", { "pyproject.toml": "[tool.uv.workspace\nmembers = [" }], + ] as const)("treats malformed %s manifests as empty", (language, files) => { + withRoot(files, (root) => expect(roots(root, language)).toEqual([])); + }); +}); diff --git a/tests/core/runner/run-adapter.test.ts b/tests/core/runner/run-adapter.test.ts index c4cf5fc..483b8b2 100644 --- a/tests/core/runner/run-adapter.test.ts +++ b/tests/core/runner/run-adapter.test.ts @@ -2,9 +2,11 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:f import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { runAdapter } from "../../../src/core/runner/run-adapter.ts"; +import { verifyTool } from "../../../src/core/runner/verify.ts"; +import type { ToolInvocation } from "../../../src/core/types.ts"; import { fakeAdapter, fakeDeps } from "../../helpers/fake-adapter.ts"; const roots: string[] = []; @@ -58,3 +60,47 @@ describe("runAdapter artifacts", () => { expect(readFileSync(join(artifactsRoot, "ts-logs/stdout.log"), "utf8")).toBe("{}"); }); }); + +describe("runAdapter tool verification", () => { + it("probes the tool with the invocation environment and cwd", async () => { + const root = mkdtempSync(join(tmpdir(), "run-adapter-test-")); + roots.push(root); + mkdirSync(join(root, "src")); + const invocation: ToolInvocation = { + bin: "fake-tool", + args: ["check"], + cwd: join(root, "work"), + env: { PATH: "/fake/venv:/usr/bin", PYTHONPATH: "/fake/src" }, + exitCodes: [0], + }; + const adapter = { + ...fakeAdapter({ id: "python-verify-environment" }), + tool: { bin: "fake-tool", version: "1.2.3" }, + command: () => invocation, + }; + const spawn = vi.fn((call: ToolInvocation) => call.args[0] === "--version" + ? { stdout: "fake-tool 1.2.3\n", stderr: "", exitCode: 0 } + : { stdout: "{}", stderr: "", exitCode: 0 }); + + await runAdapter(adapter, { + root, + isDrupal: false, + languages: ["python"], + paths: { python: ["src"] }, + exclude: [], + disabled: [], + architecture: {}, + notices: [], + configHash: "a".repeat(64), + }, { ...fakeDeps(), spawn, verify: verifyTool }); + + expect(spawn).toHaveBeenNthCalledWith(1, { + bin: "fake-tool", + args: ["--version"], + cwd: invocation.cwd, + env: invocation.env, + exitCodes: [0], + }, root); + expect(spawn).toHaveBeenNthCalledWith(2, invocation, root); + }); +}); diff --git a/tests/report/advisory.test.ts b/tests/report/advisory.test.ts index 1fd3190..04d21f5 100644 --- a/tests/report/advisory.test.ts +++ b/tests/report/advisory.test.ts @@ -177,6 +177,18 @@ describe("fallow advisory invocations", () => { ]); expect(health.exitCodes).toEqual([0, 2]); }); + + it("adds PHP format extensions to jscpd HTML only for Drupal", () => { + const standard = ADVISORY_REPORTS.find((report) => report.id === "jscpd-html")! + .command(context(configRoot("php"), "php")); + expect(standard.args).not.toContain("--formats-exts"); + + const drupal = context(configRoot("php"), "php"); + drupal.config.isDrupal = true; + const invocation = ADVISORY_REPORTS.find((report) => report.id === "jscpd-html")!.command(drupal); + expect(invocation.args).toContain("--formats-exts"); + expect(invocation.args).toContain("php:php,module,theme,install,inc,profile,engine"); + }); }); describe("fallow advisory validation", () => { diff --git a/tests/scripts/integration.test.ts b/tests/scripts/integration.test.ts index cf786a5..016af3f 100644 --- a/tests/scripts/integration.test.ts +++ b/tests/scripts/integration.test.ts @@ -1,9 +1,11 @@ import { existsSync, + lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, + symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -18,6 +20,7 @@ import { formatResultTable, dockerArgs, MUTATIONS, + mutationEvidenceOk, plannedDependencyInstalls, shouldCopyFixturePath, } from "../../scripts/integration.ts"; @@ -33,27 +36,86 @@ it("runs mounted Docker fixtures as the host user when IDs are available", () => } }); -it("plans installs only for fixture dependencies that are missing", () => { - expect(plannedDependencyInstalls({ tsNodeModules: false, phpVendor: false })).toEqual([ - { - fixture: "ts-project", - entrypoint: "npm", - command: ["install", "--no-audit", "--no-fund"], - }, - { - fixture: "php-project", - entrypoint: "composer", - command: ["install", "--no-interaction"], - }, - ]); - expect(plannedDependencyInstalls({ tsNodeModules: true, phpVendor: false })).toEqual([ - { - fixture: "php-project", - entrypoint: "composer", - command: ["install", "--no-interaction"], - }, +const tsInstall = { + fixture: "ts-project", + entrypoint: "npm", + command: ["install", "--ignore-scripts", "--no-audit", "--no-fund"], +}; +const payloadInstall = { + fixture: "payload-project", + entrypoint: "npm", + command: ["install", "--ignore-scripts", "--no-audit", "--no-fund"], +}; +const phpInstall = { + fixture: "php-project", + entrypoint: "composer", + command: ["install", "--no-interaction"], +}; +const monorepoInstall = { + fixture: "monorepo-project", + entrypoint: "corepack", + command: ["pnpm", "install", "--frozen-lockfile", "--ignore-scripts"], +}; +const python314Install = { + fixture: "python314-project", + entrypoint: "uv", + command: ["sync", "--frozen"], +}; + +const dependencyInstallCases = [ + [{ tsNodeModules: false, phpVendor: false, payloadNodeModules: false, monorepoNodeModules: true, python314Venv: true }, [tsInstall, phpInstall, payloadInstall]], + [{ tsNodeModules: true, phpVendor: false, payloadNodeModules: false, monorepoNodeModules: true, python314Venv: true }, [phpInstall, payloadInstall]], + [{ tsNodeModules: true, phpVendor: true, payloadNodeModules: false, monorepoNodeModules: true, python314Venv: true }, [payloadInstall]], + [{ tsNodeModules: false, phpVendor: false, payloadNodeModules: true, monorepoNodeModules: true, python314Venv: true }, [tsInstall, phpInstall]], + [{ tsNodeModules: true, phpVendor: false, payloadNodeModules: true, monorepoNodeModules: true, python314Venv: true }, [phpInstall]], + [{ tsNodeModules: false, phpVendor: true, payloadNodeModules: true, monorepoNodeModules: true, python314Venv: true }, [tsInstall]], + [{ tsNodeModules: false, phpVendor: true, payloadNodeModules: false, monorepoNodeModules: true, python314Venv: true }, [tsInstall, payloadInstall]], + [{ tsNodeModules: true, phpVendor: true, payloadNodeModules: true, monorepoNodeModules: true, python314Venv: true }, []], + [ + { tsNodeModules: false, phpVendor: false, payloadNodeModules: false, monorepoNodeModules: false, python314Venv: false }, + [tsInstall, phpInstall, payloadInstall, monorepoInstall, python314Install], + ], + [ + { tsNodeModules: true, phpVendor: true, payloadNodeModules: true, monorepoNodeModules: false, python314Venv: true }, + [monorepoInstall], + ], + [ + { tsNodeModules: true, phpVendor: true, payloadNodeModules: true, monorepoNodeModules: true, python314Venv: false }, + [python314Install], + ], +] as const; + +it.each(dependencyInstallCases)("plans installs only for missing dependencies (%#)", (state, expected) => { + expect(plannedDependencyInstalls(state)).toEqual(expected); +}); + +it("passes runtime installer environment and the corepack command", () => { + const args = dockerArgs( + "code-quality:test", + ["pnpm", "install", "--frozen-lockfile", "--ignore-scripts"], + "/fixture", + "corepack", + ); + + expect(args).toEqual(expect.arrayContaining([ + "--entrypoint", "corepack", + "-e", "HOME=/tmp", + "-e", "COMPOSER_HOME=/tmp/composer", + "-e", "UV_CACHE_DIR=/tmp/uv-cache", + ])); + expect(args).not.toContain("COREPACK_HOME=/tmp/corepack"); + expect(args.slice(-5)).toEqual([ + "code-quality:test", "pnpm", "install", "--frozen-lockfile", "--ignore-scripts", ]); - expect(plannedDependencyInstalls({ tsNodeModules: true, phpVendor: true })).toEqual([]); +}); + +it("passes the uv entrypoint and cache environment", () => { + const args = dockerArgs("code-quality:test", ["sync", "--frozen"], "/fixture", "uv"); + expect(args).toEqual(expect.arrayContaining([ + "--entrypoint", "uv", + "-e", "UV_CACHE_DIR=/tmp/uv-cache", + ])); + expect(args.slice(-3)).toEqual(["code-quality:test", "sync", "--frozen"]); }); function withTempRoot(action: (root: string) => void): void { @@ -92,6 +154,24 @@ it("copies a fixture without artifacts or temporary files", () => { }); }); +it.each([ + [false, true], + [true, false], +] as const)("copies symlinks with dereference=%s", (dereference, remainsLink) => { + withTempRoot((root) => { + const source = join(root, "source"); + const destination = join(root, "destination"); + mkdirSync(source); + writeFileSync(join(source, "target.txt"), "linked content", "utf8"); + symlinkSync("target.txt", join(source, "linked.txt")); + + copyFixture(source, destination, dereference); + + expect(lstatSync(join(destination, "linked.txt")).isSymbolicLink()).toBe(remainsLink); + expect(readFileSync(join(destination, "linked.txt"), "utf8")).toBe("linked content"); + }); +}); + it("applies append mutations", () => { withTempRoot((root) => { mkdirSync(join(root, "src"), { recursive: true }); @@ -112,7 +192,6 @@ it("applies content mutations", () => { fixture: "ts-project", file: "src/content.ts", content: "content\n", - expect: "regressions", }); expect(readFileSync(join(root, "src", "content.ts"), "utf8")).toBe("content\n"); }); @@ -141,6 +220,45 @@ it("defines a web duplication copy mutation", () => { }); }); +it("defines Drupal, Payload, and monorepo mutations", () => { + expect(MUTATIONS).toContainEqual(expect.objectContaining({ + fixture: "drupal-project", + file: "web/modules/custom/demo/demo.module", + expect: "regressions", + })); + expect(MUTATIONS).toContainEqual(expect.objectContaining({ + fixture: "drupal-project", + file: "web/modules/custom/demo/demo_copy.module", + copyFrom: "web/modules/custom/demo/demo.module", + expect: "regressions", + })); + expect(MUTATIONS).toContainEqual(expect.objectContaining({ + fixture: "payload-project", + file: "src/payload-types.ts", + expect: "regressions", + exitCode: 0, + })); + expect(MUTATIONS).toContainEqual(expect.objectContaining({ + fixture: "monorepo-project", + file: "packages/core/src/orphan.ts", + expect: "regressions", + })); + expect(MUTATIONS).toContainEqual(expect.objectContaining({ + fixture: "python314-project", + file: "src/demo314/extra.py", + expect: "regressions", + })); +}); + +it("checks mutation stderr in both exit-code branches with an optional expectation", () => { + const failure = { fixture: "ts-project", file: "src/x.ts", expect: "custom failure" } as const; + const allowed = { fixture: "ts-project", file: "src/x.ts", exitCode: 0 } as const; + expect(mutationEvidenceOk(failure, "custom failure found")).toBe(true); + expect(mutationEvidenceOk(failure, "regressions found")).toBe(false); + expect(mutationEvidenceOk(allowed, "all clear")).toBe(true); + expect(mutationEvidenceOk(allowed, "regressions found")).toBe(false); +}); + it("formats result rows and result tables", () => { const passing = { label: "versions", exitCode: 0, expectedExitCode: 0, stderr: "" }; const failing = {