From e28ae465bdf53fa73a9a0e4b4cb305acd28a37cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Mon, 14 Sep 2026 17:12:55 +0200 Subject: [PATCH 1/2] feat: detect Drupal extensions, workspace roots, and Payload/Next generated code Reduce first-run noise on real repository layouts: - Drupal profile scans .module, .theme, .install, .inc, .profile and .engine files as PHP in PHPCS and jscpd, with structural tree-sitter anchors. - Source roots add workspace members from manifests (pnpm-workspace.yaml, package.json workspaces, depth-1 packages, composer autoload, uv workspace, setuptools where), unioned with conventional and discovered roots. Member patterns use a bounded segment matcher instead of picomatch. - Payload/Next profile excludes generated types, import map, admin route group, migrations, seed data and Next build output, scoped to the resolved TypeScript roots, and reports a Notice. - Knip receives static framework entry patterns per workspace owner, one workspace block per owner with its own ignore list, ignoreWorkspaces for members owning no source root, and plugin toggles generated from the complete knip 6.35.1 plugin registry (verified by a test). - New fixtures drupal-project, payload-project and monorepo-project run in the integration suite, including a negative mutation for excluded code. Adds smol-toml (runtime) and knip (dev) as exact dependencies. --- .dockerignore | 1 + .gitignore | 4 +- README.md | 2 +- docs/checks.md | 2 +- docs/configuration.md | 20 +- docs/development.md | 2 +- docs/security.md | 2 +- fixtures/drupal-project/.code-quality.yml | 4 + .../.github/workflows/quality.yml | 6 + fixtures/drupal-project/Makefile | 21 + fixtures/drupal-project/composer.json | 1 + .../quality/php-cognitive-baseline.json | 6 + .../quality/php-complexity-baseline.json | 6 + .../quality/php-duplication-baseline.json | 6 + .../quality/web-duplication-baseline.json | 6 + .../web/modules/custom/demo/demo.install | 7 + .../web/modules/custom/demo/demo.module | 18 + .../web/modules/custom/demo/src/Demo.php | 13 + .../custom/demo/templates/page.html.twig | 6 + fixtures/monorepo-project/.code-quality.yml | 1 + .../.github/workflows/quality.yml | 6 + fixtures/monorepo-project/Makefile | 21 + .../monorepo-project/apps/web/package.json | 1 + .../monorepo-project/apps/web/src/main.ts | 3 + fixtures/monorepo-project/package.json | 1 + .../packages/core/package.json | 1 + .../packages/core/src/index.ts | 3 + fixtures/monorepo-project/pnpm-workspace.yaml | 1 + .../quality/ts-cognitive-baseline.json | 6 + .../quality/ts-complexity-baseline.json | 6 + .../quality/ts-duplication-baseline.json | 6 + .../quality/ts-unused-baseline.json | 6 + fixtures/monorepo-project/tsconfig.json | 11 + fixtures/payload-project/.code-quality.yml | 1 + .../.github/workflows/quality.yml | 6 + fixtures/payload-project/Makefile | 21 + fixtures/payload-project/next.config.mjs | 3 + fixtures/payload-project/package.json | 1 + fixtures/payload-project/playwright.config.ts | 3 + .../quality/ts-cognitive-baseline.json | 6 + .../quality/ts-complexity-baseline.json | 6 + .../quality/ts-duplication-baseline.json | 6 + .../quality/ts-unused-baseline.json | 6 + .../src/app/(payload)/backoffice/importMap.js | 2 + .../src/app/(payload)/layout.tsx | 4 + fixtures/payload-project/src/app/layout.tsx | 3 + fixtures/payload-project/src/app/page.tsx | 3 + .../payload-project/src/collections/Users.ts | 1 + .../src/migrations/20260101_000000.ts | 1 + fixtures/payload-project/src/payload-types.ts | 3 + .../payload-project/src/payload.config.ts | 4 + fixtures/payload-project/src/seed/seed.ts | 1 + .../payload-project/stubs/next/index.d.ts | 1 + fixtures/payload-project/stubs/next/index.js | 1 + .../payload-project/stubs/next/package.json | 1 + .../payload-project/stubs/payload/index.d.ts | 1 + .../payload-project/stubs/payload/index.js | 3 + .../stubs/payload/package.json | 1 + .../stubs/playwright-test/index.d.ts | 1 + .../stubs/playwright-test/index.js | 3 + .../stubs/playwright-test/package.json | 1 + .../payload-project/stubs/vitest/index.d.ts | 2 + .../payload-project/stubs/vitest/index.js | 5 + .../payload-project/stubs/vitest/package.json | 1 + fixtures/payload-project/tsconfig.json | 12 + fixtures/payload-project/vitest.config.ts | 3 + package.json | 2 + pnpm-lock.yaml | 555 +++++++++++++++++- scripts/integration.ts | 149 ++++- src/checks/php/phpcs-cognitive.ts | 9 +- src/checks/php/phpcs-complexity.ts | 8 +- src/checks/php/phpcs-shared.ts | 12 +- src/checks/shared/jscpd.ts | 11 + src/checks/ts/knip-plugins.ts | 31 + src/checks/ts/knip.ts | 213 +++++-- src/core/anchor/kinds.ts | 6 + src/core/config/detect.ts | 31 +- src/core/config/exclusions.ts | 19 + src/core/config/load.ts | 149 ++++- src/core/config/sources.ts | 43 +- src/core/config/workspaces.ts | 240 ++++++++ src/report/advisory.ts | 5 + tests/checks/php/phpcs.test.ts | 22 +- tests/checks/shared/jscpd.test.ts | 22 + tests/checks/ts/knip-plugins.test.ts | 51 ++ tests/checks/ts/knip.test.ts | 202 ++++++- tests/cli/init.test.ts | 18 + tests/core/anchor/service.test.ts | 9 + tests/core/config/detect.test.ts | 31 +- tests/core/config/exclusions.test.ts | 28 + tests/core/config/load.test.ts | 242 ++++++++ tests/core/config/workspaces.test.ts | 246 ++++++++ tests/report/advisory.test.ts | 12 + tests/scripts/integration.test.ts | 86 ++- 94 files changed, 2624 insertions(+), 151 deletions(-) create mode 100644 fixtures/drupal-project/.code-quality.yml create mode 100644 fixtures/drupal-project/.github/workflows/quality.yml create mode 100644 fixtures/drupal-project/Makefile create mode 100644 fixtures/drupal-project/composer.json create mode 100644 fixtures/drupal-project/quality/php-cognitive-baseline.json create mode 100644 fixtures/drupal-project/quality/php-complexity-baseline.json create mode 100644 fixtures/drupal-project/quality/php-duplication-baseline.json create mode 100644 fixtures/drupal-project/quality/web-duplication-baseline.json create mode 100644 fixtures/drupal-project/web/modules/custom/demo/demo.install create mode 100644 fixtures/drupal-project/web/modules/custom/demo/demo.module create mode 100644 fixtures/drupal-project/web/modules/custom/demo/src/Demo.php create mode 100644 fixtures/drupal-project/web/themes/custom/demo/templates/page.html.twig create mode 100644 fixtures/monorepo-project/.code-quality.yml create mode 100644 fixtures/monorepo-project/.github/workflows/quality.yml create mode 100644 fixtures/monorepo-project/Makefile create mode 100644 fixtures/monorepo-project/apps/web/package.json create mode 100644 fixtures/monorepo-project/apps/web/src/main.ts create mode 100644 fixtures/monorepo-project/package.json create mode 100644 fixtures/monorepo-project/packages/core/package.json create mode 100644 fixtures/monorepo-project/packages/core/src/index.ts create mode 100644 fixtures/monorepo-project/pnpm-workspace.yaml create mode 100644 fixtures/monorepo-project/quality/ts-cognitive-baseline.json create mode 100644 fixtures/monorepo-project/quality/ts-complexity-baseline.json create mode 100644 fixtures/monorepo-project/quality/ts-duplication-baseline.json create mode 100644 fixtures/monorepo-project/quality/ts-unused-baseline.json create mode 100644 fixtures/monorepo-project/tsconfig.json create mode 100644 fixtures/payload-project/.code-quality.yml create mode 100644 fixtures/payload-project/.github/workflows/quality.yml create mode 100644 fixtures/payload-project/Makefile create mode 100644 fixtures/payload-project/next.config.mjs create mode 100644 fixtures/payload-project/package.json create mode 100644 fixtures/payload-project/playwright.config.ts create mode 100644 fixtures/payload-project/quality/ts-cognitive-baseline.json create mode 100644 fixtures/payload-project/quality/ts-complexity-baseline.json create mode 100644 fixtures/payload-project/quality/ts-duplication-baseline.json create mode 100644 fixtures/payload-project/quality/ts-unused-baseline.json create mode 100644 fixtures/payload-project/src/app/(payload)/backoffice/importMap.js create mode 100644 fixtures/payload-project/src/app/(payload)/layout.tsx create mode 100644 fixtures/payload-project/src/app/layout.tsx create mode 100644 fixtures/payload-project/src/app/page.tsx create mode 100644 fixtures/payload-project/src/collections/Users.ts create mode 100644 fixtures/payload-project/src/migrations/20260101_000000.ts create mode 100644 fixtures/payload-project/src/payload-types.ts create mode 100644 fixtures/payload-project/src/payload.config.ts create mode 100644 fixtures/payload-project/src/seed/seed.ts create mode 100644 fixtures/payload-project/stubs/next/index.d.ts create mode 100644 fixtures/payload-project/stubs/next/index.js create mode 100644 fixtures/payload-project/stubs/next/package.json create mode 100644 fixtures/payload-project/stubs/payload/index.d.ts create mode 100644 fixtures/payload-project/stubs/payload/index.js create mode 100644 fixtures/payload-project/stubs/payload/package.json create mode 100644 fixtures/payload-project/stubs/playwright-test/index.d.ts create mode 100644 fixtures/payload-project/stubs/playwright-test/index.js create mode 100644 fixtures/payload-project/stubs/playwright-test/package.json create mode 100644 fixtures/payload-project/stubs/vitest/index.d.ts create mode 100644 fixtures/payload-project/stubs/vitest/index.js create mode 100644 fixtures/payload-project/stubs/vitest/package.json create mode 100644 fixtures/payload-project/tsconfig.json create mode 100644 fixtures/payload-project/vitest.config.ts create mode 100644 src/checks/ts/knip-plugins.ts create mode 100644 src/core/config/workspaces.ts create mode 100644 tests/checks/ts/knip-plugins.test.ts create mode 100644 tests/core/config/workspaces.test.ts diff --git a/.dockerignore b/.dockerignore index 70cc06d..2976719 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,5 +3,6 @@ dist launcher/dist artifacts fixtures/*/artifacts +fixtures/*/node_modules fixtures/php-project/vendor .git diff --git a/.gitignore b/.gitignore index 1773c70..3d62ae9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,8 @@ 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/ 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..285bf46 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -93,7 +93,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 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`. Framework entry points detected from static config files (Next app/pages routes, middleware, instrumentation, payload.config, vite/vitest/playwright configs) are added as static entry patterns, and in monorepos the generated configuration declares one Knip workspace per resolved source root owner with workspaces that own no root ignored; plugins stay disabled. 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/configuration.md b/docs/configuration.md index 30b34a0..572cbc0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -53,7 +53,7 @@ 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.`. +When a manifest detects a language, conventional roots with sources are kept, then workspace roots from manifests are added. TypeScript workspace roots come from `pnpm-workspace.yaml` packages, `package.json` workspaces, and depth-1 directories with their own `package.json`; PHP roots come from `composer.json` autoload `psr-4`, `psr-0`, and `classmap` directories; Python roots come from `[tool.uv.workspace]` members minus `exclude` and `[tool.setuptools.packages.find]` `where`. Declared TypeScript members must contain `package.json`, and declared Python members must contain `pyproject.toml`. Member patterns support literal path segments, `*`, `**`, at most one `*` inside a segment, and `!` negations. The member walk reaches depth three; each manifest pattern is capped at 200 characters and only the first 200 include and first 200 exclude patterns are considered. Leading `./` prefixes, repeated slashes, and `.` path segments are normalized. A member maps to `/src` when it exists. Only directories with source files count. When no conventional root has sources, workspace roots and depth-1 discovered roots are unioned, and a discovered directory containing a workspace root is dropped. A `Notice:` lists added roots, and `init` writes them into the generated `paths.`. A depth-1 directory with its own `package.json`, such as `website/`, becomes a TypeScript root. Depth-1 discovery remains the fallback when nothing else resolves; it ignores hidden, test, build, dependency, generated, and other conventional non-source directories. Drupal projects keep the curated custom module, theme, and profile PHP roots instead of adding Composer autoload roots. 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 +75,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 +92,26 @@ exclude: - "web/modules/custom/site/generated/**" ``` +## Payload/Next profile + +When `next.config.{js,mjs,cjs,ts,mts}` exists at the repository root, or `payload.config.{ts,js,mjs,mts}` exists at the root or under `src/`, code-quality activates the Payload/Next profile for resolved TypeScript sources. For every resolved TypeScript root ``, it excludes: + +- `/**/payload-types.ts` +- `/**/importMap.js` +- `/**/app/[(]payload[)]/**` +- `/**/migrations/**` +- `/**/migrations-*/**` +- `/**/seed/**` +- `.next/**` +- `next-env.d.ts` + +A `Notice:` identifies the active Payload/Next defaults. `.next/**` and `next-env.d.ts` remain repository-root exclusions; the generated-code patterns are scoped to TypeScript roots. Dependency-cruiser does not apply exclude globs. + ## 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..8cc9711 100644 --- a/docs/development.md +++ b/docs/development.md @@ -17,7 +17,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, 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. diff --git a/docs/security.md b/docs/security.md index a134c26..f8e5ed5 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 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..a52f7ce --- /dev/null +++ b/fixtures/drupal-project/quality/php-cognitive-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "phpcs@4.0.4", + "configHash": "e0fa6304b2c3e12e85b64f67a40efc89b20573dc649440e738beef4f68e3a3cb", + "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..a52f7ce --- /dev/null +++ b/fixtures/drupal-project/quality/php-complexity-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "phpcs@4.0.4", + "configHash": "e0fa6304b2c3e12e85b64f67a40efc89b20573dc649440e738beef4f68e3a3cb", + "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..a10f320 --- /dev/null +++ b/fixtures/drupal-project/quality/php-duplication-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "jscpd@5.2.0", + "configHash": "e0fa6304b2c3e12e85b64f67a40efc89b20573dc649440e738beef4f68e3a3cb", + "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..a10f320 --- /dev/null +++ b/fixtures/drupal-project/quality/web-duplication-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "jscpd@5.2.0", + "configHash": "e0fa6304b2c3e12e85b64f67a40efc89b20573dc649440e738beef4f68e3a3cb", + "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..8bc9d67 --- /dev/null +++ b/fixtures/monorepo-project/package.json @@ -0,0 +1 @@ +{"name":"monorepo-fixture","private":true,"type":"module"} 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-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..f15a349 --- /dev/null +++ b/fixtures/monorepo-project/quality/ts-cognitive-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "fallow@3.23.0", + "configHash": "a1fc39c1d0e3108d4200b0e7390f3c947bb47f4335d262111a170cee51e2b1dc", + "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..e878eb9 --- /dev/null +++ b/fixtures/monorepo-project/quality/ts-complexity-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "oxlint@1.82.0", + "configHash": "a1fc39c1d0e3108d4200b0e7390f3c947bb47f4335d262111a170cee51e2b1dc", + "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..da2d304 --- /dev/null +++ b/fixtures/monorepo-project/quality/ts-duplication-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "jscpd@5.2.0", + "configHash": "a1fc39c1d0e3108d4200b0e7390f3c947bb47f4335d262111a170cee51e2b1dc", + "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..5b6cabc --- /dev/null +++ b/fixtures/monorepo-project/quality/ts-unused-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "knip@6.35.1", + "configHash": "a1fc39c1d0e3108d4200b0e7390f3c947bb47f4335d262111a170cee51e2b1dc", + "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..df80a11 --- /dev/null +++ b/fixtures/payload-project/quality/ts-cognitive-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "fallow@3.23.0", + "configHash": "38b0f0af9a11119a26c1815b8d15c587b61191194c80a6fdb4575ac3397b1d93", + "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..05a0e66 --- /dev/null +++ b/fixtures/payload-project/quality/ts-complexity-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "oxlint@1.82.0", + "configHash": "38b0f0af9a11119a26c1815b8d15c587b61191194c80a6fdb4575ac3397b1d93", + "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..2fde90b --- /dev/null +++ b/fixtures/payload-project/quality/ts-duplication-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "jscpd@5.2.0", + "configHash": "38b0f0af9a11119a26c1815b8d15c587b61191194c80a6fdb4575ac3397b1d93", + "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..824bf8e --- /dev/null +++ b/fixtures/payload-project/quality/ts-unused-baseline.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "tool": "knip@6.35.1", + "configHash": "38b0f0af9a11119a26c1815b8d15c587b61191194c80a6fdb4575ac3397b1d93", + "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/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/scripts/integration.ts b/scripts/integration.ts index e89741c..20b2639 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,6 +57,88 @@ 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", @@ -88,15 +171,40 @@ interface DockerResult { interface FixtureDependencyState { tsNodeModules: boolean; phpVendor: boolean; + payloadNodeModules: boolean; } interface DependencyInstallPlan { - fixture: "ts-project" | "php-project"; + fixture: "ts-project" | "php-project" | "payload-project"; entrypoint: "npm" | "composer"; 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"], + }, +] as const satisfies readonly (DependencyInstallPlan & { state: keyof FixtureDependencyState })[]; + +const FIXTURES = [ + "ts-project", "php-project", "python-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 +270,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?.(); @@ -190,22 +303,9 @@ 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( @@ -276,6 +376,7 @@ 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")), }; for (const plan of plannedDependencyInstalls(state)) { const root = fixtureRoot(repositoryRoot, plan.fixture); @@ -331,7 +432,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/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..cd4e99c 100644 --- a/src/checks/ts/knip.ts +++ b/src/checks/ts/knip.ts @@ -1,9 +1,11 @@ -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync, lstatSync, readFileSync } from "node:fs"; +import { dirname, 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 { 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(), @@ -45,6 +48,42 @@ const DEPENDENCY_FIELDS = [ "optionalDependencies", ] 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")); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return false; @@ -72,41 +111,145 @@ 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/**/{page,layout,template,loading,error,global-error,not-found,default,route}.{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"]) { + 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 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 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/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..7887f15 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,22 @@ const DRUPAL_PATHS: Record<"php" | "web", string[]> = { web: ["web/themes/custom", "docroot/themes/custom"], }; +export const PAYLOAD_NEXT_CONFIGS = [ + "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", +] as const; + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -49,9 +66,14 @@ export function isDrupalProject(root: string): boolean { } } +export function isPayloadNextProject(root: string): boolean { + return PAYLOAD_NEXT_CONFIGS.some((file) => existsSync(join(root, file))); +} + 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 +95,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..6a9d07a 100644 --- a/src/core/config/exclusions.ts +++ b/src/core/config/exclusions.ts @@ -33,6 +33,25 @@ export const BUILTIN_EXCLUSIONS = [ "**/var/**", ] as const; +export const PAYLOAD_NEXT_EXCLUSIONS = [ + "**/payload-types.ts", + "**/importMap.js", + "**/app/[(]payload[)]/**", + "**/migrations/**", + "**/migrations-*/**", + "**/seed/**", + ".next/**", + "next-env.d.ts", +] as const; + +export function payloadNextExclusions(tsRoots: readonly string[]): string[] { + const scoped = PAYLOAD_NEXT_EXCLUSIONS.slice(0, -2); + return [ + ...tsRoots.flatMap((root) => scoped.map((pattern) => `${root}/${pattern}`)), + ...PAYLOAD_NEXT_EXCLUSIONS.slice(-2), + ]; +} + export function isExcluded(file: string, patterns: readonly string[]): boolean { return patterns.some((pattern) => picomatch(pattern, { dot: true })(file)); } diff --git a/src/core/config/load.ts b/src/core/config/load.ts index 59aa799..ae8c1c8 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, + PAYLOAD_NEXT_EXCLUSIONS, + payloadNextExclusions, + TEST_EXCLUSIONS, +} from "./exclusions.ts"; import { configHash } from "./hash.ts"; -import { defaultPaths, detectLanguages, isDrupalProject, selectArchitecture } from "./detect.ts"; +import { + defaultPaths, + detectLanguages, + isDrupalProject, + isPayloadNextProject, + selectArchitecture, +} from "./detect.ts"; import { discoverSourceRoots, findMinifiedFiles, findSourceFiles, - LANGUAGE_EXTENSIONS, + languageExtensions, } from "./sources.ts"; +import type { SourceSelection } from "./sources.ts"; +import { workspaceRoots } from "./workspaces.ts"; import { LANGUAGES, consumerConfigSchema, @@ -63,7 +76,6 @@ function readYaml(root: string): ConsumerConfig { interface LanguageResolution { languages: Language[]; - explicit: Set; } function configuredPathLanguages(config: ConsumerConfig): Language[] { @@ -74,10 +86,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 +99,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 +145,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 +205,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 +249,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 +284,60 @@ function resolveArchitecture( return architecture; } +function payloadExclusions(enabled: boolean, tsRoots?: readonly string[]): readonly string[] { + if (!enabled) return []; + return tsRoots === undefined ? PAYLOAD_NEXT_EXCLUSIONS : payloadNextExclusions(tsRoots); +} + +function appendProfileNotices( + notices: string[], + minified: string[], + payloadTypeScript: boolean, +): void { + if (minified.length > 0) notices.push(minifiedNotice(minified)); + if (!payloadTypeScript) return; + notices.push( + "Payload/Next profile: excluding generated Payload types, import map, admin route group, " + + "migrations, seed data, and Next build output", + ); +} + +function resolvedExclusions( + consumer: ConsumerConfig, + minified: string[], + payloadTypeScript: boolean, + tsRoots: readonly string[], +): string[] { + const profileExclusions = payloadExclusions(payloadTypeScript, tsRoots); + return [...consumer.exclude ?? [], ...minified, ...profileExclusions]; +} + export function loadConfig(root: string): ResolvedConfig { const consumer = readYaml(root); const isDrupal = isDrupalProject(root); + const payload = isPayloadNextProject(root); + const profileExclusions = payloadExclusions(payload); const languageResolution = resolveLanguages(root, consumer); const pathResolution = resolvePaths( root, - languageResolution.languages, - languageResolution.explicit, consumer, + languageResolution.languages, + { isDrupal, profileExclusions }, ); 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 payloadTypeScript = payload && paths.ts !== undefined; + const resolvedProfileExclusions = payloadTypeScript ? payloadNextExclusions(paths.ts ?? []) : []; + const minified = resolveMinifiedFiles(root, paths, consumer.exclude ?? [], resolvedProfileExclusions); + appendProfileNotices(pathResolution.notices, minified, payloadTypeScript); const architecture = resolveArchitecture(root, languages, consumer); const resolved = { isDrupal, languages, paths, - exclude: [...consumer.exclude ?? [], ...minified], + exclude: resolvedExclusions(consumer, minified, payloadTypeScript, paths.ts ?? []), disabled: consumer.checks?.disabled ?? [], architecture, notices: pathResolution.notices, diff --git a/src/core/config/sources.ts b/src/core/config/sources.ts index 52d81c8..8be6e11 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", @@ -30,6 +30,23 @@ export const LANGUAGE_EXTENSIONS: Readonly> web: [".twig", ".html", ".css", ".scss", ".less"], }; +export 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..d6ffccb --- /dev/null +++ b/src/core/config/workspaces.ts @@ -0,0 +1,240 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { 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") : []; +} + +function readRecord(file: string, parse: (content: string) => unknown): Record | undefined { + try { + if (statSync(file).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): string { + const source = `${member}/src`; + return existsSync(join(root, source)) ? source : member; +} + +function typeScriptCandidates(root: string): string[] { + return [...workspaceMemberDirectories(root), ...depthOnePackages(root)] + .map((member) => preferSrcSubdir(root, member)); +} + +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; +} + +function pythonCandidates(root: string): string[] { + const project = readRecord(join(root, "pyproject.toml"), parseToml); + const workspace = nestedRecord(project, ["tool", "uv", "workspace"]); + const setuptools = nestedRecord(project, ["tool", "setuptools", "packages", "find"]); + const patterns = [ + ...strings(workspace?.members), + ...strings(workspace?.exclude).map((pattern) => `!${pattern}`), + ]; + const members = expandMemberGlobs(root, patterns) + .filter((member) => hasManifest(root, member, "pyproject.toml")) + .map((member) => preferSrcSubdir(root, member)); + return [...members, ...strings(setuptools?.where)]; +} + +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) + : 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/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/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..8be1126 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,187 @@ 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"]); + const application = "src/app/**/{page,layout,template,loading,error,global-error,not-found,default,route}.{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}", + ])); + expect(generated.project).toEqual(expect.arrayContaining([ + "next.config.mjs", "src/payload.config.ts", "vitest.config.ts", + "playwright.config.ts", application, "src/middleware.{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}.{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/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/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..f000576 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,30 @@ 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); + }); }); diff --git a/tests/core/config/load.test.ts b/tests/core/config/load.test.ts index f23daaf..a345872 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, payloadNextExclusions } from "../../../src/core/config/exclusions.ts"; const fixtureRoot = join(process.cwd(), "tests/fixtures/config"); @@ -205,6 +206,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 +396,99 @@ 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/Next profile", () => { + it("adds generated exclusions and a notice and changes the config hash", () => { + 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/**", + ...payloadNextExclusions(["src"]), + ]); + expect(config.notices).toContain( + "Payload/Next profile: excluding generated Payload types, import map, admin route group, " + + "migrations, seed data, and Next build output", + ); + expect(config.configHash).not.toBe(ordinaryHash); + }); + }); +}); + +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": "{}", + "next.config.mjs": "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/Next profile: excluding generated Payload types, import map, admin route group, " + + "migrations, seed data, and Next build output", + ); + }); + }); }); describe("minified source discovery", () => { diff --git a/tests/core/config/workspaces.test.ts b/tests/core/config/workspaces.test.ts new file mode 100644 index 0000000..f5f66a3 --- /dev/null +++ b/tests/core/config/workspaces.test.ts @@ -0,0 +1,246 @@ +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"])); + }); + + 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/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..0e58bb2 100644 --- a/tests/scripts/integration.test.ts +++ b/tests/scripts/integration.test.ts @@ -18,6 +18,7 @@ import { formatResultTable, dockerArgs, MUTATIONS, + mutationEvidenceOk, plannedDependencyInstalls, shouldCopyFixturePath, } from "../../scripts/integration.ts"; @@ -33,27 +34,35 @@ 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"], - }, - ]); - expect(plannedDependencyInstalls({ tsNodeModules: true, phpVendor: true })).toEqual([]); +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 dependencyInstallCases = [ + [{ tsNodeModules: false, phpVendor: false, payloadNodeModules: false }, [tsInstall, phpInstall, payloadInstall]], + [{ tsNodeModules: true, phpVendor: false, payloadNodeModules: false }, [phpInstall, payloadInstall]], + [{ tsNodeModules: true, phpVendor: true, payloadNodeModules: false }, [payloadInstall]], + [{ tsNodeModules: false, phpVendor: false, payloadNodeModules: true }, [tsInstall, phpInstall]], + [{ tsNodeModules: true, phpVendor: false, payloadNodeModules: true }, [phpInstall]], + [{ tsNodeModules: false, phpVendor: true, payloadNodeModules: true }, [tsInstall]], + [{ tsNodeModules: false, phpVendor: true, payloadNodeModules: false }, [tsInstall, payloadInstall]], + [{ tsNodeModules: true, phpVendor: true, payloadNodeModules: true }, []], +] as const; + +it.each(dependencyInstallCases)("plans installs only for missing dependencies (%#)", (state, expected) => { + expect(plannedDependencyInstalls(state)).toEqual(expected); }); function withTempRoot(action: (root: string) => void): void { @@ -112,7 +121,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 +149,40 @@ 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", + })); +}); + +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 = { From 2cc580928eea84cf99912e3de91634457cf87bc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Fri, 18 Sep 2026 08:17:07 +0200 Subject: [PATCH 2/2] fix: keep extension tables private so the repository gate passes ts-unused --- src/core/config/sources.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/config/sources.ts b/src/core/config/sources.ts index 8be6e11..c950f06 100644 --- a/src/core/config/sources.ts +++ b/src/core/config/sources.ts @@ -23,14 +23,14 @@ export 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"], }; -export const DRUPAL_PHP_EXTENSIONS = [".module", ".theme", ".install", ".inc", ".profile", ".engine"] as const; +const DRUPAL_PHP_EXTENSIONS = [".module", ".theme", ".install", ".inc", ".profile", ".engine"] as const; export interface SourceSelection { excludes: readonly string[];