From a8730d620058d73e2196760d25cd299e94a67f0b Mon Sep 17 00:00:00 2001 From: Jerome Van Der Linden Date: Mon, 26 Oct 2026 22:08:52 +0100 Subject: [PATCH 1/2] feat: add PR quality signals ahead of automated reviews --- .github/workflows/pr-quality-comment.yml | 66 + .github/workflows/pr-quality.yml | 219 ++++ .gitignore | 3 + README.md | 12 + docs/development/pr-quality.md | 48 + eslint.complexity.config.mjs | 33 + package-lock.json | 1148 ++++++++++++++++- package.json | 5 + scripts/compare-complexity.mjs | 310 +++++ scripts/complexity-analysis.mjs | 271 ++++ scripts/complexity-coverage.mjs | 129 ++ scripts/complexity-report.mjs | 105 ++ scripts/publish-complexity-comment.mjs | 212 +++ scripts/test/compare-complexity.test.mjs | 478 +++++++ scripts/test/complexity-coverage.test.mjs | 138 ++ scripts/test/complexity-report.test.mjs | 125 ++ .../test/publish-complexity-comment.test.mjs | 466 +++++++ 17 files changed, 3756 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/pr-quality-comment.yml create mode 100644 .github/workflows/pr-quality.yml create mode 100644 docs/development/pr-quality.md create mode 100644 eslint.complexity.config.mjs create mode 100644 scripts/compare-complexity.mjs create mode 100644 scripts/complexity-analysis.mjs create mode 100644 scripts/complexity-coverage.mjs create mode 100644 scripts/complexity-report.mjs create mode 100644 scripts/publish-complexity-comment.mjs create mode 100644 scripts/test/compare-complexity.test.mjs create mode 100644 scripts/test/complexity-coverage.test.mjs create mode 100644 scripts/test/complexity-report.test.mjs create mode 100644 scripts/test/publish-complexity-comment.test.mjs diff --git a/.github/workflows/pr-quality-comment.yml b/.github/workflows/pr-quality-comment.yml new file mode 100644 index 000000000..0ca3f1925 --- /dev/null +++ b/.github/workflows/pr-quality-comment.yml @@ -0,0 +1,66 @@ +name: PR Quality Comment + +on: + workflow_run: + workflows: [PR Quality] + types: [completed] + +permissions: + contents: read + actions: read + pull-requests: write + +concurrency: + group: pr-quality-comment-${{ github.event.workflow_run.head_repository.id }}-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: false + +jobs: + comment: + if: >- + github.event.workflow_run.event == 'pull_request' && + (github.event.workflow_run.head_repository.id != github.event.repository.id || + github.event.workflow_run.actor.login == 'dependabot[bot]') + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # workflow_run uses the default branch. Never check out the PR here, run + # npm install, or execute anything from its artifact with this write token. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + path: trusted + sparse-checkout-cone-mode: false + sparse-checkout: | + /scripts/publish-complexity-comment.mjs + /scripts/complexity-report.mjs + - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + id: artifacts + with: + script: | + const { qualityArtifactNames } = await import( + `${process.env.GITHUB_WORKSPACE}/trusted/scripts/publish-complexity-comment.mjs` + ); + const run = context.payload.workflow_run; + const names = await qualityArtifactNames({ + github, repo: context.repo, runId: run.id, attempt: run.run_attempt, + }); + core.setOutput('report', names.report); + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + if: steps.artifacts.outputs.report != '' + continue-on-error: true + with: + name: ${{ steps.artifacts.outputs.report }} + path: untrusted-report + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + const { publishComplexityComment } = await import( + `${process.env.GITHUB_WORKSPACE}/trusted/scripts/publish-complexity-comment.mjs` + ); + await publishComplexityComment({ + github, context, + reportPath: `${process.env.GITHUB_WORKSPACE}/untrusted-report/comparison.json`, + warn: core.warning, + }); diff --git a/.github/workflows/pr-quality.yml b/.github/workflows/pr-quality.yml new file mode 100644 index 000000000..ae5d336fe --- /dev/null +++ b/.github/workflows/pr-quality.yml @@ -0,0 +1,219 @@ +name: PR Quality + +on: + pull_request: + +permissions: + contents: read + actions: read + +concurrency: + group: pr-quality-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + revisions: + runs-on: ubuntu-latest + outputs: + merge-base: ${{ steps.refs.outputs.merge-base }} + coverage-needed: ${{ steps.refs.outputs.coverage-needed }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + persist-credentials: false + - id: refs + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + merge_base=$(git merge-base "$BASE_SHA" "$HEAD_SHA") + echo "merge-base=$merge_base" >> "$GITHUB_OUTPUT" + if git diff --quiet "$merge_base" "$HEAD_SHA" -- lambda/ test/ \ + 'package*.json' vitest.config.js eslint.complexity.config.mjs \ + 'scripts/*complexity*' 'scripts/test/*complexity*' '.github/workflows/pr-quality*'; then + echo "coverage-needed=false" >> "$GITHUB_OUTPUT" + else + echo "coverage-needed=true" >> "$GITHUB_OUTPUT" + fi + + coverage: + name: coverage-${{ matrix.revision }} + needs: revisions + if: needs.revisions.outputs.coverage-needed == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + revision: [base, head] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.head.sha }} + path: tooling + persist-credentials: false + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ matrix.revision == 'base' && needs.revisions.outputs.merge-base || github.event.pull_request.head.sha }} + path: source + persist-credentials: false + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + cache: npm + cache-dependency-path: | + source/package-lock.json + source/lambda/yjs-server/package-lock.json + - name: Install revision dependencies + id: install + continue-on-error: true + working-directory: source + run: | + npm ci + npm ci --prefix lambda/yjs-server --ignore-scripts + - name: Measure backend coverage + id: tests + if: steps.install.outcome == 'success' + continue-on-error: true + working-directory: source + run: npm test -- --coverage --coverage.reporter=json --coverage.reportOnFailure + - name: Collect revision-bound coverage + if: always() + env: + TESTS_PASSED: ${{ steps.tests.outcome == 'success' }} + run: | + args=() + if [ "$TESTS_PASSED" = true ]; then args+=(--successful); fi + node tooling/scripts/complexity-coverage.mjs --cwd "$PWD/source" \ + --input "$PWD/source/coverage/coverage-final.json" \ + --output "$PWD/coverage.json" "${args[@]}" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: always() + with: + name: coverage-${{ matrix.revision }}-${{ github.run_attempt }} + path: coverage.json + if-no-files-found: error + retention-days: 7 + + comment: + needs: report + if: >- + always() && + github.event.pull_request.head.repo.id == github.event.repository.id && + github.actor != 'dependabot[bot]' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + actions: read + pull-requests: write + concurrency: + group: pr-quality-comment-${{ github.event.pull_request.head.repo.id }}-${{ github.event.pull_request.head.ref }} + cancel-in-progress: false + steps: + # Same-repository authors already control this PR workflow. Forks and + # Dependabot use the default-branch publisher in pr-quality-comment.yml. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + sparse-checkout-cone-mode: false + sparse-checkout: | + /scripts/publish-complexity-comment.mjs + /scripts/complexity-report.mjs + - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + id: artifacts + with: + script: | + const { qualityArtifactNames } = await import( + `${process.env.GITHUB_WORKSPACE}/scripts/publish-complexity-comment.mjs` + ); + const names = await qualityArtifactNames({ + github, repo: context.repo, runId: context.runId, + attempt: Number(process.env.GITHUB_RUN_ATTEMPT), + }); + core.setOutput('report', names.report); + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + if: steps.artifacts.outputs.report != '' + continue-on-error: true + with: + name: ${{ steps.artifacts.outputs.report }} + path: quality-report + - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + const { publishComplexityComment } = await import( + `${process.env.GITHUB_WORKSPACE}/scripts/publish-complexity-comment.mjs` + ); + await publishComplexityComment({ + github, context, + reportPath: `${process.env.GITHUB_WORKSPACE}/quality-report/comparison.json`, + warn: core.warning, + }); + + report: + needs: [revisions, coverage] + if: always() && needs.revisions.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + cache: npm + - run: npm ci + - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + id: artifacts + with: + script: | + const { qualityArtifactNames } = await import( + `${process.env.GITHUB_WORKSPACE}/scripts/publish-complexity-comment.mjs` + ); + const names = await qualityArtifactNames({ + github, repo: context.repo, runId: context.runId, + attempt: Number(process.env.GITHUB_RUN_ATTEMPT), + }); + core.setOutput('base', names.base); + core.setOutput('head', names.head); + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + if: steps.artifacts.outputs.base != '' + continue-on-error: true + with: + name: ${{ steps.artifacts.outputs.base }} + path: coverage-input/base + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + if: steps.artifacts.outputs.head != '' + continue-on-error: true + with: + name: ${{ steps.artifacts.outputs.head }} + path: coverage-input/head + - name: Compare complexity and coverage + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + COVERAGE_NEEDED: ${{ needs.revisions.outputs.coverage-needed }} + run: | + args=() + if [ "$COVERAGE_NEEDED" = false ]; then args+=(--coverage-not-needed); fi + if [ -f "coverage-input/base/coverage.json" ]; then + args+=(--base-coverage "coverage-input/base/coverage.json") + fi + if [ -f "coverage-input/head/coverage.json" ]; then + args+=(--head-coverage "coverage-input/head/coverage.json") + fi + npm run complexity:compare -- --base "$BASE_SHA" --base-ref "$BASE_REF" --head "$HEAD_SHA" "${args[@]}" + cat reports/complexity/comparison.md >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: pr-quality-${{ github.run_attempt }} + path: reports/complexity/comparison.* + if-no-files-found: error + retention-days: 7 diff --git a/.gitignore b/.gitignore index 03e4b6dac..35c574556 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,6 @@ lambda/agentcore/test/a7-mcp-config.json # dependency-cruiser generated reports reports/dependency/ + +# Local cognitive-complexity analysis +reports/complexity/ diff --git a/README.md b/README.md index be3f2be55..d6844b087 100644 --- a/README.md +++ b/README.md @@ -458,6 +458,18 @@ npm run audit:prod:all # npm audit on production deps for root + frontend (hig npm run typecheck:frontend # tsc -b on the frontend package ``` +Inspect cognitive complexity with the free SonarJS ESLint plugin: + +```bash +npm run complexity # advisory warnings for function scores above 15 +npm run complexity:compare # compare origin/main's merge base with HEAD +``` + +CI combines complexity changes, backend branch coverage, and importer counts in one PR comment, +helping reviewers find new or worsening complex functions and coverage regressions. +These signals are advisory. See [PR quality reports](docs/development/pr-quality.md) +for coverage details, thresholds, matching limits, and CI behavior. + Analyze backend module coupling with [dependency-cruiser](https://github.com/sverweij/dependency-cruiser). The rules — no circular dependencies, no cross-workspace imports, `lambda/shared/` kept as a leaf foundation, and no production code importing test files — live in `.dependency-cruiser.cjs` and are enforced by the pre-commit hook and CI: ```bash diff --git a/docs/development/pr-quality.md b/docs/development/pr-quality.md new file mode 100644 index 000000000..9259078b4 --- /dev/null +++ b/docs/development/pr-quality.md @@ -0,0 +1,48 @@ +# PR quality reports + +CI updates one PR comment with cognitive-complexity, dependency counts, and backend branch-coverage changes +between the merge base and PR head. Findings are advisory: functions above 15 appear +when complexity or coverage worsens, or when the function is new/unmatched. +Unchanged debt is omitted; improvements appear separately. + +## Reading the table + +| Column | Meaning | +| ------------------- | -------------------------------------------------------------------------------------------------- | +| Complexity / Δ | Before → after score and change | +| Depending files | Direct production importers at PR head; backend only, excluding tests and AWS/external callers | +| Branch coverage / Δ | Covered branch outcomes and change in percentage points | +| Uncovered branches | Outcomes not exercised by tests, before → after | +| Icon | 🟢 improving, 🟠 mixed/small regression, 🔴 large regression, 🟣 both metrics worsen substantially | + +Without comparable measurements, icons use current complexity: orange above 15, +red above 50, purple above 100. Coverage below 50% raises this one level. +More than 10 depending files raises an existing warning one level, capped at purple, +when coverage is below 50% or regresses. Green improvements stay green. +These are review heuristics; use the numbers to judge each finding. + +**Unknown** means coverage is missing, incompatible, or unsupported (frontend). +**n/a** means no branch outcomes; **\*** marks coverage from failing/incomplete tests. +**New or unmatched** means no reliable previous function match—not necessarily new code. +The comment shows up to 20 findings and 20 improvements; artifacts contain all results. + +## CI + +Backend/test/configuration/tooling changes run coverage at both revisions. Docs-only, +Terraform-only and frontend-only PRs skip these suites: “not needed for this change.” +Dependency analysis failures leave only those counts unknown. Partial reruns reuse artifacts only when their producers +were not rerun; missing or stale results are reported explicitly. + +Same-repository PRs can comment immediately. Forks and Dependabot need the comment +workflow on the default branch; it validates report data without executing PR code. +JSON and Markdown artifacts are retained for seven days and linked from the comment. + +## Local use + +```bash +npm run complexity +npm run complexity:compare -- --base origin/main --head HEAD +``` + +The comparison reads committed source and writes JSON/Markdown to `reports/complexity/`. +Local hooks do not collect coverage or post comments. diff --git a/eslint.complexity.config.mjs b/eslint.complexity.config.mjs new file mode 100644 index 000000000..618250187 --- /dev/null +++ b/eslint.complexity.config.mjs @@ -0,0 +1,33 @@ +import tsParser from '@typescript-eslint/parser'; +import sonarjs from 'eslint-plugin-sonarjs'; + +// A focused, advisory analysis. Oxlint remains the repository's normal linter. +export default [ + { + ignores: [ + '**/node_modules/**', + '**/.build/**', + '**/dist/**', + '**/coverage/**', + '**/.stryker-tmp/**', + '**/test/**', + '**/tests/**', + '**/__tests__/**', + '**/__mocks__/**', + '**/fixtures/**', + '**/*.{test,spec}.*', + '**/*.d.ts', + ], + }, + { + files: ['lambda/**/*.{js,mjs,cjs}', 'frontend/src/**/*.{js,jsx,ts,tsx}'], + languageOptions: { + parser: tsParser, + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + plugins: { sonarjs }, + rules: { + 'sonarjs/cognitive-complexity': ['warn', 15], + }, + }, +]; diff --git a/package-lock.json b/package-lock.json index 1f10cb7d1..6f5e8c894 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,11 +59,14 @@ "@secretlint/secretlint-rule-preset-recommend": "13.0.2", "@smithy/config-resolver": "^4.6.10", "@smithy/node-config-provider": "^3.0.0", + "@typescript-eslint/parser": "8.72.0", "@vitest/coverage-v8": "^4.1.11", "aws-sdk-client-mock": "^4.1.0", "aws-sdk-client-mock-vitest": "^7.1.0", "dependency-cruiser": "^16.10.4", "esbuild": "^0.28.1", + "eslint": "10.13.0", + "eslint-plugin-sonarjs": "4.2.2", "gremlin": "^3.8.1", "gremlin-aws-sigv4": "^3.6.1", "husky": "9.1.7", @@ -2787,6 +2790,30 @@ "node": ">=18" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -3261,6 +3288,113 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.14.5", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz", @@ -3321,6 +3455,72 @@ "hono": "^4" } }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "dev": true, @@ -3352,6 +3552,30 @@ "url": "https://opencollective.com/js-sdsl" } }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@kwsites/file-exists": { "version": "1.1.1", "dev": true, @@ -4703,6 +4927,13 @@ "node": ">=18.0.0" } }, + "node_modules/@sonarsource/analyzer-commons-configurations": { + "version": "2.31.0-5284", + "resolved": "https://registry.npmjs.org/@sonarsource/analyzer-commons-configurations/-/analyzer-commons-configurations-2.31.0-5284.tgz", + "integrity": "sha512-ruk3blMs9BE9RNSp9LPpWZAIjxJmq4Cp00E3cELIHz7Kym3WxdWGRlKOMYLRQ5tJg/de7jN5AtEElY3bGIfTcw==", + "dev": true, + "license": "LGPL-3.0-only" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "dev": true, @@ -4867,11 +5098,25 @@ "@types/ssh2": "*" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.9.1", "dev": true, @@ -4927,6 +5172,148 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript-eslint/parser": { + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.0.tgz", + "integrity": "sha512-0O+x3ynciD6zuXb1CKSu165haQV6ey53DKhHHUnujP7U+XlltJG+/keT0Xhd1HXCze+IHf7Tvt39ovyepX/LNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.72.0", + "@typescript-eslint/types": "8.72.0", + "@typescript-eslint/typescript-estree": "8.72.0", + "@typescript-eslint/visitor-keys": "8.72.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.0.tgz", + "integrity": "sha512-bzHngBbmIgzj6TsTjVUCntGx6FFLgE7mPjpO0PzHycOaZHXqS43XnrkFIHRS16LLr/xO6FHSXG3GJFo/F4bSBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.72.0", + "@typescript-eslint/types": "^8.72.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.0.tgz", + "integrity": "sha512-JewPyJOYcXBSAS7PIgufscHOwZqhTGyBRiQJhhuDln66tMchxvvt7nJ90JKKIoOelmdHA3kAQbm9cEUHc4JSEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.72.0", + "@typescript-eslint/visitor-keys": "8.72.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.0.tgz", + "integrity": "sha512-0vbxCf7xLVPSjcOdm6eQbgViH8EDJ8xnl1laLT9yoqSz2YmcSfSg5Vdl0JUvZf6rcuJ0ZivJIuMD4edTg8obaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.0.tgz", + "integrity": "sha512-Adfoy59kxNEjT5E5fnyrp7GpHYuWs8ONbdohu4ez6IU46qDq8LltG90tLmkucZB2uhYL8mqBjtXqGd8yhfjXqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.0.tgz", + "integrity": "sha512-gwLfFgWcw15tN2KXDCJvxEOQjPn+P3j/n3eSqkZ5sX4VpW9Cosodr+tWwBZRvJT3TcKx6HnPRGZMCNAiP1Xhug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.72.0", + "@typescript-eslint/tsconfig-utils": "8.72.0", + "@typescript-eslint/types": "8.72.0", + "@typescript-eslint/visitor-keys": "8.72.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.0.tgz", + "integrity": "sha512-E2mroviasaWecDzTGiGMtuEguuLVOeRggnrIn93CJuxYJzQBHbyymlT08aVQydbBal5538o+JkBKXh2sRuCyIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.72.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@vitest/coverage-v8": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", @@ -5630,6 +6017,19 @@ "resolved": "lambda/building-blocks", "link": true }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/byline": { "version": "5.0.0", "dev": true, @@ -5645,6 +6045,20 @@ "node": ">= 0.8" } }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "license": "MIT", @@ -5993,6 +6407,13 @@ } } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "license": "MIT", @@ -6322,24 +6743,304 @@ "version": "1.0.3", "license": "MIT" }, - "node_modules/estree-walker": { - "version": "3.0.3", + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/etag": { - "version": "1.8.1", + "node_modules/eslint": { + "version": "10.13.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.13.0.tgz", + "integrity": "sha512-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg==", + "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, "engines": { - "node": ">= 0.6" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "license": "MIT", + "node_modules/eslint-plugin-sonarjs": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-4.2.2.tgz", + "integrity": "sha512-usNF9GfP89ibwhfxpZaCMW40sC7UEOusDgDCk7lyqAhh4AYdbBaAgLVL5Y/QS0jSed8eJ2ACGH6zzE8ezd/yZQ==", + "dev": true, + "license": "LGPL-3.0-only", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@sonarsource/analyzer-commons-configurations": "^2.31.0-5284", + "builtin-modules": "^3.3.0", + "bytes": "^3.1.2", + "functional-red-black-tree": "^1.0.1", + "globals": "^17.12.0", + "jsx-ast-utils-x": "^0.1.0", + "lodash.merge": "^4.6.2", + "minimatch": "^10.2.6", + "scslre": "^0.3.0", + "semver": "^7.8.5", + "ts-api-utils": "^2.5.0", + "typescript": ">=5 <6.1.0", + "yaml": "^2.9.1" + }, + "peerDependencies": { + "eslint": "^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/globals": { + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "license": "MIT", "engines": { "node": ">=6" } @@ -6452,6 +7153,20 @@ "version": "1.3.2", "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", @@ -6484,6 +7199,16 @@ } } }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "license": "MIT", @@ -6503,6 +7228,42 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/forwarded": { "version": "0.2.0", "license": "MIT", @@ -6541,6 +7302,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "dev": true, + "license": "MIT" + }, "node_modules/general-info": { "resolved": "lambda/general-info", "link": true @@ -6631,6 +7399,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/global-directory": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", @@ -6702,6 +7483,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hasown": { "version": "2.0.4", "license": "MIT", @@ -6721,6 +7515,13 @@ "node": ">=16.9.0" } }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, "node_modules/hosted-git-info": { "version": "9.0.3", "dev": true, @@ -6809,6 +7610,16 @@ "node": ">= 4" } }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/index-to-position": { "version": "1.2.0", "dev": true, @@ -6884,6 +7695,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "dev": true, @@ -6892,6 +7713,19 @@ "node": ">=8" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-installed-globally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.0.tgz", @@ -7035,6 +7869,13 @@ "version": "8.0.2", "license": "BSD-2-Clause" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "dev": true, @@ -7046,11 +7887,31 @@ "node": ">=6" } }, + "node_modules/jsx-ast-utils-x": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/jsx-ast-utils-x/-/jsx-ast-utils-x-0.1.0.tgz", + "integrity": "sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/just-extend": { "version": "6.2.0", "dev": true, "license": "MIT" }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -7095,6 +7956,20 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "dev": true, @@ -7421,6 +8296,22 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash": { "version": "4.18.1", "license": "MIT" @@ -7430,6 +8321,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.truncate": { "version": "4.4.2", "dev": true, @@ -7745,6 +8643,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/negotiator": { "version": "1.0.0", "license": "MIT", @@ -7871,6 +8776,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/oxfmt": { "version": "0.72.0", "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.72.0.tgz", @@ -7972,6 +8895,38 @@ } } }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-map": { "version": "7.0.4", "dev": true, @@ -8017,6 +8972,16 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-key": { "version": "3.1.1", "license": "MIT", @@ -8118,6 +9083,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/process": { "version": "0.11.10", "license": "MIT", @@ -8228,10 +9203,40 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/purge-neptune": { "resolved": "lambda/purge-neptune", "link": true }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/qs": { "version": "6.16.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", @@ -8349,6 +9354,33 @@ "node": ">= 10.13.0" } }, + "node_modules/refa": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/refa/-/refa-0.12.1.tgz", + "integrity": "sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.8.0" + }, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/regexp-ast-analysis": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/regexp-ast-analysis/-/regexp-ast-analysis-0.7.1.tgz", + "integrity": "sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.8.0", + "refa": "^0.12.1" + }, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, "node_modules/regexp-tree": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", @@ -8510,6 +9542,21 @@ "version": "2.1.2", "license": "MIT" }, + "node_modules/scslre": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/scslre/-/scslre-0.3.0.tgz", + "integrity": "sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.8.0", + "refa": "^0.12.0", + "regexp-ast-analysis": "^0.7.0" + }, + "engines": { + "node": "^14.0.0 || >=16.0.0" + } + }, "node_modules/secretlint": { "version": "13.0.2", "dev": true, @@ -8536,7 +9583,9 @@ "link": true }, "node_modules/semver": { - "version": "7.8.1", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -9248,6 +10297,19 @@ "resolved": "lambda/trackers", "link": true }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tsconfig-paths": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", @@ -9321,6 +10383,19 @@ "dev": true, "license": "Unlicense" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-detect": { "version": "4.0.8", "dev": true, @@ -9370,6 +10445,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici": { "version": "7.30.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", @@ -9403,6 +10492,16 @@ "node": ">= 0.8" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/user-stories": { "resolved": "lambda/user-stories", "link": true @@ -9664,6 +10763,16 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/workflows": { "resolved": "lambda/workflows", "link": true @@ -9712,7 +10821,9 @@ } }, "node_modules/yaml": { - "version": "2.9.0", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", "dev": true, "license": "ISC", "bin": { @@ -9787,6 +10898,19 @@ "node": ">=8" } }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zip-stream": { "version": "6.0.1", "license": "MIT", diff --git a/package.json b/package.json index b325c920b..5c16f854a 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,8 @@ "dev:agent-output": "npm --prefix frontend run dev:agent-output", "test:coverage": "vitest run --coverage", "lint": "oxlint", + "complexity": "eslint --no-config-lookup --config eslint.complexity.config.mjs --no-inline-config lambda frontend/src", + "complexity:compare": "node scripts/compare-complexity.mjs", "format": "oxfmt", "format:check": "oxfmt --check", "secretlint": "secretlint --secretlintignore .gitignore '**/*'", @@ -84,11 +86,14 @@ "@secretlint/secretlint-rule-preset-recommend": "13.0.2", "@smithy/config-resolver": "^4.6.10", "@smithy/node-config-provider": "^3.0.0", + "@typescript-eslint/parser": "8.72.0", "@vitest/coverage-v8": "^4.1.11", "aws-sdk-client-mock": "^4.1.0", "aws-sdk-client-mock-vitest": "^7.1.0", "dependency-cruiser": "^16.10.4", "esbuild": "^0.28.1", + "eslint": "10.13.0", + "eslint-plugin-sonarjs": "4.2.2", "gremlin": "^3.8.1", "gremlin-aws-sigv4": "^3.6.1", "husky": "9.1.7", diff --git a/scripts/compare-complexity.mjs b/scripts/compare-complexity.mjs new file mode 100644 index 000000000..0f385ab60 --- /dev/null +++ b/scripts/compare-complexity.mjs @@ -0,0 +1,310 @@ +#!/usr/bin/env node +import { execFileSync } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; +import { + analyzeSource, + compareFunctions, + complexityRule, + complexityThreshold, + createAnalyzer, + summarizeFunctions, +} from './complexity-analysis.mjs'; +import { attachCoverage, hasCoverage } from './complexity-coverage.mjs'; +import { renderComparison } from './complexity-report.mjs'; +export { renderComparison } from './complexity-report.mjs'; + +const require = createRequire(import.meta.url); +const loadCoverage = async (path) => (path ? JSON.parse(await readFile(path, 'utf8')) : undefined); + +function git(cwd, args) { + return execFileSync('git', ['--literal-pathspecs', ...args], { + cwd, + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function resolveCommit(cwd, ref) { + return git(cwd, ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`]).trim(); +} + +function changedFiles(cwd, base, head) { + const fields = git(cwd, [ + 'diff', + '--name-status', + '-z', + '--find-renames', + base, + head, + '--', + ]).split('\0'); + const files = []; + for (let index = 0; fields[index];) { + const status = fields[index++]; + const oldPath = fields[index++]; + const path = status.startsWith('R') ? fields[index++] : oldPath; + files.push({ + path, + previousPath: status === 'A' ? null : oldPath, + headPath: status === 'D' ? null : path, + }); + } + return files; +} + +async function measure(cwd, analyzer, revision, path, coverage) { + if (path === null) return null; + const fileConfig = await analyzer.calculateConfigForFile(resolve(cwd, path)); + if (!fileConfig?.rules?.[complexityRule]) return null; + const tree = git(cwd, ['ls-tree', '-z', revision, '--', path]); + if (!/^100(644|755) blob /.test(tree)) { + throw new Error(`${revision}:${path}: expected a regular source file`); + } + const source = git(cwd, ['show', `${revision}:${path}`]); + return attachCoverage( + await analyzeSource(cwd, path, source), + source, + coverage?.files[path], + coverage, + revision, + ); +} + +async function dependentFileCounts(cwd, analyzer, head) { + const snapshot = await mkdtemp(join(tmpdir(), 'complexity-dependencies-')); + try { + const counts = new Map(); + const files = []; + const entries = git(cwd, ['ls-tree', '-r', '-z', head, '--', 'lambda', 'package.json']).split( + '\0', + ); + for (const entry of entries.filter(Boolean)) { + const path = entry.slice(entry.indexOf('\t') + 1); + const source = + /\.[cm]?js$/.test(path) && + (await analyzer.calculateConfigForFile(resolve(cwd, path)))?.rules?.[complexityRule]; + if (!source && !path.endsWith('package.json')) continue; + const blob = /^100(?:644|755) blob ([a-f0-9]+)\t/.exec(entry); + if (!blob) throw new Error(`${head}:${path}: expected a regular file`); + files.push({ path, objectId: blob[1] }); + if (source) counts.set(path, new Set()); + } + if (!counts.size) return new Map(); + // One Git process, using object IDs so filenames cannot affect the batch protocol. + // Read raw blobs: archive/checkout can omit or transform files via Git attributes. + const blobs = execFileSync('git', ['cat-file', '--batch'], { + cwd, + input: files.map((file) => `${file.objectId}\n`).join(''), + maxBuffer: 128 * 1024 * 1024, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let offset = 0; + for (const { path, objectId } of files) { + const headerEnd = blobs.indexOf(10, offset); + const header = /^([a-f0-9]+) blob (\d+)$/.exec(blobs.toString('ascii', offset, headerEnd)); + if (headerEnd < offset || header?.[1] !== objectId) throw new Error('Invalid Git blob batch'); + const end = headerEnd + 1 + Number(header[2]); + if (end >= blobs.length || blobs[end] !== 10) throw new Error('Truncated Git blob batch'); + await mkdir(dirname(join(snapshot, path)), { recursive: true }); + await writeFile(join(snapshot, path), blobs.subarray(headerEnd + 1, end)); + offset = end + 1; + } + if (offset !== blobs.length) throw new Error('Unexpected Git blob batch data'); + // Use the installed CLI in an isolated source snapshot: no PR configuration, + // dependencies, scripts or dirty files are loaded or executed. + const cli = fileURLToPath( + new URL('../../bin/dependency-cruise.mjs', import.meta.resolve('dependency-cruiser')), + ); + const graph = JSON.parse( + execFileSync( + process.execPath, + [ + cli, + '--no-config', + '--output-type', + 'json', + '--include-only', + '^lambda/', + '--do-not-follow', + 'node_modules', + 'lambda', + ], + { + cwd: snapshot, + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ), + ); + for (const module of graph.modules) { + if (!counts.has(module.source)) continue; + for (const dependency of module.dependencies) { + if (!dependency.couldNotResolve && dependency.resolved !== module.source) { + counts.get(dependency.resolved)?.add(module.source); + } + } + } + return new Map([...counts].map(([path, importers]) => [path, importers.size])); + } finally { + await rm(snapshot, { recursive: true, force: true }); + } +} + +export async function compareRevisions({ + cwd, + base = 'origin/main', + head = 'HEAD', + baseRef, + baseCoverage, + headCoverage, + coverageNotNeeded = false, +}) { + const baseSha = resolveCommit(cwd, base); + const headSha = resolveCommit(cwd, head); + const mergeBase = git(cwd, ['merge-base', baseSha, headSha]).trim(); + const analyzer = createAnalyzer(cwd); + const coverageNotes = []; + for (const [label, snapshot, revision] of [ + ['Merge-base', baseCoverage, mergeBase], + ['Head', headCoverage, headSha], + ]) { + if (!snapshot) { + coverageNotes.push( + `${label} coverage: ${coverageNotNeeded ? 'not needed for this change' : 'unknown (no report supplied)'}.`, + ); + } else if (!hasCoverage(snapshot)) { + coverageNotes.push( + `${label} coverage: unknown (test run or coverage collection failed to produce a report).`, + ); + } else if (snapshot.revision !== revision) { + coverageNotes.push(`${label} coverage: unknown (report belongs to a different revision).`); + } else if (snapshot.status === 'partial') { + coverageNotes.push( + `${label} coverage: measured with failing or interrupted tests (*). Counts and deltas may reflect incomplete execution.`, + ); + } + } + if ( + hasCoverage(baseCoverage) && + hasCoverage(headCoverage) && + JSON.stringify(baseCoverage.instrumenter) !== JSON.stringify(headCoverage.instrumenter) + ) { + coverageNotes.push( + 'Coverage is unknown: Node, Vitest, provider, or coverage configuration differs.', + ); + baseCoverage = undefined; + headCoverage = undefined; + } + const candidates = changedFiles(cwd, mergeBase, headSha); + // Test-only changes can expose existing complex functions. Include files whose + // measured branch outcomes changed, even when their source did not. + if ( + hasCoverage(baseCoverage) && + hasCoverage(headCoverage) && + baseCoverage.revision === mergeBase && + headCoverage.revision === headSha + ) { + const included = new Set(candidates.flatMap((file) => [file.path, file.previousPath])); + for (const [path, next] of Object.entries(headCoverage.files)) { + const old = baseCoverage.files[path]; + if ( + !included.has(path) && + old && + JSON.stringify(old.branches) !== JSON.stringify(next.branches) + ) { + candidates.push({ path, previousPath: path, headPath: path }); + } + } + } + const files = []; + for (const file of candidates) { + const before = await measure(cwd, analyzer, mergeBase, file.previousPath, baseCoverage); + const after = await measure(cwd, analyzer, headSha, file.headPath, headCoverage); + if (before === null && after === null) continue; + files.push({ + path: file.path, + previousPath: file.previousPath, + before: before ?? [], + after: after ?? [], + summary: { + before: summarizeFunctions(before ?? []), + after: summarizeFunctions(after ?? []), + }, + }); + } + const comparison = compareFunctions(files); + const rows = [...comparison.findings, ...comparison.improvements]; + let dependents = new Map(); + let dependencyNote; + if (rows.some((row) => row.path.startsWith('lambda/'))) { + try { + dependents = await dependentFileCounts(cwd, analyzer, headSha); + } catch (error) { + console.warn(`Dependency analysis failed: ${error.message}`); + dependencyNote = 'Depending files: unknown (dependency analysis failed).'; + } + } + for (const row of rows) row.dependentFiles = dependents.get(row.path) ?? null; + return { + schemaVersion: 1, + base: baseSha, + baseRef, + mergeBase, + head: headSha, + coverageNotes, + ...(dependencyNote ? { dependencyNote } : {}), + analyzer: { + rule: complexityRule, + sonarjs: require('eslint-plugin-sonarjs/package.json').version, + eslint: require('eslint/package.json').version, + parser: require('@typescript-eslint/parser/package.json').version, + threshold: complexityThreshold, + }, + files, + ...comparison, + }; +} + +async function main() { + const { values } = parseArgs({ + options: { + base: { type: 'string', default: 'origin/main' }, + head: { type: 'string', default: 'HEAD' }, + 'base-ref': { type: 'string' }, + 'output-dir': { type: 'string', default: 'reports/complexity' }, + 'base-coverage': { type: 'string' }, + 'head-coverage': { type: 'string' }, + 'coverage-not-needed': { type: 'boolean', default: false }, + }, + }); + const cwd = git(process.cwd(), ['rev-parse', '--show-toplevel']).trim(); + const report = await compareRevisions({ + cwd, + base: values.base, + head: values.head, + baseRef: values['base-ref'], + baseCoverage: await loadCoverage(values['base-coverage']), + headCoverage: await loadCoverage(values['head-coverage']), + coverageNotNeeded: values['coverage-not-needed'], + }); + const markdown = renderComparison(report); + const outputDir = resolve(cwd, values['output-dir']); + await mkdir(outputDir, { recursive: true }); + await writeFile(join(outputDir, 'comparison.json'), `${JSON.stringify(report, null, 2)}\n`); + await writeFile(join(outputDir, 'comparison.md'), markdown); + process.stdout.write(markdown); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(`Complexity comparison failed: ${error.message}`); + process.exitCode = 1; + }); +} diff --git a/scripts/complexity-analysis.mjs b/scripts/complexity-analysis.mjs new file mode 100644 index 000000000..c98242f8f --- /dev/null +++ b/scripts/complexity-analysis.mjs @@ -0,0 +1,271 @@ +import { createHash } from 'node:crypto'; +import { ESLint } from 'eslint'; +import sonarjs from 'eslint-plugin-sonarjs'; +import config from '../eslint.complexity.config.mjs'; +import { coverageChange, coverageRegressed } from './complexity-coverage.mjs'; + +export const complexityRule = 'sonarjs/cognitive-complexity'; +export const complexityThreshold = config.find((entry) => entry.rules?.[complexityRule]).rules[ + complexityRule +][1]; + +const functions = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression']); + +function propertyName(node) { + if (node.computed) return null; + return node.key?.name ?? node.key?.value?.toString() ?? null; +} + +function functionName(node) { + const parent = node.parent; + if (parent.type === 'VariableDeclarator' && parent.id.type === 'Identifier') { + return parent.id.name; + } + if (['Property', 'MethodDefinition', 'PropertyDefinition'].includes(parent.type)) { + const name = propertyName(parent); + return name === null + ? null + : `${parent.static ? 'static ' : ''}${parent.kind ?? 'init'} ${name}`; + } + if (node.id) return node.id.name; + if (parent.type === 'ExportDefaultDeclaration') return 'default'; + return null; +} + +function functionIdentity(node) { + const scope = []; + let named = true; + for (let current = node; current; current = current.parent) { + if (functions.has(current.type)) { + const name = functionName(current); + scope.unshift(name ?? ''); + named &&= name !== null; + } else if (['ClassDeclaration', 'ClassExpression'].includes(current.type)) { + const name = current.id?.name ?? current.parent.id?.name; + scope.unshift(`class ${name ?? ''}`); + named &&= Boolean(name); + } else if (['ObjectExpression', 'ArrayExpression'].includes(current.type)) { + const parent = current.parent; + const name = parent.type === 'VariableDeclarator' ? parent.id.name : propertyName(parent); + scope.unshift(`object ${name ?? ''}`); + named &&= Boolean(name); + } + } + return { name: scope.join(' / '), identity: named ? JSON.stringify(scope) : null }; +} + +function describeFunction(node, sourceCode) { + const tokens = [...node.params, node.body].flatMap((part) => + sourceCode.getTokens(part).map((token) => [token.type, token.value]), + ); + const fingerprint = createHash('sha256') + .update(JSON.stringify([node.async, node.generator, tokens])) + .digest('hex'); + return { + ...functionIdentity(node), + line: node.loc.start.line, + column: node.loc.start.column + 1, + start: node.loc.start, + end: node.loc.end, + score: 0, + fingerprint, + }; +} + +export function createAnalyzer(cwd) { + return new ESLint({ + cwd, + overrideConfigFile: true, + overrideConfig: config, + allowInlineConfig: false, + }); +} + +export async function analyzeSource(cwd, filePath, source) { + const measured = new Map(); + const sonarRule = sonarjs.rules['cognitive-complexity']; + const measurementRule = { + ...sonarRule, + create(context) { + let currentFunction; + // Capture SonarJS's numeric result while it is visiting the function. + // Diagnostic text is not a data API, and locations can be identical for + // a computed method and a function expression used as its key. + const reportingContext = Object.create(context, { + report: { + value(descriptor) { + const fn = measured.get(currentFunction); + const score = descriptor.data?.complexityAmount; + if ( + !fn || + descriptor.messageId !== 'refactorFunction' || + !Number.isSafeInteger(score) || + score <= 0 || + fn.score !== 0 + ) { + throw new Error(`${filePath}: unsupported SonarJS measurement`); + } + fn.score = score; + fn.line = descriptor.loc.start.line; + fn.column = descriptor.loc.start.column + 1; + context.report(descriptor); + }, + }, + }); + const visitors = sonarRule.create(reportingContext); + if (!visitors[':function'] || !visitors[':function:exit']) { + throw new Error('Unsupported SonarJS function visitors; check the pinned analyzer version'); + } + return { + ...visitors, + ':function'(node) { + measured.set(node, describeFunction(node, context.sourceCode)); + visitors[':function'](node); + }, + ':function:exit'(node) { + currentFunction = node; + try { + visitors[':function:exit'](node); + } finally { + currentFunction = undefined; + } + }, + }; + }, + }; + const eslint = new ESLint({ + cwd, + overrideConfigFile: true, + allowInlineConfig: false, + overrideConfig: [ + ...config, + { + plugins: { measurement: { rules: { cognitive: measurementRule } } }, + rules: { + [complexityRule]: 'off', + 'measurement/cognitive': ['warn', 0], + }, + }, + ], + }); + const [result] = await eslint.lintText(source, { filePath }); + if (result.errorCount) { + throw new Error( + `${filePath}: ${result.messages.map((message) => `${message.line ?? 1}: ${message.message}`).join('\n')}`, + ); + } + return [...measured.values()]; +} + +function groupBy(items, key) { + const groups = new Map(); + for (const item of items) { + const value = key(item); + if (value === null) continue; + const group = groups.get(value) ?? []; + group.push(item); + groups.set(value, group); + } + return groups; +} + +function comparisonRow(old, next, kind) { + return { + path: next.path, + line: next.line, + function: next.name, + previousPath: old?.path ?? null, + previousLine: old?.line ?? null, + before: old?.score ?? null, + after: next.score, + delta: old ? next.score - old.score : null, + kind, + coverage: { + before: old?.coverage ?? null, + after: next.coverage ?? null, + change: coverageChange(old?.coverage, next.coverage), + }, + }; +} + +const unchangedKey = (fn) => + JSON.stringify([ + fn.matchPath, + fn.fingerprint, + fn.score, + fn.coverage?.status === 'measured' ? [fn.coverage.covered, fn.coverage.total] : null, + ]); + +export function compareFunctions(files, threshold = complexityThreshold) { + const before = new Set( + files.flatMap((file) => + file.before.map((fn) => ({ ...fn, path: file.previousPath, matchPath: file.path })), + ), + ); + const after = new Set( + files.flatMap((file) => + file.after.map((fn) => ({ ...fn, path: file.path, matchPath: file.path })), + ), + ); + const matches = []; + const matchUnique = (key) => { + const oldGroups = groupBy(before, key); + const newGroups = groupBy(after, key); + for (const [identity, old] of oldGroups) { + const next = newGroups.get(identity); + if (old.length !== 1 || next?.length !== 1) continue; + matches.push([old[0], next[0]]); + before.delete(old[0]); + after.delete(next[0]); + } + }; + matchUnique((fn) => (fn.identity === null ? null : JSON.stringify([fn.matchPath, fn.identity]))); + // Identical repeated callbacks need no individual identity to establish that + // their complexity is unchanged. Cancel them by count within the same file. + const oldBodies = groupBy(before, unchangedKey); + for (const [key, next] of groupBy(after, unchangedKey)) { + const old = oldBodies.get(key) ?? []; + for (let index = 0; index < Math.min(old.length, next.length); index++) { + before.delete(old[index]); + after.delete(next[index]); + } + } + // Unique, unchanged bodies recognize moves, renames, and shifted anonymous + // callbacks. Changed anonymous functions deliberately remain unmatched. + matchUnique((fn) => JSON.stringify([fn.fingerprint, fn.score])); + + const findings = []; + const improvements = []; + for (const [old, next] of matches) { + if (next.score > threshold && next.score > old.score) { + findings.push( + comparisonRow(old, next, old.score <= threshold ? 'crossed-threshold' : 'increased'), + ); + } else if (next.score > threshold && coverageRegressed(old.coverage, next.coverage)) { + findings.push(comparisonRow(old, next, 'coverage-decreased')); + } + if (old.score > threshold && next.score < old.score) { + improvements.push(comparisonRow(old, next, 'improved')); + } + } + for (const next of after) { + if (next.score > threshold) findings.push(comparisonRow(null, next, 'new-or-unmatched')); + } + findings.sort( + (a, b) => + Number(coverageRegressed(b.coverage.before, b.coverage.after)) - + Number(coverageRegressed(a.coverage.before, a.coverage.after)) || + (b.coverage.after?.uncovered ?? -1) - (a.coverage.after?.uncovered ?? -1) || + (b.delta ?? b.after) - (a.delta ?? a.after) || + a.path.localeCompare(b.path), + ); + return { findings, improvements }; +} + +export function summarizeFunctions(entries, threshold = complexityThreshold) { + return { + functions: entries.length, + maximum: Math.max(0, ...entries.map((fn) => fn.score)), + aboveThreshold: entries.filter((fn) => fn.score > threshold).length, + }; +} diff --git a/scripts/complexity-coverage.mjs b/scripts/complexity-coverage.mjs new file mode 100644 index 000000000..cf193856c --- /dev/null +++ b/scripts/complexity-coverage.mjs @@ -0,0 +1,129 @@ +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { readFile, realpath, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { isAbsolute, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; + +export const sourceHash = (source) => createHash('sha256').update(source).digest('hex'); +const unknown = (reason) => ({ status: 'unknown', reason }); +const position = (point) => point.line * 1e7 + point.column; +export const hasCoverage = (snapshot) => ['complete', 'partial'].includes(snapshot?.status); + +// Assign outcomes at the branch's control location, not an arm's location: +// implicit else arms have no location and an arm can itself return a callback. +// The innermost enclosing function owns the branch. +export function attachCoverage(functions, source, file, snapshot, revision) { + let reason; + if (!hasCoverage(snapshot)) reason = 'Coverage report unavailable'; + else if (snapshot.revision !== revision) reason = 'Coverage revision does not match'; + else if (!file) reason = 'Outside coverage scope or missing from report'; + else if (file.sourceHash !== sourceHash(source)) reason = 'Coverage source does not match'; + if (reason) return functions.map((fn) => ({ ...fn, coverage: unknown(reason) })); + + const counts = new Map(functions.map((fn) => [fn, { covered: 0, total: 0 }])); + for (const branch of file.branches) { + const start = position(branch.start); + const owner = functions + .filter((fn) => position(fn.start) <= start && start < position(fn.end)) + .toSorted((a, b) => position(b.start) - position(a.start))[0]; + if (!owner) continue; // Module-level branches are not function coverage. + const count = counts.get(owner); + count.total++; + if (branch.hit) count.covered++; + } + return functions.map((fn) => { + const { covered, total } = counts.get(fn); + return { + ...fn, + coverage: { + status: 'measured', + partial: snapshot.status === 'partial', + covered, + total, + uncovered: total - covered, + percent: total ? (100 * covered) / total : null, + }, + }; + }); +} + +export function coverageChange(before, after) { + if (before?.status !== 'measured' || after?.status !== 'measured') return null; + return { + percentagePoints: + before.percent === null || after.percent === null ? null : after.percent - before.percent, + uncovered: after.uncovered - before.uncovered, + }; +} + +export function coverageRegressed(before, after) { + const change = coverageChange(before, after); + return change !== null && (change.percentagePoints < 0 || change.uncovered > 0); +} + +export async function collectCoverage({ cwd, input, successful }) { + cwd = await realpath(cwd); + const revision = execFileSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf8' }).trim(); + const snapshot = { schemaVersion: 1, revision, status: 'unavailable', files: {} }; + if (!input && !successful) return snapshot; + let raw; + try { + raw = JSON.parse(await readFile(input, 'utf8')); + } catch (error) { + if (!successful && error.code === 'ENOENT') return snapshot; + throw error; + } + const require = createRequire(resolve(cwd, 'package.json')); + snapshot.instrumenter = { + node: process.versions.node, + vitest: require('vitest/package.json').version, + provider: require('@vitest/coverage-v8/package.json').version, + config: sourceHash(await readFile(resolve(cwd, 'vitest.config.js'))), + }; + for (const [path, coverage] of Object.entries(raw)) { + const name = relative(cwd, path).split('\\').join('/'); + if (name.startsWith('../') || isAbsolute(name) || !name.startsWith('lambda/')) continue; + const branches = []; + for (const [id, branch] of Object.entries(coverage.branchMap)) { + branch.locations.forEach((_location, index) => { + const hits = coverage.b[id]?.[index]; + if (!Number.isFinite(hits) || !branch.loc?.start?.line) { + throw new Error(`Invalid branch coverage in ${name}`); + } + // V8 can infer negative else counts. Match Istanbul's coverage + // semantics: positive counts are covered; non-positive are uncovered. + branches.push({ start: branch.loc.start, hit: hits > 0 }); + }); + } + snapshot.files[name] = { + sourceHash: sourceHash(await readFile(resolve(cwd, name))), + branches, + }; + } + snapshot.status = successful ? 'complete' : 'partial'; + return snapshot; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const { values } = parseArgs({ + options: { + cwd: { type: 'string', default: process.cwd() }, + input: { type: 'string' }, + output: { type: 'string' }, + successful: { type: 'boolean', default: false }, + }, + }); + try { + const snapshot = await collectCoverage({ + cwd: resolve(values.cwd), + input: values.input, + successful: values.successful, + }); + await writeFile(values.output, `${JSON.stringify(snapshot)}\n`); + } catch (error) { + console.error(`Coverage collection failed: ${error.message}`); + process.exitCode = 1; + } +} diff --git a/scripts/complexity-report.mjs b/scripts/complexity-report.mjs new file mode 100644 index 000000000..e45602d4a --- /dev/null +++ b/scripts/complexity-report.mjs @@ -0,0 +1,105 @@ +// Dependency-free: also used by the trusted CI comment job, which never loads +// modules or Markdown supplied by a pull request. +const cell = (value) => + String(value) + .slice(0, 500) + .replace(/[&<>|`\r\n@[\]\\!*_~]/g, (character) => `&#${character.charCodeAt(0)};`); +const signed = (value) => `${value > 0 ? '+' : ''}${value}`; +const uncovered = (value) => (value?.status === 'measured' ? value.uncovered : '?'); +const percent = (coverage) => + coverage?.status !== 'measured' + ? 'unknown' + : coverage.percent === null + ? 'n/a' + : `${coverage.percent.toFixed(1)}%${coverage.partial ? '*' : ''}`; + +function currentRiskIcon(row) { + let level = row.after > 100 ? 3 : row.after > 50 ? 2 : row.after > 15 ? 1 : 0; + const coverage = row.coverage?.after; + if (coverage?.status === 'measured' && coverage.percent != null && coverage.percent < 50) { + level = Math.min(3, level + 1); + } + return ['🟢', '🟠', '🔴', '🟣'][level]; +} + +function complexityCoverageIcon(row) { + const coverageDelta = row.coverage?.change?.percentagePoints; + if (row.before == null || row.delta == null || coverageDelta == null) return currentRiskIcon(row); + + // Normalize the two different units before balancing mixed changes. + // The five-point floor keeps tiny complexity changes from looking drastic. + const complexityWorsening = row.delta / Math.max(5, row.before * 0.2); + const coverageWorsening = -coverageDelta / 10; + if ( + complexityWorsening <= 0 && + coverageWorsening <= 0 && + (complexityWorsening < 0 || coverageWorsening < 0) + ) + return '🟢'; + if (complexityWorsening >= 1 && coverageWorsening >= 1) return '🟣'; + return complexityWorsening + coverageWorsening >= 1 ? '🔴' : '🟠'; +} + +export function changeIcon(row) { + const icon = complexityCoverageIcon(row); + const { after, change } = row.coverage ?? {}; + const weakCoverage = after?.status === 'measured' && after.percent != null && after.percent < 50; + const regressingCoverage = change?.percentagePoints < 0 || change?.uncovered > 0; + // Keep improvements green. Coupling amplifies an existing warning, not debt alone. + if (icon !== '🟢' && row.dependentFiles > 10 && (weakCoverage || regressingCoverage)) { + return icon === '🟠' ? '🔴' : '🟣'; + } + return icon; +} + +function renderRows(rows) { + return [ + '| | File / function | Complexity | Δ | Branch coverage | Δ | Uncovered branches | Depending files |', + '| --- | --- | ---: | ---: | --- | ---: | ---: | ---: |', + ...rows.slice(0, 20).map((row) => { + const { before, after, change } = row.coverage ?? {}; + const coverage = `${percent(before)} → ${percent(after)}`; + const delta = change?.percentagePoints; + return `| ${changeIcon(row)} | ${cell(`${row.path}:${row.line} — ${row.function}`)} | ${row.before ?? '—'} → ${row.after} | ${row.delta === null ? '—' : signed(row.delta)} | ${coverage} | ${delta == null ? '—' : signed(Number(delta.toFixed(1)))} | ${uncovered(before)} → ${uncovered(after)} | ${row.dependentFiles ?? 'unknown'} |`; + }), + ...(rows.length > 20 + ? [`\nShowing 20 of ${rows.length}; the JSON report contains every result.`] + : []), + ].join('\n'); +} + +export function renderComparison(report) { + const lines = [ + '# Complexity and test coverage', + '', + `Merge base \`${cell(report.mergeBase)}\` → head \`${cell(report.head)}\`.`, + `Analyzed ${report.files.length} source files with source or coverage changes. Advisory threshold: ${report.analyzer.threshold}.`, + '', + `${report.findings.length} new/unmatched or worsening functions above the threshold.`, + ]; + for (const note of report.coverageNotes ?? []) lines.push('', cell(note)); + if (report.dependencyNote) lines.push('', cell(report.dependencyNote)); + if (report.findings.length) lines.push('', renderRows(report.findings)); + if (report.improvements.length) { + lines.push( + '', + `## Complexity improvements (${report.improvements.length})`, + '', + renderRows(report.improvements), + ); + } + lines.push( + '', + 'With comparable data: 🟢 Improving without either metric worsening · 🟠 Mixed or small changes · 🔴 Large net regression · 🟣 Both worsen substantially.', + 'Without comparable data: use current complexity—🟢 ≤15, 🟠 >15, 🔴 >50, 🟣 >100. Measured head coverage below 50% raises the color one level; unknown coverage leaves it unchanged.', + 'Depending files = direct production importers of the file at PR head (backend only; excludes tests and AWS/external callers). More than 10 raises an existing warning one level, capped at purple, when coverage is below 50% or regresses. Improvements stay green.', + 'Comparison scale: complexity Δ / max(5, 20% of base complexity), minus coverage Δ / 10 percentage points. Net ≥ 1 is red; both worsening by ≥ 1 unit is purple. Icons are review heuristics, not correctness guarantees; * measurements also make the icon provisional.', + 'Review order: coverage regressions first, then uncovered branch count, then complexity increase.', + 'Branch coverage uses backend Vitest/V8 outcomes assigned to the innermost function. * = measured with failing or interrupted tests; coverage and deltas may be incomplete. Frontend and missing data are unknown; n/a means no measured outcomes. Coverage Δ is measured in percentage points.', + 'Unchanged existing complexity is omitted unless coverage regresses. Improvements do not offset findings.', + 'New or unmatched means no confident function match was found; no increase is inferred.', + 'Advisory review aid, not a correctness guarantee or merge gate.', + '', + ); + return lines.join('\n'); +} diff --git a/scripts/publish-complexity-comment.mjs b/scripts/publish-complexity-comment.mjs new file mode 100644 index 000000000..a09e18cf2 --- /dev/null +++ b/scripts/publish-complexity-comment.mjs @@ -0,0 +1,212 @@ +import { lstat, readFile } from 'node:fs/promises'; +import { renderComparison } from './complexity-report.mjs'; + +export const commentMarker = ''; +const sha = (value) => typeof value === 'string' && /^[a-f0-9]{40}$/.test(value); +const integer = (value) => Number.isSafeInteger(value) && value >= 0; +const text = (value) => typeof value === 'string' && value.length <= 10000; + +function validCoverage(value) { + if (value === null) return true; + if (!value || !['unknown', 'measured'].includes(value.status)) return false; + if (value.status === 'unknown') return true; + return ( + (value.partial === undefined || typeof value.partial === 'boolean') && + [value.total, value.covered, value.uncovered].every(integer) && + value.covered + value.uncovered === value.total && + (value.total === 0 + ? value.percent === null + : Number.isFinite(value.percent) && + Math.abs(value.percent - (100 * value.covered) / value.total) < 1e-9) + ); +} + +export function validateReport(report) { + if ( + report?.schemaVersion !== 1 || + ![report.base, report.mergeBase, report.head].every(sha) || + !text(report.baseRef) || + !report.baseRef || + !(report.dependencyNote === undefined || text(report.dependencyNote)) || + !integer(report.analyzer?.threshold) || + !Array.isArray(report.files) || + report.files.length > 100000 || + !Array.isArray(report.coverageNotes) || + report.coverageNotes.length > 10 || + !report.coverageNotes.every(text) + ) + throw new Error('Invalid report metadata'); + for (const rows of [report.findings, report.improvements]) { + if (!Array.isArray(rows) || rows.length > 100000) throw new Error('Invalid report rows'); + for (const row of rows) { + if ( + !text(row.path) || + !text(row.function) || + !integer(row.line) || + !integer(row.after) || + !(row.dependentFiles == null || integer(row.dependentFiles)) || + !(row.before === null || integer(row.before)) || + row.delta !== (row.before === null ? null : row.after - row.before) || + ![ + 'new-or-unmatched', + 'crossed-threshold', + 'increased', + 'improved', + 'coverage-decreased', + ].includes(row.kind) || + !row.coverage || + !validCoverage(row.coverage.before) || + !validCoverage(row.coverage.after) + ) + throw new Error('Invalid report row'); + // Derive deltas from validated counts, not artifact-provided Markdown or deltas. + const { before, after } = row.coverage; + row.coverage.change = + before?.status === 'measured' && after?.status === 'measured' + ? { + percentagePoints: + before.percent === null || after.percent === null + ? null + : after.percent - before.percent, + uncovered: after.uncovered - before.uncovered, + } + : null; + } + } + return report; +} + +// Select the latest attempt of each producer, not the consumer's attempt. +// A producer that reran and failed must not fall back to its previous artifact. +export async function qualityArtifactNames({ github, repo, runId, attempt }) { + const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, { + ...repo, + run_id: runId, + filter: 'all', + per_page: 100, + }); + const names = {}; + for (const [role, jobName, prefix] of [ + ['base', 'coverage-base', 'coverage-base'], + ['head', 'coverage-head', 'coverage-head'], + ['report', 'report', 'pr-quality'], + ]) { + const producer = jobs + .filter((job) => job.name === jobName && job.run_attempt <= attempt) + .toSorted((a, b) => b.run_attempt - a.run_attempt)[0]; + names[role] = + producer && producer.conclusion !== 'skipped' ? `${prefix}-${producer.run_attempt}` : ''; + } + return names; +} + +// Same-repository PRs use their pinned scripts; workflow_run uses default-branch code. +// PR identity comes from GitHub; artifacts cannot select the destination. +export async function publishComplexityComment({ + github, + context, + reportPath, + attempt = Number(process.env.GITHUB_RUN_ATTEMPT), + warn = console.warn, +}) { + let run = context.payload.workflow_run; + const repo = context.repo; + let candidates; + if (context.eventName === 'pull_request') { + const pr = context.payload.pull_request; + if (pr.head.repo?.id !== context.payload.repository.id || context.actor === 'dependabot[bot]') + return; + run = { + event: 'pull_request', + name: 'PR Quality', + id: context.runId, + run_attempt: attempt, + repository: context.payload.repository, + head_sha: pr.head.sha, + head_repository: pr.head.repo, + }; + candidates = [pr]; + } + if ( + !run || + run.event !== 'pull_request' || + run.name !== 'PR Quality' || + run.repository.id !== context.payload.repository.id + ) + return; + if (!candidates) { + const pulls = await github.paginate(github.rest.pulls.list, { + ...repo, + state: 'open', + per_page: 100, + }); + candidates = pulls.filter( + (pr) => pr.head.sha === run.head_sha && pr.head.repo?.id === run.head_repository?.id, + ); + } + let report; + try { + const stat = await lstat(reportPath); + if (!stat.isFile() || stat.size > 20 * 1024 * 1024) + throw new Error('Report must be a regular file under 20 MiB'); + report = validateReport(JSON.parse(await readFile(reportPath, 'utf8'))); + } catch (error) { + warn(`Quality report unavailable: ${error.message}`); + } + for (const candidate of candidates) { + // Refresh just before writing; do not publish an old commit or target base. + const { data: pr } = await github.rest.pulls.get({ ...repo, pull_number: candidate.number }); + if ( + pr.state !== 'open' || + pr.head.sha !== run.head_sha || + pr.head.repo?.id !== run.head_repository?.id + ) + continue; + if (report && report.head !== pr.head.sha) continue; + const comments = await github.paginate(github.rest.issues.listComments, { + ...repo, + issue_number: pr.number, + per_page: 100, + }); + const existing = comments.find( + (comment) => + comment.user?.login === 'github-actions[bot]' && + comment.user?.type === 'Bot' && + comment.body?.startsWith(commentMarker), + ); + const previous = existing?.body.match(//); + if ( + previous && + (Number(previous[1]) > run.id || + (Number(previous[1]) === run.id && Number(previous[2]) > run.run_attempt)) + ) + continue; + const url = `https://github.com/${repo.owner}/${repo.repo}/actions/runs/${run.id}`; + let content = `# Complexity and test coverage\n\nReport unavailable for \`${pr.head.sha}\`. Check the CI run; this is not a clean result.`; + if (report) { + let sameBase = report.baseRef === pr.base.ref; + if (sameBase) { + // Resolve the actual target tip: pulls.get can return a stale base.sha. + const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({ + ...repo, + basehead: `${pr.base.ref}...${pr.head.sha}`, + per_page: 1, + }); + sameBase = comparison.merge_base_commit.sha === report.mergeBase; + } + content = sameBase + ? renderComparison(report) + : `# Complexity and test coverage\n\nThe target branch or merge base changed. Push an updated branch to generate a fresh comparison for \`${pr.head.sha}\`.`; + } + const preview = + content.length <= 55000 + ? content + : `${content.slice(0, 55000)}\n\nDisplay truncated; see the full report artifact.`; + const body = `${commentMarker}\n\n${preview}\n[CI run and full report artifacts](${url})`; + if (existing) { + await github.rest.issues.updateComment({ ...repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ ...repo, issue_number: pr.number, body }); + } + } +} diff --git a/scripts/test/compare-complexity.test.mjs b/scripts/test/compare-complexity.test.mjs new file mode 100644 index 000000000..256ced214 --- /dev/null +++ b/scripts/test/compare-complexity.test.mjs @@ -0,0 +1,478 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { analyzeSource, compareFunctions } from '../complexity-analysis.mjs'; +import { compareRevisions, renderComparison } from '../compare-complexity.mjs'; +import { attachCoverage, sourceHash } from '../complexity-coverage.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const script = join(root, 'scripts/compare-complexity.mjs'); +const branchFunction = (name, score) => + `function ${name}(value) { +${Array.from({ length: score }, (_, index) => `if (value === ${index}) return ${index};`).join('\n')} +return -1; +}`; + +function repository(t) { + const cwd = mkdtempSync(join(tmpdir(), 'complexity-compare-')); + t.after(() => rmSync(cwd, { recursive: true, force: true })); + const git = (...args) => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim(); + git('init', '-q', '--initial-branch=main'); + git('config', 'user.name', 'Complexity test'); + git('config', 'user.email', 'complexity@example.com'); + const put = (path, source) => { + mkdirSync(dirname(join(cwd, path)), { recursive: true }); + writeFileSync(join(cwd, path), source); + }; + const commit = (message) => { + git('add', '-A'); + git('-c', 'core.hooksPath=/dev/null', '-c', 'commit.gpgsign=false', 'commit', '-qm', message); + return git('rev-parse', 'HEAD'); + }; + return { cwd, git, put, commit }; +} + +test('collects positive and zero scores, keeping nested functions and TSX methods separate', async () => { + const source = ` +export function View({value}: {value: boolean}) { + function nested() { if (value) { if (value) return 1; } return 0; } + if (value) return
{nested()}
; + return null; +} +class Service { + run(value: boolean) { if (value) return 1; return 0; } + get idle() { return true; } + static run(value: boolean) { if (value) { if (value) return 2; } return 0; } +} +const lookup = { + left: { run: (value: boolean) => { if (value) return 1; return 0; } }, + right: { run: (value: boolean) => { if (value) return 1; return 0; } }, +}; +`; + const results = await analyzeSource(root, 'frontend/src/sample.tsx', source); + assert.deepEqual( + results.map(({ score }) => score), + [1, 3, 1, 0, 3, 1, 1], + ); + assert.equal(new Set(results.map(({ identity }) => identity)).size, results.length); + assert.equal(results[1].name, 'View / nested'); +}); + +test('ignores unrelated ESLint directives and fails explicitly on syntax errors', async () => { + const results = await analyzeSource( + root, + 'lambda/demo/index.js', + `// eslint-disable-next-line unknown/rule\n${branchFunction('run', 2)}`, + ); + assert.equal(results[0].score, 2); + await assert.rejects( + analyzeSource(root, 'lambda/demo/index.js', 'export function broken( {'), + /Parsing error/, + ); +}); + +test('attributes computed-key methods separately from functions inside their keys', async () => { + for (const invocation of ['', '()']) { + for (const keyBody of ["return 'f';", "if (x) return 'f'; return 'g';"]) { + const source = `const object = { + [function () { ${keyBody} }${invocation}]() { if (b) return 1; return 0; } + };`; + const results = await analyzeSource(root, 'lambda/demo/index.js', source); + // The function used to compute the key is visited before the method. + assert.deepEqual( + results.map(({ score }) => score), + [keyBody.startsWith('if') ? 1 : 0, 1], + ); + } + } +}); + +test('compares against merge base, not target tip, and reports only relevant changes', async (t) => { + const repo = repository(t); + const source = (scores) => + Object.entries(scores) + .map(([name, score]) => branchFunction(name, score)) + .join('\n'); + repo.put( + 'lambda/demo/index.js', + source({ crossed: 12, increased: 20, stable: 40, improved: 25, small: 1, boundary: 14 }), + ); + repo.put('lambda/demo/original.js', branchFunction('renamedFile', 18)); + repo.put( + 'lambda/demo/deleted.js', + branchFunction('deleted', 17).replaceAll('value', 'deletedInput'), + ); + const ancestor = repo.commit('base'); + // The target advances independently; this must not change the PR's "before". + repo.put('lambda/demo/index.js', source({ crossed: 2 })); + repo.commit('target advances'); + repo.git('checkout', '-qb', 'feature', ancestor); + repo.put( + 'lambda/demo/index.js', + source({ crossed: 16, increased: 23, stable: 40, improved: 19, small: 2, boundary: 15 }), + ); + repo.put('frontend/src/new.ts', branchFunction('fresh', 27)); + repo.git('mv', 'lambda/demo/original.js', 'lambda/demo/renamed.js'); + repo.git('rm', '-q', 'lambda/demo/deleted.js'); + repo.put('lambda/demo/test/ignored.js', 'not valid JavaScript {{{'); + repo.put('frontend/src/ignored.test.tsx', 'not valid TSX {{{'); + repo.put('frontend/src/types.d.ts', 'not valid declarations {{{'); + repo.put('scripts/ignored.mjs', 'not valid JavaScript {{{'); + repo.put('eslint.config.mjs', 'throw new Error("Never load PR configuration");'); + const head = repo.commit('feature'); + // Dirty work is not part of the committed-head comparison. + repo.put('lambda/demo/index.js', 'not valid JavaScript {{{'); + + const report = await compareRevisions({ cwd: repo.cwd, base: 'main', baseRef: 'main' }); + assert.equal(report.baseRef, 'main'); + assert.equal(report.mergeBase, ancestor); + assert.equal(report.head, head); + assert.notEqual(report.base, ancestor); + assert.deepEqual( + report.findings.map(({ function: name, before, after, kind }) => ({ + name, + before, + after, + kind, + })), + [ + { name: 'fresh', before: null, after: 27, kind: 'new-or-unmatched' }, + { name: 'crossed', before: 12, after: 16, kind: 'crossed-threshold' }, + { name: 'increased', before: 20, after: 23, kind: 'increased' }, + ], + ); + assert.deepEqual( + report.improvements.map(({ function: name, before, after }) => ({ name, before, after })), + [{ name: 'improved', before: 25, after: 19 }], + ); + assert.equal(report.files.length, 4); + const renamed = report.files.find(({ path }) => path === 'lambda/demo/renamed.js'); + assert.equal(renamed.previousPath, 'lambda/demo/original.js'); + assert.equal(renamed.summary.after.maximum, 18); + const cli = spawnSync(process.execPath, [script, '--base', 'main', '--base-ref', 'main'], { + cwd: repo.cwd, + encoding: 'utf8', + }); + assert.equal(cli.status, 0, cli.stderr); + assert.match(cli.stdout, /3 new\/unmatched or worsening/); + const saved = JSON.parse(readFileSync(join(repo.cwd, 'reports/complexity/comparison.json'))); + assert.deepEqual(saved, report); + assert.equal( + readFileSync(join(repo.cwd, 'reports/complexity/comparison.md'), 'utf8'), + cli.stdout, + ); + assert.match(cli.stdout, /unknown \(no report supplied\)/); + const skipped = spawnSync(process.execPath, [script, '--base', 'main', '--coverage-not-needed'], { + cwd: repo.cwd, + encoding: 'utf8', + }); + assert.equal(skipped.status, 0, skipped.stderr); + assert.match(skipped.stdout, /coverage: not needed for this change/); + assert.doesNotMatch(skipped.stdout, /no report supplied/); +}); + +test('recognizes unchanged moved functions and shifted callbacks without guessing changed callbacks', async () => { + const named = branchFunction('moveMe', 20); + const callback = `${branchFunction('callback', 17).replace('function callback', 'function')}`; + const oldSource = `${named}\nitems.map(${callback});`; + const newSource = `items.filter(x => x);\nitems.map(${callback});`; + const before = await analyzeSource(root, 'lambda/demo/from.js', oldSource); + const after = await analyzeSource(root, 'lambda/demo/from.js', newSource); + const moved = await analyzeSource(root, 'lambda/demo/to.js', named.replace('moveMe', 'newName')); + const files = [ + { path: 'lambda/demo/from.js', previousPath: 'lambda/demo/from.js', before, after }, + { path: 'lambda/demo/to.js', previousPath: null, before: [], after: moved }, + ]; + assert.deepEqual(compareFunctions(files).findings, []); + + const changed = await analyzeSource( + root, + 'lambda/demo/from.js', + `items.map(${branchFunction('callback', 19).replace('function callback', 'function')});`, + ); + const report = compareFunctions([{ ...files[0], after: changed }]); + assert.equal(report.findings.length, 1); + assert.equal(report.findings[0].kind, 'new-or-unmatched'); + assert.equal(report.findings[0].delta, null); +}); + +test('counts unique direct production importers at committed head for every function in a file', async (t) => { + const repo = repository(t); + repo.put( + 'lambda/shared/hot.js', + `export ${branchFunction('hot', 20)}\nexport ${branchFunction('other', 20)}`, + ); + const base = repo.commit('base'); + repo.put( + 'lambda/shared/hot.js', + `export ${branchFunction('hot', 25)}\nexport ${branchFunction('other', 25)}`, + ); + repo.put( + 'lambda/one/index.js', + "import '../shared/hot.js'; import { hot } from '../shared/hot.js'; throw new Error('must not execute');", + ); + repo.put('lambda/two/index.js', "export { hot } from '../shared/hot.js';"); + repo.put('lambda/three/index.cjs', "require('../shared/hot.js');"); + repo.put('lambda/four/index.js', "export const load = () => import('../shared/hot.js');"); + repo.put('lambda/unicode/café\nmodule.js', "// café 🚀\nimport '../shared/hot.js';"); + repo.put('lambda/duplicate-a/index.js', "import '../shared/hot.js';"); + repo.put('lambda/duplicate-b/index.js', "import '../shared/hot.js';"); + repo.put('lambda/empty/index.js', ''); + repo.put('.gitattributes', 'lambda/one/index.js export-ignore\n'); + repo.put('lambda/transitive/index.js', "import '../one/index.js';"); + for (const path of [ + 'lambda/test/importer.js', + 'lambda/fixtures/importer.js', + 'lambda/one.test.js', + ]) { + repo.put( + path, + path.endsWith('.test.js') ? "import './shared/hot.js';" : "import '../shared/hot.js';", + ); + } + repo.put('.dependency-cruiser.cjs', "throw new Error('must not load PR configuration');"); + repo.commit('head'); + repo.put('lambda/dirty/index.js', "import '../shared/hot.js';"); + repo.put( + 'lambda/one/index.js', + '// Removing an import in dirty source must not alter the count.', + ); + const report = await compareRevisions({ cwd: repo.cwd, base }); + assert.equal(report.findings.length, 2); + assert.deepEqual( + report.findings.map((row) => row.dependentFiles), + [7, 7], + ); +}); + +test('suppresses unchanged identical callbacks by count and flags only additional copies', async () => { + const callback = branchFunction('callback', 17).replace('function callback', 'function'); + const before = await analyzeSource(root, 'lambda/demo/index.js', `items.map(${callback});`); + const after = await analyzeSource( + root, + 'lambda/demo/index.js', + `items.map(${callback});\nothers.map(${callback});`, + ); + const report = compareFunctions([ + { path: 'lambda/demo/index.js', previousPath: 'lambda/demo/index.js', before, after }, + ]); + assert.equal(report.findings.length, 1); + assert.ok(report.findings.every(({ before: score, delta }) => score === null && delta === null)); + assert.deepEqual( + compareFunctions([ + { path: 'lambda/demo/index.js', previousPath: 'lambda/demo/index.js', before: after, after }, + ]).findings, + [], + ); + const withCoverage = (partial) => + after.map((fn) => ({ + ...fn, + coverage: { status: 'measured', covered: 1, total: 2, uncovered: 1, percent: 50, partial }, + })); + assert.deepEqual( + compareFunctions([ + { + path: 'lambda/demo/index.js', + previousPath: 'lambda/demo/index.js', + before: withCoverage(false), + after: withCoverage(true), + }, + ]).findings, + [], + 'An unrelated test failure must not make unchanged duplicate callbacks look new', + ); +}); + +test('syntax errors and missing refs fail instead of producing a clean comparison', async (t) => { + const repo = repository(t); + repo.put('lambda/demo/index.js', branchFunction('run', 2)); + const base = repo.commit('base'); + repo.put('lambda/demo/index.js', 'function broken( {'); + repo.commit('broken'); + await assert.rejects(compareRevisions({ cwd: repo.cwd, base }), /Parsing error/); + const cli = spawnSync(process.execPath, [script, '--base', base], { + cwd: repo.cwd, + encoding: 'utf8', + }); + assert.equal(cli.status, 1); + assert.match(cli.stderr, /Complexity comparison failed/); + await assert.rejects(compareRevisions({ cwd: repo.cwd, base: 'missing-reference' })); +}); + +test('rejects source symlinks instead of following them or reporting a zero score', async (t) => { + const repo = repository(t); + repo.put('lambda/demo/index.js', branchFunction('run', 2)); + const base = repo.commit('base'); + symlinkSync('index.js', join(repo.cwd, 'lambda/demo/link.js')); + repo.commit('symlink'); + await assert.rejects(compareRevisions({ cwd: repo.cwd, base }), /expected a regular source file/); +}); + +test('caps the display without dropping JSON findings and escapes Markdown content', () => { + const finding = { + path: 'lambda/a|b.js', + line: 1, + function: 'odd`name\ninjected', + before: null, + after: 20, + delta: null, + }; + const report = { + mergeBase: 'abc', + head: 'def', + analyzer: { threshold: 15 }, + files: [], + findings: Array.from({ length: 21 }, () => finding), + improvements: [], + }; + const text = renderComparison(report); + assert.match(text, /Showing 20 of 21/); + assert.match(text, /a|b<file>/); + assert.match(text, /odd`name injected/); + assert.equal(report.findings.length, 21); +}); + +test('attributes nested outcomes separately and distinguishes missing coverage from zero', async () => { + const source = `function outer(x) { + function inner(y) { if (y) return 1; return 0; } + if (x) return inner(x); + return 0; +}`; + const functions = await analyzeSource(root, 'lambda/demo/index.js', source); + const file = { + sourceHash: sourceHash(source), + branches: [ + { start: { line: 1, column: 0 }, hit: true }, + { start: { line: 2, column: 2 }, hit: false }, + { start: { line: 2, column: 24 }, hit: false }, + { start: { line: 3, column: 2 }, hit: false }, + ], + }; + const snapshot = { status: 'complete', revision: 'abc' }; + const covered = attachCoverage(functions, source, file, snapshot, 'abc'); + assert.equal(covered[0].coverage.percent, 50); + assert.equal(covered[1].coverage.percent, 0); + assert.equal(covered[0].coverage.total, 2); + for (const [input, revision] of [ + [undefined, 'abc'], + [snapshot, 'other'], + [{ status: 'unavailable' }, 'abc'], + ]) { + assert.ok( + attachCoverage(functions, source, file, input, revision).every( + (fn) => fn.coverage.status === 'unknown', + ), + ); + } + assert.equal( + attachCoverage(functions, source + '\n', file, snapshot, 'abc')[0].coverage.status, + 'unknown', + ); + assert.equal( + attachCoverage(functions, source, undefined, snapshot, 'abc')[0].coverage.status, + 'unknown', + ); + assert.equal( + attachCoverage(functions, source, { ...file, branches: [] }, snapshot, 'abc')[0].coverage + .percent, + null, + ); +}); + +test('test-only coverage regressions expose existing complex functions and reject mismatched instrumentation', async (t) => { + const repo = repository(t); + const source = branchFunction('risky', 20); + repo.put('lambda/demo/index.js', source); + const base = repo.commit('base'); + repo.put('lambda/demo/test/test.js', '// tests changed'); + const head = repo.commit('test only'); + const snapshot = (revision, hit) => ({ + status: 'complete', + revision, + instrumenter: { vitest: '4.1.11' }, + files: { + 'lambda/demo/index.js': { + sourceHash: sourceHash(source), + branches: [{ start: { line: 2, column: 0 }, hit }], + }, + }, + }); + const baseCoverage = snapshot(base, true); + const headCoverage = snapshot(head, false); + const report = await compareRevisions({ cwd: repo.cwd, base, baseCoverage, headCoverage }); + assert.equal(report.findings.length, 1); + assert.equal(report.findings[0].kind, 'coverage-decreased'); + assert.equal(report.findings[0].delta, 0); + assert.equal(report.findings[0].coverage.change.percentagePoints, -100); + assert.match(renderComparison(report), /100.0% → 0.0%/); + // Crash the actual dependency-cruiser child, leaving the comparison CLI running. + repo.put('base-coverage.json', JSON.stringify(baseCoverage)); + repo.put('head-coverage.json', JSON.stringify(headCoverage)); + repo.put( + 'crash-depcruise.cjs', + ` + if (process.argv[1]?.endsWith('dependency-cruise.mjs')) { + throw new Error('simulated dependency-cruiser crash'); + } + `, + ); + const crashed = spawnSync( + process.execPath, + [ + script, + '--base', + base, + '--base-coverage', + 'base-coverage.json', + '--head-coverage', + 'head-coverage.json', + ], + { + cwd: repo.cwd, + encoding: 'utf8', + env: { + ...process.env, + NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --require ${JSON.stringify(join(repo.cwd, 'crash-depcruise.cjs'))}`, + }, + }, + ); + assert.equal(crashed.status, 0, crashed.stderr); + const degraded = JSON.parse( + readFileSync(join(repo.cwd, 'reports/complexity/comparison.json'), 'utf8'), + ); + assert.deepEqual( + degraded.findings, + report.findings.map((row) => ({ ...row, dependentFiles: null })), + ); + assert.deepEqual(degraded.coverageNotes, report.coverageNotes); + assert.match(crashed.stdout, /Depending files: unknown \(dependency analysis failed\)/); + assert.match(crashed.stdout, /100.0% → 0.0%/); + assert.doesNotMatch(crashed.stdout, /Report unavailable|simulated dependency-cruiser crash/); + assert.match(crashed.stderr, /Dependency analysis failed:.*simulated dependency-cruiser crash/s); + headCoverage.status = 'partial'; + const partial = await compareRevisions({ cwd: repo.cwd, base, baseCoverage, headCoverage }); + assert.equal(partial.findings[0].coverage.change.percentagePoints, -100); + assert.equal(partial.findings[0].coverage.after.partial, true); + assert.match(renderComparison(partial), /100.0% → 0.0%\*/); + assert.match( + renderComparison(partial), + /Head coverage: measured with failing or interrupted tests/, + ); + headCoverage.status = 'complete'; + headCoverage.instrumenter.vitest = 'different'; + const incompatible = await compareRevisions({ cwd: repo.cwd, base, baseCoverage, headCoverage }); + assert.equal(incompatible.findings.length, 0); + assert.match(incompatible.coverageNotes[0], /differs/); + const unavailable = await compareRevisions({ + cwd: repo.cwd, + base, + baseCoverage, + headCoverage: { status: 'unavailable', revision: head, files: {} }, + }); + assert.equal(unavailable.files.length, 0); + assert.match(renderComparison(unavailable), /Head coverage: unknown.*failed/); +}); diff --git a/scripts/test/complexity-coverage.test.mjs b/scripts/test/complexity-coverage.test.mjs new file mode 100644 index 000000000..0c2f55e3a --- /dev/null +++ b/scripts/test/complexity-coverage.test.mjs @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { appendFile, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve, join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { analyzeSource } from '../complexity-analysis.mjs'; +import { attachCoverage, collectCoverage } from '../complexity-coverage.mjs'; + +const root = fileURLToPath(new URL('../..', import.meta.url)); + +test('collects real Vitest/V8 outcomes and binds them to source and revision', async (t) => { + const cwd = await mkdtemp(join(tmpdir(), 'complexity-v8-')); + t.after(() => rm(cwd, { recursive: true, force: true })); + await mkdir(join(cwd, 'lambda/demo'), { recursive: true }); + await symlink(resolve(root, 'node_modules'), join(cwd, 'node_modules')); + const source = `export function outer(x) { + function inner(y) { if (y === 2) return 20; return 10; } + if (x) return inner(x); + return 0; +} +export function neverCalled(x) { if (x) return 1; return 0; } +export function choose(x) { return x ? () => { if (x > 1) return 2; return 1; } : () => 0; } +`; + await writeFile(join(cwd, 'lambda/demo/index.js'), source); + await writeFile(join(cwd, 'package.json'), '{"type":"module"}'); + await writeFile( + join(cwd, 'vitest.config.js'), + `export default { test: { + include: ['sample.test.js'], + coverage: { provider: 'v8', reporter: ['json'], include: ['lambda/**/*.js'] } + } };`, + ); + await writeFile( + join(cwd, 'sample.test.js'), + `import { test, expect } from 'vitest'; +import { outer, choose } from './lambda/demo/index.js'; +test('one path', () => { expect(outer(1)).toBe(10); expect(choose(1)()).toBe(1); }); +`, + ); + const git = (...args) => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim(); + git('init', '-q'); + git('add', 'lambda', 'package.json', 'vitest.config.js', 'sample.test.js'); + git( + '-c', + 'user.name=Test', + '-c', + 'user.email=test@example.com', + '-c', + 'core.hooksPath=/dev/null', + '-c', + 'commit.gpgsign=false', + 'commit', + '-qm', + 'fixture', + ); + execFileSync( + process.execPath, + [join(root, 'node_modules/vitest/vitest.mjs'), 'run', '--coverage'], + { + cwd, + stdio: 'pipe', + timeout: 60000, + }, + ); + const snapshot = await collectCoverage({ + cwd, + input: join(cwd, 'coverage/coverage-final.json'), + successful: true, + }); + const functions = attachCoverage( + await analyzeSource(root, 'lambda/demo/index.js', source), + source, + snapshot.files['lambda/demo/index.js'], + snapshot, + git('rev-parse', 'HEAD'), + ); + assert.equal(snapshot.status, 'complete'); + assert.equal(functions.length, 6); + assert.equal(functions[0].coverage.percent, 50); + assert.equal(functions[1].coverage.percent, 50); + assert.equal(functions[2].coverage.percent, 0); + assert.equal(functions[3].name, 'choose'); + assert.equal(functions[3].coverage.total, 2); + assert.equal(functions[3].coverage.percent, 50); + assert.equal(functions[4].coverage.total, 2); + assert.equal(functions[4].coverage.percent, 50); + // Real repository V8 output includes negative inferred else counts. Istanbul + // treats these exactly like zero, rather than rejecting the entire report. + const coveragePath = join(cwd, 'coverage/coverage-final.json'); + const raw = JSON.parse(await readFile(coveragePath, 'utf8')); + for (const file of Object.values(raw)) { + for (const counts of Object.values(file.b)) { + for (let index = 0; index < counts.length; index++) { + if (counts[index] === 0) counts[index] = -1; + } + } + } + await writeFile(coveragePath, JSON.stringify(raw)); + const inferred = await collectCoverage({ cwd, input: coveragePath, successful: true }); + assert.deepEqual(inferred.files, snapshot.files); + await appendFile( + join(cwd, 'sample.test.js'), + ` +test('failing assertion still executes a branch', () => { expect(outer(0)).toBe(999); }); +`, + ); + const failingRun = spawnSync( + process.execPath, + [ + join(root, 'node_modules/vitest/vitest.mjs'), + 'run', + '--coverage', + '--coverage.reportOnFailure', + ], + { cwd, encoding: 'utf8', timeout: 60000 }, + ); + assert.equal(failingRun.status, 1, failingRun.stderr); + const partial = await collectCoverage({ + cwd, + input: join(cwd, 'coverage/coverage-final.json'), + successful: false, + }); + assert.equal(partial.status, 'partial'); + const partialFunctions = attachCoverage( + await analyzeSource(root, 'lambda/demo/index.js', source), + source, + partial.files['lambda/demo/index.js'], + partial, + partial.revision, + ); + assert.equal(partialFunctions[0].coverage.percent, 100); + assert.equal(partialFunctions[0].coverage.partial, true); + const failed = await collectCoverage({ cwd, input: 'not-needed', successful: false }); + assert.equal(failed.status, 'unavailable'); + assert.deepEqual(failed.files, {}); +}); diff --git a/scripts/test/complexity-report.test.mjs b/scripts/test/complexity-report.test.mjs new file mode 100644 index 000000000..aa56e3067 --- /dev/null +++ b/scripts/test/complexity-report.test.mjs @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { changeIcon, renderComparison } from '../complexity-report.mjs'; + +const row = (before, after, coverageDelta) => ({ + path: 'lambda/demo/index.js', + line: 1, + function: 'run', + before, + after, + delta: before === null ? null : after - before, + coverage: { change: { percentagePoints: coverageDelta } }, +}); + +test('green requires improvement without a worsening metric', () => { + assert.equal(changeIcon(row(50, 40, 10)), '🟢'); + assert.equal(changeIcon(row(50, 40, 0)), '🟢'); + assert.equal(changeIcon(row(50, 50, 5)), '🟢'); + assert.equal(changeIcon(row(50, 40, -1)), '🟠'); + assert.equal(changeIcon(row(50, 51, 30)), '🟠'); + assert.equal(changeIcon(row(50, 50, 0)), '🟠'); +}); + +test('purple requires both metrics to cross their substantial-regression thresholds', () => { + assert.equal(changeIcon(row(50, 60, -10)), '🟣'); + assert.equal(changeIcon(row(50, 59, -10)), '🔴'); + assert.equal(changeIcon(row(50, 60, -9)), '🔴'); + assert.equal(changeIcon(row(100, 120, -20)), '🟣'); +}); + +test('mixed changes are balanced using relative complexity and coverage percentage points', () => { + assert.equal(changeIcon(row(50, 55, -5)), '🔴'); + assert.equal(changeIcon(row(50, 54, -5)), '🟠'); + assert.equal(changeIcon(row(50, 60, 5)), '🟠'); + assert.equal(changeIcon(row(50, 70, 5)), '🔴'); + assert.equal(changeIcon(row(100, 110, -5)), '🔴'); + assert.equal(changeIcon(row(50, 40, -20)), '🔴'); +}); + +test('a five-point floor avoids exaggerating tiny complexity increases', () => { + assert.equal(changeIcon(row(1, 2, -10)), '🔴'); + assert.equal(changeIcon(row(1, 6, -10)), '🟣'); + assert.equal(changeIcon(row(0, 5, -10)), '🟣'); +}); + +test('missing comparisons use current complexity, with strict threshold boundaries', () => { + for (const [score, icon] of [ + [15, '🟢'], + [16, '🟠'], + [50, '🟠'], + [51, '🔴'], + [100, '🔴'], + [101, '🟣'], + ]) { + assert.equal(changeIcon(row(null, score, null)), icon, `complexity ${score}`); + } + assert.equal(changeIcon(row(50, 70, null)), '🔴'); + assert.equal(changeIcon({ ...row(50, 70, -20), coverage: undefined }), '🔴'); +}); + +test('low measured head coverage escalates current risk, but unknown and branchless coverage do not', () => { + const current = (score, percent, status = 'measured') => ({ + ...row(null, score, null), + coverage: { after: { status, percent } }, + }); + assert.equal(changeIcon(current(20, 49.9)), '🔴'); + assert.equal(changeIcon(current(70, 0)), '🟣'); + assert.equal(changeIcon(current(101, 0)), '🟣'); + assert.equal(changeIcon(current(20, 50)), '🟠'); + assert.equal(changeIcon(current(70, 100)), '🔴'); + assert.equal(changeIcon(current(20, null)), '🟠'); + assert.equal(changeIcon(current(70, null, 'unknown')), '🔴'); +}); + +test('Markdown uses a leading icon instead of the Signal column, with an explanatory legend', () => { + const report = { + mergeBase: 'a'.repeat(40), + head: 'b'.repeat(40), + analyzer: { threshold: 15 }, + files: [], + findings: [{ ...row(50, 60, -10), dependentFiles: 12 }], + improvements: [row(50, 40, 10)], + }; + const markdown = renderComparison(report); + assert.match(markdown, /\| 🟣 \| lambda\/demo\/index.js/); + assert.match(markdown, /\| 🟢 \| lambda\/demo\/index.js/); + assert.doesNotMatch(markdown, /\| Signal \|/); + assert.match(markdown, /Without comparable data: use current complexity/); + assert.match(markdown, /Icons are review heuristics/); + assert.match(markdown, /\| Depending files \|/); + assert.match(markdown, /\| 12 \|/); + assert.match(markdown, /\| unknown \|/); +}); + +test('high usage amplifies coverage risk one level, but preserves green improvements', () => { + const risk = (count, delta, percent = 80) => ({ + ...row(50, 52, delta), + dependentFiles: count, + coverage: { after: { status: 'measured', percent }, change: { percentagePoints: delta } }, + }); + assert.equal(changeIcon(risk(10, -1)), '🟠'); + assert.equal(changeIcon(risk(11, -1)), '🔴'); + assert.equal(changeIcon(risk(11, -10)), '🟣'); + assert.equal(changeIcon(risk(11, 0, 49.9)), '🔴'); + assert.equal(changeIcon(risk(11, 0, 50)), '🟠'); + assert.equal(changeIcon(risk(null, -1)), '🟠'); + assert.equal(changeIcon({ ...row(null, 20, null), dependentFiles: 50 }), '🟠'); + assert.equal( + changeIcon({ + ...row(50, 40, 5), + dependentFiles: 50, + coverage: { after: { status: 'measured', percent: 40 }, change: { percentagePoints: 5 } }, + }), + '🟢', + ); + assert.equal(changeIcon({ ...row(50, 60, -10), dependentFiles: 50 }), '🟣'); + assert.equal( + changeIcon({ + ...row(50, 52, 0), + dependentFiles: 11, + coverage: { change: { percentagePoints: 0, uncovered: 1 } }, + }), + '🔴', + ); +}); diff --git a/scripts/test/publish-complexity-comment.test.mjs b/scripts/test/publish-complexity-comment.test.mjs new file mode 100644 index 000000000..64f121c32 --- /dev/null +++ b/scripts/test/publish-complexity-comment.test.mjs @@ -0,0 +1,466 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import test from 'node:test'; +import yaml from 'js-yaml'; +import { + commentMarker, + publishComplexityComment, + qualityArtifactNames, + validateReport, +} from '../publish-complexity-comment.mjs'; + +const base = 'a'.repeat(40); +const head = 'b'.repeat(40); +const report = () => ({ + schemaVersion: 1, + baseRef: 'main', + base, + mergeBase: base, + head, + analyzer: { threshold: 15 }, + files: [], + coverageNotes: [], + improvements: [], + findings: [ + { + path: 'lambda/@maintainers|evil.js', + function: '[click](https://example.com)', + line: 1, + before: 16, + after: 20, + delta: 4, + dependentFiles: 12, + kind: 'increased', + coverage: { + before: { status: 'measured', total: 4, covered: 4, uncovered: 0, percent: 100 }, + after: { status: 'measured', total: 4, covered: 2, uncovered: 2, percent: 50 }, + change: { percentagePoints: 'untrusted', uncovered: 1000 }, + }, + }, + ], +}); + +async function fixture(t, contents = report()) { + const cwd = await mkdtemp(join(tmpdir(), 'quality-comment-')); + t.after(() => rm(cwd, { recursive: true, force: true })); + const reportPath = join(cwd, 'comparison.json'); + await writeFile(reportPath, JSON.stringify(contents)); + const pr = { + number: 42, + state: 'open', + head: { sha: head, repo: { id: 2 } }, + base: { sha: base, ref: 'main' }, + }; + const comments = []; + const writes = []; + const context = { + eventName: 'workflow_run', + repo: { owner: 'example', repo: 'project' }, + payload: { + repository: { id: 1 }, + workflow_run: { + event: 'pull_request', + name: 'PR Quality', + id: 123, + run_attempt: 1, + repository: { id: 1 }, + head_repository: { id: 2 }, + head_sha: head, + }, + }, + }; + const listPulls = () => {}; + const listComments = () => {}; + const github = { + paginate: async (method) => (method === listPulls ? [pr] : comments), + rest: { + repos: { + compareCommitsWithBasehead: async (input) => { + assert.equal(input.basehead, `main...${head}`); + return { data: { merge_base_commit: { sha: base } } }; + }, + }, + pulls: { list: listPulls, get: async () => ({ data: pr }) }, + issues: { + listComments, + createComment: async (input) => writes.push({ action: 'create', ...input }), + updateComment: async (input) => writes.push({ action: 'update', ...input }), + }, + }, + }; + return { github, context, reportPath, warn: () => {}, pr, comments, writes }; +} + +test('posts to the GitHub-verified fork PR and escapes artifact content', async (t) => { + const data = report(); + data.pullNumber = 999; // Artifacts do not choose the destination. + data.dependencyNote = 'Depending files: unknown (dependency analysis failed).'; + data.findings[0].dependentFiles = null; + const f = await fixture(t, data); + await publishComplexityComment(f); + assert.equal(f.writes.length, 1); + assert.equal(f.writes[0].issue_number, 42); + assert.equal(f.writes[0].action, 'create'); + assert.match(f.writes[0].body, /100.0% → 50.0%/); + assert.match(f.writes[0].body, /Depending files: unknown \(dependency analysis failed\)/); + assert.doesNotMatch(f.writes[0].body, /@maintainers||\[click\]/); + assert.match(f.writes[0].body, /@maintainers/); +}); + +test('updates only the bot-owned marked comment and ignores stale reruns', async (t) => { + const f = await fixture(t); + f.comments.push( + { id: 1, user: { login: 'human', type: 'User' }, body: commentMarker }, + { + id: 2, + user: { login: 'github-actions[bot]', type: 'Bot' }, + body: `${commentMarker}\n`, + }, + ); + await publishComplexityComment(f); + assert.equal(f.writes[0].action, 'update'); + assert.equal(f.writes[0].comment_id, 2); + f.writes.length = 0; + f.comments[1].body = `${commentMarker}\n`; + await publishComplexityComment(f); + assert.deepEqual(f.writes, []); +}); + +test('does not post stale head, wrong repository, or a non-PR workflow', async (t) => { + for (const change of [ + (f) => { + f.pr.head.sha = 'c'.repeat(40); + }, + (f) => { + f.pr.head.repo.id = 3; + }, + (f) => { + f.context.payload.workflow_run.repository.id = 3; + }, + (f) => { + f.context.payload.workflow_run.event = 'push'; + }, + ]) { + const f = await fixture(t); + change(f); + await publishComplexityComment(f); + assert.deepEqual(f.writes, []); + } + const f = await fixture(t); + f.github.rest.pulls.get = async () => ({ data: { ...f.pr, state: 'closed' } }); + await publishComplexityComment(f); + assert.deepEqual(f.writes, []); +}); + +test('replaces stale results with an explicit unavailable message on malformed or missing artifacts', async (t) => { + for (const contents of [{ findings: [] }, report()]) { + const f = await fixture(t, contents); + if (contents.schemaVersion) await rm(f.reportPath); + await publishComplexityComment(f); + assert.equal(f.writes.length, 1); + assert.match(f.writes[0].body, /Report unavailable/); + assert.doesNotMatch(f.writes[0].body, /0 new\/unmatched/); + } +}); + +test('validates numbers and derives coverage deltas rather than trusting the artifact', () => { + const valid = validateReport(report()); + assert.equal(valid.findings[0].coverage.change.percentagePoints, -50); + for (const change of [ + (r) => { + r.findings[0].after = 'injection'; + }, + (r) => { + r.head = ''; + }, + (r) => { + r.findings[0].coverage.after.percent = 100; + }, + (r) => { + r.findings[0].coverage.after = {}; + }, + (r) => { + r.findings[0].delta = 999; + }, + (r) => { + r.findings[0].dependentFiles = '@maintainers'; + }, + (r) => { + r.findings[0].dependentFiles = -1; + }, + (r) => { + r.dependencyNote = {}; + }, + ]) { + const data = report(); + change(data); + assert.throws(() => validateReport(data), /Invalid report/); + } +}); + +test('bounds the rendered comment even when escaping expands long artifact strings', async (t) => { + const data = report(); + data.findings = Array.from({ length: 20 }, () => ({ + ...data.findings[0], + path: '&'.repeat(500), + })); + data.improvements = data.findings; + const f = await fixture(t, data); + await publishComplexityComment(f); + assert.equal(f.writes.length, 1); + assert.ok(f.writes[0].body.length < 60000); + assert.match(f.writes[0].body, /Display truncated/); +}); + +async function sameRepositoryFixture(t) { + const f = await fixture(t); + f.pr.head.repo.id = 1; + f.context.eventName = 'pull_request'; + f.context.actor = 'maintainer'; + f.context.runId = 123; + f.attempt = 1; + delete f.context.payload.workflow_run; + f.context.payload.pull_request = structuredClone(f.pr); + return f; +} + +for (const [name, createFixture] of [ + ['same repository', sameRepositoryFixture], + ['fork', fixture], +]) { + test(`${name}: posts validated JSON through the shared publisher`, async (t) => { + const f = await createFixture(t); + await publishComplexityComment(f); + assert.equal(f.writes.length, 1); + assert.match(f.writes[0].body, /100.0% → 50.0%/); + assert.doesNotMatch(f.writes[0].body, /@maintainers||\[click\]/); + assert.match(f.writes[0].body, //); + }); + + test(`${name}: target tip advances but merge base is unchanged`, async (t) => { + const f = await createFixture(t); + f.pr.base.sha = 'c'.repeat(40); + await publishComplexityComment(f); + assert.equal(f.writes.length, 1); + assert.match(f.writes[0].body, /100.0% → 50.0%/); + }); + + test(`${name}: retarget or changed merge base produces an explicit stale notice`, async (t) => { + for (const retarget of [true, false]) { + const f = await createFixture(t); + if (retarget) f.pr.base.ref = 'release'; + else + f.github.rest.repos.compareCommitsWithBasehead = async () => ({ + data: { merge_base_commit: { sha: 'c'.repeat(40) } }, + }); + await publishComplexityComment(f); + assert.equal(f.writes.length, 1); + assert.match(f.writes[0].body, /target branch or merge base changed/); + assert.doesNotMatch(f.writes[0].body, /100.0% → 50.0%/); + } + }); + + test(`${name}: comment-only rerun retains the earlier producer's report`, async (t) => { + const f = await createFixture(t); + f.attempt = 2; + if (f.context.payload.workflow_run) f.context.payload.workflow_run.run_attempt = 2; + f.comments.push({ + id: 2, + user: { login: 'github-actions[bot]', type: 'Bot' }, + body: `${commentMarker}\n\nGood report`, + }); + // The selector below resolves report attempt 1 for download; publishing it + // uses attempt 2 for ordering without changing the validated measurements. + await publishComplexityComment(f); + assert.equal(f.writes[0].action, 'update'); + assert.match(f.writes[0].body, /100.0% → 50.0%/); + assert.match(f.writes[0].body, //); + }); +} + +test('same-repository path rejects forks, Dependabot, and stale PR state', async (t) => { + for (const change of [ + (f) => { + f.context.payload.pull_request.head.repo.id = 2; + }, + (f) => { + f.context.actor = 'dependabot[bot]'; + }, + (f) => { + f.context.eventName = 'push'; + }, + (f) => { + f.pr.head.sha = 'c'.repeat(40); + }, + (f) => { + f.pr.head.repo.id = 2; + }, + (f) => { + f.pr.state = 'closed'; + }, + ]) { + const f = await sameRepositoryFixture(t); + change(f); + await publishComplexityComment(f); + assert.deepEqual(f.writes, []); + } +}); + +async function selectArtifacts(jobs, attempt = 2) { + return qualityArtifactNames({ + repo: { owner: 'example', repo: 'project' }, + runId: 123, + attempt, + github: { + rest: { actions: { listJobsForWorkflowRun: 'jobs' } }, + paginate: async (method, params) => { + assert.equal(method, 'jobs'); + assert.equal(params.run_id, 123); + assert.equal(params.filter, 'all'); + return jobs; + }, + }, + }); +} +const job = (name, run_attempt, conclusion = 'success') => ({ name, run_attempt, conclusion }); + +test('report rerun reuses coverage from producers that were not rerun', async () => { + assert.deepEqual( + await selectArtifacts([ + job('coverage-base', 1), + job('coverage-head', 1), + job('report', 1, 'failure'), + job('report', 2, null), + ]), + { base: 'coverage-base-1', head: 'coverage-head-1', report: 'pr-quality-2' }, + ); +}); + +test('comment-only rerun resolves the previous report producer', async () => { + assert.equal( + ( + await selectArtifacts([ + job('report', 1), + job('comment', 1, 'failure'), + job('comment', 2, null), + ]) + ).report, + 'pr-quality-1', + ); +}); + +test('failed producer rerun never falls back to an older artifact', async () => { + assert.deepEqual( + await selectArtifacts([ + job('coverage-base', 1), + job('coverage-head', 1), + job('report', 1), + job('coverage-head', 2, 'failure'), + job('report', 2, 'failure'), + job('coverage-base', 3), + job('report', 3), + ]), + { base: 'coverage-base-1', head: 'coverage-head-2', report: 'pr-quality-2' }, + ); +}); + +test('skipped producers do not supply old measurements', async () => { + assert.deepEqual( + await selectArtifacts([ + job('coverage-base', 1), + job('coverage-base', 2, 'skipped'), + job('coverage-head', 2, 'skipped'), + ]), + { base: '', head: '', report: '' }, + ); +}); + +test('both workflow paths download selected producer artifacts and call the shared publisher', () => { + for (const file of ['pr-quality.yml', 'pr-quality-comment.yml']) { + const workflow = yaml.load( + readFileSync(new URL(`../../.github/workflows/${file}`, import.meta.url), 'utf8'), + ); + const steps = workflow.jobs.comment.steps; + const scripts = steps.filter((step) => step.with?.script).map((step) => step.with.script); + assert.ok(scripts.some((script) => script.includes('qualityArtifactNames'))); + assert.ok(scripts.some((script) => script.includes('await publishComplexityComment'))); + const download = steps.find((step) => step.uses.startsWith('actions/download-artifact@')); + assert.equal(download.with.name, '${{ steps.artifacts.outputs.report }}'); + const checkout = steps.find((step) => step.uses.startsWith('actions/checkout@')); + assert.equal(checkout.with['persist-credentials'], false); + if (file === 'pr-quality-comment.yml') assert.equal(checkout.with.ref, undefined); + else assert.equal(checkout.with.ref, '${{ github.event.pull_request.head.sha }}'); + } +}); + +test('workflow skips backend suites for docs, Terraform and frontend but detects backend inputs', async (t) => { + const cwd = await mkdtemp(join(tmpdir(), 'quality-paths-')); + t.after(() => rm(cwd, { recursive: true, force: true })); + const git = (...args) => + execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + git('init', '-q'); + git( + '-c', + 'user.name=Test', + '-c', + 'user.email=test@example.com', + '-c', + 'commit.gpgsign=false', + 'commit', + '--allow-empty', + '-qm', + 'base', + ); + const baseSha = git('rev-parse', 'HEAD'); + const workflow = yaml.load( + readFileSync(new URL('../../.github/workflows/pr-quality.yml', import.meta.url), 'utf8'), + ); + const script = workflow.jobs.revisions.steps.find((step) => step.id === 'refs').run; + assert.equal(workflow.jobs.coverage.if, "needs.revisions.outputs.coverage-needed == 'true'"); + for (const [path, expected] of [ + ['docs/guide.md', false], + ['terraform/main.tf', false], + ['frontend/src/index.ts', false], + ['lambda/intents/index.js', true], + ['test/setup.js', true], + ['package-lock.json', true], + ['vitest.config.js', true], + ['eslint.complexity.config.mjs', true], + ['scripts/complexity-coverage.mjs', true], + ['scripts/test/compare-complexity.test.mjs', true], + ['.github/workflows/pr-quality.yml', true], + ]) { + git('checkout', '-q', '--detach', baseSha); + await mkdir(dirname(join(cwd, path)), { recursive: true }); + await writeFile(join(cwd, path), 'fixture\n'); + git('add', '--', path); + git( + '-c', + 'user.name=Test', + '-c', + 'user.email=test@example.com', + '-c', + 'commit.gpgsign=false', + 'commit', + '-qm', + path, + ); + const output = join(cwd, 'outputs'); + await writeFile(output, ''); + execFileSync('bash', ['-e', '-c', script], { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + BASE_SHA: baseSha, + HEAD_SHA: git('rev-parse', 'HEAD'), + GITHUB_OUTPUT: output, + }, + }); + assert.match(readFileSync(output, 'utf8'), new RegExp(`coverage-needed=${expected}\n`), path); + } +}); From 18ba63804892dc7d24afe1a0d649a61604359ddf Mon Sep 17 00:00:00 2001 From: Jerome Van Der Linden Date: Tue, 27 Oct 2026 00:00:28 +0100 Subject: [PATCH 2/2] fix: focus PR quality comments and expose detailed artifacts --- .github/workflows/pr-quality.yml | 38 +++-- docs/development/pr-quality.md | 22 +-- scripts/compare-complexity.mjs | 110 ++++++------- scripts/complexity-report.mjs | 66 ++++++-- scripts/publish-complexity-comment.mjs | 6 +- scripts/test/compare-complexity.test.mjs | 148 +++++++++++++++--- scripts/test/complexity-report.test.mjs | 49 +++++- .../test/publish-complexity-comment.test.mjs | 42 +++-- 8 files changed, 346 insertions(+), 135 deletions(-) diff --git a/.github/workflows/pr-quality.yml b/.github/workflows/pr-quality.yml index ae5d336fe..1e65ead8e 100644 --- a/.github/workflows/pr-quality.yml +++ b/.github/workflows/pr-quality.yml @@ -26,11 +26,11 @@ jobs: - id: refs env: BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_SHA: ${{ github.event.pull_request.head.sha }} run: | - merge_base=$(git merge-base "$BASE_SHA" "$HEAD_SHA") + merge_base=$(git merge-base "$BASE_SHA" "$PR_SHA") echo "merge-base=$merge_base" >> "$GITHUB_OUTPUT" - if git diff --quiet "$merge_base" "$HEAD_SHA" -- lambda/ test/ \ + if git diff --quiet "$merge_base" "$PR_SHA" -- lambda/ test/ \ 'package*.json' vitest.config.js eslint.complexity.config.mjs \ 'scripts/*complexity*' 'scripts/test/*complexity*' '.github/workflows/pr-quality*'; then echo "coverage-needed=false" >> "$GITHUB_OUTPUT" @@ -47,7 +47,7 @@ jobs: strategy: fail-fast: false matrix: - revision: [base, head] + revision: [base, pr] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -181,7 +181,7 @@ jobs: attempt: Number(process.env.GITHUB_RUN_ATTEMPT), }); core.setOutput('base', names.base); - core.setOutput('head', names.head); + core.setOutput('pr', names.pr); - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 if: steps.artifacts.outputs.base != '' continue-on-error: true @@ -189,15 +189,15 @@ jobs: name: ${{ steps.artifacts.outputs.base }} path: coverage-input/base - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - if: steps.artifacts.outputs.head != '' + if: steps.artifacts.outputs.pr != '' continue-on-error: true with: - name: ${{ steps.artifacts.outputs.head }} - path: coverage-input/head + name: ${{ steps.artifacts.outputs.pr }} + path: coverage-input/pr - name: Compare complexity and coverage env: BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_SHA: ${{ github.event.pull_request.head.sha }} BASE_REF: ${{ github.event.pull_request.base.ref }} COVERAGE_NEEDED: ${{ needs.revisions.outputs.coverage-needed }} run: | @@ -206,14 +206,26 @@ jobs: if [ -f "coverage-input/base/coverage.json" ]; then args+=(--base-coverage "coverage-input/base/coverage.json") fi - if [ -f "coverage-input/head/coverage.json" ]; then - args+=(--head-coverage "coverage-input/head/coverage.json") + if [ -f "coverage-input/pr/coverage.json" ]; then + args+=(--pr-coverage "coverage-input/pr/coverage.json") fi - npm run complexity:compare -- --base "$BASE_SHA" --base-ref "$BASE_REF" --head "$HEAD_SHA" "${args[@]}" + npm run complexity:compare -- --base "$BASE_SHA" --base-ref "$BASE_REF" --pr "$PR_SHA" "${args[@]}" cat reports/complexity/comparison.md >> "$GITHUB_STEP_SUMMARY" - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: pr-quality-${{ github.run_attempt }} - path: reports/complexity/comparison.* + path: reports/complexity/comparison.json + if-no-files-found: error + retention-days: 7 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: complexity-${{ github.run_attempt }} + path: reports/complexity/complexity.* + if-no-files-found: error + retention-days: 7 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: dependencies-${{ github.run_attempt }} + path: reports/complexity/dependencies.* if-no-files-found: error retention-days: 7 diff --git a/docs/development/pr-quality.md b/docs/development/pr-quality.md index 9259078b4..16426fcee 100644 --- a/docs/development/pr-quality.md +++ b/docs/development/pr-quality.md @@ -1,19 +1,19 @@ # PR quality reports CI updates one PR comment with cognitive-complexity, dependency counts, and backend branch-coverage changes -between the merge base and PR head. Findings are advisory: functions above 15 appear +for source files changed between the merge base and PR commit. Findings are advisory: functions above 15 appear when complexity or coverage worsens, or when the function is new/unmatched. Unchanged debt is omitted; improvements appear separately. ## Reading the table -| Column | Meaning | -| ------------------- | -------------------------------------------------------------------------------------------------- | -| Complexity / Δ | Before → after score and change | -| Depending files | Direct production importers at PR head; backend only, excluding tests and AWS/external callers | -| Branch coverage / Δ | Covered branch outcomes and change in percentage points | -| Uncovered branches | Outcomes not exercised by tests, before → after | -| Icon | 🟢 improving, 🟠 mixed/small regression, 🔴 large regression, 🟣 both metrics worsen substantially | +| Column | Meaning | +| ------------------- | ---------------------------------------------------------------------------------------------------- | +| Complexity / Δ | Before → after score and change | +| Depending files | Direct production importers at the PR commit; backend only, excluding tests and AWS/external callers | +| Branch coverage / Δ | Covered branch outcomes and change in percentage points | +| Uncovered branches | Outcomes not exercised by tests, before → after | +| Icon | 🟢 improving, 🟠 mixed/small regression, 🔴 large regression, 🟣 both metrics worsen substantially | Without comparable measurements, icons use current complexity: orange above 15, red above 50, purple above 100. Coverage below 50% raises this one level. @@ -35,13 +35,15 @@ were not rerun; missing or stale results are reported explicitly. Same-repository PRs can comment immediately. Forks and Dependabot need the comment workflow on the default branch; it validates report data without executing PR code. -JSON and Markdown artifacts are retained for seven days and linked from the comment. +Artifacts are retained for seven days: `complexity-*` lists every function in changed files with +base/PR scores and coverage; `dependencies-*` lists their direct production importers. +Both include Markdown and JSON. `pr-quality-*` holds the data used to publish the comment. ## Local use ```bash npm run complexity -npm run complexity:compare -- --base origin/main --head HEAD +npm run complexity:compare -- --base origin/main --pr HEAD ``` The comparison reads committed source and writes JSON/Markdown to `reports/complexity/`. diff --git a/scripts/compare-complexity.mjs b/scripts/compare-complexity.mjs index 0f385ab60..94f5edc2f 100644 --- a/scripts/compare-complexity.mjs +++ b/scripts/compare-complexity.mjs @@ -15,7 +15,11 @@ import { summarizeFunctions, } from './complexity-analysis.mjs'; import { attachCoverage, hasCoverage } from './complexity-coverage.mjs'; -import { renderComparison } from './complexity-report.mjs'; +import { + renderComparison, + renderComplexityDetails, + renderDependencyDetails, +} from './complexity-report.mjs'; export { renderComparison } from './complexity-report.mjs'; const require = createRequire(import.meta.url); @@ -34,16 +38,10 @@ function resolveCommit(cwd, ref) { return git(cwd, ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`]).trim(); } -function changedFiles(cwd, base, head) { - const fields = git(cwd, [ - 'diff', - '--name-status', - '-z', - '--find-renames', - base, - head, - '--', - ]).split('\0'); +function changedFiles(cwd, base, pr) { + const fields = git(cwd, ['diff', '--name-status', '-z', '--find-renames', base, pr, '--']).split( + '\0', + ); const files = []; for (let index = 0; fields[index];) { const status = fields[index++]; @@ -52,7 +50,7 @@ function changedFiles(cwd, base, head) { files.push({ path, previousPath: status === 'A' ? null : oldPath, - headPath: status === 'D' ? null : path, + prPath: status === 'D' ? null : path, }); } return files; @@ -76,12 +74,12 @@ async function measure(cwd, analyzer, revision, path, coverage) { ); } -async function dependentFileCounts(cwd, analyzer, head) { +async function dependentFiles(cwd, analyzer, pr) { const snapshot = await mkdtemp(join(tmpdir(), 'complexity-dependencies-')); try { const counts = new Map(); const files = []; - const entries = git(cwd, ['ls-tree', '-r', '-z', head, '--', 'lambda', 'package.json']).split( + const entries = git(cwd, ['ls-tree', '-r', '-z', pr, '--', 'lambda', 'package.json']).split( '\0', ); for (const entry of entries.filter(Boolean)) { @@ -91,7 +89,7 @@ async function dependentFileCounts(cwd, analyzer, head) { (await analyzer.calculateConfigForFile(resolve(cwd, path)))?.rules?.[complexityRule]; if (!source && !path.endsWith('package.json')) continue; const blob = /^100(?:644|755) blob ([a-f0-9]+)\t/.exec(entry); - if (!blob) throw new Error(`${head}:${path}: expected a regular file`); + if (!blob) throw new Error(`${pr}:${path}: expected a regular file`); files.push({ path, objectId: blob[1] }); if (source) counts.set(path, new Set()); } @@ -151,7 +149,7 @@ async function dependentFileCounts(cwd, analyzer, head) { } } } - return new Map([...counts].map(([path, importers]) => [path, importers.size])); + return new Map([...counts].map(([path, importers]) => [path, [...importers].toSorted()])); } finally { await rm(snapshot, { recursive: true, force: true }); } @@ -160,20 +158,20 @@ async function dependentFileCounts(cwd, analyzer, head) { export async function compareRevisions({ cwd, base = 'origin/main', - head = 'HEAD', + pr = 'HEAD', baseRef, baseCoverage, - headCoverage, + prCoverage, coverageNotNeeded = false, }) { const baseSha = resolveCommit(cwd, base); - const headSha = resolveCommit(cwd, head); - const mergeBase = git(cwd, ['merge-base', baseSha, headSha]).trim(); + const prSha = resolveCommit(cwd, pr); + const mergeBase = git(cwd, ['merge-base', baseSha, prSha]).trim(); const analyzer = createAnalyzer(cwd); const coverageNotes = []; for (const [label, snapshot, revision] of [ ['Merge-base', baseCoverage, mergeBase], - ['Head', headCoverage, headSha], + ['PR', prCoverage, prSha], ]) { if (!snapshot) { coverageNotes.push( @@ -193,44 +191,25 @@ export async function compareRevisions({ } if ( hasCoverage(baseCoverage) && - hasCoverage(headCoverage) && - JSON.stringify(baseCoverage.instrumenter) !== JSON.stringify(headCoverage.instrumenter) + hasCoverage(prCoverage) && + JSON.stringify(baseCoverage.instrumenter) !== JSON.stringify(prCoverage.instrumenter) ) { coverageNotes.push( 'Coverage is unknown: Node, Vitest, provider, or coverage configuration differs.', ); baseCoverage = undefined; - headCoverage = undefined; - } - const candidates = changedFiles(cwd, mergeBase, headSha); - // Test-only changes can expose existing complex functions. Include files whose - // measured branch outcomes changed, even when their source did not. - if ( - hasCoverage(baseCoverage) && - hasCoverage(headCoverage) && - baseCoverage.revision === mergeBase && - headCoverage.revision === headSha - ) { - const included = new Set(candidates.flatMap((file) => [file.path, file.previousPath])); - for (const [path, next] of Object.entries(headCoverage.files)) { - const old = baseCoverage.files[path]; - if ( - !included.has(path) && - old && - JSON.stringify(old.branches) !== JSON.stringify(next.branches) - ) { - candidates.push({ path, previousPath: path, headPath: path }); - } - } + prCoverage = undefined; } + const candidates = changedFiles(cwd, mergeBase, prSha); const files = []; for (const file of candidates) { const before = await measure(cwd, analyzer, mergeBase, file.previousPath, baseCoverage); - const after = await measure(cwd, analyzer, headSha, file.headPath, headCoverage); + const after = await measure(cwd, analyzer, prSha, file.prPath, prCoverage); if (before === null && after === null) continue; files.push({ path: file.path, previousPath: file.previousPath, + prPath: file.prPath, before: before ?? [], after: after ?? [], summary: { @@ -243,21 +222,32 @@ export async function compareRevisions({ const rows = [...comparison.findings, ...comparison.improvements]; let dependents = new Map(); let dependencyNote; - if (rows.some((row) => row.path.startsWith('lambda/'))) { + if (files.some((file) => file.prPath?.startsWith('lambda/'))) { try { - dependents = await dependentFileCounts(cwd, analyzer, headSha); + dependents = await dependentFiles(cwd, analyzer, prSha); } catch (error) { console.warn(`Dependency analysis failed: ${error.message}`); dependencyNote = 'Depending files: unknown (dependency analysis failed).'; } } - for (const row of rows) row.dependentFiles = dependents.get(row.path) ?? null; + for (const row of rows) row.dependentFiles = dependents.get(row.path)?.length ?? null; + const dependencies = files.map((file) => ({ + path: file.path, + importers: dependents.get(file.prPath) ?? null, + reason: !file.prPath + ? 'File deleted in PR' + : !file.prPath.startsWith('lambda/') + ? 'Backend only' + : dependents.has(file.prPath) + ? null + : 'Dependency analysis unavailable', + })); return { schemaVersion: 1, base: baseSha, baseRef, mergeBase, - head: headSha, + pr: prSha, coverageNotes, ...(dependencyNote ? { dependencyNote } : {}), analyzer: { @@ -268,6 +258,7 @@ export async function compareRevisions({ threshold: complexityThreshold, }, files, + dependencies, ...comparison, }; } @@ -276,11 +267,11 @@ async function main() { const { values } = parseArgs({ options: { base: { type: 'string', default: 'origin/main' }, - head: { type: 'string', default: 'HEAD' }, + pr: { type: 'string', default: 'HEAD' }, 'base-ref': { type: 'string' }, 'output-dir': { type: 'string', default: 'reports/complexity' }, 'base-coverage': { type: 'string' }, - 'head-coverage': { type: 'string' }, + 'pr-coverage': { type: 'string' }, 'coverage-not-needed': { type: 'boolean', default: false }, }, }); @@ -288,10 +279,10 @@ async function main() { const report = await compareRevisions({ cwd, base: values.base, - head: values.head, + pr: values.pr, baseRef: values['base-ref'], baseCoverage: await loadCoverage(values['base-coverage']), - headCoverage: await loadCoverage(values['head-coverage']), + prCoverage: await loadCoverage(values['pr-coverage']), coverageNotNeeded: values['coverage-not-needed'], }); const markdown = renderComparison(report); @@ -299,6 +290,17 @@ async function main() { await mkdir(outputDir, { recursive: true }); await writeFile(join(outputDir, 'comparison.json'), `${JSON.stringify(report, null, 2)}\n`); await writeFile(join(outputDir, 'comparison.md'), markdown); + const { schemaVersion, mergeBase, pr, analyzer, coverageNotes, files, dependencies } = report; + await writeFile( + join(outputDir, 'complexity.json'), + `${JSON.stringify({ schemaVersion, mergeBase, pr, analyzer, coverageNotes, files }, null, 2)}\n`, + ); + await writeFile(join(outputDir, 'complexity.md'), renderComplexityDetails(report)); + await writeFile( + join(outputDir, 'dependencies.json'), + `${JSON.stringify({ schemaVersion, pr, scope: 'Direct production importers, backend only', files: dependencies }, null, 2)}\n`, + ); + await writeFile(join(outputDir, 'dependencies.md'), renderDependencyDetails(report)); process.stdout.write(markdown); } diff --git a/scripts/complexity-report.mjs b/scripts/complexity-report.mjs index e45602d4a..d537ed8b2 100644 --- a/scripts/complexity-report.mjs +++ b/scripts/complexity-report.mjs @@ -72,8 +72,7 @@ export function renderComparison(report) { const lines = [ '# Complexity and test coverage', '', - `Merge base \`${cell(report.mergeBase)}\` → head \`${cell(report.head)}\`.`, - `Analyzed ${report.files.length} source files with source or coverage changes. Advisory threshold: ${report.analyzer.threshold}.`, + `Analyzed ${report.files.length} source files changed by this PR. Advisory threshold: ${report.analyzer.threshold}.`, '', `${report.findings.length} new/unmatched or worsening functions above the threshold.`, ]; @@ -90,16 +89,61 @@ export function renderComparison(report) { } lines.push( '', - 'With comparable data: 🟢 Improving without either metric worsening · 🟠 Mixed or small changes · 🔴 Large net regression · 🟣 Both worsen substantially.', - 'Without comparable data: use current complexity—🟢 ≤15, 🟠 >15, 🔴 >50, 🟣 >100. Measured head coverage below 50% raises the color one level; unknown coverage leaves it unchanged.', - 'Depending files = direct production importers of the file at PR head (backend only; excludes tests and AWS/external callers). More than 10 raises an existing warning one level, capped at purple, when coverage is below 50% or regresses. Improvements stay green.', - 'Comparison scale: complexity Δ / max(5, 20% of base complexity), minus coverage Δ / 10 percentage points. Net ≥ 1 is red; both worsening by ≥ 1 unit is purple. Icons are review heuristics, not correctness guarantees; * measurements also make the icon provisional.', - 'Review order: coverage regressions first, then uncovered branch count, then complexity increase.', - 'Branch coverage uses backend Vitest/V8 outcomes assigned to the innermost function. * = measured with failing or interrupted tests; coverage and deltas may be incomplete. Frontend and missing data are unknown; n/a means no measured outcomes. Coverage Δ is measured in percentage points.', - 'Unchanged existing complexity is omitted unless coverage regresses. Improvements do not offset findings.', - 'New or unmatched means no confident function match was found; no increase is inferred.', - 'Advisory review aid, not a correctness guarantee or merge gate.', + '🟢 Improving · 🟠 Needs attention · 🔴 High risk · 🟣 Highest risk', + '', + 'unknown = unavailable · n/a = no branches · \\* = incomplete tests', '', ); return lines.join('\n'); } + +export function renderComplexityDetails(report) { + const lines = [ + '# Complexity details', + '', + 'All functions in PR-changed files, including those below the advisory threshold.', + '', + 'unknown = unavailable · n/a = no branches · \\* = incomplete tests', + ]; + for (const note of report.coverageNotes ?? []) lines.push('', cell(note)); + if (!report.files.length) lines.push('', 'No source files changed.'); + for (const file of report.files) { + lines.push('', `## ${cell(file.path)}`); + if (file.previousPath && file.previousPath !== file.path) { + lines.push('', `Previously: ${cell(file.previousPath)}`); + } + lines.push( + '', + '| Revision | Function | Line | Complexity | Branch coverage | Uncovered branches |', + '| --- | --- | ---: | ---: | ---: | ---: |', + ); + for (const [revision, functions] of [ + ['Base', file.before], + ['PR', file.after], + ]) { + for (const fn of functions) { + lines.push( + `| ${revision} | ${cell(fn.name)} | ${fn.line} | ${fn.score} | ${percent(fn.coverage)} | ${uncovered(fn.coverage)} |`, + ); + } + } + if (!file.before.length && !file.after.length) lines.push('', 'No functions.'); + } + return `${lines.join('\n')}\n`; +} + +export function renderDependencyDetails(report) { + const lines = [ + '# Depending files', + '', + 'Direct production importers at the PR commit. Backend only; excludes tests and external callers.', + ]; + if (!report.dependencies.length) lines.push('', 'No source files changed.'); + for (const file of report.dependencies) { + lines.push('', `## ${cell(file.path)}`, ''); + if (file.importers === null) lines.push(cell(file.reason)); + else if (!file.importers.length) lines.push('No direct production importers.'); + else lines.push(...file.importers.map((path) => `- ${cell(path)}`)); + } + return `${lines.join('\n')}\n`; +} diff --git a/scripts/publish-complexity-comment.mjs b/scripts/publish-complexity-comment.mjs index a09e18cf2..2548a94de 100644 --- a/scripts/publish-complexity-comment.mjs +++ b/scripts/publish-complexity-comment.mjs @@ -24,7 +24,7 @@ function validCoverage(value) { export function validateReport(report) { if ( report?.schemaVersion !== 1 || - ![report.base, report.mergeBase, report.head].every(sha) || + ![report.base, report.mergeBase, report.pr].every(sha) || !text(report.baseRef) || !report.baseRef || !(report.dependencyNote === undefined || text(report.dependencyNote)) || @@ -88,7 +88,7 @@ export async function qualityArtifactNames({ github, repo, runId, attempt }) { const names = {}; for (const [role, jobName, prefix] of [ ['base', 'coverage-base', 'coverage-base'], - ['head', 'coverage-head', 'coverage-head'], + ['pr', 'coverage-pr', 'coverage-pr'], ['report', 'report', 'pr-quality'], ]) { const producer = jobs @@ -162,7 +162,7 @@ export async function publishComplexityComment({ pr.head.repo?.id !== run.head_repository?.id ) continue; - if (report && report.head !== pr.head.sha) continue; + if (report && report.pr !== pr.head.sha) continue; const comments = await github.paginate(github.rest.issues.listComments, { ...repo, issue_number: pr.number, diff --git a/scripts/test/compare-complexity.test.mjs b/scripts/test/compare-complexity.test.mjs index 256ced214..28430896f 100644 --- a/scripts/test/compare-complexity.test.mjs +++ b/scripts/test/compare-complexity.test.mjs @@ -123,14 +123,14 @@ test('compares against merge base, not target tip, and reports only relevant cha repo.put('frontend/src/types.d.ts', 'not valid declarations {{{'); repo.put('scripts/ignored.mjs', 'not valid JavaScript {{{'); repo.put('eslint.config.mjs', 'throw new Error("Never load PR configuration");'); - const head = repo.commit('feature'); - // Dirty work is not part of the committed-head comparison. + const pr = repo.commit('feature'); + // Dirty work is not part of the committed PR comparison. repo.put('lambda/demo/index.js', 'not valid JavaScript {{{'); const report = await compareRevisions({ cwd: repo.cwd, base: 'main', baseRef: 'main' }); assert.equal(report.baseRef, 'main'); assert.equal(report.mergeBase, ancestor); - assert.equal(report.head, head); + assert.equal(report.pr, pr); assert.notEqual(report.base, ancestor); assert.deepEqual( report.findings.map(({ function: name, before, after, kind }) => ({ @@ -165,6 +165,34 @@ test('compares against merge base, not target tip, and reports only relevant cha readFileSync(join(repo.cwd, 'reports/complexity/comparison.md'), 'utf8'), cli.stdout, ); + const details = JSON.parse(readFileSync(join(repo.cwd, 'reports/complexity/complexity.json'))); + assert.deepEqual(details.files, report.files); + assert.equal(details.mergeBase, ancestor); + assert.equal(details.pr, pr); + const detailMarkdown = readFileSync(join(repo.cwd, 'reports/complexity/complexity.md'), 'utf8'); + assert.match(detailMarkdown, /\| Base \| small \| \d+ \| 1 \|/); + assert.match(detailMarkdown, /\| PR \| small \| \d+ \| 2 \|/); + assert.match(detailMarkdown, /\| PR \| stable \| \d+ \| 40 \|/); + assert.match(detailMarkdown, /Previously: lambda\/demo\/original.js/); + assert.match(detailMarkdown, /\| Base \| deleted \|/); + assert.doesNotMatch(detailMarkdown, /\| PR \| deleted \|/); + const dependencies = JSON.parse( + readFileSync(join(repo.cwd, 'reports/complexity/dependencies.json')), + ); + assert.deepEqual(dependencies.files, report.dependencies); + assert.equal(dependencies.pr, pr); + assert.deepEqual( + dependencies.files.find((file) => file.path === 'lambda/demo/deleted.js'), + { + path: 'lambda/demo/deleted.js', + importers: null, + reason: 'File deleted in PR', + }, + ); + assert.equal( + dependencies.files.find((file) => file.path === 'frontend/src/new.ts').reason, + 'Backend only', + ); assert.match(cli.stdout, /unknown \(no report supplied\)/); const skipped = spawnSync(process.execPath, [script, '--base', 'main', '--coverage-not-needed'], { cwd: repo.cwd, @@ -200,7 +228,7 @@ test('recognizes unchanged moved functions and shifted callbacks without guessin assert.equal(report.findings[0].delta, null); }); -test('counts unique direct production importers at committed head for every function in a file', async (t) => { +test('counts unique direct production importers at the PR commit for every function in a file', async (t) => { const repo = repository(t); repo.put( 'lambda/shared/hot.js', @@ -235,7 +263,7 @@ test('counts unique direct production importers at committed head for every func ); } repo.put('.dependency-cruiser.cjs', "throw new Error('must not load PR configuration');"); - repo.commit('head'); + repo.commit('pr'); repo.put('lambda/dirty/index.js', "import '../shared/hot.js';"); repo.put( 'lambda/one/index.js', @@ -247,6 +275,40 @@ test('counts unique direct production importers at committed head for every func report.findings.map((row) => row.dependentFiles), [7, 7], ); + assert.deepEqual( + report.dependencies.find((file) => file.path === 'lambda/shared/hot.js').importers, + [ + 'lambda/duplicate-a/index.js', + 'lambda/duplicate-b/index.js', + 'lambda/four/index.js', + 'lambda/one/index.js', + 'lambda/three/index.cjs', + 'lambda/two/index.js', + 'lambda/unicode/café\nmodule.js', + ], + ); + assert.deepEqual( + report.dependencies.find((file) => file.path === 'lambda/one/index.js').importers, + ['lambda/transitive/index.js'], + ); +}); + +test('dependency details include changed files even when no function is flagged', async (t) => { + const repo = repository(t); + repo.put('lambda/shared/calm.js', `export ${branchFunction('calm', 1)}`); + repo.put('lambda/demo/index.js', "import '../shared/calm.js';"); + const base = repo.commit('base'); + repo.put('lambda/shared/calm.js', `export ${branchFunction('calm', 2)}`); + repo.commit('small change'); + const report = await compareRevisions({ cwd: repo.cwd, base }); + assert.deepEqual(report.findings, []); + assert.deepEqual(report.dependencies, [ + { + path: 'lambda/shared/calm.js', + importers: ['lambda/demo/index.js'], + reason: null, + }, + ]); }); test('suppresses unchanged identical callbacks by count and flags only additional copies', async () => { @@ -323,7 +385,7 @@ test('caps the display without dropping JSON findings and escapes Markdown conte }; const report = { mergeBase: 'abc', - head: 'def', + pr: 'def', analyzer: { threshold: 15 }, files: [], findings: Array.from({ length: 21 }, () => finding), @@ -383,13 +445,14 @@ test('attributes nested outcomes separately and distinguishes missing coverage f ); }); -test('test-only coverage regressions expose existing complex functions and reject mismatched instrumentation', async (t) => { +test('coverage regressions only report files changed by the PR and reject mismatched instrumentation', async (t) => { const repo = repository(t); const source = branchFunction('risky', 20); repo.put('lambda/demo/index.js', source); + repo.put('lambda/demo/unchanged.js', source); const base = repo.commit('base'); repo.put('lambda/demo/test/test.js', '// tests changed'); - const head = repo.commit('test only'); + const testOnlyPr = repo.commit('test only'); const snapshot = (revision, hit) => ({ status: 'complete', revision, @@ -399,19 +462,43 @@ test('test-only coverage regressions expose existing complex functions and rejec sourceHash: sourceHash(source), branches: [{ start: { line: 2, column: 0 }, hit }], }, + 'lambda/demo/unchanged.js': { + sourceHash: sourceHash(source), + branches: [{ start: { line: 2, column: 0 }, hit }], + }, }, }); const baseCoverage = snapshot(base, true); - const headCoverage = snapshot(head, false); - const report = await compareRevisions({ cwd: repo.cwd, base, baseCoverage, headCoverage }); + const testOnlyReport = await compareRevisions({ + cwd: repo.cwd, + base, + baseCoverage, + prCoverage: snapshot(testOnlyPr, false), + }); + assert.deepEqual(testOnlyReport.files, []); + assert.deepEqual(testOnlyReport.findings, []); + assert.deepEqual(testOnlyReport.improvements, []); + // A file edit without a complexity change must still expose coverage regressions. + const changedSource = source.replace('return -1;', 'return -2;'); + repo.put('lambda/demo/index.js', changedSource); + const pr = repo.commit('source changed'); + const prCoverage = snapshot(pr, false); + prCoverage.files['lambda/demo/index.js'].sourceHash = sourceHash(changedSource); + const report = await compareRevisions({ cwd: repo.cwd, base, baseCoverage, prCoverage }); + assert.deepEqual( + report.files.map((file) => file.path), + ['lambda/demo/index.js'], + ); + assert.deepEqual(report.improvements, []); assert.equal(report.findings.length, 1); + assert.equal(report.findings[0].path, 'lambda/demo/index.js'); assert.equal(report.findings[0].kind, 'coverage-decreased'); assert.equal(report.findings[0].delta, 0); assert.equal(report.findings[0].coverage.change.percentagePoints, -100); assert.match(renderComparison(report), /100.0% → 0.0%/); // Crash the actual dependency-cruiser child, leaving the comparison CLI running. repo.put('base-coverage.json', JSON.stringify(baseCoverage)); - repo.put('head-coverage.json', JSON.stringify(headCoverage)); + repo.put('pr-coverage.json', JSON.stringify(prCoverage)); repo.put( 'crash-depcruise.cjs', ` @@ -426,10 +513,12 @@ test('test-only coverage regressions expose existing complex functions and rejec script, '--base', base, + '--pr', + pr, '--base-coverage', 'base-coverage.json', - '--head-coverage', - 'head-coverage.json', + '--pr-coverage', + 'pr-coverage.json', ], { cwd: repo.cwd, @@ -453,26 +542,41 @@ test('test-only coverage regressions expose existing complex functions and rejec assert.match(crashed.stdout, /100.0% → 0.0%/); assert.doesNotMatch(crashed.stdout, /Report unavailable|simulated dependency-cruiser crash/); assert.match(crashed.stderr, /Dependency analysis failed:.*simulated dependency-cruiser crash/s); - headCoverage.status = 'partial'; - const partial = await compareRevisions({ cwd: repo.cwd, base, baseCoverage, headCoverage }); + const dependencyDetails = JSON.parse( + readFileSync(join(repo.cwd, 'reports/complexity/dependencies.json'), 'utf8'), + ); + assert.deepEqual(dependencyDetails.files, [ + { + path: 'lambda/demo/index.js', + importers: null, + reason: 'Dependency analysis unavailable', + }, + ]); + assert.match( + readFileSync(join(repo.cwd, 'reports/complexity/complexity.md'), 'utf8'), + /\| PR \| risky \| \d+ \| 20 \| 0.0% \| 1 \|/, + ); + prCoverage.status = 'partial'; + const partial = await compareRevisions({ cwd: repo.cwd, base, baseCoverage, prCoverage }); assert.equal(partial.findings[0].coverage.change.percentagePoints, -100); assert.equal(partial.findings[0].coverage.after.partial, true); assert.match(renderComparison(partial), /100.0% → 0.0%\*/); assert.match( renderComparison(partial), - /Head coverage: measured with failing or interrupted tests/, + /PR coverage: measured with failing or interrupted tests/, ); - headCoverage.status = 'complete'; - headCoverage.instrumenter.vitest = 'different'; - const incompatible = await compareRevisions({ cwd: repo.cwd, base, baseCoverage, headCoverage }); + prCoverage.status = 'complete'; + prCoverage.instrumenter.vitest = 'different'; + const incompatible = await compareRevisions({ cwd: repo.cwd, base, baseCoverage, prCoverage }); assert.equal(incompatible.findings.length, 0); assert.match(incompatible.coverageNotes[0], /differs/); const unavailable = await compareRevisions({ cwd: repo.cwd, base, baseCoverage, - headCoverage: { status: 'unavailable', revision: head, files: {} }, + prCoverage: { status: 'unavailable', revision: pr, files: {} }, }); - assert.equal(unavailable.files.length, 0); - assert.match(renderComparison(unavailable), /Head coverage: unknown.*failed/); + assert.equal(unavailable.files.length, 1); + assert.equal(unavailable.findings.length, 0); + assert.match(renderComparison(unavailable), /PR coverage: unknown.*failed/); }); diff --git a/scripts/test/complexity-report.test.mjs b/scripts/test/complexity-report.test.mjs index aa56e3067..510868dc0 100644 --- a/scripts/test/complexity-report.test.mjs +++ b/scripts/test/complexity-report.test.mjs @@ -1,6 +1,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { changeIcon, renderComparison } from '../complexity-report.mjs'; +import { + changeIcon, + renderComparison, + renderComplexityDetails, + renderDependencyDetails, +} from '../complexity-report.mjs'; const row = (before, after, coverageDelta) => ({ path: 'lambda/demo/index.js', @@ -12,6 +17,32 @@ const row = (before, after, coverageDelta) => ({ coverage: { change: { percentagePoints: coverageDelta } }, }); +test('detail artifacts include every function and importer, with escaped Markdown', () => { + const functions = Array.from({ length: 25 }, (_, index) => ({ + name: `fn${index}|`, + line: index + 1, + score: 1, + coverage: { status: 'measured', percent: 50, uncovered: 1 }, + })); + const report = { + files: [{ path: 'lambda/a|b.js', before: functions, after: functions }], + dependencies: [ + { + path: 'lambda/a|b.js', + importers: Array.from({ length: 25 }, (_, index) => `lambda/${index}|.js`), + }, + ], + }; + const complexity = renderComplexityDetails(report); + assert.equal(complexity.split('\n').filter((line) => /^\| (Base|PR) \|/.test(line)).length, 50); + assert.match(complexity, /fn24|<tag>/); + assert.doesNotMatch(complexity, /|Showing 20/); + const dependencies = renderDependencyDetails(report); + assert.equal(dependencies.split('\n').filter((line) => line.startsWith('- ')).length, 25); + assert.match(dependencies, /lambda\/24|<tag>.js/); + assert.match(renderDependencyDetails({ dependencies: [] }), /No source files changed/); +}); + test('green requires improvement without a worsening metric', () => { assert.equal(changeIcon(row(50, 40, 10)), '🟢'); assert.equal(changeIcon(row(50, 40, 0)), '🟢'); @@ -58,7 +89,7 @@ test('missing comparisons use current complexity, with strict threshold boundari assert.equal(changeIcon({ ...row(50, 70, -20), coverage: undefined }), '🔴'); }); -test('low measured head coverage escalates current risk, but unknown and branchless coverage do not', () => { +test('low measured PR coverage escalates current risk, but unknown and branchless coverage do not', () => { const current = (score, percent, status = 'measured') => ({ ...row(null, score, null), coverage: { after: { status, percent } }, @@ -72,10 +103,10 @@ test('low measured head coverage escalates current risk, but unknown and branchl assert.equal(changeIcon(current(70, null, 'unknown')), '🔴'); }); -test('Markdown uses a leading icon instead of the Signal column, with an explanatory legend', () => { +test('Markdown keeps the table and a compact two-line legend', () => { const report = { mergeBase: 'a'.repeat(40), - head: 'b'.repeat(40), + pr: 'b'.repeat(40), analyzer: { threshold: 15 }, files: [], findings: [{ ...row(50, 60, -10), dependentFiles: 12 }], @@ -85,8 +116,14 @@ test('Markdown uses a leading icon instead of the Signal column, with an explana assert.match(markdown, /\| 🟣 \| lambda\/demo\/index.js/); assert.match(markdown, /\| 🟢 \| lambda\/demo\/index.js/); assert.doesNotMatch(markdown, /\| Signal \|/); - assert.match(markdown, /Without comparable data: use current complexity/); - assert.match(markdown, /Icons are review heuristics/); + assert.doesNotMatch(markdown, /Merge base|a{40}|b{40}/); + assert.ok( + markdown.endsWith( + '🟢 Improving · 🟠 Needs attention · 🔴 High risk · 🟣 Highest risk\n\n' + + 'unknown = unavailable · n/a = no branches · \\* = incomplete tests\n', + ), + ); + assert.ok(markdown.length < 1500, 'A two-row report should stay brief'); assert.match(markdown, /\| Depending files \|/); assert.match(markdown, /\| 12 \|/); assert.match(markdown, /\| unknown \|/); diff --git a/scripts/test/publish-complexity-comment.test.mjs b/scripts/test/publish-complexity-comment.test.mjs index 64f121c32..516dc7702 100644 --- a/scripts/test/publish-complexity-comment.test.mjs +++ b/scripts/test/publish-complexity-comment.test.mjs @@ -14,13 +14,13 @@ import { } from '../publish-complexity-comment.mjs'; const base = 'a'.repeat(40); -const head = 'b'.repeat(40); +const prSha = 'b'.repeat(40); const report = () => ({ schemaVersion: 1, baseRef: 'main', base, mergeBase: base, - head, + pr: prSha, analyzer: { threshold: 15 }, files: [], coverageNotes: [], @@ -52,7 +52,7 @@ async function fixture(t, contents = report()) { const pr = { number: 42, state: 'open', - head: { sha: head, repo: { id: 2 } }, + head: { sha: prSha, repo: { id: 2 } }, base: { sha: base, ref: 'main' }, }; const comments = []; @@ -69,7 +69,7 @@ async function fixture(t, contents = report()) { run_attempt: 1, repository: { id: 1 }, head_repository: { id: 2 }, - head_sha: head, + head_sha: prSha, }, }, }; @@ -80,7 +80,7 @@ async function fixture(t, contents = report()) { rest: { repos: { compareCommitsWithBasehead: async (input) => { - assert.equal(input.basehead, `main...${head}`); + assert.equal(input.basehead, `main...${prSha}`); return { data: { merge_base_commit: { sha: base } } }; }, }, @@ -130,7 +130,7 @@ test('updates only the bot-owned marked comment and ignores stale reruns', async assert.deepEqual(f.writes, []); }); -test('does not post stale head, wrong repository, or a non-PR workflow', async (t) => { +test('does not post stale PR commit, wrong repository, or a non-PR workflow', async (t) => { for (const change of [ (f) => { f.pr.head.sha = 'c'.repeat(40); @@ -175,7 +175,7 @@ test('validates numbers and derives coverage deltas rather than trusting the art r.findings[0].after = 'injection'; }, (r) => { - r.head = ''; + r.pr = ''; }, (r) => { r.findings[0].coverage.after.percent = 100; @@ -332,11 +332,11 @@ test('report rerun reuses coverage from producers that were not rerun', async () assert.deepEqual( await selectArtifacts([ job('coverage-base', 1), - job('coverage-head', 1), + job('coverage-pr', 1), job('report', 1, 'failure'), job('report', 2, null), ]), - { base: 'coverage-base-1', head: 'coverage-head-1', report: 'pr-quality-2' }, + { base: 'coverage-base-1', pr: 'coverage-pr-1', report: 'pr-quality-2' }, ); }); @@ -357,14 +357,14 @@ test('failed producer rerun never falls back to an older artifact', async () => assert.deepEqual( await selectArtifacts([ job('coverage-base', 1), - job('coverage-head', 1), + job('coverage-pr', 1), job('report', 1), - job('coverage-head', 2, 'failure'), + job('coverage-pr', 2, 'failure'), job('report', 2, 'failure'), job('coverage-base', 3), job('report', 3), ]), - { base: 'coverage-base-1', head: 'coverage-head-2', report: 'pr-quality-2' }, + { base: 'coverage-base-1', pr: 'coverage-pr-2', report: 'pr-quality-2' }, ); }); @@ -373,9 +373,9 @@ test('skipped producers do not supply old measurements', async () => { await selectArtifacts([ job('coverage-base', 1), job('coverage-base', 2, 'skipped'), - job('coverage-head', 2, 'skipped'), + job('coverage-pr', 2, 'skipped'), ]), - { base: '', head: '', report: '' }, + { base: '', pr: '', report: '' }, ); }); @@ -393,7 +393,17 @@ test('both workflow paths download selected producer artifacts and call the shar const checkout = steps.find((step) => step.uses.startsWith('actions/checkout@')); assert.equal(checkout.with['persist-credentials'], false); if (file === 'pr-quality-comment.yml') assert.equal(checkout.with.ref, undefined); - else assert.equal(checkout.with.ref, '${{ github.event.pull_request.head.sha }}'); + else { + assert.equal(checkout.with.ref, '${{ github.event.pull_request.head.sha }}'); + const uploads = workflow.jobs.report.steps + .filter((step) => step.uses?.startsWith('actions/upload-artifact@')) + .map((step) => [step.with.name, step.with.path]); + assert.deepEqual(uploads, [ + ['pr-quality-${{ github.run_attempt }}', 'reports/complexity/comparison.json'], + ['complexity-${{ github.run_attempt }}', 'reports/complexity/complexity.*'], + ['dependencies-${{ github.run_attempt }}', 'reports/complexity/dependencies.*'], + ]); + } } }); @@ -457,7 +467,7 @@ test('workflow skips backend suites for docs, Terraform and frontend but detects env: { ...process.env, BASE_SHA: baseSha, - HEAD_SHA: git('rev-parse', 'HEAD'), + PR_SHA: git('rev-parse', 'HEAD'), GITHUB_OUTPUT: output, }, });