From aedcded41e2a874a4437858e9f65000013e9957e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Wed, 23 Sep 2026 10:48:32 +0200 Subject: [PATCH 1/2] fix: apply findings from the pilot on real Runroom repositories - Image ships `patch` so composer-patches can apply Drupal patches. - deptry passes first-party packages from the Python paths, counts repeated diagnostics, excludes tests with picomatch-compatible regexes, and maps distributions to modules from the consumer .venv using the declared dependency spelling; untrusted names are validated before reaching the map. - TS is detected from depth-1 directories with their own package.json. - Knip runs from the sole owning package when the root has no package.json and adds the global-not-found and next-intl request entries. - Python module-level diagnostics no longer print anchor fallback notices. - JSX files get their own thresholds: complexity 15, 120 lines per function. - POLICY_VERSION 2026-09-23.2; baselines regenerated. - Release 1.3.0. --- Dockerfile | 2 +- docs/checks.md | 4 +- docs/ci.md | 2 + .../quality/php-cognitive-baseline.json | 2 +- .../quality/php-complexity-baseline.json | 2 +- .../quality/php-duplication-baseline.json | 2 +- .../quality/web-duplication-baseline.json | 2 +- .../quality/ts-cognitive-baseline.json | 2 +- .../quality/ts-complexity-baseline.json | 2 +- .../quality/ts-duplication-baseline.json | 2 +- .../quality/ts-unused-baseline.json | 2 +- .../quality/ts-cognitive-baseline.json | 2 +- .../quality/ts-complexity-baseline.json | 2 +- .../quality/ts-duplication-baseline.json | 2 +- .../quality/ts-unused-baseline.json | 2 +- .../quality/php-architecture-baseline.json | 2 +- .../quality/php-cognitive-baseline.json | 2 +- .../quality/php-complexity-baseline.json | 2 +- .../quality/php-duplication-baseline.json | 2 +- .../php-unused-composer-unused-baseline.json | 2 +- .../quality/php-unused-phpstan-baseline.json | 2 +- .../php-unused-require-checker-baseline.json | 2 +- .../quality/python-architecture-baseline.json | 2 +- .../quality/python-cognitive-baseline.json | 2 +- .../quality/python-complexity-baseline.json | 2 +- .../quality/python-duplication-baseline.json | 2 +- .../python-unused-deptry-baseline.json | 2 +- .../python-unused-vulture-baseline.json | 2 +- .../quality/python-architecture-baseline.json | 2 +- .../quality/python-cognitive-baseline.json | 2 +- .../quality/python-complexity-baseline.json | 2 +- .../quality/python-duplication-baseline.json | 2 +- .../python-unused-deptry-baseline.json | 2 +- .../python-unused-vulture-baseline.json | 2 +- .../quality/ts-architecture-baseline.json | 2 +- .../quality/ts-cognitive-baseline.json | 2 +- .../quality/ts-complexity-baseline.json | 2 +- .../quality/ts-duplication-baseline.json | 2 +- .../quality/ts-unused-baseline.json | 2 +- .../quality/web-duplication-baseline.json | 2 +- launcher/package.json | 2 +- package.json | 2 +- quality/ts-architecture-baseline.json | 2 +- quality/ts-cognitive-baseline.json | 2 +- quality/ts-complexity-baseline.json | 2 +- quality/ts-duplication-baseline.json | 6 +- quality/ts-unused-baseline.json | 2 +- src/checks/python/deptry.ts | 311 +++++++++++++++++- src/checks/shared/anchored.ts | 5 +- src/checks/ts/knip.ts | 122 +++++-- src/checks/ts/oxlint.ts | 34 +- src/cli/version.ts | 2 +- src/core/anchor/service.ts | 27 +- src/core/config/detect.ts | 11 +- src/core/config/load.ts | 2 +- src/core/config/policy.ts | 3 +- src/core/config/workspaces.ts | 6 +- tests/checks/python/deptry.test.ts | 160 ++++++++- tests/checks/python/ruff.test.ts | 10 + tests/checks/python/vulture.test.ts | 27 ++ tests/checks/ts/knip-plugins.test.ts | 1 + tests/checks/ts/knip.test.ts | 148 ++++++++- tests/checks/ts/oxlint.test.ts | 28 ++ tests/core/config/hash.test.ts | 2 +- tests/core/config/load.test.ts | 44 +++ 65 files changed, 924 insertions(+), 119 deletions(-) diff --git a/Dockerfile b/Dockerfile index a0ac856..7c6bcf4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ php8.4-pgsql php8.4-sqlite3 php8.4-soap php8.4-xsl php8.4-imagick \ php8.4-memcached php8.4-amqp php8.4-mongodb php8.4-igbinary php8.4-msgpack \ php8.4-uuid php8.4-yaml php8.4-ldap php8.4-gmp php8.4-bz2 php8.4-mailparse \ - php8.4-ds unzip composer python3 python3-venv git ca-certificates curl \ + php8.4-ds unzip composer python3 python3-venv git patch ca-certificates curl \ && rm -rf /var/lib/apt/lists/* COPY docker/php/99-code-quality.ini /etc/php/8.4/cli/conf.d/99-code-quality.ini # TS/JS tools (typescript is required by dependency-cruiser to resolve TS sources) diff --git a/docs/checks.md b/docs/checks.md index bde0b86..5359885 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -10,7 +10,7 @@ The image owns policy. Consumer configuration can select languages, paths, exclu | Check | Language | Tool | Fixed threshold and semantics | | --- | --- | --- | --- | -| Complexity | TS/JS | Oxlint 1.82.0 | Complexity 10; 60 lines per function excluding blanks and comments; 4 parameters; depth 3; 3 nested callbacks. Only warning diagnostics are accepted. | +| Complexity | TS/JS | Oxlint 1.82.0 | Complexity 10 and 60 lines per function; `.tsx`/`.jsx` use 15 and 120. Lines exclude blanks and comments. Parameters 4; depth 3; nested callbacks 3. Only warning diagnostics are accepted. | | Complexity | PHP | PHPCS 4.0.4, Slevomat 8.31.1, Runroom standard | Cyclomatic complexity 10; nesting 3; function length 60; parameters 4. | | Complexity | Python | Ruff 0.16.6 | C901 10; PLR0915 60 statements; PLR0913 4 arguments; PLR1702 3 nested blocks. | | Cognitive | TS/JS | Fallow 3.23.0 | Values greater than 15 block. | @@ -27,6 +27,8 @@ The image owns policy. Consumer configuration can select languages, paths, exclu Built-in test, generated-file, and framework exclusions apply before these checks. See [Configuration](configuration.md#built-in-exclusions). +When `.venv` or `venv` is present, deptry reads installed distribution metadata there to resolve package names to import modules; symlinked environment directories are ignored. Python dependency setup should install into the consumer virtualenv, for example with `uv sync` or `pip install` targeting `.venv`. + ## v1 tool pins `versions` prints the 15 check-tool binary pins, two library pins, and the image Runtime group; `doctor` verifies the pinned tools and runtime availability. diff --git a/docs/ci.md b/docs/ci.md index 27ffc18..46259e9 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -48,6 +48,8 @@ jobs: For npm, use `setup: npm ci`. For Composer, use `setup: composer install` or a suitable non-interactive variant such as `setup: composer install --no-interaction`. +Setup is one shell command and may chain installers, for example `setup: composer install --no-interaction && npm ci`. Drupal and Symfony repositories with a frontend need both dependency trees because ts-unused requires `node_modules/`. The image includes `patch`, so Composer patch plugins used by Drupal can apply project patches. + The image supports these dependency setup recipes: - `setup: pnpm install --frozen-lockfile` — Corepack honors the repository's `packageManager`; pnpm 10.17.1 and 11.5.2 are pre-cached, and other pins download on demand. diff --git a/fixtures/drupal-project/quality/php-cognitive-baseline.json b/fixtures/drupal-project/quality/php-cognitive-baseline.json index 9ab7c8b..8640984 100644 --- a/fixtures/drupal-project/quality/php-cognitive-baseline.json +++ b/fixtures/drupal-project/quality/php-cognitive-baseline.json @@ -1,6 +1,6 @@ { "version": 1, "tool": "phpcs@4.0.4", - "configHash": "788bbffbbd6d43bd4fcab5854d279bebdf4215471f87f6d6b60cd83bacc49260", + "configHash": "53123ba920bf014cf44db8e33125cc10047ac88c9d3b49e266eb479eb4758c6a", "findings": {} } diff --git a/fixtures/drupal-project/quality/php-complexity-baseline.json b/fixtures/drupal-project/quality/php-complexity-baseline.json index 9ab7c8b..8640984 100644 --- a/fixtures/drupal-project/quality/php-complexity-baseline.json +++ b/fixtures/drupal-project/quality/php-complexity-baseline.json @@ -1,6 +1,6 @@ { "version": 1, "tool": "phpcs@4.0.4", - "configHash": "788bbffbbd6d43bd4fcab5854d279bebdf4215471f87f6d6b60cd83bacc49260", + "configHash": "53123ba920bf014cf44db8e33125cc10047ac88c9d3b49e266eb479eb4758c6a", "findings": {} } diff --git a/fixtures/drupal-project/quality/php-duplication-baseline.json b/fixtures/drupal-project/quality/php-duplication-baseline.json index dfc2ad3..0ccfb1c 100644 --- a/fixtures/drupal-project/quality/php-duplication-baseline.json +++ b/fixtures/drupal-project/quality/php-duplication-baseline.json @@ -1,6 +1,6 @@ { "version": 1, "tool": "jscpd@5.2.0", - "configHash": "788bbffbbd6d43bd4fcab5854d279bebdf4215471f87f6d6b60cd83bacc49260", + "configHash": "53123ba920bf014cf44db8e33125cc10047ac88c9d3b49e266eb479eb4758c6a", "findings": {} } diff --git a/fixtures/drupal-project/quality/web-duplication-baseline.json b/fixtures/drupal-project/quality/web-duplication-baseline.json index dfc2ad3..0ccfb1c 100644 --- a/fixtures/drupal-project/quality/web-duplication-baseline.json +++ b/fixtures/drupal-project/quality/web-duplication-baseline.json @@ -1,6 +1,6 @@ { "version": 1, "tool": "jscpd@5.2.0", - "configHash": "788bbffbbd6d43bd4fcab5854d279bebdf4215471f87f6d6b60cd83bacc49260", + "configHash": "53123ba920bf014cf44db8e33125cc10047ac88c9d3b49e266eb479eb4758c6a", "findings": {} } diff --git a/fixtures/monorepo-project/quality/ts-cognitive-baseline.json b/fixtures/monorepo-project/quality/ts-cognitive-baseline.json index 69c7fba..348461e 100644 --- a/fixtures/monorepo-project/quality/ts-cognitive-baseline.json +++ b/fixtures/monorepo-project/quality/ts-cognitive-baseline.json @@ -1,6 +1,6 @@ { "version": 1, "tool": "fallow@3.23.0", - "configHash": "88b87f9ea62ee2e3991852bd7460e0a27c882032757df6cc05de9aca45a891f5", + "configHash": "00bd4808f9cac65eb405e457499b1f41c7b54da398c2040234612ae1058420ce", "findings": {} } diff --git a/fixtures/monorepo-project/quality/ts-complexity-baseline.json b/fixtures/monorepo-project/quality/ts-complexity-baseline.json index 9b72f40..973b259 100644 --- a/fixtures/monorepo-project/quality/ts-complexity-baseline.json +++ b/fixtures/monorepo-project/quality/ts-complexity-baseline.json @@ -1,6 +1,6 @@ { "version": 1, "tool": "oxlint@1.82.0", - "configHash": "88b87f9ea62ee2e3991852bd7460e0a27c882032757df6cc05de9aca45a891f5", + "configHash": "00bd4808f9cac65eb405e457499b1f41c7b54da398c2040234612ae1058420ce", "findings": {} } diff --git a/fixtures/monorepo-project/quality/ts-duplication-baseline.json b/fixtures/monorepo-project/quality/ts-duplication-baseline.json index bc1b2d2..5000860 100644 --- a/fixtures/monorepo-project/quality/ts-duplication-baseline.json +++ b/fixtures/monorepo-project/quality/ts-duplication-baseline.json @@ -1,6 +1,6 @@ { "version": 1, "tool": "jscpd@5.2.0", - "configHash": "88b87f9ea62ee2e3991852bd7460e0a27c882032757df6cc05de9aca45a891f5", + "configHash": "00bd4808f9cac65eb405e457499b1f41c7b54da398c2040234612ae1058420ce", "findings": {} } diff --git a/fixtures/monorepo-project/quality/ts-unused-baseline.json b/fixtures/monorepo-project/quality/ts-unused-baseline.json index d666c26..f104f44 100644 --- a/fixtures/monorepo-project/quality/ts-unused-baseline.json +++ b/fixtures/monorepo-project/quality/ts-unused-baseline.json @@ -1,6 +1,6 @@ { "version": 1, "tool": "knip@6.35.1", - "configHash": "88b87f9ea62ee2e3991852bd7460e0a27c882032757df6cc05de9aca45a891f5", + "configHash": "00bd4808f9cac65eb405e457499b1f41c7b54da398c2040234612ae1058420ce", "findings": {} } diff --git a/fixtures/payload-project/quality/ts-cognitive-baseline.json b/fixtures/payload-project/quality/ts-cognitive-baseline.json index a5489eb..e2e5269 100644 --- a/fixtures/payload-project/quality/ts-cognitive-baseline.json +++ b/fixtures/payload-project/quality/ts-cognitive-baseline.json @@ -1,6 +1,6 @@ { "version": 1, "tool": "fallow@3.23.0", - "configHash": "beaa41f62a18fac8e6869e8c035c9dfc258c4aa2e57aaf93f0db204cf6ff9f00", + "configHash": "867bd69f8d32bc03219a1c955e1b6ce3ffbf8f7b2703c35e534c3f4777e81d1d", "findings": {} } diff --git a/fixtures/payload-project/quality/ts-complexity-baseline.json b/fixtures/payload-project/quality/ts-complexity-baseline.json index bc45494..79a717d 100644 --- a/fixtures/payload-project/quality/ts-complexity-baseline.json +++ b/fixtures/payload-project/quality/ts-complexity-baseline.json @@ -1,6 +1,6 @@ { "version": 1, "tool": "oxlint@1.82.0", - "configHash": "beaa41f62a18fac8e6869e8c035c9dfc258c4aa2e57aaf93f0db204cf6ff9f00", + "configHash": "867bd69f8d32bc03219a1c955e1b6ce3ffbf8f7b2703c35e534c3f4777e81d1d", "findings": {} } diff --git a/fixtures/payload-project/quality/ts-duplication-baseline.json b/fixtures/payload-project/quality/ts-duplication-baseline.json index 462a5b8..43d6a24 100644 --- a/fixtures/payload-project/quality/ts-duplication-baseline.json +++ b/fixtures/payload-project/quality/ts-duplication-baseline.json @@ -1,6 +1,6 @@ { "version": 1, "tool": "jscpd@5.2.0", - "configHash": "beaa41f62a18fac8e6869e8c035c9dfc258c4aa2e57aaf93f0db204cf6ff9f00", + "configHash": "867bd69f8d32bc03219a1c955e1b6ce3ffbf8f7b2703c35e534c3f4777e81d1d", "findings": {} } diff --git a/fixtures/payload-project/quality/ts-unused-baseline.json b/fixtures/payload-project/quality/ts-unused-baseline.json index 1d76605..b8bba27 100644 --- a/fixtures/payload-project/quality/ts-unused-baseline.json +++ b/fixtures/payload-project/quality/ts-unused-baseline.json @@ -1,6 +1,6 @@ { "version": 1, "tool": "knip@6.35.1", - "configHash": "beaa41f62a18fac8e6869e8c035c9dfc258c4aa2e57aaf93f0db204cf6ff9f00", + "configHash": "867bd69f8d32bc03219a1c955e1b6ce3ffbf8f7b2703c35e534c3f4777e81d1d", "findings": {} } diff --git a/fixtures/php-project/quality/php-architecture-baseline.json b/fixtures/php-project/quality/php-architecture-baseline.json index 3bfa68c..2234d3f 100644 --- a/fixtures/php-project/quality/php-architecture-baseline.json +++ b/fixtures/php-project/quality/php-architecture-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "deptrac@4.7.1", - "configHash": "289388ed5f404165811c82fc643dc408cd8010a59eff9dc59e1c78db8c0a1a88", + "configHash": "29ac30fb881c7d568a321486efec2918d30d7bc513767a6849b9b85d64767409", "findings": { "src/Domain/Entity.php | deptrac:Domain-on-Infrastructure | /class:Entity/method:save": 1, "src/Infrastructure/Repo.php | deptrac:Infrastructure-on-Domain | /class:Repo/method:store": 1 diff --git a/fixtures/php-project/quality/php-cognitive-baseline.json b/fixtures/php-project/quality/php-cognitive-baseline.json index 343a782..2216031 100644 --- a/fixtures/php-project/quality/php-cognitive-baseline.json +++ b/fixtures/php-project/quality/php-cognitive-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "phpcs@4.0.4", - "configHash": "289388ed5f404165811c82fc643dc408cd8010a59eff9dc59e1c78db8c0a1a88", + "configHash": "29ac30fb881c7d568a321486efec2918d30d7bc513767a6849b9b85d64767409", "findings": { "src/Service/Complex.php | cognitive-complexity | /class:Complex/method:run": 17 } diff --git a/fixtures/php-project/quality/php-complexity-baseline.json b/fixtures/php-project/quality/php-complexity-baseline.json index dcbfb4e..2b2cf3a 100644 --- a/fixtures/php-project/quality/php-complexity-baseline.json +++ b/fixtures/php-project/quality/php-complexity-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "phpcs@4.0.4", - "configHash": "289388ed5f404165811c82fc643dc408cd8010a59eff9dc59e1c78db8c0a1a88", + "configHash": "29ac30fb881c7d568a321486efec2918d30d7bc513767a6849b9b85d64767409", "findings": { "src/Service/Complex.php | Generic.Metrics.CyclomaticComplexity | /class:Complex/method:run": 12, "src/Service/Complex.php | Generic.Metrics.NestingLevel | /class:Complex/method:run": 4, diff --git a/fixtures/php-project/quality/php-duplication-baseline.json b/fixtures/php-project/quality/php-duplication-baseline.json index a3800ed..88d035d 100644 --- a/fixtures/php-project/quality/php-duplication-baseline.json +++ b/fixtures/php-project/quality/php-duplication-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "jscpd@5.2.0", - "configHash": "289388ed5f404165811c82fc643dc408cd8010a59eff9dc59e1c78db8c0a1a88", + "configHash": "29ac30fb881c7d568a321486efec2918d30d7bc513767a6849b9b85d64767409", "findings": { "4e1f30eae51b9473": 1 } diff --git a/fixtures/php-project/quality/php-unused-composer-unused-baseline.json b/fixtures/php-project/quality/php-unused-composer-unused-baseline.json index e8583d6..853aaca 100644 --- a/fixtures/php-project/quality/php-unused-composer-unused-baseline.json +++ b/fixtures/php-project/quality/php-unused-composer-unused-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "composer-unused@0.9.6", - "configHash": "289388ed5f404165811c82fc643dc408cd8010a59eff9dc59e1c78db8c0a1a88", + "configHash": "29ac30fb881c7d568a321486efec2918d30d7bc513767a6849b9b85d64767409", "findings": { "composer.json | unused-package | psr/log": 1, "composer.json | unused-package | symfony/yaml": 1 diff --git a/fixtures/php-project/quality/php-unused-phpstan-baseline.json b/fixtures/php-project/quality/php-unused-phpstan-baseline.json index b56b051..3385f1e 100644 --- a/fixtures/php-project/quality/php-unused-phpstan-baseline.json +++ b/fixtures/php-project/quality/php-unused-phpstan-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "phpstan@2.2.13", - "configHash": "289388ed5f404165811c82fc643dc408cd8010a59eff9dc59e1c78db8c0a1a88", + "configHash": "29ac30fb881c7d568a321486efec2918d30d7bc513767a6849b9b85d64767409", "findings": { "src/Domain/Entity.php | dead-code | /class:Entity/method:save#shipmonk.deadMethod#save": 1, "src/Service/Complex.php | dead-code | /class:Complex/method:run#shipmonk.deadMethod#run": 1, diff --git a/fixtures/php-project/quality/php-unused-require-checker-baseline.json b/fixtures/php-project/quality/php-unused-require-checker-baseline.json index d638ad0..118b0a6 100644 --- a/fixtures/php-project/quality/php-unused-require-checker-baseline.json +++ b/fixtures/php-project/quality/php-unused-require-checker-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "composer-require-checker@4.24.0", - "configHash": "289388ed5f404165811c82fc643dc408cd8010a59eff9dc59e1c78db8c0a1a88", + "configHash": "29ac30fb881c7d568a321486efec2918d30d7bc513767a6849b9b85d64767409", "findings": { "composer.json | unknown-symbol | Composer\\InstalledVersions": 1 } diff --git a/fixtures/python-project/quality/python-architecture-baseline.json b/fixtures/python-project/quality/python-architecture-baseline.json index 7272d2b..fda1aad 100644 --- a/fixtures/python-project/quality/python-architecture-baseline.json +++ b/fixtures/python-project/quality/python-architecture-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "lint-imports@2.15", - "configHash": "1864e3ffa415a8a465e63df9e079b80fa7770548a924223fd029429878f9b2f0", + "configHash": "f127c3795c6e86c03c5c17e708a386d6a4ca9ea669d3b39e551a3ae768e583b6", "findings": { "src/demo_app/domain/model.py | import-linter:Layers | demo_app.infra.db": 1 } diff --git a/fixtures/python-project/quality/python-cognitive-baseline.json b/fixtures/python-project/quality/python-cognitive-baseline.json index 90cccd4..5bf49b1 100644 --- a/fixtures/python-project/quality/python-cognitive-baseline.json +++ b/fixtures/python-project/quality/python-cognitive-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "complexipy@8.0.1", - "configHash": "1864e3ffa415a8a465e63df9e079b80fa7770548a924223fd029429878f9b2f0", + "configHash": "f127c3795c6e86c03c5c17e708a386d6a4ca9ea669d3b39e551a3ae768e583b6", "findings": { "src/demo_app/complex.py | cognitive-complexity | /function:busy": 20 } diff --git a/fixtures/python-project/quality/python-complexity-baseline.json b/fixtures/python-project/quality/python-complexity-baseline.json index a62a8a4..72f3d8c 100644 --- a/fixtures/python-project/quality/python-complexity-baseline.json +++ b/fixtures/python-project/quality/python-complexity-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "ruff@0.16.6", - "configHash": "1864e3ffa415a8a465e63df9e079b80fa7770548a924223fd029429878f9b2f0", + "configHash": "f127c3795c6e86c03c5c17e708a386d6a4ca9ea669d3b39e551a3ae768e583b6", "findings": { "src/demo_app/complex.py | C901 | /function:busy": 12, "src/demo_app/complex.py | PLR0913 | /function:busy": 5 diff --git a/fixtures/python-project/quality/python-duplication-baseline.json b/fixtures/python-project/quality/python-duplication-baseline.json index 762a62a..1fbd724 100644 --- a/fixtures/python-project/quality/python-duplication-baseline.json +++ b/fixtures/python-project/quality/python-duplication-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "jscpd@5.2.0", - "configHash": "1864e3ffa415a8a465e63df9e079b80fa7770548a924223fd029429878f9b2f0", + "configHash": "f127c3795c6e86c03c5c17e708a386d6a4ca9ea669d3b39e551a3ae768e583b6", "findings": { "534ad38acbaa6ef4": 1 } diff --git a/fixtures/python-project/quality/python-unused-deptry-baseline.json b/fixtures/python-project/quality/python-unused-deptry-baseline.json index 3f9c418..51d5101 100644 --- a/fixtures/python-project/quality/python-unused-deptry-baseline.json +++ b/fixtures/python-project/quality/python-unused-deptry-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "deptry@0.25.1", - "configHash": "1864e3ffa415a8a465e63df9e079b80fa7770548a924223fd029429878f9b2f0", + "configHash": "f127c3795c6e86c03c5c17e708a386d6a4ca9ea669d3b39e551a3ae768e583b6", "findings": { "pyproject.toml | deptry-DEP002 | requests": 1 } diff --git a/fixtures/python-project/quality/python-unused-vulture-baseline.json b/fixtures/python-project/quality/python-unused-vulture-baseline.json index e74aa83..0c19cd8 100644 --- a/fixtures/python-project/quality/python-unused-vulture-baseline.json +++ b/fixtures/python-project/quality/python-unused-vulture-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "vulture@2.16", - "configHash": "1864e3ffa415a8a465e63df9e079b80fa7770548a924223fd029429878f9b2f0", + "configHash": "f127c3795c6e86c03c5c17e708a386d6a4ca9ea669d3b39e551a3ae768e583b6", "findings": { "src/demo_app/client.py | vulture-function | /function:get_client#get_client": 1, "src/demo_app/complex.py | vulture-function | /function:busy#busy": 1, diff --git a/fixtures/python314-project/quality/python-architecture-baseline.json b/fixtures/python314-project/quality/python-architecture-baseline.json index e0b076a..56e2a57 100644 --- a/fixtures/python314-project/quality/python-architecture-baseline.json +++ b/fixtures/python314-project/quality/python-architecture-baseline.json @@ -1,6 +1,6 @@ { "version": 1, "tool": "lint-imports@2.15", - "configHash": "d6504752db11a714a47a836e496cdf929c1a2b3c6f601a789f7ef0679aedc2bb", + "configHash": "056f6c0e10b0196b5d5258f47bc47bba24ce689a840b404502460ce88043efe6", "findings": {} } diff --git a/fixtures/python314-project/quality/python-cognitive-baseline.json b/fixtures/python314-project/quality/python-cognitive-baseline.json index 9a01a14..8792d65 100644 --- a/fixtures/python314-project/quality/python-cognitive-baseline.json +++ b/fixtures/python314-project/quality/python-cognitive-baseline.json @@ -1,6 +1,6 @@ { "version": 1, "tool": "complexipy@8.0.1", - "configHash": "d6504752db11a714a47a836e496cdf929c1a2b3c6f601a789f7ef0679aedc2bb", + "configHash": "056f6c0e10b0196b5d5258f47bc47bba24ce689a840b404502460ce88043efe6", "findings": {} } diff --git a/fixtures/python314-project/quality/python-complexity-baseline.json b/fixtures/python314-project/quality/python-complexity-baseline.json index 0befa9e..0dac7f1 100644 --- a/fixtures/python314-project/quality/python-complexity-baseline.json +++ b/fixtures/python314-project/quality/python-complexity-baseline.json @@ -1,6 +1,6 @@ { "version": 1, "tool": "ruff@0.16.6", - "configHash": "d6504752db11a714a47a836e496cdf929c1a2b3c6f601a789f7ef0679aedc2bb", + "configHash": "056f6c0e10b0196b5d5258f47bc47bba24ce689a840b404502460ce88043efe6", "findings": {} } diff --git a/fixtures/python314-project/quality/python-duplication-baseline.json b/fixtures/python314-project/quality/python-duplication-baseline.json index 21749ed..c27a7e1 100644 --- a/fixtures/python314-project/quality/python-duplication-baseline.json +++ b/fixtures/python314-project/quality/python-duplication-baseline.json @@ -1,6 +1,6 @@ { "version": 1, "tool": "jscpd@5.2.0", - "configHash": "d6504752db11a714a47a836e496cdf929c1a2b3c6f601a789f7ef0679aedc2bb", + "configHash": "056f6c0e10b0196b5d5258f47bc47bba24ce689a840b404502460ce88043efe6", "findings": {} } diff --git a/fixtures/python314-project/quality/python-unused-deptry-baseline.json b/fixtures/python314-project/quality/python-unused-deptry-baseline.json index 2c44e8c..dee6381 100644 --- a/fixtures/python314-project/quality/python-unused-deptry-baseline.json +++ b/fixtures/python314-project/quality/python-unused-deptry-baseline.json @@ -1,6 +1,6 @@ { "version": 1, "tool": "deptry@0.25.1", - "configHash": "d6504752db11a714a47a836e496cdf929c1a2b3c6f601a789f7ef0679aedc2bb", + "configHash": "056f6c0e10b0196b5d5258f47bc47bba24ce689a840b404502460ce88043efe6", "findings": {} } diff --git a/fixtures/python314-project/quality/python-unused-vulture-baseline.json b/fixtures/python314-project/quality/python-unused-vulture-baseline.json index 5c2f667..499d1b6 100644 --- a/fixtures/python314-project/quality/python-unused-vulture-baseline.json +++ b/fixtures/python314-project/quality/python-unused-vulture-baseline.json @@ -1,6 +1,6 @@ { "version": 1, "tool": "vulture@2.16", - "configHash": "d6504752db11a714a47a836e496cdf929c1a2b3c6f601a789f7ef0679aedc2bb", + "configHash": "056f6c0e10b0196b5d5258f47bc47bba24ce689a840b404502460ce88043efe6", "findings": {} } diff --git a/fixtures/ts-project/quality/ts-architecture-baseline.json b/fixtures/ts-project/quality/ts-architecture-baseline.json index cfae244..a719696 100644 --- a/fixtures/ts-project/quality/ts-architecture-baseline.json +++ b/fixtures/ts-project/quality/ts-architecture-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "depcruise@18.2.0", - "configHash": "09cea93df07b517f2cc1af3418a1bcca1128f0b056897ba948a03afad4aa1609", + "configHash": "61d7745be542a70d4ba2343dd4ab86580e5b290f95f733782b2c43d0923fc861", "findings": { "src/ui/view.ts | no-ui-to-db | src/db/repo.ts": 1 } diff --git a/fixtures/ts-project/quality/ts-cognitive-baseline.json b/fixtures/ts-project/quality/ts-cognitive-baseline.json index cbf5e86..ee1270f 100644 --- a/fixtures/ts-project/quality/ts-cognitive-baseline.json +++ b/fixtures/ts-project/quality/ts-cognitive-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "fallow@3.23.0", - "configHash": "09cea93df07b517f2cc1af3418a1bcca1128f0b056897ba948a03afad4aa1609", + "configHash": "61d7745be542a70d4ba2343dd4ab86580e5b290f95f733782b2c43d0923fc861", "findings": { "src/cognitive.ts | cognitive-complexity | /function:nested": 21, "src/complexity.ts | cognitive-complexity | /function:busy": 21 diff --git a/fixtures/ts-project/quality/ts-complexity-baseline.json b/fixtures/ts-project/quality/ts-complexity-baseline.json index 1483af5..e6d0ccd 100644 --- a/fixtures/ts-project/quality/ts-complexity-baseline.json +++ b/fixtures/ts-project/quality/ts-complexity-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "oxlint@1.82.0", - "configHash": "09cea93df07b517f2cc1af3418a1bcca1128f0b056897ba948a03afad4aa1609", + "configHash": "61d7745be542a70d4ba2343dd4ab86580e5b290f95f733782b2c43d0923fc861", "findings": { "src/cognitive.ts | eslint(max-depth) | /function:nested/if/block/if/block/if/block/if": 4, "src/cognitive.ts | eslint(max-depth) | /function:nested/if/block/if/block/if/block/if/block/if": 5, diff --git a/fixtures/ts-project/quality/ts-duplication-baseline.json b/fixtures/ts-project/quality/ts-duplication-baseline.json index 05e9609..768f479 100644 --- a/fixtures/ts-project/quality/ts-duplication-baseline.json +++ b/fixtures/ts-project/quality/ts-duplication-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "jscpd@5.2.0", - "configHash": "09cea93df07b517f2cc1af3418a1bcca1128f0b056897ba948a03afad4aa1609", + "configHash": "61d7745be542a70d4ba2343dd4ab86580e5b290f95f733782b2c43d0923fc861", "findings": { "52345a7776aa96cf": 1, "f8c7fd6b65e1d3fa": 1 diff --git a/fixtures/ts-project/quality/ts-unused-baseline.json b/fixtures/ts-project/quality/ts-unused-baseline.json index 407fb2f..c40a65f 100644 --- a/fixtures/ts-project/quality/ts-unused-baseline.json +++ b/fixtures/ts-project/quality/ts-unused-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "knip@6.35.1", - "configHash": "09cea93df07b517f2cc1af3418a1bcca1128f0b056897ba948a03afad4aa1609", + "configHash": "61d7745be542a70d4ba2343dd4ab86580e5b290f95f733782b2c43d0923fc861", "findings": { "package.json | unused-dependency | left-pad": 1, "src/cognitive.ts | unused-file | src/cognitive.ts": 1, diff --git a/fixtures/web-project/quality/web-duplication-baseline.json b/fixtures/web-project/quality/web-duplication-baseline.json index cd56842..742559c 100644 --- a/fixtures/web-project/quality/web-duplication-baseline.json +++ b/fixtures/web-project/quality/web-duplication-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "jscpd@5.2.0", - "configHash": "81a07efba6c46a7fab23adec58c898034dab35b276ff98d48bad64dba1395541", + "configHash": "ad04ca85d4d964bd41ea99e7d981446c102d2f57ea78cfa1f539f1db5a803c7c", "findings": { "49b1e091be111932": 1, "bf23efa9a8a4e58b": 1 diff --git a/launcher/package.json b/launcher/package.json index 8d6e172..1511ebc 100644 --- a/launcher/package.json +++ b/launcher/package.json @@ -1,6 +1,6 @@ { "name": "@runroom/code-quality", - "version": "1.2.0", + "version": "1.3.0", "description": "Runroom incremental quality gate — runs the pinned ghcr.io/runroom/code-quality image", "type": "module", "scripts": { diff --git a/package.json b/package.json index da42a79..7c6c0ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@runroom/code-quality", - "version": "1.2.0", + "version": "1.3.0", "private": true, "type": "module", "engines": { "node": ">=24" }, diff --git a/quality/ts-architecture-baseline.json b/quality/ts-architecture-baseline.json index 1a4a76a..e7d692f 100644 --- a/quality/ts-architecture-baseline.json +++ b/quality/ts-architecture-baseline.json @@ -1,6 +1,6 @@ { "version": 1, "tool": "depcruise@18.2.0", - "configHash": "5efc8f54a48ed2847d32f8e54797f7fbecb80d40283bfa6a5b922fd4503610db", + "configHash": "0df258234321ea227301beb0f76fddbf18654e73d4b97510dfa78cdea6be43d2", "findings": {} } diff --git a/quality/ts-cognitive-baseline.json b/quality/ts-cognitive-baseline.json index a80bec9..83ce6ab 100644 --- a/quality/ts-cognitive-baseline.json +++ b/quality/ts-cognitive-baseline.json @@ -1,6 +1,6 @@ { "version": 1, "tool": "fallow@3.23.0", - "configHash": "5efc8f54a48ed2847d32f8e54797f7fbecb80d40283bfa6a5b922fd4503610db", + "configHash": "0df258234321ea227301beb0f76fddbf18654e73d4b97510dfa78cdea6be43d2", "findings": {} } diff --git a/quality/ts-complexity-baseline.json b/quality/ts-complexity-baseline.json index 544cb0e..bc39587 100644 --- a/quality/ts-complexity-baseline.json +++ b/quality/ts-complexity-baseline.json @@ -1,6 +1,6 @@ { "version": 1, "tool": "oxlint@1.82.0", - "configHash": "5efc8f54a48ed2847d32f8e54797f7fbecb80d40283bfa6a5b922fd4503610db", + "configHash": "0df258234321ea227301beb0f76fddbf18654e73d4b97510dfa78cdea6be43d2", "findings": {} } diff --git a/quality/ts-duplication-baseline.json b/quality/ts-duplication-baseline.json index 19bdf78..76e09b2 100644 --- a/quality/ts-duplication-baseline.json +++ b/quality/ts-duplication-baseline.json @@ -1,6 +1,8 @@ { "version": 1, "tool": "jscpd@5.2.0", - "configHash": "5efc8f54a48ed2847d32f8e54797f7fbecb80d40283bfa6a5b922fd4503610db", - "findings": {} + "configHash": "0df258234321ea227301beb0f76fddbf18654e73d4b97510dfa78cdea6be43d2", + "findings": { + "e2f034c2f4a637b9": 1 + } } diff --git a/quality/ts-unused-baseline.json b/quality/ts-unused-baseline.json index dd880bc..ed9b4d0 100644 --- a/quality/ts-unused-baseline.json +++ b/quality/ts-unused-baseline.json @@ -1,7 +1,7 @@ { "version": 1, "tool": "knip@6.35.1", - "configHash": "5efc8f54a48ed2847d32f8e54797f7fbecb80d40283bfa6a5b922fd4503610db", + "configHash": "0df258234321ea227301beb0f76fddbf18654e73d4b97510dfa78cdea6be43d2", "findings": { "src/core/config/detect.ts | unused-export | /#NEXT_CONFIGS": 1, "src/core/config/detect.ts | unused-export | /#PAYLOAD_CONFIGS": 1, diff --git a/src/checks/python/deptry.ts b/src/checks/python/deptry.ts index 81f2e05..1d519b0 100644 --- a/src/checks/python/deptry.ts +++ b/src/checks/python/deptry.ts @@ -1,8 +1,13 @@ +import { closeSync, lstatSync, openSync, readFileSync, readSync, readdirSync } from "node:fs"; +import { basename, extname, join } from "node:path"; + import { z } from "zod"; +import { parse as parseToml } from "smol-toml"; import { assertInScope, excludeGlobs, FindingsBuilder, relativizeFrom, toolOutput } from "../shared/kit.ts"; import type { CheckAdapter, CheckContext, ParsedFindings } from "../shared/kit.ts"; import { pythonInvocation } from "./interpreter.ts"; +import { readRecord } from "../../core/config/workspaces.ts"; const reportSchema = z.array(z.looseObject({ error: z.looseObject({ @@ -17,15 +22,33 @@ const reportSchema = z.array(z.looseObject({ }), })); +const MAX_PACKAGE_METADATA_SIZE = 1024 * 1024; +const MAX_METADATA_HEADER_SIZE = 64 * 1024; +const MAX_DISTRIBUTIONS = 2000; +const MODULE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u; +const PACKAGE_NAME = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/u; + function escapeCharacter(character: string): string { return /[\\.^$+{}()|[\]]/u.test(character) ? `\\${character}` : character; } function globSegmentRegex(segment: string): string { let result = ""; - for (const character of segment) { + for (let index = 0; index < segment.length; index += 1) { + const character = segment[index]!; if (character === "*") result += "[^/]*"; else if (character === "?") result += "[^/]"; + else if (character === "{" && segment.indexOf("}", index + 1) > index) { + const end = segment.indexOf("}", index + 1); + const choices = segment.slice(index + 1, end).split(",").map((choice) => + [...choice].map(escapeCharacter).join("")); + result += `(?:${choices.join("|")})`; + index = end; + } else if (character === "[" && segment.indexOf("]", index + 1) > index) { + const end = segment.indexOf("]", index + 1); + result += segment.slice(index, end + 1); + index = end; + } else result += escapeCharacter(character); } return result; @@ -33,24 +56,37 @@ function globSegmentRegex(segment: string): string { export function deptryExcludeRegex(glob: string): string { const segments = glob.split("/"); - const leadingGlobstar = segments[0] === "**"; - const trailingGlobstar = segments.at(-1) === "**"; - const start = leadingGlobstar ? 1 : 0; - const end = trailingGlobstar ? -1 : undefined; - const body = segments.slice(start, end).map(globSegmentRegex).join("/"); - const prefix = leadingGlobstar ? "(^|/)" : "^"; - const suffix = trailingGlobstar ? "(/|$)" : "$"; - return `${prefix}${body}${suffix}`; + if (segments.length === 1 && segments[0] === "**") return "^.*$"; + let result = "^"; + for (let index = 0; index < segments.length; index += 1) { + const segment = segments[index]!; + if (segment === "**") { + if (index === 0) result += "(?:.*/)?"; + else if (index === segments.length - 1) result += "(?:/.*)?"; + else result += "/(?:[^/]+/)*"; + } else { + if (index > 0 && segments[index - 1] !== "**") result += "/"; + result += globSegmentRegex(segment); + } + } + return `${result}$`; } export function deptryFindings(ctx: CheckContext, input: unknown): ParsedFindings { const report = reportSchema.parse(input); const findings = new FindingsBuilder(); + const grouped = new Map[number]; count: number }>(); for (const diagnostic of report) { const file = relativizeFrom(ctx.root, diagnostic.location.file); assertInScope(file, ctx.paths, ["pyproject.toml", "setup.py"]); + const key = `${file}\0${diagnostic.error.code}\0${diagnostic.module}`; + const existing = grouped.get(key); + grouped.set(key, { diagnostic, count: (existing?.count ?? 0) + 1 }); + } + for (const { diagnostic, count } of grouped.values()) { + const file = relativizeFrom(ctx.root, diagnostic.location.file); findings.add({ - file, rule: `deptry-${diagnostic.error.code}`, anchor: diagnostic.module, value: 1, + file, rule: `deptry-${diagnostic.error.code}`, anchor: diagnostic.module, value: count, message: diagnostic.error.message, ...(diagnostic.location.line === null ? {} : { line: diagnostic.location.line }), ...(diagnostic.location.column === null ? {} : { column: diagnostic.location.column }), @@ -59,19 +95,258 @@ export function deptryFindings(ctx: CheckContext, input: unknown): ParsedFinding return findings.build(); } +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 readSmallFile(path: string): string | undefined { + try { + const stats = lstatSync(path); + if (!stats.isFile() || stats.size > MAX_PACKAGE_METADATA_SIZE) return undefined; + return readFileSync(path, "utf8"); + } catch { + return undefined; + } +} + +function readMetadataHeader(path: string): string | undefined { + let descriptor: number | undefined; + try { + const stats = lstatSync(path); + if (!stats.isFile()) return undefined; + const buffer = Buffer.alloc(Math.min(stats.size, MAX_METADATA_HEADER_SIZE)); + descriptor = openSync(path, "r"); + const bytes = readSync(descriptor, buffer, 0, buffer.length, 0); + return buffer.toString("utf8", 0, bytes).split(/\r?\n\r?\n/u, 1)[0]; + } catch { + return undefined; + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} + +function safeDirectories(path: string): string[] { + if (!isDirectory(path)) return []; + return readdirSync(path, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink()) + .map((entry) => entry.name); +} + +function sitePackages(root: string): string[] { + for (const environment of [join(root, ".venv"), join(root, "venv")]) { + if (!isDirectory(environment)) continue; + const sites: string[] = []; + const lib = join(environment, "lib"); + for (const python of safeDirectories(lib).filter((name) => name.startsWith("python"))) { + const site = join(lib, python, "site-packages"); + if (isDirectory(site)) sites.push(site); + } + const windowsSite = join(environment, "Lib", "site-packages"); + if (isDirectory(windowsSite)) sites.push(windowsSite); + if (sites.length > 0) return sites; + } + return []; +} + +function normalizedName(name: string): string { + return name.toLowerCase().replace(/[-_.]+/gu, "-"); +} + +function recordValue(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record : undefined; +} + +function dependencyStrings(value: unknown): string[] { + return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : []; +} + +function dependencyTable(value: unknown): string[] { + const table = recordValue(value); + return table === undefined ? [] : Object.values(table).flatMap(dependencyStrings); +} + +function dependencyName(entry: string): string | undefined { + return /^[A-Za-z0-9][A-Za-z0-9._-]*/u.exec(entry.trim())?.[0]; +} + +function tableKeys(value: unknown): string[] { + return Object.keys(recordValue(value) ?? {}).filter((name) => PACKAGE_NAME.test(name)); +} + +function poetryGroupDependencies(poetry: Record | undefined): string[] { + const groups = recordValue(poetry?.group); + if (groups === undefined) return []; + return Object.values(groups).flatMap((group) => tableKeys(recordValue(group)?.dependencies)); +} + +function toolDeclarations(tool: Record | undefined): string[] { + const poetry = recordValue(tool?.poetry); + return [ + ...tableKeys(poetry?.dependencies), ...poetryGroupDependencies(poetry), + ...dependencyTable(recordValue(tool?.pdm)?.["dev-dependencies"]), + ...dependencyStrings(recordValue(tool?.uv)?.["dev-dependencies"]), + ]; +} + +function manifestDeclarations(manifest: Record | undefined): string[] { + const project = recordValue(manifest?.project); + const tool = recordValue(manifest?.tool); + return [ + ...dependencyStrings(project?.dependencies), + ...dependencyTable(project?.["optional-dependencies"]), + ...dependencyTable(manifest?.["dependency-groups"]), + ...toolDeclarations(tool), + ]; +} + +function requirementDeclarations(root: string): string[] { + if (!isDirectory(root)) return []; + return readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isFile() && /^requirements.*\.txt$/u.test(entry.name)) + .flatMap((entry) => (readSmallFile(join(root, entry.name)) ?? "").split(/\r?\n/u)); +} + +function declaredDependencySpellings(root: string): Map { + const manifest = readRecord(join(root, "pyproject.toml"), parseToml); + const declarations = [...manifestDeclarations(manifest), ...requirementDeclarations(root)]; + const spellings = new Map(); + for (const declaration of declarations) { + const name = dependencyName(declaration); + if (name !== undefined && !spellings.has(normalizedName(name))) spellings.set(normalizedName(name), name); + } + return spellings; +} + +function distributionIdentity( + directory: string, + metadata?: string, +): { normalized: string; spelling: string } | undefined { + const declared = /^Name:[ \t]*(.+)$/imu.exec(metadata ?? "")?.[1]?.trim(); + const stem = directory.slice(0, -".dist-info".length); + const spelling = declared ?? stem.replace(/-[0-9][^-]*$/u, ""); + if (!PACKAGE_NAME.test(spelling)) return undefined; + return { normalized: normalizedName(spelling), spelling }; +} + +function topLevelModules(content: string): string[] { + return content.split(/\r?\n/u).map((line) => line.trim()).filter((name) => MODULE_NAME.test(name)); +} + +function recordPath(line: string): string { + if (!line.startsWith('"')) return line.split(",", 1)[0] ?? ""; + let path = ""; + for (let index = 1; index < line.length; index += 1) { + const character = line[index]!; + if (character !== '"') path += character; + else if (line[index + 1] === '"') { + path += '"'; + index += 1; + } else break; + } + return path; +} + +function recordModules(content: string): string[] { + const modules = new Set(); + for (const line of content.split(/\r?\n/u)) { + const path = recordPath(line).replaceAll("\\", "/"); + if (!path.endsWith(".py") || path.startsWith("../") || path.startsWith("/")) continue; + const first = path.split("/", 1)[0] ?? ""; + if (first.endsWith(".dist-info") || first === "__pycache__") continue; + const module = first.endsWith(".py") ? basename(first, ".py") : first; + if (MODULE_NAME.test(module)) modules.add(module); + } + return [...modules].toSorted(); +} + +function distributionModules(directory: string): string[] { + const topLevel = readSmallFile(join(directory, "top_level.txt")); + if (topLevel !== undefined) return topLevelModules(topLevel); + const record = readSmallFile(join(directory, "RECORD")); + return record === undefined ? [] : recordModules(record); +} + +function deptryPackageModuleMap(root: string): string | undefined { + const mappings = new Map }>(); + const declared = declaredDependencySpellings(root); + for (const site of sitePackages(root)) { + const distributions = safeDirectories(site) + .filter((name) => name.endsWith(".dist-info")) + .toSorted() + .slice(0, MAX_DISTRIBUTIONS); + for (const directory of distributions) { + const absolute = join(site, directory); + const identity = distributionIdentity(directory, readMetadataHeader(join(absolute, "METADATA"))); + const modules = distributionModules(absolute); + if (identity === undefined || modules.length === 0) continue; + const known = mappings.get(identity.normalized) ?? { + spelling: identity.spelling, modules: new Set(), + }; + for (const module of modules) known.modules.add(module); + mappings.set(identity.normalized, known); + } + } + if (mappings.size === 0) return undefined; + return [...mappings].toSorted(([left], [right]) => left.localeCompare(right)) + .map(([normalized, mapping]) => { + const spelling = declared.get(normalized) ?? mapping.spelling; + return `${spelling}=${[...mapping.modules].toSorted().join("|")}`; + }).join(","); +} + +function childFirstParty(absolute: string): string[] { + return readdirSync(absolute, { withFileTypes: true }).flatMap((entry) => { + if (entry.isDirectory() && isFile(join(absolute, entry.name, "__init__.py"))) return [entry.name]; + if (entry.isFile() && extname(entry.name) === ".py" && entry.name !== "__init__.py") { + return [basename(entry.name, ".py")]; + } + return []; + }); +} + +function pathFirstParty(root: string, path: string): string[] { + if (path === ".") return []; + const absolute = join(root, path); + if (!isDirectory(absolute)) return []; + return isFile(join(absolute, "__init__.py")) ? [basename(path)] : childFirstParty(absolute); +} + +function deptryFirstParty(ctx: Pick): string[] { + return [...new Set(ctx.paths.flatMap((path) => pathFirstParty(ctx.root, path)))].toSorted(); +} + export const deptryAdapter: CheckAdapter = { id: "python-unused-deptry", check: "unused", language: "python", tool: { bin: "deptry", version: "0.25.1" }, applicability: () => ({ kind: "run" }), configFiles: (ctx) => toolOutput(ctx, "deptry.json").clear(), - command: (ctx) => pythonInvocation(ctx, { - bin: "deptry", - args: [...ctx.paths, "--json-output", toolOutput(ctx, "deptry.json").path, "--no-ansi", - ...excludeGlobs(ctx.config, true).flatMap((glob) => [ - "--exclude", deptryExcludeRegex(glob), - ])], - exitCodes: [0, 1], - }), + command: (ctx) => { + const packageModuleMap = deptryPackageModuleMap(ctx.root); + return pythonInvocation(ctx, { + bin: "deptry", + args: [...ctx.paths, "--json-output", toolOutput(ctx, "deptry.json").path, "--no-ansi", + ...deptryFirstParty(ctx).flatMap((name) => ["--known-first-party", name]), + ...(packageModuleMap === undefined + ? [] : ["--package-module-name-map", packageModuleMap]), + ...excludeGlobs(ctx.config, true).flatMap((glob) => [ + "--extend-exclude", deptryExcludeRegex(glob), + ])], + exitCodes: [0, 1], + }); + }, artifactOutputs: (ctx) => toolOutput(ctx, "deptry.json").outputs(), parse: (ctx) => Promise.resolve(deptryFindings( ctx, JSON.parse(toolOutput(ctx, "deptry.json").read("deptry")) as unknown, diff --git a/src/checks/shared/anchored.ts b/src/checks/shared/anchored.ts index 8a075d4..12de61d 100644 --- a/src/checks/shared/anchored.ts +++ b/src/checks/shared/anchored.ts @@ -69,11 +69,14 @@ export async function addAnchoredFinding( assertInScope(file, ctx.paths); const source = ctx.readSource(file); const location = detailLocation(source, input); - const anchor = await resolveAnchor(ctx, { + let anchor = await resolveAnchor(ctx, { file, source, offset: byteOffset(source, input), blockMode: input.blockMode, fallback: input.fallbackAnchor, symbol: input.symbol, line: location.line, }); + if (anchor === "/" && input.anchorSuffix === undefined) { + anchor = input.symbol === undefined ? `~L${location.line}` : `/~${input.symbol}`; + } const message = input.message === undefined ? {} : { message: input.message }; const threshold = input.threshold === undefined ? {} : { threshold: input.threshold }; findings.add({ diff --git a/src/checks/ts/knip.ts b/src/checks/ts/knip.ts index c068513..1767107 100644 --- a/src/checks/ts/knip.ts +++ b/src/checks/ts/knip.ts @@ -1,5 +1,5 @@ import { existsSync, lstatSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { isAbsolute, join } from "node:path"; import { z } from "zod"; @@ -48,7 +48,8 @@ const DEPENDENCY_FIELDS = [ "optionalDependencies", ] as const; const NEXT_APP_ENTRIES = [ - "page", "layout", "template", "loading", "error", "global-error", "not-found", "default", "route", + "page", "layout", "template", "loading", "error", "global-error", "not-found", "global-not-found", + "default", "route", "icon", "apple-icon", "opengraph-image", "twitter-image", "sitemap", "robots", "manifest", "forbidden", "unauthorized", ] as const; @@ -89,10 +90,19 @@ function isDirectory(path: string): boolean { } } +function packageRecord(packageFile: string): Record | undefined { + try { + const parsed: unknown = JSON.parse(readFileSync(packageFile, "utf8")); + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) + ? parsed as Record : undefined; + } catch { + return undefined; + } +} + function declaresDependencies(packageFile: string): boolean { - const parsed: unknown = JSON.parse(readFileSync(packageFile, "utf8")); - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return false; - const packageJson = parsed as Record; + const packageJson = packageRecord(packageFile); + if (packageJson === undefined) return false; return DEPENDENCY_FIELDS.some((field) => { const entries = packageJson[field]; return typeof entries === "object" @@ -102,11 +112,40 @@ function declaresDependencies(packageFile: string): boolean { }); } +function declaresDependency(root: string, name: string): boolean { + const packageFile = join(root, "package.json"); + if (!existsSync(packageFile)) return false; + const packageJson = packageRecord(packageFile); + if (packageJson === undefined) return false; + return DEPENDENCY_FIELDS.some((field) => { + const dependencies = packageJson[field]; + return typeof dependencies === "object" && dependencies !== null + && !Array.isArray(dependencies) && Object.hasOwn(dependencies, name); + }); +} + +function executionOwner(root: string, paths: readonly string[]): string | null { + if (isFile(join(root, "package.json"))) return "."; + const owners = new Set(paths.map((path) => ownerOf(root, path))); + if (owners.size !== 1) return null; + const owner = [...owners][0]; + if (owner !== undefined && !safeWorkspaceName(owner)) { + return fail(`ts-unused: unsafe workspace owner ${owner}`); + } + return owner ?? null; +} + function requireNodeModules(config: ResolvedConfig): Applicability { - const packageFile = join(config.root, "package.json"); - if (!existsSync(packageFile) - || existsSync(join(config.root, "node_modules")) - || !declaresDependencies(packageFile)) { + const owner = executionOwner(config.root, config.paths.ts ?? []); + if (owner === null) { + return { + kind: "skip", + reason: "ts-unused needs a root package.json when TypeScript spans several packages", + }; + } + const packageRoot = owner === "." ? config.root : join(config.root, owner); + const packageFile = join(packageRoot, "package.json"); + if (existsSync(join(packageRoot, "node_modules")) || !declaresDependencies(packageFile)) { return { kind: "run" }; } return { @@ -167,7 +206,10 @@ function frameworkEntries( detection: FrameworkDetection, ): string[] { const applications = detection.nextLike ? nextEntries(root, paths) : []; - return [...detection.configs, ...applications]; + const internationalization = detection.nextLike && declaresDependency(root, "next-intl") + ? ["i18n/request.{ts,tsx,js,jsx}", "src/i18n/request.{ts,tsx,js,jsx}"] + : []; + return [...detection.configs, ...applications, ...internationalization]; } function relativeToOwner(path: string, owner: string): string { @@ -218,10 +260,25 @@ function groupedPaths(ctx: CheckContext): Map { return groups; } +function flatConfig(ctx: CheckContext, owner: string): string { + const root = owner === "." ? ctx.root : join(ctx.root, owner); + const paths = ctx.paths.map((path) => relativeToOwner(path, owner)); + const frameworks = frameworkEntries(root, paths, detectFrameworks(root)); + return JSON.stringify({ + entry: [...sourceEntries(paths), ...frameworks, ...TEST_ENTRIES], + project: [...projectEntries(paths), ...frameworks, ...TEST_ENTRIES], + ignore: workspaceIgnore(excludeGlobs(ctx.config, true), owner), + ...PLUGIN_FLAGS, + }, null, 2); +} + function knipConfig(ctx: CheckContext): string { + const owner = executionOwner(ctx.root, ctx.paths); + if (owner !== null && owner !== ".") return flatConfig(ctx, owner); const members = workspaceMemberDirectories(ctx.root); const groups = groupedPaths(ctx); - const workspaceMode = members.length > 0 || [...groups.keys()].some((owner) => owner !== "."); + const workspaceMode = members.length > 0 + || [...groups.keys()].some((candidateOwner) => candidateOwner !== "."); if (!workspaceMode) { const frameworks = frameworkEntries(ctx.root, ctx.paths, detectFrameworks(ctx.root)); return JSON.stringify({ @@ -232,9 +289,9 @@ function knipConfig(ctx: CheckContext): string { }, 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), + for (const [groupOwner, paths] of groups) workspaceGroups.set(groupOwner, paths); + const workspaces = Object.fromEntries([...workspaceGroups].map(([groupOwner, paths]) => [ + groupOwner, workspaceBlock(ctx, groupOwner, paths), ])); const ignoredMembers = members.filter((member) => !groups.has(member)).filter((member) => { if (safeWorkspaceName(member)) return true; @@ -248,6 +305,15 @@ function knipConfig(ctx: CheckContext): string { }, null, 2); } +function reportedFile(ctx: CheckContext, file: string): string { + const owner = executionOwner(ctx.root, ctx.paths); + const nested = owner !== null && owner !== "."; + const candidate = nested && !isAbsolute(file) + ? `${owner}/${file}` + : file; + return relativize(ctx.root, candidate); +} + function knipConfigFile(ctx: CheckContext): GeneratedFile { return { path: "knip.json", content: knipConfig(ctx) }; } @@ -288,7 +354,7 @@ async function exportAnchor( function addFiles(ctx: CheckContext, issue: z.infer, out: FindingsBuilder): void { for (const item of issue.files ?? []) { - const file = relativize(ctx.root, item.name); + const file = reportedFile(ctx, item.name); assertInScope(file, ctx.paths); out.add({ file, rule: "unused-file", anchor: file, value: 1, ...optionalLocation(item), @@ -302,7 +368,7 @@ async function addExports( issue: z.infer, out: FindingsBuilder, ): Promise { - const file = relativize(ctx.root, issue.file); + const file = reportedFile(ctx, issue.file); for (const item of [...(issue.exports ?? []), ...(issue.types ?? [])]) { assertInScope(file, ctx.paths); const anchor = await exportAnchor(ctx, file, item); @@ -319,7 +385,7 @@ function addDependencies( out: FindingsBuilder, ): void { for (const item of [...(issue.dependencies ?? []), ...(issue.devDependencies ?? [])]) { - const file = relativize(ctx.root, issue.file); + const file = reportedFile(ctx, issue.file); assertInScope(file, [], ["package.json", "**/package.json"]); out.add({ file, rule: "unused-dependency", anchor: item.name, value: 1, ...optionalLocation(item), @@ -347,15 +413,19 @@ export const knipAdapter: CheckAdapter = { tool: { bin: "knip", version: "6.35.1" }, applicability: requireNodeModules, configFiles: (ctx) => [knipConfigFile(ctx)], - command: (ctx) => ({ - bin: "knip", - args: [ - "--config", join(ctx.tempDir, "knip.json"), "--reporter", "json", - "--include", "files,dependencies,devDependencies,exports,types", - "--no-progress", "--no-config-hints", - ], - exitCodes: [0, 1], - }), + command: (ctx) => { + const owner = executionOwner(ctx.root, ctx.paths); + return { + bin: "knip", + args: [ + "--config", join(ctx.tempDir, "knip.json"), "--reporter", "json", + "--include", "files,dependencies,devDependencies,exports,types", + "--no-progress", "--no-config-hints", + ], + ...(owner === null || owner === "." ? {} : { cwd: join(ctx.root, owner) }), + exitCodes: [0, 1], + }; + }, parse: (ctx, result) => knipFindings( ctx, parseJsonOutput(result.stdout, "knip", result.stderr), diff --git a/src/checks/ts/oxlint.ts b/src/checks/ts/oxlint.ts index c224d98..ecf2dc6 100644 --- a/src/checks/ts/oxlint.ts +++ b/src/checks/ts/oxlint.ts @@ -6,10 +6,15 @@ import { addAnchoredFinding, excludeGlobs, extractMeasurement, fail, FindingsBui import type { CheckAdapter, CheckContext, ParsedFindings } from "../shared/kit.ts"; import type { ResolvedConfig } from "../../core/config/types.ts"; -const METRICS: Record = { - "eslint(complexity)": { pattern: /complexity of (\d+)\./u, threshold: POLICY.complexity }, +const METRICS: Record = { + "eslint(complexity)": { + pattern: /complexity of (\d+)\./u, + threshold: POLICY.complexity, + jsxThreshold: POLICY.jsx.complexity, + }, "eslint(max-lines-per-function)": { pattern: /too many lines \((\d+)\)/u, threshold: POLICY.maxLinesPerFunction, + jsxThreshold: POLICY.jsx.maxLinesPerFunction, }, "eslint(max-params)": { pattern: /too many parameters \((\d+)\)/u, threshold: POLICY.maxParams }, "eslint(max-depth)": { pattern: /nested too deeply \((\d+)\)/u, threshold: POLICY.maxDepth }, @@ -50,9 +55,27 @@ export function oxlintConfig(_config: ResolvedConfig): string { "max-depth": ["warn", POLICY.maxDepth], "max-nested-callbacks": ["warn", POLICY.maxNestedCallbacks], }, + overrides: [{ + files: ["**/*.tsx", "**/*.jsx"], + rules: { + complexity: ["warn", POLICY.jsx.complexity], + "max-lines-per-function": ["warn", { + max: POLICY.jsx.maxLinesPerFunction, + skipBlankLines: true, + skipComments: true, + }], + }, + }], }, null, 2); } +function metricThreshold( + metric: { threshold: number; jsxThreshold?: number }, + file: string, +): number { + return /\.(?:tsx|jsx)$/u.test(file) ? metric.jsxThreshold ?? metric.threshold : metric.threshold; +} + export async function oxlintFindings(ctx: CheckContext, input: unknown): Promise { const report = reportSchema.parse(input); if (report.number_of_files === 0) return fail("oxlint scanned no files"); @@ -65,13 +88,16 @@ export async function oxlintFindings(ctx: CheckContext, input: unknown): Promise ); } const file = relativize(ctx.root, diagnostic.filename); + const threshold = metricThreshold(metric, file); + const value = extractMeasurement(metric.pattern, diagnostic.message, diagnostic.code); + if (value <= threshold) return fail(`${diagnostic.code} is below policy threshold ${threshold}`); const blockMode = diagnostic.code === "eslint(max-depth)"; await addAnchoredFinding(ctx, findings, { file, rule: diagnostic.code, - value: extractMeasurement(metric.pattern, diagnostic.message, diagnostic.code), + value, offset: diagnostic.labels[0]!.span.offset, blockMode, symbol: SYMBOL.exec(diagnostic.message)?.[1], - message: diagnostic.message, threshold: metric.threshold, + message: diagnostic.message, threshold, }); } return findings.build(); diff --git a/src/cli/version.ts b/src/cli/version.ts index c65effe..0037b31 100644 --- a/src/cli/version.ts +++ b/src/cli/version.ts @@ -1 +1 @@ -export const CLI_VERSION = "1.2.0"; +export const CLI_VERSION = "1.3.0"; diff --git a/src/core/anchor/service.ts b/src/core/anchor/service.ts index 781a95c..6b42968 100644 --- a/src/core/anchor/service.ts +++ b/src/core/anchor/service.ts @@ -95,6 +95,27 @@ function hasErrorAncestor(node: Node): boolean { return false; } +interface TargetContext { + blockMode: boolean; + byteOffset: number; + file: string; + grammar: Grammar; + leaf: Node; + root: Node; + source: string; +} + +function diagnosticTarget(context: TargetContext): Node | "/" { + const { blockMode, byteOffset, file, grammar, leaf, root, source } = context; + const target = blockMode ? blockTarget(grammar, leaf) : functionTarget(grammar, leaf, source); + if (target) return target; + if (grammar !== "python") return anchorError("cannot identify", file, byteOffset); + if (root.hasError && (hasErrorAncestor(leaf) || isInsideError(root, leaf))) { + return anchorError("unparsed", file); + } + return "/"; +} + export function createAnchorService(dir = assetsDir()): AnchorService { const languages = new Map>(); let ready: Promise | undefined; @@ -124,8 +145,10 @@ export function createAnchorService(dir = assetsDir()): AnchorService { const index = byteOffsetToIndex(source, byteOffset); const leaf = tree.rootNode.descendantForIndex(index) ?? anchorError("cannot identify", file, byteOffset); - const target = blockMode ? blockTarget(grammar, leaf) : functionTarget(grammar, leaf, source); - if (!target) return anchorError("cannot identify", file, byteOffset); + const target = diagnosticTarget({ + blockMode, byteOffset, file, grammar, leaf, root: tree.rootNode, source, + }); + if (target === "/") return target; if (tree.rootNode.hasError && (hasErrorAncestor(target) || isInsideError(tree.rootNode, target))) { return anchorError("unparsed", file); diff --git a/src/core/config/detect.ts b/src/core/config/detect.ts index 1c56639..b817e0e 100644 --- a/src/core/config/detect.ts +++ b/src/core/config/detect.ts @@ -6,6 +6,7 @@ import { LANGUAGES, type Language } from "./schema.ts"; import { findSourceFiles, languageExtensions } from "./sources.ts"; import type { SourceSelection } from "./sources.ts"; import type { ArchitectureSelection } from "./types.ts"; +import { workspaceRoots } from "./workspaces.ts"; const MANIFESTS: Partial> = { ts: ["package.json"], @@ -113,9 +114,17 @@ function drupalPaths(root: string, language: Language): string[] { return DRUPAL_PATHS[language].filter((path) => existsSync(join(root, path))); } -export function detectLanguages(root: string): Language[] { +export function detectLanguages(root: string, consumerExcludes: readonly string[] = []): Language[] { return LANGUAGES.filter((language) => { if (drupalPaths(root, language).length > 0) return true; + if (language === "ts") { + const selection: SourceSelection = { + excludes: [...BUILTIN_EXCLUSIONS, ...consumerExcludes, ...TEST_EXCLUSIONS], + extensions: languageExtensions("ts", false), + }; + return existsSync(join(root, "package.json")) + || workspaceRoots(root, "ts", selection).length > 0; + } return language === "web" ? webSourcesExist(root) : MANIFESTS[language]?.some((manifest) => existsSync(join(root, manifest))) === true; diff --git a/src/core/config/load.ts b/src/core/config/load.ts index 862eaa1..41a2a1a 100644 --- a/src/core/config/load.ts +++ b/src/core/config/load.ts @@ -85,7 +85,7 @@ function configuredPathLanguages(config: ConsumerConfig): Language[] { function resolveLanguages(root: string, config: ConsumerConfig): LanguageResolution { const configuredPaths = configuredPathLanguages(config); - const detected = detectLanguages(root); + const detected = detectLanguages(root, config.exclude ?? []); const languages = config.languages ?? [...new Set([...detected, ...configuredPaths])]; return { languages: [...languages] }; } diff --git a/src/core/config/policy.ts b/src/core/config/policy.ts index 9960c39..fa47f49 100644 --- a/src/core/config/policy.ts +++ b/src/core/config/policy.ts @@ -1,4 +1,4 @@ -export const POLICY_VERSION = "2026-09-23.1"; +export const POLICY_VERSION = "2026-09-23.2"; export const POLICY = { complexity: 10, @@ -7,6 +7,7 @@ export const POLICY = { maxDepth: 3, maxNestedCallbacks: 3, cognitive: 15, + jsx: { complexity: 15, maxLinesPerFunction: 120 }, duplication: { mode: "mild", minTokens: 50, minLines: 5 }, advisoryDuplication: { mode: "semantic", near: true, minTokens: 50, minLines: 5 }, } as const; diff --git a/src/core/config/workspaces.ts b/src/core/config/workspaces.ts index 10b38e0..e217274 100644 --- a/src/core/config/workspaces.ts +++ b/src/core/config/workspaces.ts @@ -157,9 +157,13 @@ function isConventionalName(name: string): boolean { return name.startsWith(".") || DISCOVERY_EXCLUDED_ROOTS.has(name); } +const DEPTH_ONE_TS_EXCLUSIONS = new Set([ + "static", "cypress", "e2e", "examples", "fixtures", "playwright", "storybook-static", +]); + function depthOnePackages(root: string): string[] { return safeDirectories(root) - .filter((name) => !isConventionalName(name)) + .filter((name) => !isConventionalName(name) && !DEPTH_ONE_TS_EXCLUSIONS.has(name)) .filter((name) => hasManifest(root, name, "package.json")); } diff --git a/tests/checks/python/deptry.test.ts b/tests/checks/python/deptry.test.ts index 07d969c..bed18a2 100644 --- a/tests/checks/python/deptry.test.ts +++ b/tests/checks/python/deptry.test.ts @@ -1,14 +1,16 @@ -import { existsSync, readFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; +import picomatch from "picomatch"; import { deptryAdapter, deptryExcludeRegex, deptryFindings, } from "../../../src/checks/python/deptry.ts"; -import { BUILTIN_EXCLUSIONS } from "../../../src/core/config/exclusions.ts"; +import { BUILTIN_EXCLUSIONS, TEST_EXCLUSIONS } from "../../../src/core/config/exclusions.ts"; import { checkContext } from "../../helpers/check-context.ts"; const root = resolve("fixtures/python-project"); @@ -19,6 +21,27 @@ function report(code: string): unknown { location: { file: "pyproject.toml", line: null, column: null } }]; } +function writeDistributions(site: string, distributions: Record>): void { + for (const [directory, files] of Object.entries(distributions)) { + mkdirSync(join(site, directory), { recursive: true }); + for (const [file, content] of Object.entries(files)) { + writeFileSync(join(site, directory, file), content); + } + } +} + +function writeDependencyManifest(projectRoot: string): void { + writeFileSync(join(projectRoot, "pyproject.toml"), [ + "[project]", "dependencies = ['Pillow>=11', 'PyJWT>=2']", + "[project.optional-dependencies]", "images = ['python-pptx>=1']", + "[dependency-groups]", "dev = ['PyMuPDF>=1']", + "[tool.poetry.dependencies]", "PoetryPkg = '^1'", + "[tool.poetry.group.dev.dependencies]", "PoetryGroup = '^1'", + "[tool.pdm.dev-dependencies]", "test = ['PdmPkg>=1']", + "[tool.uv]", "dev-dependencies = ['UvPkg>=1']", "", + ].join("\n")); +} + describe("deptry synthetic parser", () => { const ctx = checkContext("/r", "python"); @@ -32,21 +55,56 @@ describe("deptry synthetic parser", () => { }); }); + it("counts repeated diagnostics with the same file, code, and module", () => { + const diagnostic = (report("DEP001") as unknown[])[0]; + expect(deptryFindings(ctx, [diagnostic, diagnostic, diagnostic]).findings).toEqual({ + "pyproject.toml | deptry-DEP001 | requests": 3, + }); + }); + it("converts built-in exclusions to Rust-compatible regexes", () => { expect(BUILTIN_EXCLUSIONS.map(deptryExcludeRegex)).toEqual([ - "(^|/)node_modules(/|$)", "(^|/)vendor(/|$)", "(^|/)\\.venv(/|$)", - "(^|/)dist(/|$)", "(^|/)artifacts(/|$)", - "(^|/)public/build(/|$)", "(^|/)[^/]*\\.min\\.js$", - "(^|/)[^/]*\\.min\\.css$", "(^|/)web/core(/|$)", - "(^|/)docroot/core(/|$)", "(^|/)modules/contrib(/|$)", - "(^|/)themes/contrib(/|$)", "(^|/)profiles/contrib(/|$)", - "(^|/)libraries(/|$)", "(^|/)sites/[^/]*/files(/|$)", - "(^|/)drush(/|$)", "(^|/)ddev\\.provision(/|$)", "(^|/)var(/|$)", + "^(?:.*/)?node_modules(?:/.*)?$", "^(?:.*/)?vendor(?:/.*)?$", + "^(?:.*/)?\\.venv(?:/.*)?$", "^(?:.*/)?dist(?:/.*)?$", + "^(?:.*/)?artifacts(?:/.*)?$", "^(?:.*/)?public/build(?:/.*)?$", + "^(?:.*/)?[^/]*\\.min\\.js$", "^(?:.*/)?[^/]*\\.min\\.css$", + "^(?:.*/)?web/core(?:/.*)?$", "^(?:.*/)?docroot/core(?:/.*)?$", + "^(?:.*/)?modules/contrib(?:/.*)?$", "^(?:.*/)?themes/contrib(?:/.*)?$", + "^(?:.*/)?profiles/contrib(?:/.*)?$", "^(?:.*/)?libraries(?:/.*)?$", + "^(?:.*/)?sites/[^/]*/files(?:/.*)?$", "^(?:.*/)?drush(?:/.*)?$", + "^(?:.*/)?ddev\\.provision(?:/.*)?$", "^(?:.*/)?var(?:/.*)?$", ]); }); + it("matches built-in and test exclusions like picomatch for representative paths", () => { + const paths = [ + "backend/tests/api/test_health.py", "tests/test_root.py", "backend/test/unit.py", + "backend/test_health.py", "backend/health_test.py", "backend/conftest.py", + "src/a.test.ts", "src/a.spec.ts", "src/__tests__/a.ts", "src/ThingTest.php", + "node_modules/a/index.js", "app/vendor/a.php", ".venv/lib/site.py", "src/dist/a.js", + "artifacts/a.json", "public/build/a.js", "src/a.min.js", "src/a.min.css", + "web/core/a.php", "docroot/core/a.php", "modules/contrib/a.php", + "themes/contrib/a.php", "profiles/contrib/a.php", "libraries/a.js", + "sites/default/files/a.png", "drush/a.php", "ddev.provision/a.php", "var/cache/a", + "backend/api/health.py", "src/a.ts", "vendorized/a.py", + "src/generated/a.py", "src/a/generated/b.py", "src/a/b.py", "a/x.py", "b/y.py", + "foo1.py", "nested/foo2.py", "foo3.py", "anything/at/all.txt", + ]; + const globs = [...BUILTIN_EXCLUSIONS, ...TEST_EXCLUSIONS, + "src/**/generated/**", "src/**/*.py", "**", "{a,b}/**", "**/foo[12].py"]; + for (const glob of globs) { + const regex = new RegExp(deptryExcludeRegex(glob), "u"); + const matcher = picomatch(glob, { dot: true }); + for (const path of paths) expect(regex.test(path), `${glob}: ${path}`).toBe(matcher(path)); + } + }); + +}); + +describe("deptry command configuration", () => { + it("converts and escapes a consumer exclusion glob", () => { - expect(deptryExcludeRegex("**/generated.v1/**")).toBe("(^|/)generated\\.v1(/|$)"); + expect(deptryExcludeRegex("**/generated.v1/**")).toBe("^(?:.*/)?generated\\.v1(?:/.*)?$"); expect(deptryExcludeRegex("src/generated?.py")).toBe("^src/generated[^/]\\.py$"); }); @@ -54,8 +112,84 @@ describe("deptry synthetic parser", () => { const configured = checkContext("/r", "python"); configured.config.exclude.push("**/generated.v1/**"); const args = deptryAdapter.command(configured).args; - expect(args).toContain("(^|/)generated\\.v1(/|$)"); + expect(args).toContain("^(?:.*/)?generated\\.v1(?:/.*)?$"); expect(args.every((arg) => !arg.includes("(?="))).toBe(true); + expect(args).toContain("--extend-exclude"); + expect(args).not.toContain("--exclude"); + }); + + it("passes package directories and src-layout modules as known first party", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "deptry-first-party-")); + try { + mkdirSync(join(tempRoot, "backend")); + mkdirSync(join(tempRoot, "src/domain"), { recursive: true }); + mkdirSync(join(tempRoot, "scripts")); + writeFileSync(join(tempRoot, "__init__.py"), ""); + writeFileSync(join(tempRoot, "single.py"), ""); + writeFileSync(join(tempRoot, "backend/__init__.py"), ""); + writeFileSync(join(tempRoot, "src/domain/__init__.py"), ""); + writeFileSync(join(tempRoot, "src/service.py"), ""); + writeFileSync(join(tempRoot, "scripts/report.py"), ""); + const configured = checkContext(tempRoot, "python"); + configured.paths = [".", "backend", "src", "scripts", "single.py"]; + const args = deptryAdapter.command(configured).args; + expect(args.filter((arg) => arg === "--known-first-party")).toHaveLength(4); + expect(args).toEqual(expect.arrayContaining(["backend", "domain", "service", "report"])); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + +}); + +describe("deptry consumer virtualenv mapping", () => { + + it("maps installed distributions to modules from a consumer virtualenv", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "deptry-venv-map-")); + try { + const site = join(tempRoot, ".venv/lib/python3.13/site-packages"); + const distributions: Record> = { + "python_pptx-1.0.dist-info": { + METADATA: "Metadata-Version: 2.4\nName: Python-PPTX\n", + "top_level.txt": "pptx\n", + }, + "Pillow-11.0.dist-info": { + METADATA: "Name: pillow\n", + RECORD: "PIL/__init__.py,,\nPIL/Image.py,,\nPillow-11.0.dist-info/METADATA,,\n", + }, + "PyJWT-2.0.dist-info": { METADATA: "Name: PyJWT\n", RECORD: "jwt/__init__.py,,\n" }, + "PyMuPDF-1.0.dist-info": { METADATA: "Name: PyMuPDF\n", RECORD: "fitz/__init__.py,,\n" }, + "Some-Package-1.0.dist-info": { + METADATA: "Name: some-package\n", "top_level.txt": "some\n", + }, + "poetrypkg-1.0.dist-info": { METADATA: "Name: poetrypkg\n", "top_level.txt": "poetry_mod\n" }, + "poetrygroup-1.0.dist-info": { + METADATA: "Name: poetrygroup\n", "top_level.txt": "poetry_group_mod\n", + }, + "pdmpkg-1.0.dist-info": { METADATA: "Name: pdmpkg\n", "top_level.txt": "pdm_mod\n" }, + "uvpkg-1.0.dist-info": { METADATA: "Name: uvpkg\n", "top_level.txt": "uv_mod\n" }, + "evil-1.0.dist-info": { METADATA: "Name: zz,requests=os|sys\n", "top_level.txt": "os\n" }, + "a,b=c-1.0.dist-info": { "top_level.txt": "sys\n" }, + }; + writeDistributions(site, distributions); + writeDependencyManifest(tempRoot); + writeFileSync(join(tempRoot, "requirements-dev.txt"), "Some-Package==1\n"); + const configured = checkContext(tempRoot, "python"); + const args = deptryAdapter.command(configured).args; + const map = args[args.indexOf("--package-module-name-map") + 1]; + expect(map).toBe( + "PdmPkg=pdm_mod,Pillow=PIL,PoetryGroup=poetry_group_mod,PoetryPkg=poetry_mod,PyJWT=jwt," + + "PyMuPDF=fitz,python-pptx=pptx,Some-Package=some,UvPkg=uv_mod", + ); + expect(map).not.toMatch(/zz|requests=os|a,b=c/u); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("omits the package-module map without a consumer virtualenv", () => { + expect(deptryAdapter.command(checkContext("/r", "python")).args) + .not.toContain("--package-module-name-map"); }); }); diff --git a/tests/checks/python/ruff.test.ts b/tests/checks/python/ruff.test.ts index d2f9520..0db5bd1 100644 --- a/tests/checks/python/ruff.test.ts +++ b/tests/checks/python/ruff.test.ts @@ -59,6 +59,16 @@ describe("ruff config and synthetic parser", () => { report("C901", "busy is too complex (21 > 20)"), )).rejects.toThrow("Unparsable metric message"); }); + + it("uses a quiet line fallback for a module-level diagnostic without a suffix", async () => { + const context = checkContext("/r", "python", { "src/a.py": "value = 1\n" }); + const parsed = await ruffFindings(context, [{ + code: "C901", message: "module is too complex (11 > 10)", filename: "src/a.py", + location: { row: 1, column: 1 }, + }]); + expect(parsed.findings).toEqual({ "src/a.py | C901 | ~L1": 11 }); + expect(context.config.notices).toEqual([]); + }); }); describe("ruff captured fixture", () => { diff --git a/tests/checks/python/vulture.test.ts b/tests/checks/python/vulture.test.ts index 1cebce6..34ddcc3 100644 --- a/tests/checks/python/vulture.test.ts +++ b/tests/checks/python/vulture.test.ts @@ -33,6 +33,33 @@ describe("vulture synthetic parser", () => { "vulture-unreachable_code", "vulture-unsatisfiable_condition"])); }); + it("anchors module-level unused imports and variables at the file root without a notice", async () => { + const moduleContext = checkContext("/r", "python", { + "src/a.py": "import os\nunused = 1\n", + }); + const parsed = await vultureFindings(moduleContext, [ + "src/a.py:1: unused import 'os' (90% confidence)", + "src/a.py:2: unused variable 'unused' (60% confidence)", + ].join("\n")); + expect(parsed.findings).toEqual({ + "src/a.py | vulture-import | /#os": 1, + "src/a.py | vulture-variable | /#unused": 1, + }); + expect(moduleContext.config.notices).toEqual([]); + }); + + it("keeps the fallback notice for a diagnostic in an unparseable module region", async () => { + const broken = checkContext("/r", "python", { "src/a.py": "import (\n" }); + const parsed = await vultureFindings( + broken, + "src/a.py:1: unused import 'broken' (90% confidence)", + ); + expect(parsed.findings).toEqual({ "src/a.py | vulture-import | /#broken": 1 }); + expect(broken.config.notices).toEqual([ + "anchors for src/a.py fall back to symbol/line keys (grammar could not parse the file)", + ]); + }); + it("runner rejects vulture exit code 1", () => { const invocation = vultureAdapter.command(ctx); expect(() => spawnTool({ ...invocation, bin: process.execPath, diff --git a/tests/checks/ts/knip-plugins.test.ts b/tests/checks/ts/knip-plugins.test.ts index 943da1f..5d79a58 100644 --- a/tests/checks/ts/knip-plugins.test.ts +++ b/tests/checks/ts/knip-plugins.test.ts @@ -35,6 +35,7 @@ describe("generated Knip plugin flags", () => { const root = mkdtempSync(join(tmpdir(), "knip-plugins-")); try { mkdirSync(join(root, "server/src"), { recursive: true }); + writeFileSync(join(root, "package.json"), "{}"); writeFileSync(join(root, "server/package.json"), "{}"); const workspaceContext = checkContext(root, "ts"); workspaceContext.paths = ["server/src"]; diff --git a/tests/checks/ts/knip.test.ts b/tests/checks/ts/knip.test.ts index b3991aa..788cb36 100644 --- a/tests/checks/ts/knip.test.ts +++ b/tests/checks/ts/knip.test.ts @@ -94,6 +94,124 @@ describe("knip synthetic parser", () => { }); +describe("knip single nested package execution", () => { + it("runs from the sole owner with flat rebased config and dependency checks", async () => { + const tempRoot = mkdtempSync(join(tmpdir(), "knip-owner-")); + try { + mkdirSync(join(tempRoot, "frontend/src"), { recursive: true }); + mkdirSync(join(tempRoot, "frontend/node_modules")); + writeFileSync(join(tempRoot, "frontend/package.json"), JSON.stringify({ + dependencies: { "left-pad": "1.3.0" }, + })); + writeFileSync(join(tempRoot, "frontend/src/index.ts"), "export const unused = 1;\n"); + const ctx = checkContext(tempRoot, "ts"); + ctx.paths = ["frontend/src"]; + ctx.config.paths.ts = ["frontend/src"]; + expect(knipAdapter.applicability(ctx.config)).toEqual({ kind: "run" }); + expect(knipAdapter.command(ctx).cwd).toBe(join(tempRoot, "frontend")); + const config = JSON.parse(knipAdapter.configFiles(ctx)[0]!.content) as Record; + expect(config).not.toHaveProperty("workspaces"); + expect(config.project).toEqual(expect.arrayContaining(["src/**/*.{ts,tsx,js,jsx,mjs,cjs}"])); + const parsed = await knipFindings(ctx, { issues: [ + { file: "src/index.ts", files: [{ name: "src/index.ts" }] }, + { file: "package.json", dependencies: [{ name: "left-pad" }] }, + ] }); + expect(parsed.findings).toEqual({ + "frontend/package.json | unused-dependency | left-pad": 1, + "frontend/src/index.ts | unused-file | frontend/src/index.ts": 1, + }); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("checks node_modules under the sole nested owner", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "knip-owner-")); + try { + mkdirSync(join(tempRoot, "frontend/src"), { recursive: true }); + writeFileSync(join(tempRoot, "frontend/package.json"), JSON.stringify({ + dependencies: { "left-pad": "1.3.0" }, + })); + writeFileSync(join(tempRoot, "frontend/src/index.ts"), ""); + const ctx = checkContext(tempRoot, "ts"); + ctx.config.paths.ts = ["frontend/src"]; + expect(knipAdapter.applicability(ctx.config)).toMatchObject({ kind: "error" }); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + +}); + +describe("knip nested owner report paths", () => { + + it("always prefixes relative reports with the nested owner", async () => { + const tempRoot = mkdtempSync(join(tmpdir(), "knip-owner-")); + try { + mkdirSync(join(tempRoot, "app/app"), { recursive: true }); + writeFileSync(join(tempRoot, "app/package.json"), "{}"); + writeFileSync(join(tempRoot, "app/app/page.tsx"), "export default 1;\n"); + const ctx = checkContext(tempRoot, "ts"); + ctx.paths = ["app/app"]; + ctx.config.paths.ts = ["app/app"]; + const parsed = await knipFindings(ctx, { issues: [{ + file: "app/page.tsx", files: [{ name: "app/page.tsx" }], + }] }); + expect(parsed.findings).toEqual({ + "app/app/page.tsx | unused-file | app/app/page.tsx": 1, + }); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); +}); + +describe("knip nested package applicability", () => { + + it("runs with no package owner when all paths belong to the repository root", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "knip-owner-")); + try { + mkdirSync(join(tempRoot, "src")); + const ctx = checkContext(tempRoot, "ts"); + ctx.config.paths.ts = ["src"]; + expect(knipAdapter.applicability(ctx.config)).toEqual({ kind: "run" }); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("treats malformed package.json as declaring no dependencies", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "knip-owner-")); + try { + writeFileSync(join(tempRoot, "package.json"), "{"); + writeFileSync(join(tempRoot, "next.config.ts"), "export default {};\n"); + expect(knipAdapter.applicability(checkContext(tempRoot, "ts").config)) + .toEqual({ kind: "run" }); + expect(() => knipAdapter.configFiles(checkContext(tempRoot, "ts"))).not.toThrow(); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("skips without a root manifest when TypeScript spans owners", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "knip-owner-")); + try { + for (const owner of ["frontend", "admin"]) { + mkdirSync(join(tempRoot, owner, "src"), { recursive: true }); + writeFileSync(join(tempRoot, owner, "package.json"), "{}"); + } + const ctx = checkContext(tempRoot, "ts"); + ctx.config.paths.ts = ["frontend/src", "admin/src"]; + expect(knipAdapter.applicability(ctx.config)).toEqual({ + kind: "skip", + reason: "ts-unused needs a root package.json when TypeScript spans several packages", + }); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); +}); + it("accepts unused dependencies from workspace package manifests", async () => { const input = { issues: [{ file: "packages/cli/package.json", @@ -203,7 +321,7 @@ describe("knip static framework entries", () => { "src/proxy.ts": "export function proxy() {}\n", "src/instrumentation-client.js": "export {};\n", }, ["src"]); - const application = "src/app/**/{page,layout,template,loading,error,global-error,not-found,default,route,icon,apple-icon,opengraph-image,twitter-image,sitemap,robots,manifest,forbidden,unauthorized}.{ts,tsx,js,jsx}"; + const application = "src/app/**/{page,layout,template,loading,error,global-error,not-found,global-not-found,default,route,icon,apple-icon,opengraph-image,twitter-image,sitemap,robots,manifest,forbidden,unauthorized}.{ts,tsx,js,jsx}"; expect(generated.entry).toEqual(expect.arrayContaining([ "next.config.mjs", "src/payload.config.ts", "vitest.config.ts", "playwright.config.ts", application, "src/middleware.{ts,js}", "src/proxy.{ts,js}", @@ -216,6 +334,20 @@ describe("knip static framework entries", () => { ])); expect(generated.entry).not.toContain("src/pages/**/*.{ts,tsx,js,jsx}"); }); + + it("adds next-intl request configuration entries for the owner package", () => { + const generated = generatedConfigAt({ + "package.json": JSON.stringify({ dependencies: { "next-intl": "^4.0.0" } }), + "next.config.ts": "export default {};\n", + "src/app/page.tsx": "export default function Page() { return null; }\n", + }, ["src"]); + expect(generated.entry).toEqual(expect.arrayContaining([ + "i18n/request.{ts,tsx,js,jsx}", "src/i18n/request.{ts,tsx,js,jsx}", + ])); + expect(generated.project).toEqual(expect.arrayContaining([ + "i18n/request.{ts,tsx,js,jsx}", "src/i18n/request.{ts,tsx,js,jsx}", + ])); + }); }); describe("knip workspace config", () => { @@ -294,10 +426,22 @@ describe("knip workspace framework config", () => { "apps/api/src/index.ts": "", }, ["apps/api/src", "apps/web/src"]); const workspaces = generated.workspaces as Record>; - const appPattern = "src/app/**/{page,layout,template,loading,error,global-error,not-found,default,route,icon,apple-icon,opengraph-image,twitter-image,sitemap,robots,manifest,forbidden,unauthorized}.{ts,tsx,js,jsx}"; + const appPattern = "src/app/**/{page,layout,template,loading,error,global-error,not-found,global-not-found,default,route,icon,apple-icon,opengraph-image,twitter-image,sitemap,robots,manifest,forbidden,unauthorized}.{ts,tsx,js,jsx}"; expect(workspaces["apps/web"]?.entry).toEqual(expect.arrayContaining(["next.config.mjs", appPattern])); expect(workspaces["apps/api"]?.entry).not.toContain(appPattern); }); + + it("adds next-intl request entries relative to a nested owner", () => { + const generated = generatedConfigAt({ + "apps/web/package.json": JSON.stringify({ dependencies: { "next-intl": "^4.0.0" } }), + "apps/web/next.config.ts": "export default {};\n", + "apps/web/src/app/page.tsx": "export default function Page() { return null; }\n", + }, ["apps/web/src"]); + expect(generated.entry).toEqual(expect.arrayContaining([ + "i18n/request.{ts,tsx,js,jsx}", "src/i18n/request.{ts,tsx,js,jsx}", + ])); + expect(generated.entry).not.toContain("apps/web/i18n/request.{ts,tsx,js,jsx}"); + }); }); describe("knip workspace safety config", () => { diff --git a/tests/checks/ts/oxlint.test.ts b/tests/checks/ts/oxlint.test.ts index ed46411..6e4e376 100644 --- a/tests/checks/ts/oxlint.test.ts +++ b/tests/checks/ts/oxlint.test.ts @@ -24,6 +24,7 @@ describe("oxlint config and command", () => { const config = JSON.parse(oxlintConfig(checkContext(root).config)) as { categories: Record; rules: Record; + overrides: unknown[]; }; expect(config.categories).toEqual({ correctness: "off", suspicious: "off", pedantic: "off", perf: "off", @@ -36,6 +37,15 @@ describe("oxlint config and command", () => { "max-depth": ["warn", 3], "max-nested-callbacks": ["warn", 3], }); + expect(config.overrides).toEqual([{ + files: ["**/*.tsx", "**/*.jsx"], + rules: { + complexity: ["warn", 15], + "max-lines-per-function": ["warn", { + max: 120, skipBlankLines: true, skipComments: true, + }], + }, + }]); const commandContext = checkContext(root); commandContext.config.exclude.push("custom/**"); const args = oxlintAdapter.command(commandContext).args; @@ -113,6 +123,24 @@ describe("oxlint synthetic parser", () => { }); }); +describe("oxlint JSX policy", () => { + it("uses JSX complexity thresholds when parsing TSX diagnostics", async () => { + const source = "export function named(): number { return 1; }\n"; + const tsxContext = checkContext("/r", "ts", { "src/a.tsx": source }); + const below = report("eslint(complexity)", "complexity of 14.") as { + diagnostics: Array<{ filename: string }>; + }; + below.diagnostics[0]!.filename = "src/a.tsx"; + await expect(oxlintFindings(tsxContext, below)).rejects.toThrow("below policy threshold 15"); + const above = report("eslint(complexity)", "complexity of 16.") as { + diagnostics: Array<{ filename: string }>; + }; + above.diagnostics[0]!.filename = "src/a.tsx"; + const parsed = await oxlintFindings(tsxContext, above); + expect(Object.values(parsed.details)[0]).toMatchObject({ value: 16, threshold: 15 }); + }); +}); + describe("oxlint structural recovery", () => { it("anchors max-params function types as named methods", async () => { const typed = "export type Repo = { search: (a, b, c, d, e) => Promise };\n"; diff --git a/tests/core/config/hash.test.ts b/tests/core/config/hash.test.ts index 3211e04..0691a18 100644 --- a/tests/core/config/hash.test.ts +++ b/tests/core/config/hash.test.ts @@ -43,7 +43,7 @@ describe("configHash", () => { }); it("matches the current policy hash when no runtime target is supplied", () => { - expect(configHash(baseConfig)).toBe("5725438a068191a110bfba12477cad782ab11bb21f0077cb9c781244d80c9906"); + expect(configHash(baseConfig)).toBe("da9ca134df6efc017dfcf25dcf2f8005d26762252382f1a2f04d23ebe15d14b9"); }); it("changes when the derived Python runtime target changes", () => { diff --git a/tests/core/config/load.test.ts b/tests/core/config/load.test.ts index f3276ec..ca975cf 100644 --- a/tests/core/config/load.test.ts +++ b/tests/core/config/load.test.ts @@ -211,6 +211,50 @@ describe("loadConfig auto-detection notices", () => { }); describe("loadConfig workspace roots", () => { + it("detects a depth-one TypeScript package beside a Python application", () => { + withRoot({ + "pyproject.toml": "{}", + "src/app.py": "", + "frontend/package.json": "{}", + "frontend/src/index.ts": "", + }, [], (root) => { + const config = loadConfig(root); + expect(config.languages).toEqual(["ts", "python"]); + expect(config.paths.ts).toEqual(["frontend/src"]); + }); + }); + + it.each(["node_modules/vendor", "tests/frontend"])( + "does not detect TypeScript from an excluded %s package", + (directory) => withRoot({ + "pyproject.toml": "{}", + "src/app.py": "", + [`${directory}/package.json`]: "{}", + [`${directory}/src/index.ts`]: "", + }, [], (root) => expect(loadConfig(root).languages).toEqual(["python"])), + ); + + it("does not detect TypeScript from a consumer-excluded depth-one package", () => { + withRoot({ + ".code-quality.yml": "exclude: [frontend/**]\n", + "pyproject.toml": "{}", + "src/app.py": "", + "frontend/package.json": "{}", + "frontend/src/index.ts": "", + }, [], (root) => expect(loadConfig(root).languages).toEqual(["python"])); + }); + + it("does not detect TypeScript from a depth-one Cypress package", () => { + withRoot({ + "pyproject.toml": "{}", "src/app.py": "", + "cypress/package.json": "{}", "cypress/e2e/x.cy.ts": "", + }, [], (root) => expect(loadConfig(root).languages).toEqual(["python"])); + }); + +}); + +describe("loadConfig manifest 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`); From 6a370cf0e17adabb2a412749ffdee108bc8b8252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Wed, 23 Sep 2026 10:49:22 +0200 Subject: [PATCH 2/2] test: read the CLI version from the source instead of a literal --- tests/cli/init.test.ts | 5 +++-- tests/cli/program.test.ts | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/cli/init.test.ts b/tests/cli/init.test.ts index 7323084..5d9bd60 100644 --- a/tests/cli/init.test.ts +++ b/tests/cli/init.test.ts @@ -17,6 +17,7 @@ import { MAKEFILE_SNIPPET } from "../../src/cli/scaffold.ts"; import type { CliDeps } from "../../src/cli/deps.ts"; import { fakeAdapter, fakeDeps } from "../helpers/fake-adapter.ts"; import { createStyle } from "../../src/cli/style.ts"; +import { CLI_VERSION } from "../../src/cli/version.ts"; const roots: string[] = []; @@ -256,8 +257,8 @@ describe("init discovered configuration", () => { expect(output.at(-1)).toBe( " Next review .code-quality.yml, run make quality, commit quality/\n", ); - expect(output.filter((line) => line.includes("code-quality 1.2.0"))).toEqual([ - " code-quality 1.2.0 · init · ts\n", + expect(output.filter((line) => line.includes(`code-quality ${CLI_VERSION}`))).toEqual([ + ` code-quality ${CLI_VERSION} · init · ts\n`, ]); expect(errors).toEqual([]); }); diff --git a/tests/cli/program.test.ts b/tests/cli/program.test.ts index 34b9cbe..3f7a2e4 100644 --- a/tests/cli/program.test.ts +++ b/tests/cli/program.test.ts @@ -18,6 +18,7 @@ import type { CliDeps } from "../../src/cli/deps.ts"; import { fakeAdapter, fakeDeps } from "../helpers/fake-adapter.ts"; import { TOOL_PINS } from "../../src/registry.ts"; import { createStyle } from "../../src/cli/style.ts"; +import { CLI_VERSION } from "../../src/cli/version.ts"; const roots: string[] = []; @@ -55,7 +56,7 @@ let errors: string[] = []; function expectRegressionOutput(): void { expect(outputs).toEqual([ - " code-quality 1.2.0 · ts · 3 checks\n", + ` code-quality ${CLI_VERSION} · ts · 3 checks\n`, "\n", " ✔ ts-alpha node 0.0.0 0 findings\n", " ✖ ts-beta node 0.0.0 1 finding · 1 new\n",