mirror of
https://github.com/langgenius/dify.git
synced 2026-03-17 12:57:51 +08:00
feat(diff-coverage): implement coverage analysis for changed components (#33514)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
256
web/scripts/check-components-diff-coverage-lib.mjs
Normal file
256
web/scripts/check-components-diff-coverage-lib.mjs
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user