diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml
index ec9d1c98c8..be2595a599 100644
--- a/.github/workflows/web-tests.yml
+++ b/.github/workflows/web-tests.yml
@@ -89,14 +89,24 @@ jobs:
- name: Merge reports
run: vp test --merge-reports --reporter=json --reporter=agent --coverage
- - name: Check app/components diff coverage
+ - 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: Coverage Summary
+ - name: Check Coverage Summary
if: always()
id: coverage-summary
run: |
@@ -105,313 +115,15 @@ jobs:
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=false" >> "$GITHUB_OUTPUT"
- echo "### 🚨 Test Coverage Report :test_tube:" >> "$GITHUB_STEP_SUMMARY"
- echo "Coverage data not found. Ensure Vitest runs with coverage enabled." >> "$GITHUB_STEP_SUMMARY"
+ if [ -f "$COVERAGE_FILE" ] || [ -f "$COVERAGE_SUMMARY_FILE" ]; then
+ echo "has_coverage=true" >> "$GITHUB_OUTPUT"
exit 0
fi
- echo "has_coverage=true" >> "$GITHUB_OUTPUT"
-
- node <<'NODE' >> "$GITHUB_STEP_SUMMARY"
- const fs = require('fs');
- const path = require('path');
- let libCoverage = null;
-
- try {
- libCoverage = require('istanbul-lib-coverage');
- } catch (error) {
- libCoverage = null;
- }
-
- const summaryPath = path.join('coverage', 'coverage-summary.json');
- const finalPath = path.join('coverage', 'coverage-final.json');
-
- const hasSummary = fs.existsSync(summaryPath);
- const hasFinal = fs.existsSync(finalPath);
-
- if (!hasSummary && !hasFinal) {
- console.log('### Test Coverage Summary :test_tube:');
- console.log('');
- console.log('No coverage data found.');
- process.exit(0);
- }
-
- const summary = hasSummary
- ? JSON.parse(fs.readFileSync(summaryPath, 'utf8'))
- : null;
- const coverage = hasFinal
- ? JSON.parse(fs.readFileSync(finalPath, 'utf8'))
- : null;
-
- const getLineCoverageFromStatements = (statementMap, statementHits) => {
- const lineHits = {};
-
- if (!statementMap || !statementHits) {
- return lineHits;
- }
-
- Object.entries(statementMap).forEach(([key, statement]) => {
- const line = statement?.start?.line;
- if (!line) {
- return;
- }
- const hits = statementHits[key] ?? 0;
- const previous = lineHits[line];
- lineHits[line] = previous === undefined ? hits : Math.max(previous, hits);
- });
-
- return lineHits;
- };
-
- const getFileCoverage = (entry) => (
- libCoverage ? libCoverage.createFileCoverage(entry) : null
- );
-
- const getLineHits = (entry, fileCoverage) => {
- const lineHits = entry.l ?? {};
- if (Object.keys(lineHits).length > 0) {
- return lineHits;
- }
- if (fileCoverage) {
- return fileCoverage.getLineCoverage();
- }
- return getLineCoverageFromStatements(entry.statementMap ?? {}, entry.s ?? {});
- };
-
- const getUncoveredLines = (entry, fileCoverage, lineHits) => {
- if (lineHits && Object.keys(lineHits).length > 0) {
- return Object.entries(lineHits)
- .filter(([, count]) => count === 0)
- .map(([line]) => Number(line))
- .sort((a, b) => a - b);
- }
- if (fileCoverage) {
- return fileCoverage.getUncoveredLines();
- }
- return [];
- };
-
- const totals = {
- lines: { covered: 0, total: 0 },
- statements: { covered: 0, total: 0 },
- branches: { covered: 0, total: 0 },
- functions: { covered: 0, total: 0 },
- };
- const fileSummaries = [];
-
- if (summary) {
- const totalEntry = summary.total ?? {};
- ['lines', 'statements', 'branches', 'functions'].forEach((key) => {
- if (totalEntry[key]) {
- totals[key].covered = totalEntry[key].covered ?? 0;
- totals[key].total = totalEntry[key].total ?? 0;
- }
- });
-
- Object.entries(summary)
- .filter(([file]) => file !== 'total')
- .forEach(([file, data]) => {
- fileSummaries.push({
- file,
- pct: data.lines?.pct ?? data.statements?.pct ?? 0,
- lines: {
- covered: data.lines?.covered ?? 0,
- total: data.lines?.total ?? 0,
- },
- });
- });
- } else if (coverage) {
- Object.entries(coverage).forEach(([file, entry]) => {
- const fileCoverage = getFileCoverage(entry);
- const lineHits = getLineHits(entry, fileCoverage);
- const statementHits = entry.s ?? {};
- const branchHits = entry.b ?? {};
- const functionHits = entry.f ?? {};
-
- const lineTotal = Object.keys(lineHits).length;
- const lineCovered = Object.values(lineHits).filter((n) => n > 0).length;
-
- const statementTotal = Object.keys(statementHits).length;
- const statementCovered = Object.values(statementHits).filter((n) => n > 0).length;
-
- const branchTotal = Object.values(branchHits).reduce((acc, branches) => acc + branches.length, 0);
- const branchCovered = Object.values(branchHits).reduce(
- (acc, branches) => acc + branches.filter((n) => n > 0).length,
- 0,
- );
-
- const functionTotal = Object.keys(functionHits).length;
- const functionCovered = Object.values(functionHits).filter((n) => n > 0).length;
-
- totals.lines.total += lineTotal;
- totals.lines.covered += lineCovered;
- totals.statements.total += statementTotal;
- totals.statements.covered += statementCovered;
- totals.branches.total += branchTotal;
- totals.branches.covered += branchCovered;
- totals.functions.total += functionTotal;
- totals.functions.covered += functionCovered;
-
- const pct = (covered, tot) => (tot > 0 ? (covered / tot) * 100 : 0);
-
- fileSummaries.push({
- file,
- pct: pct(lineCovered || statementCovered, lineTotal || statementTotal),
- lines: {
- covered: lineCovered || statementCovered,
- total: lineTotal || statementTotal,
- },
- });
- });
- }
-
- const pct = (covered, tot) => (tot > 0 ? ((covered / tot) * 100).toFixed(2) : '0.00');
-
- console.log('### Test Coverage Summary :test_tube:');
- console.log('');
- console.log('| Metric | Coverage | Covered / Total |');
- console.log('|--------|----------|-----------------|');
- console.log(`| Lines | ${pct(totals.lines.covered, totals.lines.total)}% | ${totals.lines.covered} / ${totals.lines.total} |`);
- console.log(`| Statements | ${pct(totals.statements.covered, totals.statements.total)}% | ${totals.statements.covered} / ${totals.statements.total} |`);
- console.log(`| Branches | ${pct(totals.branches.covered, totals.branches.total)}% | ${totals.branches.covered} / ${totals.branches.total} |`);
- console.log(`| Functions | ${pct(totals.functions.covered, totals.functions.total)}% | ${totals.functions.covered} / ${totals.functions.total} |`);
-
- console.log('');
- console.log('File coverage (lowest lines first)
');
- console.log('');
- console.log('```');
- fileSummaries
- .sort((a, b) => (a.pct - b.pct) || (b.lines.total - a.lines.total))
- .slice(0, 25)
- .forEach(({ file, pct, lines }) => {
- console.log(`${pct.toFixed(2)}%\t${lines.covered}/${lines.total}\t${file}`);
- });
- console.log('```');
- console.log(' ');
-
- if (coverage) {
- const pctValue = (covered, tot) => {
- if (tot === 0) {
- return '0';
- }
- return ((covered / tot) * 100)
- .toFixed(2)
- .replace(/\.?0+$/, '');
- };
-
- const formatLineRanges = (lines) => {
- if (lines.length === 0) {
- return '';
- }
- const ranges = [];
- let start = lines[0];
- let end = lines[0];
-
- for (let i = 1; i < lines.length; i += 1) {
- const current = lines[i];
- 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(',');
- };
-
- const tableTotals = {
- statements: { covered: 0, total: 0 },
- branches: { covered: 0, total: 0 },
- functions: { covered: 0, total: 0 },
- lines: { covered: 0, total: 0 },
- };
- const tableRows = Object.entries(coverage)
- .map(([file, entry]) => {
- const fileCoverage = getFileCoverage(entry);
- const lineHits = getLineHits(entry, fileCoverage);
- const statementHits = entry.s ?? {};
- const branchHits = entry.b ?? {};
- const functionHits = entry.f ?? {};
-
- const lineTotal = Object.keys(lineHits).length;
- const lineCovered = Object.values(lineHits).filter((n) => n > 0).length;
- const statementTotal = Object.keys(statementHits).length;
- const statementCovered = Object.values(statementHits).filter((n) => n > 0).length;
- const branchTotal = Object.values(branchHits).reduce((acc, branches) => acc + branches.length, 0);
- const branchCovered = Object.values(branchHits).reduce(
- (acc, branches) => acc + branches.filter((n) => n > 0).length,
- 0,
- );
- const functionTotal = Object.keys(functionHits).length;
- const functionCovered = Object.values(functionHits).filter((n) => n > 0).length;
-
- tableTotals.lines.total += lineTotal;
- tableTotals.lines.covered += lineCovered;
- tableTotals.statements.total += statementTotal;
- tableTotals.statements.covered += statementCovered;
- tableTotals.branches.total += branchTotal;
- tableTotals.branches.covered += branchCovered;
- tableTotals.functions.total += functionTotal;
- tableTotals.functions.covered += functionCovered;
-
- const uncoveredLines = getUncoveredLines(entry, fileCoverage, lineHits);
-
- const filePath = entry.path ?? file;
- const relativePath = path.isAbsolute(filePath)
- ? path.relative(process.cwd(), filePath)
- : filePath;
-
- return {
- file: relativePath || file,
- statements: pctValue(statementCovered, statementTotal),
- branches: pctValue(branchCovered, branchTotal),
- functions: pctValue(functionCovered, functionTotal),
- lines: pctValue(lineCovered, lineTotal),
- uncovered: formatLineRanges(uncoveredLines),
- };
- })
- .sort((a, b) => a.file.localeCompare(b.file));
-
- const columns = [
- { key: 'file', header: 'File', align: 'left' },
- { key: 'statements', header: '% Stmts', align: 'right' },
- { key: 'branches', header: '% Branch', align: 'right' },
- { key: 'functions', header: '% Funcs', align: 'right' },
- { key: 'lines', header: '% Lines', align: 'right' },
- { key: 'uncovered', header: 'Uncovered Line #s', align: 'left' },
- ];
-
- const allFilesRow = {
- file: 'All files',
- statements: pctValue(tableTotals.statements.covered, tableTotals.statements.total),
- branches: pctValue(tableTotals.branches.covered, tableTotals.branches.total),
- functions: pctValue(tableTotals.functions.covered, tableTotals.functions.total),
- lines: pctValue(tableTotals.lines.covered, tableTotals.lines.total),
- uncovered: '',
- };
-
- const rowsForOutput = [allFilesRow, ...tableRows];
- const formatRow = (row) => `| ${columns
- .map(({ key }) => String(row[key] ?? ''))
- .join(' | ')} |`;
- const headerRow = `| ${columns.map(({ header }) => header).join(' | ')} |`;
- const dividerRow = `| ${columns
- .map(({ align }) => (align === 'right' ? '---:' : ':---'))
- .join(' | ')} |`;
-
- console.log('');
- console.log('Vitest coverage table
');
- console.log('');
- console.log(headerRow);
- console.log(dividerRow);
- rowsForOutput.forEach((row) => console.log(formatRow(row)));
- console.log(' ');
- }
- NODE
+ 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'
diff --git a/web/__tests__/check-components-diff-coverage.test.ts b/web/__tests__/check-components-diff-coverage.test.ts
index 79f6c8fd26..62e5ff5ed5 100644
--- a/web/__tests__/check-components-diff-coverage.test.ts
+++ b/web/__tests__/check-components-diff-coverage.test.ts
@@ -163,9 +163,38 @@ describe('check-components-diff-coverage helpers', () => {
expect(coverage).toEqual({
covered: 0,
- total: 2,
+ 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 },
],
})
diff --git a/web/__tests__/components-coverage-common.test.ts b/web/__tests__/components-coverage-common.test.ts
new file mode 100644
index 0000000000..ab189ed854
--- /dev/null
+++ b/web/__tests__/components-coverage-common.test.ts
@@ -0,0 +1,72 @@
+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/scripts/check-components-diff-coverage-lib.mjs b/web/scripts/check-components-diff-coverage-lib.mjs
index 48abc14613..354424d878 100644
--- a/web/scripts/check-components-diff-coverage-lib.mjs
+++ b/web/scripts/check-components-diff-coverage-lib.mjs
@@ -131,14 +131,15 @@ export function getChangedBranchCoverage(entry, changedLines) {
let total = 0
for (const [branchId, branch] of Object.entries(entry.branchMap ?? {})) {
- if (!branchIntersectsChangedLines(branch, changedLines))
- continue
-
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)
- for (let armIndex = 0; armIndex < armCount; armIndex += 1) {
+ if (impactedArmIndexes.length === 0)
+ continue
+
+ for (const armIndex of impactedArmIndexes) {
total += 1
if ((hits[armIndex] ?? 0) > 0) {
covered += 1
@@ -219,22 +220,50 @@ function emptyIgnoreResult(changedLines = []) {
}
}
-function branchIntersectsChangedLines(branch, changedLines) {
+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 (rangeIntersectsChangedLines(branch.loc, changedLines))
+ if (branch.line && changedLines.has(branch.line))
return true
- const locations = getBranchLocations(branch)
- if (locations.some(location => rangeIntersectsChangedLines(location, changedLines)))
+ const branchRange = branch.loc ?? branch
+ if (!rangeIntersectsChangedLines(branchRange, changedLines))
+ return false
+
+ if (locations.length === 0 || locations.length < armCount)
return true
- return branch.line ? changedLines.has(branch.line) : false
-}
+ for (const lineNumber of changedLines) {
+ if (!lineTouchesLocation(lineNumber, branchRange))
+ continue
+ if (!locations.some(location => lineTouchesLocation(lineNumber, location)))
+ return true
+ }
-function getBranchLocations(branch) {
- return Array.isArray(branch?.locations) ? branch.locations.filter(Boolean) : []
+ return false
}
function rangeIntersectsChangedLines(location, changedLines) {
@@ -268,6 +297,15 @@ function getFirstChangedLineInRange(location, changedLines, fallbackLine = 1) {
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
}
diff --git a/web/scripts/check-components-diff-coverage.mjs b/web/scripts/check-components-diff-coverage.mjs
index 8cf3c96223..e8822b84d4 100644
--- a/web/scripts/check-components-diff-coverage.mjs
+++ b/web/scripts/check-components-diff-coverage.mjs
@@ -6,41 +6,34 @@ import {
getChangedBranchCoverage,
getChangedStatementCoverage,
getIgnoredChangedLinesFromFile,
- getLineHits,
- normalizeToRepoRelative,
parseChangedLineMap,
} from './check-components-diff-coverage-lib.mjs'
+import { COMPONENT_COVERAGE_EXCLUDE_LABEL } from './component-coverage-filters.mjs'
import {
- collectComponentCoverageExcludedFiles,
- COMPONENT_COVERAGE_EXCLUDE_LABEL,
-} from './component-coverage-filters.mjs'
-import {
- COMPONENTS_GLOBAL_THRESHOLDS,
- EXCLUDED_COMPONENT_MODULES,
- getComponentModuleThreshold,
-} from './components-coverage-thresholds.mjs'
+ APP_COMPONENTS_PREFIX,
+ createComponentCoverageContext,
+ getModuleName,
+ isAnyComponentSourceFile,
+ isExcludedComponentSourceFile,
+ isTrackedComponentSourceFile,
+ loadTrackedCoverageEntries,
+} from './components-coverage-common.mjs'
+import { EXCLUDED_COMPONENT_MODULES } from './components-coverage-thresholds.mjs'
-const APP_COMPONENTS_PREFIX = 'web/app/components/'
-const APP_COMPONENTS_COVERAGE_PREFIX = 'app/components/'
-const SHARED_TEST_PREFIX = 'web/__tests__/'
-const STRICT_TEST_FILE_TOUCH = process.env.STRICT_COMPONENT_TEST_TOUCH === 'true'
-const EXCLUDED_MODULES_LABEL = [...EXCLUDED_COMPONENT_MODULES].sort().join(', ')
const DIFF_RANGE_MODE = process.env.DIFF_RANGE_MODE === 'exact' ? 'exact' : 'merge-base'
+const EXCLUDED_MODULES_LABEL = [...EXCLUDED_COMPONENT_MODULES].sort().join(', ')
const repoRoot = repoRootFromCwd()
-const webRoot = path.join(repoRoot, 'web')
-const excludedComponentCoverageFiles = new Set(
- collectComponentCoverageExcludedFiles(path.join(webRoot, 'app/components'), { pathPrefix: 'web/app/components' }),
-)
+const context = createComponentCoverageContext(repoRoot)
const baseSha = process.env.BASE_SHA?.trim()
const headSha = process.env.HEAD_SHA?.trim() || 'HEAD'
-const coverageFinalPath = path.join(webRoot, 'coverage', 'coverage-final.json')
+const coverageFinalPath = path.join(context.webRoot, 'coverage', 'coverage-final.json')
if (!baseSha || /^0+$/.test(baseSha)) {
appendSummary([
- '### app/components Diff Coverage',
+ '### app/components Pure Diff Coverage',
'',
- 'Skipped diff coverage check because `BASE_SHA` was not available.',
+ 'Skipped pure diff coverage check because `BASE_SHA` was not available.',
])
process.exit(0)
}
@@ -53,52 +46,27 @@ if (!fs.existsSync(coverageFinalPath)) {
const coverage = JSON.parse(fs.readFileSync(coverageFinalPath, 'utf8'))
const changedFiles = getChangedFiles(baseSha, headSha)
const changedComponentSourceFiles = changedFiles.filter(isAnyComponentSourceFile)
-const changedSourceFiles = changedComponentSourceFiles.filter(isTrackedComponentSourceFile)
-const changedExcludedSourceFiles = changedComponentSourceFiles.filter(isExcludedComponentSourceFile)
-const changedTestFiles = changedFiles.filter(isRelevantTestFile)
+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 = 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,
- sharedTestPrefix: SHARED_TEST_PREFIX,
- webRoot,
- })
- if (!isTrackedComponentSourceFile(repoRelativePath))
- continue
-
- coverageEntries.set(repoRelativePath, entry)
-}
-
-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 coverageEntries = loadTrackedCoverageEntries(coverage, context)
const diffChanges = getChangedLineMap(baseSha, headSha)
const diffRows = []
const ignoredDiffLines = []
const invalidIgnorePragmas = []
for (const [file, changedLines] of diffChanges.entries()) {
- if (!isTrackedComponentSourceFile(file))
+ 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,
@@ -106,6 +74,7 @@ for (const [file, changedLines] of diffChanges.entries()) {
reason,
})
}
+
for (const invalidPragma of ignoreInfo.invalidPragmas) {
invalidIgnorePragmas.push({
file,
@@ -137,40 +106,15 @@ const diffTotals = diffRows.reduce((acc, row) => {
const diffStatementFailures = diffRows.filter(row => row.statements.uncoveredLines.length > 0)
const diffBranchFailures = diffRows.filter(row => row.branches.uncoveredBranches.length > 0)
-const overallThresholdFailures = getThresholdFailures(overallCoverage, COMPONENTS_GLOBAL_THRESHOLDS)
-const moduleCoverageRows = [...moduleCoverageMap.entries()]
- .map(([moduleName, stats]) => ({
- moduleName,
- stats,
- thresholds: getComponentModuleThreshold(moduleName),
- }))
- .map(row => ({
- ...row,
- failures: row.thresholds ? getThresholdFailures(row.stats, row.thresholds) : [],
- }))
-const moduleThresholdFailures = moduleCoverageRows
- .filter(row => row.failures.length > 0)
- .flatMap(row => row.failures.map(failure => ({
- moduleName: row.moduleName,
- ...failure,
- })))
-const hasRelevantTestChanges = changedTestFiles.length > 0
-const missingTestTouch = !hasRelevantTestChanges
appendSummary(buildSummary({
- overallCoverage,
- overallThresholdFailures,
- moduleCoverageRows,
- moduleThresholdFailures,
+ changedSourceFiles,
diffBranchFailures,
diffRows,
diffStatementFailures,
diffTotals,
- changedSourceFiles,
- changedTestFiles,
ignoredDiffLines,
invalidIgnorePragmas,
- missingTestTouch,
}))
if (process.env.CI) {
@@ -178,44 +122,37 @@ if (process.env.CI) {
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 (
- overallThresholdFailures.length > 0
- || moduleThresholdFailures.length > 0
- || diffStatementFailures.length > 0
+ diffStatementFailures.length > 0
|| diffBranchFailures.length > 0
|| invalidIgnorePragmas.length > 0
- || (STRICT_TEST_FILE_TOUCH && missingTestTouch)
) {
process.exit(1)
}
function buildSummary({
- overallCoverage,
- overallThresholdFailures,
- moduleCoverageRows,
- moduleThresholdFailures,
+ changedSourceFiles,
diffBranchFailures,
diffRows,
diffStatementFailures,
diffTotals,
- changedSourceFiles,
- changedTestFiles,
ignoredDiffLines,
invalidIgnorePragmas,
- missingTestTouch,
}) {
const lines = [
- '### app/components Diff Coverage',
+ '### app/components Pure Diff Coverage',
'',
`Compared \`${baseSha.slice(0, 12)}\` -> \`${headSha.slice(0, 12)}\``,
`Diff range mode: \`${DIFF_RANGE_MODE}\``,
@@ -225,60 +162,11 @@ function buildSummary({
'',
'| Check | Result | Details |',
'|---|---:|---|',
- `| Overall tracked lines | ${formatPercent(overallCoverage.lines)} | ${overallCoverage.lines.covered}/${overallCoverage.lines.total}; threshold ${COMPONENTS_GLOBAL_THRESHOLDS.lines}% |`,
- `| Overall tracked statements | ${formatPercent(overallCoverage.statements)} | ${overallCoverage.statements.covered}/${overallCoverage.statements.total}; threshold ${COMPONENTS_GLOBAL_THRESHOLDS.statements}% |`,
- `| Overall tracked functions | ${formatPercent(overallCoverage.functions)} | ${overallCoverage.functions.covered}/${overallCoverage.functions.total}; threshold ${COMPONENTS_GLOBAL_THRESHOLDS.functions}% |`,
- `| Overall tracked branches | ${formatPercent(overallCoverage.branches)} | ${overallCoverage.branches.covered}/${overallCoverage.branches.total}; threshold ${COMPONENTS_GLOBAL_THRESHOLDS.branches}% |`,
`| Changed statements | ${formatDiffPercent(diffTotals.statements)} | ${diffTotals.statements.covered}/${diffTotals.statements.total} |`,
`| Changed branches | ${formatDiffPercent(diffTotals.branches)} | ${diffTotals.branches.covered}/${diffTotals.branches.total} |`,
'',
]
- if (overallThresholdFailures.length > 0) {
- lines.push('Overall thresholds failed:')
- for (const failure of overallThresholdFailures)
- lines.push(`- ${failure.metric}: ${failure.actual.toFixed(2)}% < ${failure.expected}%`)
- lines.push('')
- }
-
- if (moduleThresholdFailures.length > 0) {
- lines.push('Module thresholds failed:')
- for (const failure of moduleThresholdFailures)
- lines.push(`- ${failure.moduleName} ${failure.metric}: ${failure.actual.toFixed(2)}% < ${failure.expected}%`)
- lines.push('')
- }
-
- const moduleRows = moduleCoverageRows
- .map(({ moduleName, stats, thresholds, failures }) => ({
- moduleName,
- lines: percentage(stats.lines.covered, stats.lines.total),
- statements: percentage(stats.statements.covered, stats.statements.total),
- functions: percentage(stats.functions.covered, stats.functions.total),
- branches: percentage(stats.branches.covered, stats.branches.total),
- thresholds,
- failures,
- }))
- .sort((a, b) => {
- if (a.failures.length !== b.failures.length)
- return b.failures.length - a.failures.length
-
- return a.lines - b.lines || a.moduleName.localeCompare(b.moduleName)
- })
-
- lines.push('Module coverage
')
- lines.push('')
- lines.push('| Module | Lines | Statements | Functions | Branches | Thresholds | Status |')
- lines.push('|---|---:|---:|---:|---:|---|---|')
- for (const row of moduleRows) {
- const thresholdLabel = row.thresholds
- ? `L${row.thresholds.lines}/S${row.thresholds.statements}/F${row.thresholds.functions}/B${row.thresholds.branches}`
- : 'n/a'
- const status = row.thresholds ? (row.failures.length > 0 ? 'fail' : 'pass') : 'info'
- lines.push(`| ${row.moduleName} | ${row.lines.toFixed(2)}% | ${row.statements.toFixed(2)}% | ${row.functions.toFixed(2)}% | ${row.branches.toFixed(2)}% | ${thresholdLabel} | ${status} |`)
- }
- lines.push(' ')
- lines.push('')
-
const changedRows = diffRows
.filter(row => row.statements.total > 0 || row.branches.total > 0)
.sort((a, b) => {
@@ -297,59 +185,43 @@ function buildSummary({
lines.push('')
lines.push('')
- if (missingTestTouch) {
- lines.push(`Warning: tracked source files changed under \`web/app/components/\`, but no test files changed under \`web/app/components/**\` or \`web/__tests__/\`.`)
- if (STRICT_TEST_FILE_TOUCH)
- lines.push('`STRICT_COMPONENT_TEST_TOUCH=true` is enabled, so this warning fails the check.')
- lines.push('')
- }
- else {
- lines.push(`Relevant test files changed: ${changedTestFiles.length}`)
- lines.push('')
- }
-
if (diffStatementFailures.length > 0) {
lines.push('Uncovered changed statements:')
- for (const row of diffStatementFailures) {
+ 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) {
+ 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) {
+ 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) {
+ 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(`Changed statement coverage: ${formatDiffPercent(diffTotals.statements)}`)
- lines.push(`Changed branch coverage: ${formatDiffPercent(diffTotals.branches)}`)
+ lines.push('Blocking rules: uncovered changed statements, uncovered changed branches, invalid ignore pragmas.')
return lines
}
function buildSkipSummary(changedExcludedSourceFiles) {
const lines = [
- '### app/components Diff Coverage',
+ '### app/components Pure Diff Coverage',
'',
`Excluded modules: \`${EXCLUDED_MODULES_LABEL}\``,
`Excluded file kinds: \`${COMPONENT_COVERAGE_EXCLUDE_LABEL}\``,
@@ -357,18 +229,18 @@ function buildSkipSummary(changedExcludedSourceFiles) {
]
if (changedExcludedSourceFiles.length > 0) {
- lines.push('Only excluded component modules or type-only files changed, so diff coverage check was skipped.')
+ 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 source changes under tracked `web/app/components/`. Diff coverage check skipped.')
+ lines.push('No tracked source changes under `web/app/components/`. Pure diff coverage skipped.')
}
return lines
}
function getChangedFiles(base, head) {
- const output = execGit(['diff', '--name-only', '--diff-filter=ACMR', ...buildGitDiffRevisionArgs(base, head, DIFF_RANGE_MODE), '--', 'web/app/components', 'web/__tests__'])
+ const output = execGit(['diff', '--name-only', '--diff-filter=ACMR', ...buildGitDiffRevisionArgs(base, head, DIFF_RANGE_MODE), '--', APP_COMPONENTS_PREFIX])
return output
.split('\n')
.map(line => line.trim())
@@ -376,127 +248,8 @@ function getChangedFiles(base, head) {
}
function getChangedLineMap(base, head) {
- const diff = execGit(['diff', '--unified=0', '--no-color', '--diff-filter=ACMR', ...buildGitDiffRevisionArgs(base, head, DIFF_RANGE_MODE), '--', 'web/app/components'])
- return parseChangedLineMap(diff, isTrackedComponentSourceFile)
-}
-
-function isAnyComponentSourceFile(filePath) {
- return filePath.startsWith(APP_COMPONENTS_PREFIX)
- && /\.(?:ts|tsx)$/.test(filePath)
- && !isTestLikePath(filePath)
-}
-
-function isTrackedComponentSourceFile(filePath) {
- return isAnyComponentSourceFile(filePath)
- && !isExcludedComponentSourceFile(filePath)
-}
-
-function isExcludedComponentSourceFile(filePath) {
- return isAnyComponentSourceFile(filePath)
- && (
- EXCLUDED_COMPONENT_MODULES.has(getModuleName(filePath))
- || excludedComponentCoverageFiles.has(filePath)
- )
-}
-
-function isRelevantTestFile(filePath) {
- return filePath.startsWith(SHARED_TEST_PREFIX)
- || (filePath.startsWith(APP_COMPONENTS_PREFIX) && isTestLikePath(filePath) && !isExcludedComponentTestFile(filePath))
-}
-
-function isExcludedComponentTestFile(filePath) {
- if (!filePath.startsWith(APP_COMPONENTS_PREFIX))
- return false
-
- return EXCLUDED_COMPONENT_MODULES.has(getModuleName(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 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,
- },
- }
-}
-
-function sumCoverageStats(rows) {
- const total = createEmptyCoverageStats()
- for (const row of rows)
- addCoverageStats(total, row)
- return total
-}
-
-function mergeCoverageStats(map, moduleName, stats) {
- const existing = map.get(moduleName) ?? createEmptyCoverageStats()
- addCoverageStats(existing, stats)
- map.set(moduleName, existing)
-}
-
-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 createEmptyCoverageStats() {
- return {
- lines: { covered: 0, total: 0 },
- statements: { covered: 0, total: 0 },
- functions: { covered: 0, total: 0 },
- branches: { covered: 0, total: 0 },
- }
-}
-
-function getThresholdFailures(stats, thresholds) {
- const failures = []
- for (const metric of ['lines', 'statements', 'functions', 'branches']) {
- const actual = percentage(stats[metric].covered, stats[metric].total)
- const expected = thresholds[metric]
- if (actual < expected) {
- failures.push({
- metric,
- actual,
- expected,
- })
- }
- }
- return failures
-}
-
-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]
+ const diff = execGit(['diff', '--unified=0', '--no-color', '--diff-filter=ACMR', ...buildGitDiffRevisionArgs(base, head, DIFF_RANGE_MODE), '--', APP_COMPONENTS_PREFIX])
+ return parseChangedLineMap(diff, filePath => isTrackedComponentSourceFile(filePath, context.excludedComponentCoverageFiles))
}
function formatLineRanges(lines) {
@@ -536,10 +289,6 @@ function percentage(covered, total) {
return (covered / total) * 100
}
-function formatPercent(metric) {
- return `${percentage(metric.covered, metric.total).toFixed(2)}%`
-}
-
function formatDiffPercent(metric) {
if (metric.total === 0)
return 'n/a'
diff --git a/web/scripts/components-coverage-common.mjs b/web/scripts/components-coverage-common.mjs
new file mode 100644
index 0000000000..e50da1d178
--- /dev/null
+++ b/web/scripts/components-coverage-common.mjs
@@ -0,0 +1,195 @@
+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/report-components-coverage-baseline.mjs b/web/scripts/report-components-coverage-baseline.mjs
new file mode 100644
index 0000000000..16445b4689
--- /dev/null
+++ b/web/scripts/report-components-coverage-baseline.mjs
@@ -0,0 +1,165 @@
+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
new file mode 100644
index 0000000000..0d384c2f4a
--- /dev/null
+++ b/web/scripts/report-components-test-touch.mjs
@@ -0,0 +1,129 @@
+import { execFileSync } from 'node:child_process'
+import fs from 'node:fs'
+import {
+ buildGitDiffRevisionArgs,
+} from './check-components-diff-coverage-lib.mjs'
+import {
+ createComponentCoverageContext,
+ isAnyWebTestFile,
+ isRelevantTestFile,
+ isTrackedComponentSourceFile,
+} from './components-coverage-common.mjs'
+
+const DIFF_RANGE_MODE = process.env.DIFF_RANGE_MODE === 'exact' ? 'exact' : 'merge-base'
+
+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 changedFiles = getChangedFiles(baseSha, headSha)
+const changedSourceFiles = changedFiles.filter(filePath => isTrackedComponentSourceFile(filePath, context.excludedComponentCoverageFiles))
+
+if (changedSourceFiles.length === 0) {
+ appendSummary([
+ '### app/components Test Touch',
+ '',
+ '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,
+ changedSourceFiles,
+ totalChangedWebTests,
+}))
+
+function buildSummary({
+ changedOtherWebTestFiles,
+ changedRelevantTestFiles,
+ changedSourceFiles,
+ totalChangedWebTests,
+}) {
+ const lines = [
+ '### app/components Test Touch',
+ '',
+ `Compared \`${baseSha.slice(0, 12)}\` -> \`${headSha.slice(0, 12)}\``,
+ `Diff range mode: \`${DIFF_RANGE_MODE}\``,
+ '',
+ `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 getChangedFiles(base, head) {
+ const output = execGit(['diff', '--name-only', '--diff-filter=ACMR', ...buildGitDiffRevisionArgs(base, head, DIFF_RANGE_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()
+}