diff --git a/.github/actions/setup-web/action.yml b/.github/actions/setup-web/action.yml index 54702c914a..1c7104a5dc 100644 --- a/.github/actions/setup-web/action.yml +++ b/.github/actions/setup-web/action.yml @@ -4,7 +4,7 @@ runs: using: composite steps: - name: Setup Vite+ - uses: voidzero-dev/setup-vp@b5d848f5a62488f3d3d920f8aa6ac318a60c5f07 # v1 + uses: voidzero-dev/setup-vp@4a524139920f87f9f7080d3b8545acac019e1852 # v1.0.0 with: node-version-file: "./web/.nvmrc" cache: true diff --git a/.github/workflows/anti-slop.yml b/.github/workflows/anti-slop.yml index c0d1818691..b0f0a36bc9 100644 --- a/.github/workflows/anti-slop.yml +++ b/.github/workflows/anti-slop.yml @@ -12,7 +12,7 @@ jobs: anti-slop: runs-on: ubuntu-latest steps: - - uses: peakoss/anti-slop@v0 + - uses: peakoss/anti-slop@85daca1880e9e1af197fc06ea03349daf08f4202 # v0.2.1 with: github-token: ${{ secrets.GITHUB_TOKEN }} close-pr: false diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index 12d7ff33c7..28e19ba6a4 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -27,7 +27,7 @@ jobs: persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: enable-cache: true python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 73ca94f98f..8947ae4030 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -39,7 +39,7 @@ jobs: with: python-version: "3.11" - - uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0 + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 - name: Generate Docker Compose if: steps.docker-compose-changes.outputs.any_changed == 'true' diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml index c567a4bfe0..ffb9734e48 100644 --- a/.github/workflows/db-migration-test.yml +++ b/.github/workflows/db-migration-test.yml @@ -19,7 +19,7 @@ jobs: persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: enable-cache: true python-version: "3.12" @@ -69,7 +69,7 @@ jobs: persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: enable-cache: true python-version: "3.12" diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index a19cb50abc..ad07b53632 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -62,10 +62,7 @@ jobs: needs: check-changes if: needs.check-changes.outputs.web-changed == 'true' uses: ./.github/workflows/web-tests.yml - with: - base_sha: ${{ github.event.before || github.event.pull_request.base.sha }} - diff_range_mode: ${{ github.event.before && 'exact' || 'merge-base' }} - head_sha: ${{ github.event.after || github.event.pull_request.head.sha || github.sha }} + secrets: inherit style-check: name: Style Check diff --git a/.github/workflows/pyrefly-diff.yml b/.github/workflows/pyrefly-diff.yml index f50df229d5..a00f469bbe 100644 --- a/.github/workflows/pyrefly-diff.yml +++ b/.github/workflows/pyrefly-diff.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: 0 - name: Setup Python & UV - uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: enable-cache: true diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index 5e037d2541..657a481f74 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -33,7 +33,7 @@ jobs: - name: Setup UV and Python if: steps.changed-files.outputs.any_changed == 'true' - uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: enable-cache: false python-version: "3.12" diff --git a/.github/workflows/translate-i18n-claude.yml b/.github/workflows/translate-i18n-claude.yml index 9af6649328..849f965c36 100644 --- a/.github/workflows/translate-i18n-claude.yml +++ b/.github/workflows/translate-i18n-claude.yml @@ -120,7 +120,7 @@ jobs: - name: Run Claude Code for Translation Sync if: steps.detect_changes.outputs.CHANGED_FILES != '' - uses: anthropics/claude-code-action@cd77b50d2b0808657f8e6774085c8bf54484351c # v1.0.72 + uses: anthropics/claude-code-action@df37d2f0760a4b5683a6e617c9325bc1a36443f6 # v1.0.75 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/vdb-tests.yml b/.github/workflows/vdb-tests.yml index 0b771c1af7..f45f2137d6 100644 --- a/.github/workflows/vdb-tests.yml +++ b/.github/workflows/vdb-tests.yml @@ -31,7 +31,7 @@ jobs: remove_tool_cache: true - name: Setup UV and Python - uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: enable-cache: true python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml index b705ad4166..d40cd4bfeb 100644 --- a/.github/workflows/web-tests.yml +++ b/.github/workflows/web-tests.yml @@ -2,16 +2,9 @@ name: Web Tests on: workflow_call: - inputs: - base_sha: + secrets: + CODECOV_TOKEN: required: false - type: string - diff_range_mode: - required: false - type: string - head_sha: - required: false - type: string permissions: contents: read @@ -63,7 +56,7 @@ jobs: needs: [test] runs-on: ubuntu-latest env: - VITEST_COVERAGE_SCOPE: app-components + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} defaults: run: shell: bash @@ -89,50 +82,14 @@ jobs: - name: Merge reports run: vp test --merge-reports --coverage --silent=passed-only - - name: Report app/components baseline coverage - run: node ./scripts/report-components-coverage-baseline.mjs - - - name: Report app/components test touch - env: - BASE_SHA: ${{ inputs.base_sha }} - DIFF_RANGE_MODE: ${{ inputs.diff_range_mode }} - HEAD_SHA: ${{ inputs.head_sha }} - run: node ./scripts/report-components-test-touch.mjs - - - name: Check app/components pure diff coverage - env: - BASE_SHA: ${{ inputs.base_sha }} - DIFF_RANGE_MODE: ${{ inputs.diff_range_mode }} - HEAD_SHA: ${{ inputs.head_sha }} - run: node ./scripts/check-components-diff-coverage.mjs - - - name: Check Coverage Summary - if: always() - id: coverage-summary - run: | - set -eo pipefail - - COVERAGE_FILE="coverage/coverage-final.json" - COVERAGE_SUMMARY_FILE="coverage/coverage-summary.json" - - if [ -f "$COVERAGE_FILE" ] || [ -f "$COVERAGE_SUMMARY_FILE" ]; then - echo "has_coverage=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "has_coverage=false" >> "$GITHUB_OUTPUT" - echo "### 🚨 app/components Diff Coverage" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "Coverage artifacts not found. Ensure Vitest merge reports ran with coverage enabled." >> "$GITHUB_STEP_SUMMARY" - - - name: Upload Coverage Artifact - if: steps.coverage-summary.outputs.has_coverage == 'true' - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + - name: Report coverage + if: ${{ env.CODECOV_TOKEN != '' }} + uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3 with: - name: web-coverage-report - path: web/coverage - retention-days: 30 - if-no-files-found: error + directory: web/coverage + flags: web + env: + CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }} web-build: name: Web Build diff --git a/web/__tests__/check-components-diff-coverage.test.ts b/web/__tests__/check-components-diff-coverage.test.ts deleted file mode 100644 index 62e5ff5ed5..0000000000 --- a/web/__tests__/check-components-diff-coverage.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { - buildGitDiffRevisionArgs, - getChangedBranchCoverage, - getChangedStatementCoverage, - getIgnoredChangedLinesFromSource, - normalizeToRepoRelative, - parseChangedLineMap, -} from '../scripts/check-components-diff-coverage-lib.mjs' - -describe('check-components-diff-coverage helpers', () => { - it('should build exact and merge-base git diff revision args', () => { - expect(buildGitDiffRevisionArgs('base-sha', 'head-sha', 'exact')).toEqual(['base-sha', 'head-sha']) - expect(buildGitDiffRevisionArgs('base-sha', 'head-sha')).toEqual(['base-sha...head-sha']) - }) - - it('should parse changed line maps from unified diffs', () => { - const diff = [ - 'diff --git a/web/app/components/share/a.ts b/web/app/components/share/a.ts', - '+++ b/web/app/components/share/a.ts', - '@@ -10,0 +11,2 @@', - '+const a = 1', - '+const b = 2', - 'diff --git a/web/app/components/base/b.ts b/web/app/components/base/b.ts', - '+++ b/web/app/components/base/b.ts', - '@@ -20 +21 @@', - '+const c = 3', - 'diff --git a/web/README.md b/web/README.md', - '+++ b/web/README.md', - '@@ -1 +1 @@', - '+ignore me', - ].join('\n') - - const lineMap = parseChangedLineMap(diff, (filePath: string) => filePath.startsWith('web/app/components/')) - - expect([...lineMap.entries()]).toEqual([ - ['web/app/components/share/a.ts', new Set([11, 12])], - ['web/app/components/base/b.ts', new Set([21])], - ]) - }) - - it('should normalize coverage and absolute paths to repo-relative paths', () => { - const repoRoot = '/repo' - const webRoot = '/repo/web' - - expect(normalizeToRepoRelative('web/app/components/share/a.ts', { - appComponentsCoveragePrefix: 'app/components/', - appComponentsPrefix: 'web/app/components/', - repoRoot, - sharedTestPrefix: 'web/__tests__/', - webRoot, - })).toBe('web/app/components/share/a.ts') - - expect(normalizeToRepoRelative('app/components/share/a.ts', { - appComponentsCoveragePrefix: 'app/components/', - appComponentsPrefix: 'web/app/components/', - repoRoot, - sharedTestPrefix: 'web/__tests__/', - webRoot, - })).toBe('web/app/components/share/a.ts') - - expect(normalizeToRepoRelative('/repo/web/app/components/share/a.ts', { - appComponentsCoveragePrefix: 'app/components/', - appComponentsPrefix: 'web/app/components/', - repoRoot, - sharedTestPrefix: 'web/__tests__/', - webRoot, - })).toBe('web/app/components/share/a.ts') - }) - - it('should calculate changed statement coverage from changed lines', () => { - const entry = { - s: { 0: 1, 1: 0 }, - statementMap: { - 0: { start: { line: 10 }, end: { line: 10 } }, - 1: { start: { line: 12 }, end: { line: 13 } }, - }, - } - - const coverage = getChangedStatementCoverage(entry, new Set([10, 12])) - - expect(coverage).toEqual({ - covered: 1, - total: 2, - uncoveredLines: [12], - }) - }) - - it('should report the first changed line inside a multi-line uncovered statement', () => { - const entry = { - s: { 0: 0 }, - statementMap: { - 0: { start: { line: 10 }, end: { line: 14 } }, - }, - } - - const coverage = getChangedStatementCoverage(entry, new Set([13, 14])) - - expect(coverage).toEqual({ - covered: 0, - total: 1, - uncoveredLines: [13], - }) - }) - - it('should fail changed lines when a source file has no coverage entry', () => { - const coverage = getChangedStatementCoverage(undefined, new Set([42, 43])) - - expect(coverage).toEqual({ - covered: 0, - total: 2, - uncoveredLines: [42, 43], - }) - }) - - it('should calculate changed branch coverage using changed branch definitions', () => { - const entry = { - b: { - 0: [1, 0], - }, - branchMap: { - 0: { - line: 20, - loc: { start: { line: 20 }, end: { line: 20 } }, - locations: [ - { start: { line: 20 }, end: { line: 20 } }, - { start: { line: 21 }, end: { line: 21 } }, - ], - type: 'if', - }, - }, - } - - const coverage = getChangedBranchCoverage(entry, new Set([20])) - - expect(coverage).toEqual({ - covered: 1, - total: 2, - uncoveredBranches: [ - { armIndex: 1, line: 21 }, - ], - }) - }) - - it('should report the first changed line inside a multi-line uncovered branch arm', () => { - const entry = { - b: { - 0: [0, 0], - }, - branchMap: { - 0: { - line: 30, - loc: { start: { line: 30 }, end: { line: 35 } }, - locations: [ - { start: { line: 31 }, end: { line: 34 } }, - { start: { line: 35 }, end: { line: 38 } }, - ], - type: 'if', - }, - }, - } - - const coverage = getChangedBranchCoverage(entry, new Set([33])) - - expect(coverage).toEqual({ - covered: 0, - total: 1, - uncoveredBranches: [ - { armIndex: 0, line: 33 }, - ], - }) - }) - - it('should require all branch arms when the branch condition changes', () => { - const entry = { - b: { - 0: [0, 0], - }, - branchMap: { - 0: { - line: 30, - loc: { start: { line: 30 }, end: { line: 35 } }, - locations: [ - { start: { line: 31 }, end: { line: 34 } }, - { start: { line: 35 }, end: { line: 38 } }, - ], - type: 'if', - }, - }, - } - - const coverage = getChangedBranchCoverage(entry, new Set([30])) - - expect(coverage).toEqual({ - covered: 0, - total: 2, - uncoveredBranches: [ - { armIndex: 0, line: 31 }, - { armIndex: 1, line: 35 }, - ], - }) - }) - - it('should ignore changed lines with valid pragma reasons and report invalid pragmas', () => { - const sourceCode = [ - 'const a = 1', - 'const b = 2 // diff-coverage-ignore-line: defensive fallback', - 'const c = 3 // diff-coverage-ignore-line:', - 'const d = 4 // diff-coverage-ignore-line: not changed', - ].join('\n') - - const result = getIgnoredChangedLinesFromSource(sourceCode, new Set([2, 3])) - - expect([...result.effectiveChangedLines]).toEqual([3]) - expect([...result.ignoredLines.entries()]).toEqual([ - [2, 'defensive fallback'], - ]) - expect(result.invalidPragmas).toEqual([ - { line: 3, reason: 'missing ignore reason' }, - ]) - }) -}) diff --git a/web/__tests__/component-coverage-filters.test.ts b/web/__tests__/component-coverage-filters.test.ts deleted file mode 100644 index cacc1e2142..0000000000 --- a/web/__tests__/component-coverage-filters.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import fs from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { - collectComponentCoverageExcludedFiles, - COMPONENT_COVERAGE_EXCLUDE_LABEL, - getComponentCoverageExclusionReasons, -} from '../scripts/component-coverage-filters.mjs' - -describe('component coverage filters', () => { - describe('getComponentCoverageExclusionReasons', () => { - it('should exclude type-only files by basename', () => { - expect( - getComponentCoverageExclusionReasons( - 'web/app/components/share/text-generation/types.ts', - 'export type ShareMode = "run-once" | "run-batch"', - ), - ).toContain('type-only') - }) - - it('should exclude pure barrel files', () => { - expect( - getComponentCoverageExclusionReasons( - 'web/app/components/base/amplitude/index.ts', - [ - 'export { default } from "./AmplitudeProvider"', - 'export { resetUser, trackEvent } from "./utils"', - ].join('\n'), - ), - ).toContain('pure-barrel') - }) - - it('should exclude generated files from marker comments', () => { - expect( - getComponentCoverageExclusionReasons( - 'web/app/components/base/icons/src/vender/workflow/Answer.tsx', - [ - '// GENERATE BY script', - '// DON NOT EDIT IT MANUALLY', - 'export default function Icon() {', - ' return null', - '}', - ].join('\n'), - ), - ).toContain('generated') - }) - - it('should exclude pure static files with exported constants only', () => { - expect( - getComponentCoverageExclusionReasons( - 'web/app/components/workflow/note-node/constants.ts', - [ - 'import { NoteTheme } from "./types"', - 'export const CUSTOM_NOTE_NODE = "custom-note"', - 'export const THEME_MAP = {', - ' [NoteTheme.blue]: { title: "bg-blue-100" },', - '}', - ].join('\n'), - ), - ).toContain('pure-static') - }) - - it('should keep runtime logic files tracked', () => { - expect( - getComponentCoverageExclusionReasons( - 'web/app/components/workflow/nodes/trigger-schedule/default.ts', - [ - 'const validate = (value: string) => value.trim()', - 'export const nodeDefault = {', - ' value: validate("x"),', - '}', - ].join('\n'), - ), - ).toEqual([]) - }) - }) - - describe('collectComponentCoverageExcludedFiles', () => { - const tempDirs: string[] = [] - - afterEach(() => { - for (const dir of tempDirs) - fs.rmSync(dir, { recursive: true, force: true }) - tempDirs.length = 0 - }) - - it('should collect excluded files for coverage config and keep runtime files out', () => { - const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'component-coverage-filters-')) - tempDirs.push(rootDir) - - fs.mkdirSync(path.join(rootDir, 'barrel'), { recursive: true }) - fs.mkdirSync(path.join(rootDir, 'icons'), { recursive: true }) - fs.mkdirSync(path.join(rootDir, 'static'), { recursive: true }) - fs.mkdirSync(path.join(rootDir, 'runtime'), { recursive: true }) - - fs.writeFileSync(path.join(rootDir, 'barrel', 'index.ts'), 'export { default } from "./Button"\n') - fs.writeFileSync(path.join(rootDir, 'icons', 'generated-icon.tsx'), '// @generated\nexport default function Icon() { return null }\n') - fs.writeFileSync(path.join(rootDir, 'static', 'constants.ts'), 'export const COLORS = { primary: "#fff" }\n') - fs.writeFileSync(path.join(rootDir, 'runtime', 'config.ts'), 'export const config = makeConfig()\n') - fs.writeFileSync(path.join(rootDir, 'runtime', 'types.ts'), 'export type Config = { value: string }\n') - - expect(collectComponentCoverageExcludedFiles(rootDir, { pathPrefix: 'app/components' })).toEqual([ - 'app/components/barrel/index.ts', - 'app/components/icons/generated-icon.tsx', - 'app/components/runtime/types.ts', - 'app/components/static/constants.ts', - ]) - }) - }) - - it('should describe the excluded coverage categories', () => { - expect(COMPONENT_COVERAGE_EXCLUDE_LABEL).toBe('type-only files, pure barrel files, generated files, pure static files') - }) -}) diff --git a/web/__tests__/components-coverage-common.test.ts b/web/__tests__/components-coverage-common.test.ts deleted file mode 100644 index ab189ed854..0000000000 --- a/web/__tests__/components-coverage-common.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { - getCoverageStats, - isRelevantTestFile, - isTrackedComponentSourceFile, - loadTrackedCoverageEntries, -} from '../scripts/components-coverage-common.mjs' - -describe('components coverage common helpers', () => { - it('should identify tracked component source files and relevant tests', () => { - const excludedComponentCoverageFiles = new Set([ - 'web/app/components/share/types.ts', - ]) - - expect(isTrackedComponentSourceFile('web/app/components/share/index.tsx', excludedComponentCoverageFiles)).toBe(true) - expect(isTrackedComponentSourceFile('web/app/components/share/types.ts', excludedComponentCoverageFiles)).toBe(false) - expect(isTrackedComponentSourceFile('web/app/components/provider/index.tsx', excludedComponentCoverageFiles)).toBe(false) - - expect(isRelevantTestFile('web/__tests__/share/text-generation-run-once-flow.test.tsx')).toBe(true) - expect(isRelevantTestFile('web/app/components/share/__tests__/index.spec.tsx')).toBe(true) - expect(isRelevantTestFile('web/utils/format.spec.ts')).toBe(false) - }) - - it('should load only tracked coverage entries from mixed coverage paths', () => { - const context = { - excludedComponentCoverageFiles: new Set([ - 'web/app/components/share/types.ts', - ]), - repoRoot: '/repo', - webRoot: '/repo/web', - } - const coverage = { - '/repo/web/app/components/provider/index.tsx': { - path: '/repo/web/app/components/provider/index.tsx', - statementMap: { 0: { start: { line: 1 }, end: { line: 1 } } }, - s: { 0: 1 }, - }, - 'app/components/share/index.tsx': { - path: 'app/components/share/index.tsx', - statementMap: { 0: { start: { line: 2 }, end: { line: 2 } } }, - s: { 0: 1 }, - }, - 'app/components/share/types.ts': { - path: 'app/components/share/types.ts', - statementMap: { 0: { start: { line: 3 }, end: { line: 3 } } }, - s: { 0: 1 }, - }, - } - - expect([...loadTrackedCoverageEntries(coverage, context).keys()]).toEqual([ - 'web/app/components/share/index.tsx', - ]) - }) - - it('should calculate coverage stats using statement-derived line hits', () => { - const entry = { - b: { 0: [1, 0] }, - f: { 0: 1, 1: 0 }, - s: { 0: 1, 1: 0 }, - statementMap: { - 0: { start: { line: 10 }, end: { line: 10 } }, - 1: { start: { line: 12 }, end: { line: 13 } }, - }, - } - - expect(getCoverageStats(entry)).toEqual({ - branches: { covered: 1, total: 2 }, - functions: { covered: 1, total: 2 }, - lines: { covered: 1, total: 2 }, - statements: { covered: 1, total: 2 }, - }) - }) -}) diff --git a/web/config/index.ts b/web/config/index.ts index e8526479a1..3f7d26c623 100644 --- a/web/config/index.ts +++ b/web/config/index.ts @@ -281,8 +281,7 @@ Thought: {{agent_scratchpad}} `, } -export const VAR_REGEX - = /\{\{(#[\w-]{1,50}(\.\d+)?(\.[a-z_]\w{0,29}){1,10}#)\}\}/gi +export const VAR_REGEX = /\{\{(#[\w-]{1,50}(\.\d+)?(\.[a-z_]\w{0,29}){1,10}#)\}\}/gi export const resetReg = () => (VAR_REGEX.lastIndex = 0) diff --git a/web/scripts/check-components-diff-coverage-lib.mjs b/web/scripts/check-components-diff-coverage-lib.mjs deleted file mode 100644 index 9436bf9453..0000000000 --- a/web/scripts/check-components-diff-coverage-lib.mjs +++ /dev/null @@ -1,407 +0,0 @@ -import fs from 'node:fs' -import path from 'node:path' - -const DIFF_COVERAGE_IGNORE_LINE_TOKEN = 'diff-coverage-ignore-line:' -const DEFAULT_BRANCH_REF_CANDIDATES = ['origin/main', 'main'] - -export function normalizeDiffRangeMode(mode) { - return mode === 'exact' ? 'exact' : 'merge-base' -} - -export function buildGitDiffRevisionArgs(base, head, mode = 'merge-base') { - return mode === 'exact' - ? [base, head] - : [`${base}...${head}`] -} - -export function resolveGitDiffContext({ - base, - head, - mode = 'merge-base', - execGit, -}) { - const requestedMode = normalizeDiffRangeMode(mode) - const context = { - base, - head, - mode: requestedMode, - requestedMode, - reason: null, - useCombinedMergeDiff: false, - } - - if (requestedMode !== 'exact' || !base || !head || !execGit) - return context - - const baseCommit = resolveCommitSha(base, execGit) ?? base - const headCommit = resolveCommitSha(head, execGit) ?? head - const parents = getCommitParents(headCommit, execGit) - if (parents.length < 2) - return context - - const [firstParent, secondParent] = parents - if (firstParent !== baseCommit) - return context - - const defaultBranchRef = resolveDefaultBranchRef(execGit) - if (!defaultBranchRef || !isAncestor(secondParent, defaultBranchRef, execGit)) - return context - - return { - ...context, - reason: `ignored merge from ${defaultBranchRef}`, - useCombinedMergeDiff: true, - } -} - -export function parseChangedLineMap(diff, isTrackedComponentSourceFile) { - const lineMap = new Map() - let currentFile = null - - for (const line of diff.split('\n')) { - if (line.startsWith('+++ b/')) { - currentFile = line.slice(6).trim() - continue - } - - if (!currentFile || !isTrackedComponentSourceFile(currentFile)) - continue - - const match = line.match(/^@{2,}(?: -\d+(?:,\d+)?)+ \+(\d+)(?:,(\d+))? @{2,}/) - if (!match) - continue - - const start = Number(match[1]) - const count = match[2] ? Number(match[2]) : 1 - if (count === 0) - continue - - const linesForFile = lineMap.get(currentFile) ?? new Set() - for (let offset = 0; offset < count; offset += 1) - linesForFile.add(start + offset) - lineMap.set(currentFile, linesForFile) - } - - return lineMap -} - -export function normalizeToRepoRelative(filePath, { - appComponentsCoveragePrefix, - appComponentsPrefix, - repoRoot, - sharedTestPrefix, - webRoot, -}) { - if (!filePath) - return '' - - if (filePath.startsWith(appComponentsPrefix) || filePath.startsWith(sharedTestPrefix)) - return filePath - - if (filePath.startsWith(appComponentsCoveragePrefix)) - return `web/${filePath}` - - const absolutePath = path.isAbsolute(filePath) - ? filePath - : path.resolve(webRoot, filePath) - - return path.relative(repoRoot, absolutePath).split(path.sep).join('/') -} - -export function getLineHits(entry) { - if (entry?.l && Object.keys(entry.l).length > 0) - return entry.l - - const lineHits = {} - for (const [statementId, statement] of Object.entries(entry?.statementMap ?? {})) { - const line = statement?.start?.line - if (!line) - continue - - const hits = entry?.s?.[statementId] ?? 0 - const previous = lineHits[line] - lineHits[line] = previous === undefined ? hits : Math.max(previous, hits) - } - - return lineHits -} - -export function getChangedStatementCoverage(entry, changedLines) { - const normalizedChangedLines = [...(changedLines ?? [])].sort((a, b) => a - b) - if (!entry) { - return { - covered: 0, - total: normalizedChangedLines.length, - uncoveredLines: normalizedChangedLines, - } - } - - const uncoveredLines = [] - let covered = 0 - let total = 0 - - for (const [statementId, statement] of Object.entries(entry.statementMap ?? {})) { - if (!rangeIntersectsChangedLines(statement, changedLines)) - continue - - total += 1 - const hits = entry.s?.[statementId] ?? 0 - if (hits > 0) { - covered += 1 - continue - } - - uncoveredLines.push(getFirstChangedLineInRange(statement, normalizedChangedLines)) - } - - return { - covered, - total, - uncoveredLines: uncoveredLines.sort((a, b) => a - b), - } -} - -export function getChangedBranchCoverage(entry, changedLines) { - const normalizedChangedLines = [...(changedLines ?? [])].sort((a, b) => a - b) - if (!entry) { - return { - covered: 0, - total: 0, - uncoveredBranches: [], - } - } - - const uncoveredBranches = [] - let covered = 0 - let total = 0 - - for (const [branchId, branch] of Object.entries(entry.branchMap ?? {})) { - const hits = Array.isArray(entry.b?.[branchId]) ? entry.b[branchId] : [] - const locations = getBranchLocations(branch) - const armCount = Math.max(locations.length, hits.length) - const impactedArmIndexes = getImpactedBranchArmIndexes(branch, changedLines, armCount) - - if (impactedArmIndexes.length === 0) - continue - - for (const armIndex of impactedArmIndexes) { - total += 1 - if ((hits[armIndex] ?? 0) > 0) { - covered += 1 - continue - } - - const location = locations[armIndex] ?? branch.loc ?? branch - uncoveredBranches.push({ - armIndex, - line: getFirstChangedLineInRange(location, normalizedChangedLines, branch.line ?? 1), - }) - } - } - - uncoveredBranches.sort((a, b) => a.line - b.line || a.armIndex - b.armIndex) - return { - covered, - total, - uncoveredBranches, - } -} - -export function getIgnoredChangedLinesFromFile(filePath, changedLines) { - if (!fs.existsSync(filePath)) - return emptyIgnoreResult(changedLines) - - const sourceCode = fs.readFileSync(filePath, 'utf8') - return getIgnoredChangedLinesFromSource(sourceCode, changedLines) -} - -export function getIgnoredChangedLinesFromSource(sourceCode, changedLines) { - const ignoredLines = new Map() - const invalidPragmas = [] - const changedLineSet = new Set(changedLines ?? []) - - const sourceLines = sourceCode.split('\n') - sourceLines.forEach((lineText, index) => { - const lineNumber = index + 1 - const commentIndex = lineText.indexOf('//') - if (commentIndex < 0) - return - - const tokenIndex = lineText.indexOf(DIFF_COVERAGE_IGNORE_LINE_TOKEN, commentIndex + 2) - if (tokenIndex < 0) - return - - const reason = lineText.slice(tokenIndex + DIFF_COVERAGE_IGNORE_LINE_TOKEN.length).trim() - if (!changedLineSet.has(lineNumber)) - return - - if (!reason) { - invalidPragmas.push({ - line: lineNumber, - reason: 'missing ignore reason', - }) - return - } - - ignoredLines.set(lineNumber, reason) - }) - - const effectiveChangedLines = new Set( - [...changedLineSet].filter(lineNumber => !ignoredLines.has(lineNumber)), - ) - - return { - effectiveChangedLines, - ignoredLines, - invalidPragmas, - } -} - -function emptyIgnoreResult(changedLines = []) { - return { - effectiveChangedLines: new Set(changedLines), - ignoredLines: new Map(), - invalidPragmas: [], - } -} - -function getCommitParents(ref, execGit) { - const output = tryExecGit(execGit, ['rev-list', '--parents', '-n', '1', ref]) - if (!output) - return [] - - return output - .trim() - .split(/\s+/) - .slice(1) -} - -function resolveCommitSha(ref, execGit) { - return tryExecGit(execGit, ['rev-parse', '--verify', ref])?.trim() ?? null -} - -function resolveDefaultBranchRef(execGit) { - const originHeadRef = tryExecGit(execGit, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'])?.trim() - if (originHeadRef) - return originHeadRef - - for (const ref of DEFAULT_BRANCH_REF_CANDIDATES) { - if (tryExecGit(execGit, ['rev-parse', '--verify', '-q', ref])) - return ref - } - - return null -} - -function isAncestor(ancestorRef, descendantRef, execGit) { - try { - execGit(['merge-base', '--is-ancestor', ancestorRef, descendantRef]) - return true - } - catch { - return false - } -} - -function tryExecGit(execGit, args) { - try { - return execGit(args) - } - catch { - return null - } -} - -function getBranchLocations(branch) { - return Array.isArray(branch?.locations) ? branch.locations.filter(Boolean) : [] -} - -function getImpactedBranchArmIndexes(branch, changedLines, armCount) { - if (!changedLines || changedLines.size === 0 || armCount === 0) - return [] - - const locations = getBranchLocations(branch) - if (isWholeBranchTouched(branch, changedLines, locations, armCount)) - return Array.from({ length: armCount }, (_, armIndex) => armIndex) - - const impactedArmIndexes = [] - for (let armIndex = 0; armIndex < armCount; armIndex += 1) { - const location = locations[armIndex] - if (rangeIntersectsChangedLines(location, changedLines)) - impactedArmIndexes.push(armIndex) - } - - return impactedArmIndexes -} - -function isWholeBranchTouched(branch, changedLines, locations, armCount) { - if (!changedLines || changedLines.size === 0) - return false - - if (branch.line && changedLines.has(branch.line)) - return true - - const branchRange = branch.loc ?? branch - if (!rangeIntersectsChangedLines(branchRange, changedLines)) - return false - - if (locations.length === 0 || locations.length < armCount) - return true - - for (const lineNumber of changedLines) { - if (!lineTouchesLocation(lineNumber, branchRange)) - continue - if (!locations.some(location => lineTouchesLocation(lineNumber, location))) - return true - } - - return false -} - -function rangeIntersectsChangedLines(location, changedLines) { - if (!location || !changedLines || changedLines.size === 0) - return false - - const startLine = getLocationStartLine(location) - const endLine = getLocationEndLine(location) ?? startLine - if (!startLine || !endLine) - return false - - for (const lineNumber of changedLines) { - if (lineNumber >= startLine && lineNumber <= endLine) - return true - } - - return false -} - -function getFirstChangedLineInRange(location, changedLines, fallbackLine = 1) { - const startLine = getLocationStartLine(location) - const endLine = getLocationEndLine(location) ?? startLine - if (!startLine || !endLine) - return startLine ?? fallbackLine - - for (const lineNumber of changedLines) { - if (lineNumber >= startLine && lineNumber <= endLine) - return lineNumber - } - - return startLine ?? fallbackLine -} - -function lineTouchesLocation(lineNumber, location) { - const startLine = getLocationStartLine(location) - const endLine = getLocationEndLine(location) ?? startLine - if (!startLine || !endLine) - return false - - return lineNumber >= startLine && lineNumber <= endLine -} - -function getLocationStartLine(location) { - return location?.start?.line ?? location?.line ?? null -} - -function getLocationEndLine(location) { - return location?.end?.line ?? location?.line ?? null -} diff --git a/web/scripts/check-components-diff-coverage-lib.spec.ts b/web/scripts/check-components-diff-coverage-lib.spec.ts deleted file mode 100644 index 4c99193e8e..0000000000 --- a/web/scripts/check-components-diff-coverage-lib.spec.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { parseChangedLineMap, resolveGitDiffContext } from './check-components-diff-coverage-lib.mjs' - -function createExecGitMock(responses: Record) { - return vi.fn((args: string[]) => { - const key = args.join(' ') - const response = responses[key] - - if (response instanceof Error) - throw response - - if (response === undefined) - throw new Error(`Unexpected git args: ${key}`) - - return response - }) -} - -describe('resolveGitDiffContext', () => { - it('switches exact diff to combined merge diff when head merges origin/main into the branch', () => { - const execGit = createExecGitMock({ - 'rev-parse --verify feature-parent-sha': 'feature-parent-sha\n', - 'rev-parse --verify merge-sha': 'merge-sha\n', - 'rev-list --parents -n 1 merge-sha': 'merge-sha feature-parent-sha main-parent-sha\n', - 'symbolic-ref --quiet --short refs/remotes/origin/HEAD': 'origin/main\n', - 'merge-base --is-ancestor main-parent-sha origin/main': '', - }) - - expect(resolveGitDiffContext({ - base: 'feature-parent-sha', - head: 'merge-sha', - mode: 'exact', - execGit, - })).toEqual({ - base: 'feature-parent-sha', - head: 'merge-sha', - mode: 'exact', - requestedMode: 'exact', - reason: 'ignored merge from origin/main', - useCombinedMergeDiff: true, - }) - }) - - it('falls back to origin/main when origin/HEAD is unavailable', () => { - const execGit = createExecGitMock({ - 'rev-parse --verify feature-parent-sha': 'feature-parent-sha\n', - 'rev-parse --verify merge-sha': 'merge-sha\n', - 'rev-list --parents -n 1 merge-sha': 'merge-sha feature-parent-sha main-parent-sha\n', - 'symbolic-ref --quiet --short refs/remotes/origin/HEAD': new Error('missing origin/HEAD'), - 'rev-parse --verify -q origin/main': 'main-tip-sha\n', - 'merge-base --is-ancestor main-parent-sha origin/main': '', - }) - - expect(resolveGitDiffContext({ - base: 'feature-parent-sha', - head: 'merge-sha', - mode: 'exact', - execGit, - })).toEqual({ - base: 'feature-parent-sha', - head: 'merge-sha', - mode: 'exact', - requestedMode: 'exact', - reason: 'ignored merge from origin/main', - useCombinedMergeDiff: true, - }) - }) - - it('keeps exact diff when the second parent is not the default branch', () => { - const execGit = createExecGitMock({ - 'rev-parse --verify feature-parent-sha': 'feature-parent-sha\n', - 'rev-parse --verify merge-sha': 'merge-sha\n', - 'rev-list --parents -n 1 merge-sha': 'merge-sha feature-parent-sha topic-parent-sha\n', - 'symbolic-ref --quiet --short refs/remotes/origin/HEAD': 'origin/main\n', - 'merge-base --is-ancestor topic-parent-sha origin/main': new Error('not ancestor'), - }) - - expect(resolveGitDiffContext({ - base: 'feature-parent-sha', - head: 'merge-sha', - mode: 'exact', - execGit, - })).toEqual({ - base: 'feature-parent-sha', - head: 'merge-sha', - mode: 'exact', - requestedMode: 'exact', - reason: null, - useCombinedMergeDiff: false, - }) - }) -}) - -describe('parseChangedLineMap', () => { - it('parses regular diff hunks', () => { - const diff = [ - 'diff --git a/web/app/components/example.tsx b/web/app/components/example.tsx', - '+++ b/web/app/components/example.tsx', - '@@ -10,0 +11,2 @@', - ].join('\n') - - const changedLineMap = parseChangedLineMap(diff, () => true) - - expect([...changedLineMap.get('web/app/components/example.tsx') ?? []]).toEqual([11, 12]) - }) - - it('parses combined merge diff hunks', () => { - const diff = [ - 'diff --cc web/app/components/example.tsx', - '+++ b/web/app/components/example.tsx', - '@@@ -10,0 -10,0 +11,3 @@@', - ].join('\n') - - const changedLineMap = parseChangedLineMap(diff, () => true) - - expect([...changedLineMap.get('web/app/components/example.tsx') ?? []]).toEqual([11, 12, 13]) - }) -}) diff --git a/web/scripts/check-components-diff-coverage.mjs b/web/scripts/check-components-diff-coverage.mjs deleted file mode 100644 index e11d21165c..0000000000 --- a/web/scripts/check-components-diff-coverage.mjs +++ /dev/null @@ -1,362 +0,0 @@ -import { execFileSync } from 'node:child_process' -import fs from 'node:fs' -import path from 'node:path' -import { - buildGitDiffRevisionArgs, - getChangedBranchCoverage, - getChangedStatementCoverage, - getIgnoredChangedLinesFromFile, - normalizeDiffRangeMode, - parseChangedLineMap, - resolveGitDiffContext, -} from './check-components-diff-coverage-lib.mjs' -import { COMPONENT_COVERAGE_EXCLUDE_LABEL } from './component-coverage-filters.mjs' -import { - APP_COMPONENTS_PREFIX, - createComponentCoverageContext, - getModuleName, - isAnyComponentSourceFile, - isExcludedComponentSourceFile, - isTrackedComponentSourceFile, - loadTrackedCoverageEntries, -} from './components-coverage-common.mjs' -import { EXCLUDED_COMPONENT_MODULES } from './components-coverage-thresholds.mjs' - -const REQUESTED_DIFF_RANGE_MODE = normalizeDiffRangeMode(process.env.DIFF_RANGE_MODE) -const EXCLUDED_MODULES_LABEL = [...EXCLUDED_COMPONENT_MODULES].sort().join(', ') - -const repoRoot = repoRootFromCwd() -const context = createComponentCoverageContext(repoRoot) -const baseSha = process.env.BASE_SHA?.trim() -const headSha = process.env.HEAD_SHA?.trim() || 'HEAD' -const coverageFinalPath = path.join(context.webRoot, 'coverage', 'coverage-final.json') - -if (!baseSha || /^0+$/.test(baseSha)) { - appendSummary([ - '### app/components Pure Diff Coverage', - '', - 'Skipped pure diff coverage check because `BASE_SHA` was not available.', - ]) - process.exit(0) -} - -if (!fs.existsSync(coverageFinalPath)) { - console.error(`Coverage report not found at ${coverageFinalPath}`) - process.exit(1) -} - -const diffContext = resolveGitDiffContext({ - base: baseSha, - head: headSha, - mode: REQUESTED_DIFF_RANGE_MODE, - execGit, -}) -const coverage = JSON.parse(fs.readFileSync(coverageFinalPath, 'utf8')) -const changedFiles = getChangedFiles(diffContext) -const changedComponentSourceFiles = changedFiles.filter(isAnyComponentSourceFile) -const changedSourceFiles = changedComponentSourceFiles.filter(filePath => isTrackedComponentSourceFile(filePath, context.excludedComponentCoverageFiles)) -const changedExcludedSourceFiles = changedComponentSourceFiles.filter(filePath => isExcludedComponentSourceFile(filePath, context.excludedComponentCoverageFiles)) - -if (changedSourceFiles.length === 0) { - appendSummary(buildSkipSummary(changedExcludedSourceFiles)) - process.exit(0) -} - -const coverageEntries = loadTrackedCoverageEntries(coverage, context) -const diffChanges = getChangedLineMap(diffContext) -const diffRows = [] -const ignoredDiffLines = [] -const invalidIgnorePragmas = [] - -for (const [file, changedLines] of diffChanges.entries()) { - if (!isTrackedComponentSourceFile(file, context.excludedComponentCoverageFiles)) - continue - - const entry = coverageEntries.get(file) - const ignoreInfo = getIgnoredChangedLinesFromFile(path.join(repoRoot, file), changedLines) - - for (const [line, reason] of ignoreInfo.ignoredLines.entries()) { - ignoredDiffLines.push({ - file, - line, - reason, - }) - } - - for (const invalidPragma of ignoreInfo.invalidPragmas) { - invalidIgnorePragmas.push({ - file, - ...invalidPragma, - }) - } - - const statements = getChangedStatementCoverage(entry, ignoreInfo.effectiveChangedLines) - const branches = getChangedBranchCoverage(entry, ignoreInfo.effectiveChangedLines) - diffRows.push({ - branches, - file, - ignoredLineCount: ignoreInfo.ignoredLines.size, - moduleName: getModuleName(file), - statements, - }) -} - -const diffTotals = diffRows.reduce((acc, row) => { - acc.statements.total += row.statements.total - acc.statements.covered += row.statements.covered - acc.branches.total += row.branches.total - acc.branches.covered += row.branches.covered - return acc -}, { - branches: { total: 0, covered: 0 }, - statements: { total: 0, covered: 0 }, -}) - -const diffStatementFailures = diffRows.filter(row => row.statements.uncoveredLines.length > 0) -const diffBranchFailures = diffRows.filter(row => row.branches.uncoveredBranches.length > 0) - -appendSummary(buildSummary({ - changedSourceFiles, - diffContext, - diffBranchFailures, - diffRows, - diffStatementFailures, - diffTotals, - ignoredDiffLines, - invalidIgnorePragmas, -})) - -if (process.env.CI) { - for (const failure of diffStatementFailures.slice(0, 20)) { - const firstLine = failure.statements.uncoveredLines[0] ?? 1 - console.log(`::error file=${failure.file},line=${firstLine}::Uncovered changed statements: ${formatLineRanges(failure.statements.uncoveredLines)}`) - } - - for (const failure of diffBranchFailures.slice(0, 20)) { - const firstBranch = failure.branches.uncoveredBranches[0] - const line = firstBranch?.line ?? 1 - console.log(`::error file=${failure.file},line=${line}::Uncovered changed branches: ${formatBranchRefs(failure.branches.uncoveredBranches)}`) - } - - for (const invalidPragma of invalidIgnorePragmas.slice(0, 20)) { - console.log(`::error file=${invalidPragma.file},line=${invalidPragma.line}::Invalid diff coverage ignore pragma: ${invalidPragma.reason}`) - } -} - -if ( - diffStatementFailures.length > 0 - || diffBranchFailures.length > 0 - || invalidIgnorePragmas.length > 0 -) { - process.exit(1) -} - -function buildSummary({ - changedSourceFiles, - diffContext, - diffBranchFailures, - diffRows, - diffStatementFailures, - diffTotals, - ignoredDiffLines, - invalidIgnorePragmas, -}) { - const lines = [ - '### app/components Pure Diff Coverage', - '', - ...buildDiffContextSummary(diffContext), - '', - `Excluded modules: \`${EXCLUDED_MODULES_LABEL}\``, - `Excluded file kinds: \`${COMPONENT_COVERAGE_EXCLUDE_LABEL}\``, - '', - '| Check | Result | Details |', - '|---|---:|---|', - `| Changed statements | ${formatDiffPercent(diffTotals.statements)} | ${diffTotals.statements.covered}/${diffTotals.statements.total} |`, - `| Changed branches | ${formatDiffPercent(diffTotals.branches)} | ${diffTotals.branches.covered}/${diffTotals.branches.total} |`, - '', - ] - - const changedRows = diffRows - .filter(row => row.statements.total > 0 || row.branches.total > 0) - .sort((a, b) => { - const aScore = percentage(a.statements.covered + a.branches.covered, a.statements.total + a.branches.total) - const bScore = percentage(b.statements.covered + b.branches.covered, b.statements.total + b.branches.total) - return aScore - bScore || a.file.localeCompare(b.file) - }) - - lines.push('
Changed file coverage') - lines.push('') - lines.push('| File | Module | Changed statements | Statement coverage | Uncovered statements | Changed branches | Branch coverage | Uncovered branches | Ignored lines |') - lines.push('|---|---|---:|---:|---|---:|---:|---|---:|') - for (const row of changedRows) { - lines.push(`| ${row.file.replace('web/', '')} | ${row.moduleName} | ${row.statements.total} | ${formatDiffPercent(row.statements)} | ${formatLineRanges(row.statements.uncoveredLines)} | ${row.branches.total} | ${formatDiffPercent(row.branches)} | ${formatBranchRefs(row.branches.uncoveredBranches)} | ${row.ignoredLineCount} |`) - } - lines.push('
') - lines.push('') - - if (diffStatementFailures.length > 0) { - lines.push('Uncovered changed statements:') - for (const row of diffStatementFailures) - lines.push(`- ${row.file.replace('web/', '')}: ${formatLineRanges(row.statements.uncoveredLines)}`) - lines.push('') - } - - if (diffBranchFailures.length > 0) { - lines.push('Uncovered changed branches:') - for (const row of diffBranchFailures) - lines.push(`- ${row.file.replace('web/', '')}: ${formatBranchRefs(row.branches.uncoveredBranches)}`) - lines.push('') - } - - if (ignoredDiffLines.length > 0) { - lines.push('Ignored changed lines via pragma:') - for (const ignoredLine of ignoredDiffLines) - lines.push(`- ${ignoredLine.file.replace('web/', '')}:${ignoredLine.line} - ${ignoredLine.reason}`) - lines.push('') - } - - if (invalidIgnorePragmas.length > 0) { - lines.push('Invalid diff coverage ignore pragmas:') - for (const invalidPragma of invalidIgnorePragmas) - lines.push(`- ${invalidPragma.file.replace('web/', '')}:${invalidPragma.line} - ${invalidPragma.reason}`) - lines.push('') - } - - lines.push(`Changed source files checked: ${changedSourceFiles.length}`) - lines.push('Blocking rules: uncovered changed statements, uncovered changed branches, invalid ignore pragmas.') - - return lines -} - -function buildSkipSummary(changedExcludedSourceFiles) { - const lines = [ - '### app/components Pure Diff Coverage', - '', - ...buildDiffContextSummary(diffContext), - '', - `Excluded modules: \`${EXCLUDED_MODULES_LABEL}\``, - `Excluded file kinds: \`${COMPONENT_COVERAGE_EXCLUDE_LABEL}\``, - '', - ] - - if (changedExcludedSourceFiles.length > 0) { - lines.push('Only excluded component modules or type-only files changed, so pure diff coverage was skipped.') - lines.push(`Skipped files: ${changedExcludedSourceFiles.length}`) - } - else { - lines.push('No tracked source changes under `web/app/components/`. Pure diff coverage skipped.') - } - - return lines -} - -function buildDiffContextSummary(diffContext) { - const lines = [ - `Compared \`${diffContext.base.slice(0, 12)}\` -> \`${diffContext.head.slice(0, 12)}\``, - ] - - if (diffContext.useCombinedMergeDiff) { - lines.push(`Requested diff range mode: \`${diffContext.requestedMode}\``) - lines.push(`Effective diff strategy: \`combined-merge\` (${diffContext.reason})`) - } - else if (diffContext.reason) { - lines.push(`Requested diff range mode: \`${diffContext.requestedMode}\``) - lines.push(`Effective diff range mode: \`${diffContext.mode}\` (${diffContext.reason})`) - } - else { - lines.push(`Diff range mode: \`${diffContext.mode}\``) - } - - return lines -} - -function getChangedFiles(diffContext) { - if (diffContext.useCombinedMergeDiff) { - const output = execGit(['diff-tree', '--cc', '--no-commit-id', '--name-only', '-r', diffContext.head, '--', APP_COMPONENTS_PREFIX]) - return output - .split('\n') - .map(line => line.trim()) - .filter(Boolean) - } - - const output = execGit(['diff', '--name-only', '--diff-filter=ACMR', ...buildGitDiffRevisionArgs(diffContext.base, diffContext.head, diffContext.mode), '--', APP_COMPONENTS_PREFIX]) - return output - .split('\n') - .map(line => line.trim()) - .filter(Boolean) -} - -function getChangedLineMap(diffContext) { - if (diffContext.useCombinedMergeDiff) { - const diff = execGit(['diff-tree', '--cc', '--no-commit-id', '-r', '--unified=0', diffContext.head, '--', APP_COMPONENTS_PREFIX]) - return parseChangedLineMap(diff, filePath => isTrackedComponentSourceFile(filePath, context.excludedComponentCoverageFiles)) - } - - const diff = execGit(['diff', '--unified=0', '--no-color', '--diff-filter=ACMR', ...buildGitDiffRevisionArgs(diffContext.base, diffContext.head, diffContext.mode), '--', APP_COMPONENTS_PREFIX]) - return parseChangedLineMap(diff, filePath => isTrackedComponentSourceFile(filePath, context.excludedComponentCoverageFiles)) -} - -function formatLineRanges(lines) { - if (!lines || lines.length === 0) - return '' - - const ranges = [] - let start = lines[0] - let end = lines[0] - - for (let index = 1; index < lines.length; index += 1) { - const current = lines[index] - if (current === end + 1) { - end = current - continue - } - - ranges.push(start === end ? `${start}` : `${start}-${end}`) - start = current - end = current - } - - ranges.push(start === end ? `${start}` : `${start}-${end}`) - return ranges.join(', ') -} - -function formatBranchRefs(branches) { - if (!branches || branches.length === 0) - return '' - - return branches.map(branch => `${branch.line}[${branch.armIndex}]`).join(', ') -} - -function percentage(covered, total) { - if (total === 0) - return 100 - return (covered / total) * 100 -} - -function formatDiffPercent(metric) { - if (metric.total === 0) - return 'n/a' - - return `${percentage(metric.covered, metric.total).toFixed(2)}%` -} - -function appendSummary(lines) { - const content = `${lines.join('\n')}\n` - if (process.env.GITHUB_STEP_SUMMARY) - fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, content) - console.log(content) -} - -function execGit(args) { - return execFileSync('git', args, { - cwd: repoRoot, - encoding: 'utf8', - }) -} - -function repoRootFromCwd() { - return execFileSync('git', ['rev-parse', '--show-toplevel'], { - cwd: process.cwd(), - encoding: 'utf8', - }).trim() -} diff --git a/web/scripts/component-coverage-filters.mjs b/web/scripts/component-coverage-filters.mjs deleted file mode 100644 index e33c843cb4..0000000000 --- a/web/scripts/component-coverage-filters.mjs +++ /dev/null @@ -1,316 +0,0 @@ -import fs from 'node:fs' -import path from 'node:path' -import tsParser from '@typescript-eslint/parser' - -const TS_TSX_FILE_PATTERN = /\.(?:ts|tsx)$/ -const TYPE_COVERAGE_EXCLUDE_BASENAMES = new Set([ - 'type', - 'types', - 'declarations', -]) -const GENERATED_FILE_COMMENT_PATTERNS = [ - /@generated/i, - /\bauto-?generated\b/i, - /\bgenerated by\b/i, - /\bgenerate by\b/i, - /\bdo not edit\b/i, - /\bdon not edit\b/i, -] -const PARSER_OPTIONS = { - ecmaVersion: 'latest', - sourceType: 'module', - ecmaFeatures: { jsx: true }, -} - -const collectedExcludedFilesCache = new Map() - -export const COMPONENT_COVERAGE_EXCLUDE_LABEL = 'type-only files, pure barrel files, generated files, pure static files' - -export function isTypeCoverageExcludedComponentFile(filePath) { - return TYPE_COVERAGE_EXCLUDE_BASENAMES.has(getPathBaseNameWithoutExtension(filePath)) -} - -export function getComponentCoverageExclusionReasons(filePath, sourceCode) { - if (!isEligibleComponentSourceFilePath(filePath)) - return [] - - const reasons = [] - if (isTypeCoverageExcludedComponentFile(filePath)) - reasons.push('type-only') - - if (typeof sourceCode !== 'string' || sourceCode.length === 0) - return reasons - - if (isGeneratedComponentFile(sourceCode)) - reasons.push('generated') - - const ast = parseComponentFile(sourceCode) - if (!ast) - return reasons - - if (isPureBarrelComponentFile(ast)) - reasons.push('pure-barrel') - else if (isPureStaticComponentFile(ast)) - reasons.push('pure-static') - - return reasons -} - -export function collectComponentCoverageExcludedFiles(rootDir, options = {}) { - const normalizedRootDir = path.resolve(rootDir) - const pathPrefix = normalizePathPrefix(options.pathPrefix ?? '') - const cacheKey = `${normalizedRootDir}::${pathPrefix}` - const cached = collectedExcludedFilesCache.get(cacheKey) - if (cached) - return cached - - const files = [] - walkComponentFiles(normalizedRootDir, (absolutePath) => { - const relativePath = path.relative(normalizedRootDir, absolutePath).split(path.sep).join('/') - const prefixedPath = pathPrefix ? `${pathPrefix}/${relativePath}` : relativePath - const sourceCode = fs.readFileSync(absolutePath, 'utf8') - if (getComponentCoverageExclusionReasons(prefixedPath, sourceCode).length > 0) - files.push(prefixedPath) - }) - - files.sort((a, b) => a.localeCompare(b)) - collectedExcludedFilesCache.set(cacheKey, files) - return files -} - -function normalizePathPrefix(pathPrefix) { - return pathPrefix.replace(/\\/g, '/').replace(/\/$/, '') -} - -function walkComponentFiles(currentDir, onFile) { - if (!fs.existsSync(currentDir)) - return - - const entries = fs.readdirSync(currentDir, { withFileTypes: true }) - for (const entry of entries) { - const entryPath = path.join(currentDir, entry.name) - if (entry.isDirectory()) { - if (entry.name === '__tests__' || entry.name === '__mocks__') - continue - walkComponentFiles(entryPath, onFile) - continue - } - - if (!isEligibleComponentSourceFilePath(entry.name)) - continue - - onFile(entryPath) - } -} - -function isEligibleComponentSourceFilePath(filePath) { - return TS_TSX_FILE_PATTERN.test(filePath) - && !isTestLikePath(filePath) -} - -function isTestLikePath(filePath) { - return /(?:^|\/)__tests__\//.test(filePath) - || /(?:^|\/)__mocks__\//.test(filePath) - || /\.(?:spec|test)\.(?:ts|tsx)$/.test(filePath) - || /\.stories\.(?:ts|tsx)$/.test(filePath) - || /\.d\.ts$/.test(filePath) -} - -function getPathBaseNameWithoutExtension(filePath) { - if (!filePath) - return '' - - const normalizedPath = filePath.replace(/\\/g, '/') - const fileName = normalizedPath.split('/').pop() ?? '' - return fileName.replace(TS_TSX_FILE_PATTERN, '') -} - -function isGeneratedComponentFile(sourceCode) { - const leadingText = sourceCode.split('\n').slice(0, 5).join('\n') - return GENERATED_FILE_COMMENT_PATTERNS.some(pattern => pattern.test(leadingText)) -} - -function parseComponentFile(sourceCode) { - try { - return tsParser.parse(sourceCode, PARSER_OPTIONS) - } - catch { - return null - } -} - -function isPureBarrelComponentFile(ast) { - let hasRuntimeReExports = false - - for (const statement of ast.body) { - if (statement.type === 'ExportAllDeclaration') { - hasRuntimeReExports = true - continue - } - - if (statement.type === 'ExportNamedDeclaration' && statement.source) { - hasRuntimeReExports = hasRuntimeReExports || statement.exportKind !== 'type' - continue - } - - if (statement.type === 'TSInterfaceDeclaration' || statement.type === 'TSTypeAliasDeclaration') - continue - - return false - } - - return hasRuntimeReExports -} - -function isPureStaticComponentFile(ast) { - const importedStaticBindings = collectImportedStaticBindings(ast.body) - const staticBindings = new Set() - let hasRuntimeValue = false - - for (const statement of ast.body) { - if (statement.type === 'ImportDeclaration') - continue - - if (statement.type === 'TSInterfaceDeclaration' || statement.type === 'TSTypeAliasDeclaration') - continue - - if (statement.type === 'ExportAllDeclaration') - return false - - if (statement.type === 'ExportNamedDeclaration' && statement.source) - return false - - if (statement.type === 'ExportDefaultDeclaration') { - if (!isStaticExpression(statement.declaration, staticBindings, importedStaticBindings)) - return false - hasRuntimeValue = true - continue - } - - if (statement.type === 'ExportNamedDeclaration' && statement.declaration) { - if (!handleStaticDeclaration(statement.declaration, staticBindings, importedStaticBindings)) - return false - hasRuntimeValue = true - continue - } - - if (statement.type === 'ExportNamedDeclaration' && statement.specifiers.length > 0) { - const allStaticSpecifiers = statement.specifiers.every((specifier) => { - if (specifier.type !== 'ExportSpecifier' || specifier.exportKind === 'type') - return false - return specifier.local.type === 'Identifier' && staticBindings.has(specifier.local.name) - }) - if (!allStaticSpecifiers) - return false - hasRuntimeValue = true - continue - } - - if (!handleStaticDeclaration(statement, staticBindings, importedStaticBindings)) - return false - hasRuntimeValue = true - } - - return hasRuntimeValue -} - -function handleStaticDeclaration(statement, staticBindings, importedStaticBindings) { - if (statement.type !== 'VariableDeclaration' || statement.kind !== 'const') - return false - - for (const declarator of statement.declarations) { - if (declarator.id.type !== 'Identifier' || !declarator.init) - return false - - if (!isStaticExpression(declarator.init, staticBindings, importedStaticBindings)) - return false - - staticBindings.add(declarator.id.name) - } - - return true -} - -function collectImportedStaticBindings(statements) { - const importedBindings = new Set() - - for (const statement of statements) { - if (statement.type !== 'ImportDeclaration') - continue - - const importSource = String(statement.source.value ?? '') - const isTypeLikeSource = isTypeCoverageExcludedComponentFile(importSource) - const importIsStatic = statement.importKind === 'type' || isTypeLikeSource - if (!importIsStatic) - continue - - for (const specifier of statement.specifiers) { - if (specifier.local?.type === 'Identifier') - importedBindings.add(specifier.local.name) - } - } - - return importedBindings -} - -function isStaticExpression(node, staticBindings, importedStaticBindings) { - switch (node.type) { - case 'Literal': - return true - case 'Identifier': - return staticBindings.has(node.name) || importedStaticBindings.has(node.name) - case 'TemplateLiteral': - return node.expressions.every(expression => isStaticExpression(expression, staticBindings, importedStaticBindings)) - case 'ArrayExpression': - return node.elements.every(element => !element || isStaticExpression(element, staticBindings, importedStaticBindings)) - case 'ObjectExpression': - return node.properties.every((property) => { - if (property.type === 'SpreadElement') - return isStaticExpression(property.argument, staticBindings, importedStaticBindings) - - if (property.type !== 'Property' || property.method) - return false - - if (property.computed && !isStaticExpression(property.key, staticBindings, importedStaticBindings)) - return false - - if (property.shorthand) - return property.value.type === 'Identifier' && staticBindings.has(property.value.name) - - return isStaticExpression(property.value, staticBindings, importedStaticBindings) - }) - case 'UnaryExpression': - return isStaticExpression(node.argument, staticBindings, importedStaticBindings) - case 'BinaryExpression': - case 'LogicalExpression': - return isStaticExpression(node.left, staticBindings, importedStaticBindings) - && isStaticExpression(node.right, staticBindings, importedStaticBindings) - case 'ConditionalExpression': - return isStaticExpression(node.test, staticBindings, importedStaticBindings) - && isStaticExpression(node.consequent, staticBindings, importedStaticBindings) - && isStaticExpression(node.alternate, staticBindings, importedStaticBindings) - case 'MemberExpression': - return isStaticMemberExpression(node, staticBindings, importedStaticBindings) - case 'ChainExpression': - return isStaticExpression(node.expression, staticBindings, importedStaticBindings) - case 'TSAsExpression': - case 'TSSatisfiesExpression': - case 'TSTypeAssertion': - case 'TSNonNullExpression': - return isStaticExpression(node.expression, staticBindings, importedStaticBindings) - case 'ParenthesizedExpression': - return isStaticExpression(node.expression, staticBindings, importedStaticBindings) - default: - return false - } -} - -function isStaticMemberExpression(node, staticBindings, importedStaticBindings) { - if (!isStaticExpression(node.object, staticBindings, importedStaticBindings)) - return false - - if (!node.computed) - return node.property.type === 'Identifier' - - return isStaticExpression(node.property, staticBindings, importedStaticBindings) -} diff --git a/web/scripts/components-coverage-common.mjs b/web/scripts/components-coverage-common.mjs deleted file mode 100644 index e50da1d178..0000000000 --- a/web/scripts/components-coverage-common.mjs +++ /dev/null @@ -1,195 +0,0 @@ -import fs from 'node:fs' -import path from 'node:path' -import { getLineHits, normalizeToRepoRelative } from './check-components-diff-coverage-lib.mjs' -import { collectComponentCoverageExcludedFiles } from './component-coverage-filters.mjs' -import { EXCLUDED_COMPONENT_MODULES } from './components-coverage-thresholds.mjs' - -export const APP_COMPONENTS_ROOT = 'web/app/components' -export const APP_COMPONENTS_PREFIX = `${APP_COMPONENTS_ROOT}/` -export const APP_COMPONENTS_COVERAGE_PREFIX = 'app/components/' -export const SHARED_TEST_PREFIX = 'web/__tests__/' - -export function createComponentCoverageContext(repoRoot) { - const webRoot = path.join(repoRoot, 'web') - const excludedComponentCoverageFiles = new Set( - collectComponentCoverageExcludedFiles(path.join(webRoot, 'app/components'), { pathPrefix: APP_COMPONENTS_ROOT }), - ) - - return { - excludedComponentCoverageFiles, - repoRoot, - webRoot, - } -} - -export function loadTrackedCoverageEntries(coverage, context) { - const coverageEntries = new Map() - - for (const [file, entry] of Object.entries(coverage)) { - const repoRelativePath = normalizeToRepoRelative(entry.path ?? file, { - appComponentsCoveragePrefix: APP_COMPONENTS_COVERAGE_PREFIX, - appComponentsPrefix: APP_COMPONENTS_PREFIX, - repoRoot: context.repoRoot, - sharedTestPrefix: SHARED_TEST_PREFIX, - webRoot: context.webRoot, - }) - - if (!isTrackedComponentSourceFile(repoRelativePath, context.excludedComponentCoverageFiles)) - continue - - coverageEntries.set(repoRelativePath, entry) - } - - return coverageEntries -} - -export function collectTrackedComponentSourceFiles(context) { - const trackedFiles = [] - - walkComponentSourceFiles(path.join(context.webRoot, 'app/components'), (absolutePath) => { - const repoRelativePath = path.relative(context.repoRoot, absolutePath).split(path.sep).join('/') - if (isTrackedComponentSourceFile(repoRelativePath, context.excludedComponentCoverageFiles)) - trackedFiles.push(repoRelativePath) - }) - - trackedFiles.sort((a, b) => a.localeCompare(b)) - return trackedFiles -} - -export function isTestLikePath(filePath) { - return /(?:^|\/)__tests__\//.test(filePath) - || /(?:^|\/)__mocks__\//.test(filePath) - || /\.(?:spec|test)\.(?:ts|tsx)$/.test(filePath) - || /\.stories\.(?:ts|tsx)$/.test(filePath) - || /\.d\.ts$/.test(filePath) -} - -export function getModuleName(filePath) { - const relativePath = filePath.slice(APP_COMPONENTS_PREFIX.length) - if (!relativePath) - return '(root)' - - const segments = relativePath.split('/') - return segments.length === 1 ? '(root)' : segments[0] -} - -export function isAnyComponentSourceFile(filePath) { - return filePath.startsWith(APP_COMPONENTS_PREFIX) - && /\.(?:ts|tsx)$/.test(filePath) - && !isTestLikePath(filePath) -} - -export function isExcludedComponentSourceFile(filePath, excludedComponentCoverageFiles) { - return isAnyComponentSourceFile(filePath) - && ( - EXCLUDED_COMPONENT_MODULES.has(getModuleName(filePath)) - || excludedComponentCoverageFiles.has(filePath) - ) -} - -export function isTrackedComponentSourceFile(filePath, excludedComponentCoverageFiles) { - return isAnyComponentSourceFile(filePath) - && !isExcludedComponentSourceFile(filePath, excludedComponentCoverageFiles) -} - -export function isTrackedComponentTestFile(filePath) { - return filePath.startsWith(APP_COMPONENTS_PREFIX) - && isTestLikePath(filePath) - && !EXCLUDED_COMPONENT_MODULES.has(getModuleName(filePath)) -} - -export function isRelevantTestFile(filePath) { - return filePath.startsWith(SHARED_TEST_PREFIX) - || isTrackedComponentTestFile(filePath) -} - -export function isAnyWebTestFile(filePath) { - return filePath.startsWith('web/') - && isTestLikePath(filePath) -} - -export function getCoverageStats(entry) { - const lineHits = getLineHits(entry) - const statementHits = Object.values(entry.s ?? {}) - const functionHits = Object.values(entry.f ?? {}) - const branchHits = Object.values(entry.b ?? {}).flat() - - return { - lines: { - covered: Object.values(lineHits).filter(count => count > 0).length, - total: Object.keys(lineHits).length, - }, - statements: { - covered: statementHits.filter(count => count > 0).length, - total: statementHits.length, - }, - functions: { - covered: functionHits.filter(count => count > 0).length, - total: functionHits.length, - }, - branches: { - covered: branchHits.filter(count => count > 0).length, - total: branchHits.length, - }, - } -} - -export function sumCoverageStats(rows) { - const total = createEmptyCoverageStats() - for (const row of rows) - addCoverageStats(total, row) - return total -} - -export function mergeCoverageStats(map, moduleName, stats) { - const existing = map.get(moduleName) ?? createEmptyCoverageStats() - addCoverageStats(existing, stats) - map.set(moduleName, existing) -} - -export function percentage(covered, total) { - if (total === 0) - return 100 - return (covered / total) * 100 -} - -export function formatPercent(metric) { - return `${percentage(metric.covered, metric.total).toFixed(2)}%` -} - -function createEmptyCoverageStats() { - return { - lines: { covered: 0, total: 0 }, - statements: { covered: 0, total: 0 }, - functions: { covered: 0, total: 0 }, - branches: { covered: 0, total: 0 }, - } -} - -function addCoverageStats(target, source) { - for (const metric of ['lines', 'statements', 'functions', 'branches']) { - target[metric].covered += source[metric].covered - target[metric].total += source[metric].total - } -} - -function walkComponentSourceFiles(currentDir, onFile) { - if (!fs.existsSync(currentDir)) - return - - const entries = fs.readdirSync(currentDir, { withFileTypes: true }) - for (const entry of entries) { - const entryPath = path.join(currentDir, entry.name) - if (entry.isDirectory()) { - if (entry.name === '__tests__' || entry.name === '__mocks__') - continue - walkComponentSourceFiles(entryPath, onFile) - continue - } - - if (!/\.(?:ts|tsx)$/.test(entry.name) || isTestLikePath(entry.name)) - continue - - onFile(entryPath) - } -} diff --git a/web/scripts/components-coverage-thresholds.mjs b/web/scripts/components-coverage-thresholds.mjs deleted file mode 100644 index fedd579947..0000000000 --- a/web/scripts/components-coverage-thresholds.mjs +++ /dev/null @@ -1,128 +0,0 @@ -// Floors were set from the app/components baseline captured on 2026-03-13, -// with a small buffer to avoid CI noise on existing code. -export const EXCLUDED_COMPONENT_MODULES = new Set([ - 'devtools', - 'provider', -]) - -export const COMPONENTS_GLOBAL_THRESHOLDS = { - lines: 58, - statements: 58, - functions: 58, - branches: 54, -} - -export const COMPONENT_MODULE_THRESHOLDS = { - 'app': { - lines: 45, - statements: 45, - functions: 50, - branches: 35, - }, - 'app-sidebar': { - lines: 95, - statements: 95, - functions: 95, - branches: 90, - }, - 'apps': { - lines: 90, - statements: 90, - functions: 85, - branches: 80, - }, - 'base': { - lines: 95, - statements: 95, - functions: 90, - branches: 95, - }, - 'billing': { - lines: 95, - statements: 95, - functions: 95, - branches: 95, - }, - 'custom': { - lines: 95, - statements: 95, - functions: 95, - branches: 95, - }, - 'datasets': { - lines: 95, - statements: 95, - functions: 95, - branches: 90, - }, - 'develop': { - lines: 95, - statements: 95, - functions: 95, - branches: 90, - }, - 'explore': { - lines: 95, - statements: 95, - functions: 95, - branches: 85, - }, - 'goto-anything': { - lines: 90, - statements: 90, - functions: 90, - branches: 90, - }, - 'header': { - lines: 95, - statements: 95, - functions: 95, - branches: 95, - }, - 'plugins': { - lines: 90, - statements: 90, - functions: 90, - branches: 85, - }, - 'rag-pipeline': { - lines: 95, - statements: 95, - functions: 95, - branches: 90, - }, - 'share': { - lines: 95, - statements: 95, - functions: 95, - branches: 95, - }, - 'signin': { - lines: 95, - statements: 95, - functions: 95, - branches: 95, - }, - 'tools': { - lines: 95, - statements: 95, - functions: 90, - branches: 90, - }, - 'workflow': { - lines: 15, - statements: 15, - functions: 10, - branches: 10, - }, - 'workflow-app': { - lines: 20, - statements: 20, - functions: 25, - branches: 15, - }, -} - -export function getComponentModuleThreshold(moduleName) { - return COMPONENT_MODULE_THRESHOLDS[moduleName] ?? null -} diff --git a/web/scripts/report-components-coverage-baseline.mjs b/web/scripts/report-components-coverage-baseline.mjs deleted file mode 100644 index 16445b4689..0000000000 --- a/web/scripts/report-components-coverage-baseline.mjs +++ /dev/null @@ -1,165 +0,0 @@ -import { execFileSync } from 'node:child_process' -import fs from 'node:fs' -import path from 'node:path' -import { COMPONENT_COVERAGE_EXCLUDE_LABEL } from './component-coverage-filters.mjs' -import { - collectTrackedComponentSourceFiles, - createComponentCoverageContext, - formatPercent, - getCoverageStats, - getModuleName, - loadTrackedCoverageEntries, - mergeCoverageStats, - percentage, - sumCoverageStats, -} from './components-coverage-common.mjs' -import { - COMPONENTS_GLOBAL_THRESHOLDS, - EXCLUDED_COMPONENT_MODULES, - getComponentModuleThreshold, -} from './components-coverage-thresholds.mjs' - -const EXCLUDED_MODULES_LABEL = [...EXCLUDED_COMPONENT_MODULES].sort().join(', ') - -const repoRoot = repoRootFromCwd() -const context = createComponentCoverageContext(repoRoot) -const coverageFinalPath = path.join(context.webRoot, 'coverage', 'coverage-final.json') - -if (!fs.existsSync(coverageFinalPath)) { - console.error(`Coverage report not found at ${coverageFinalPath}`) - process.exit(1) -} - -const coverage = JSON.parse(fs.readFileSync(coverageFinalPath, 'utf8')) -const trackedSourceFiles = collectTrackedComponentSourceFiles(context) -const coverageEntries = loadTrackedCoverageEntries(coverage, context) -const fileCoverageRows = [] -const moduleCoverageMap = new Map() - -for (const [file, entry] of coverageEntries.entries()) { - const stats = getCoverageStats(entry) - const moduleName = getModuleName(file) - fileCoverageRows.push({ file, moduleName, ...stats }) - mergeCoverageStats(moduleCoverageMap, moduleName, stats) -} - -const overallCoverage = sumCoverageStats(fileCoverageRows) -const overallTargetGaps = getTargetGaps(overallCoverage, COMPONENTS_GLOBAL_THRESHOLDS) -const moduleCoverageRows = [...moduleCoverageMap.entries()] - .map(([moduleName, stats]) => ({ - moduleName, - stats, - targets: getComponentModuleThreshold(moduleName), - })) - .map(row => ({ - ...row, - targetGaps: row.targets ? getTargetGaps(row.stats, row.targets) : [], - })) - .sort((a, b) => { - const aWorst = Math.min(...a.targetGaps.map(gap => gap.delta), Number.POSITIVE_INFINITY) - const bWorst = Math.min(...b.targetGaps.map(gap => gap.delta), Number.POSITIVE_INFINITY) - return aWorst - bWorst || a.moduleName.localeCompare(b.moduleName) - }) - -appendSummary(buildSummary({ - coverageEntriesCount: coverageEntries.size, - moduleCoverageRows, - overallCoverage, - overallTargetGaps, - trackedSourceFilesCount: trackedSourceFiles.length, -})) - -function buildSummary({ - coverageEntriesCount, - moduleCoverageRows, - overallCoverage, - overallTargetGaps, - trackedSourceFilesCount, -}) { - const lines = [ - '### app/components Baseline Coverage', - '', - `Excluded modules: \`${EXCLUDED_MODULES_LABEL}\``, - `Excluded file kinds: \`${COMPONENT_COVERAGE_EXCLUDE_LABEL}\``, - '', - `Coverage entries: ${coverageEntriesCount}/${trackedSourceFilesCount} tracked source files`, - '', - '| Metric | Current | Target | Delta |', - '|---|---:|---:|---:|', - `| Lines | ${formatPercent(overallCoverage.lines)} | ${COMPONENTS_GLOBAL_THRESHOLDS.lines}% | ${formatDelta(overallCoverage.lines, COMPONENTS_GLOBAL_THRESHOLDS.lines)} |`, - `| Statements | ${formatPercent(overallCoverage.statements)} | ${COMPONENTS_GLOBAL_THRESHOLDS.statements}% | ${formatDelta(overallCoverage.statements, COMPONENTS_GLOBAL_THRESHOLDS.statements)} |`, - `| Functions | ${formatPercent(overallCoverage.functions)} | ${COMPONENTS_GLOBAL_THRESHOLDS.functions}% | ${formatDelta(overallCoverage.functions, COMPONENTS_GLOBAL_THRESHOLDS.functions)} |`, - `| Branches | ${formatPercent(overallCoverage.branches)} | ${COMPONENTS_GLOBAL_THRESHOLDS.branches}% | ${formatDelta(overallCoverage.branches, COMPONENTS_GLOBAL_THRESHOLDS.branches)} |`, - '', - ] - - if (coverageEntriesCount !== trackedSourceFilesCount) { - lines.push('Warning: coverage report did not include every tracked component source file. CI should set `VITEST_COVERAGE_SCOPE=app-components` before collecting coverage.') - lines.push('') - } - - if (overallTargetGaps.length > 0) { - lines.push('Below baseline targets:') - for (const gap of overallTargetGaps) - lines.push(`- overall ${gap.metric}: ${gap.actual.toFixed(2)}% < ${gap.target}%`) - lines.push('') - } - - lines.push('
Module baseline coverage') - lines.push('') - lines.push('| Module | Lines | Statements | Functions | Branches | Targets | Status |') - lines.push('|---|---:|---:|---:|---:|---|---|') - for (const row of moduleCoverageRows) { - const targetsLabel = row.targets - ? `L${row.targets.lines}/S${row.targets.statements}/F${row.targets.functions}/B${row.targets.branches}` - : 'n/a' - const status = row.targets - ? (row.targetGaps.length > 0 ? 'below-target' : 'at-target') - : 'unconfigured' - lines.push(`| ${row.moduleName} | ${percentage(row.stats.lines.covered, row.stats.lines.total).toFixed(2)}% | ${percentage(row.stats.statements.covered, row.stats.statements.total).toFixed(2)}% | ${percentage(row.stats.functions.covered, row.stats.functions.total).toFixed(2)}% | ${percentage(row.stats.branches.covered, row.stats.branches.total).toFixed(2)}% | ${targetsLabel} | ${status} |`) - } - lines.push('
') - lines.push('') - lines.push('Report only: baseline targets no longer gate CI. The blocking rule is the pure diff coverage step.') - - return lines -} - -function getTargetGaps(stats, targets) { - const gaps = [] - for (const metric of ['lines', 'statements', 'functions', 'branches']) { - const actual = percentage(stats[metric].covered, stats[metric].total) - const target = targets[metric] - const delta = actual - target - if (delta < 0) { - gaps.push({ - actual, - delta, - metric, - target, - }) - } - } - return gaps -} - -function formatDelta(metric, target) { - const actual = percentage(metric.covered, metric.total) - const delta = actual - target - const sign = delta >= 0 ? '+' : '' - return `${sign}${delta.toFixed(2)}%` -} - -function appendSummary(lines) { - const content = `${lines.join('\n')}\n` - if (process.env.GITHUB_STEP_SUMMARY) - fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, content) - console.log(content) -} - -function repoRootFromCwd() { - return execFileSync('git', ['rev-parse', '--show-toplevel'], { - cwd: process.cwd(), - encoding: 'utf8', - }).trim() -} diff --git a/web/scripts/report-components-test-touch.mjs b/web/scripts/report-components-test-touch.mjs deleted file mode 100644 index 43f316e39a..0000000000 --- a/web/scripts/report-components-test-touch.mjs +++ /dev/null @@ -1,168 +0,0 @@ -import { execFileSync } from 'node:child_process' -import fs from 'node:fs' -import { - buildGitDiffRevisionArgs, - normalizeDiffRangeMode, - resolveGitDiffContext, -} from './check-components-diff-coverage-lib.mjs' -import { - createComponentCoverageContext, - isAnyWebTestFile, - isRelevantTestFile, - isTrackedComponentSourceFile, -} from './components-coverage-common.mjs' - -const REQUESTED_DIFF_RANGE_MODE = normalizeDiffRangeMode(process.env.DIFF_RANGE_MODE) - -const repoRoot = repoRootFromCwd() -const context = createComponentCoverageContext(repoRoot) -const baseSha = process.env.BASE_SHA?.trim() -const headSha = process.env.HEAD_SHA?.trim() || 'HEAD' - -if (!baseSha || /^0+$/.test(baseSha)) { - appendSummary([ - '### app/components Test Touch', - '', - 'Skipped test-touch report because `BASE_SHA` was not available.', - ]) - process.exit(0) -} - -const diffContext = resolveGitDiffContext({ - base: baseSha, - head: headSha, - mode: REQUESTED_DIFF_RANGE_MODE, - execGit, -}) -const changedFiles = getChangedFiles(diffContext) -const changedSourceFiles = changedFiles.filter(filePath => isTrackedComponentSourceFile(filePath, context.excludedComponentCoverageFiles)) - -if (changedSourceFiles.length === 0) { - appendSummary([ - '### app/components Test Touch', - '', - ...buildDiffContextSummary(diffContext), - '', - 'No tracked source changes under `web/app/components/`. Test-touch report skipped.', - ]) - process.exit(0) -} - -const changedRelevantTestFiles = changedFiles.filter(isRelevantTestFile) -const changedOtherWebTestFiles = changedFiles.filter(filePath => isAnyWebTestFile(filePath) && !isRelevantTestFile(filePath)) -const totalChangedWebTests = [...new Set([...changedRelevantTestFiles, ...changedOtherWebTestFiles])] - -appendSummary(buildSummary({ - changedOtherWebTestFiles, - changedRelevantTestFiles, - diffContext, - changedSourceFiles, - totalChangedWebTests, -})) - -function buildSummary({ - changedOtherWebTestFiles, - changedRelevantTestFiles, - diffContext, - changedSourceFiles, - totalChangedWebTests, -}) { - const lines = [ - '### app/components Test Touch', - '', - ...buildDiffContextSummary(diffContext), - '', - `Tracked source files changed: ${changedSourceFiles.length}`, - `Component-local or shared integration tests changed: ${changedRelevantTestFiles.length}`, - `Other web tests changed: ${changedOtherWebTestFiles.length}`, - `Total changed web tests: ${totalChangedWebTests.length}`, - '', - ] - - if (totalChangedWebTests.length === 0) { - lines.push('Warning: no frontend test files changed alongside tracked component source changes.') - lines.push('') - } - - if (changedRelevantTestFiles.length > 0) { - lines.push('
Changed component-local or shared tests') - lines.push('') - for (const filePath of changedRelevantTestFiles.slice(0, 40)) - lines.push(`- ${filePath.replace('web/', '')}`) - if (changedRelevantTestFiles.length > 40) - lines.push(`- ... ${changedRelevantTestFiles.length - 40} more`) - lines.push('
') - lines.push('') - } - - if (changedOtherWebTestFiles.length > 0) { - lines.push('
Changed other web tests') - lines.push('') - for (const filePath of changedOtherWebTestFiles.slice(0, 40)) - lines.push(`- ${filePath.replace('web/', '')}`) - if (changedOtherWebTestFiles.length > 40) - lines.push(`- ... ${changedOtherWebTestFiles.length - 40} more`) - lines.push('
') - lines.push('') - } - - lines.push('Report only: test-touch is now advisory and no longer blocks the diff coverage gate.') - return lines -} - -function buildDiffContextSummary(diffContext) { - const lines = [ - `Compared \`${diffContext.base.slice(0, 12)}\` -> \`${diffContext.head.slice(0, 12)}\``, - ] - - if (diffContext.useCombinedMergeDiff) { - lines.push(`Requested diff range mode: \`${diffContext.requestedMode}\``) - lines.push(`Effective diff strategy: \`combined-merge\` (${diffContext.reason})`) - } - else if (diffContext.reason) { - lines.push(`Requested diff range mode: \`${diffContext.requestedMode}\``) - lines.push(`Effective diff range mode: \`${diffContext.mode}\` (${diffContext.reason})`) - } - else { - lines.push(`Diff range mode: \`${diffContext.mode}\``) - } - - return lines -} - -function getChangedFiles(diffContext) { - if (diffContext.useCombinedMergeDiff) { - const output = execGit(['diff-tree', '--cc', '--no-commit-id', '--name-only', '-r', diffContext.head, '--', 'web']) - return output - .split('\n') - .map(line => line.trim()) - .filter(Boolean) - } - - const output = execGit(['diff', '--name-only', '--diff-filter=ACMR', ...buildGitDiffRevisionArgs(diffContext.base, diffContext.head, diffContext.mode), '--', 'web']) - return output - .split('\n') - .map(line => line.trim()) - .filter(Boolean) -} - -function appendSummary(lines) { - const content = `${lines.join('\n')}\n` - if (process.env.GITHUB_STEP_SUMMARY) - fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, content) - console.log(content) -} - -function execGit(args) { - return execFileSync('git', args, { - cwd: repoRoot, - encoding: 'utf8', - }) -} - -function repoRootFromCwd() { - return execFileSync('git', ['rev-parse', '--show-toplevel'], { - cwd: process.cwd(), - encoding: 'utf8', - }).trim() -} diff --git a/web/vite.config.ts b/web/vite.config.ts index 0df333a91b..665d2d0a5f 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -7,24 +7,15 @@ import { defineConfig } from 'vite-plus' import { createCodeInspectorPlugin, createForceInspectorClientInjectionPlugin } from './plugins/vite/code-inspector' import { customI18nHmrPlugin } from './plugins/vite/custom-i18n-hmr' import { nextStaticImageTestPlugin } from './plugins/vite/next-static-image-test' -import { collectComponentCoverageExcludedFiles } from './scripts/component-coverage-filters.mjs' -import { EXCLUDED_COMPONENT_MODULES } from './scripts/components-coverage-thresholds.mjs' const projectRoot = path.dirname(fileURLToPath(import.meta.url)) const isCI = !!process.env.CI -const coverageScope = process.env.VITEST_COVERAGE_SCOPE const browserInitializerInjectTarget = path.resolve(projectRoot, 'app/components/browser-initializer.tsx') -const excludedAppComponentsCoveragePaths = [...EXCLUDED_COMPONENT_MODULES] - .map(moduleName => `app/components/${moduleName}/**`) export default defineConfig(({ mode }) => { const isTest = mode === 'test' const isStorybook = process.env.STORYBOOK === 'true' || process.argv.some(arg => arg.toLowerCase().includes('storybook')) - const isAppComponentsCoverage = coverageScope === 'app-components' - const excludedComponentCoverageFiles = isAppComponentsCoverage - ? collectComponentCoverageExcludedFiles(path.join(projectRoot, 'app/components'), { pathPrefix: 'app/components' }) - : [] return { plugins: isTest @@ -90,21 +81,6 @@ export default defineConfig(({ mode }) => { coverage: { provider: 'v8', reporter: isCI ? ['json', 'json-summary'] : ['text', 'json', 'json-summary'], - ...(isAppComponentsCoverage - ? { - include: ['app/components/**/*.{ts,tsx}'], - exclude: [ - 'app/components/**/*.d.ts', - 'app/components/**/*.spec.{ts,tsx}', - 'app/components/**/*.test.{ts,tsx}', - 'app/components/**/__tests__/**', - 'app/components/**/__mocks__/**', - 'app/components/**/*.stories.{ts,tsx}', - ...excludedComponentCoverageFiles, - ...excludedAppComponentsCoveragePaths, - ], - } - : {}), }, }, }