mirror of
https://github.com/langgenius/dify.git
synced 2026-03-18 05:09:54 +08:00
408 lines
10 KiB
JavaScript
408 lines
10 KiB
JavaScript
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
|
|
}
|