feat(diff-coverage): implement coverage analysis for changed components (#33514)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
Coding On Star
2026-03-16 14:39:57 +08:00
committed by GitHub
parent 915ee385db
commit 59327e4f10
3 changed files with 517 additions and 116 deletions

View File

@ -0,0 +1,256 @@
import fs from 'node:fs'
import path from 'node:path'
const DIFF_COVERAGE_IGNORE_LINE_TOKEN = 'diff-coverage-ignore-line:'
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(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/)
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(statement.start.line)
}
return {
covered,
total,
uncoveredLines: uncoveredLines.sort((a, b) => a - b),
}
}
export function getChangedBranchCoverage(entry, changedLines) {
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 ?? {})) {
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)
for (let armIndex = 0; armIndex < armCount; armIndex += 1) {
total += 1
if ((hits[armIndex] ?? 0) > 0) {
covered += 1
continue
}
const location = locations[armIndex] ?? branch.loc ?? branch
uncoveredBranches.push({
armIndex,
line: getLocationStartLine(location) ?? 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 branchIntersectsChangedLines(branch, changedLines) {
if (!changedLines || changedLines.size === 0)
return false
if (rangeIntersectsChangedLines(branch.loc, changedLines))
return true
const locations = getBranchLocations(branch)
if (locations.some(location => rangeIntersectsChangedLines(location, changedLines)))
return true
return branch.line ? changedLines.has(branch.line) : false
}
function getBranchLocations(branch) {
return Array.isArray(branch?.locations) ? branch.locations.filter(Boolean) : []
}
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 getLocationStartLine(location) {
return location?.start?.line ?? location?.line ?? null
}
function getLocationEndLine(location) {
return location?.end?.line ?? location?.line ?? null
}