227 lines
13 KiB
JavaScript
227 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|||
|
|
import { readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs'
|
||
|
|
import { resolve, relative, basename } from 'node:path'
|
||
|
|
import { execFileSync } from 'node:child_process'
|
||
|
|
import { fileURLToPath } from 'node:url'
|
||
|
|
|
||
|
|
const moduleName = 'lastleg-service'
|
||
|
|
const testClass = /(?:^Test.+|.+(?:Test|Tests|TestCase))\.java$/
|
||
|
|
const words = source => new Set(source.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) || [])
|
||
|
|
const tables = source => new Set(source.match(/\bll_[a-z0-9_]+\b/g) || [])
|
||
|
|
const writtenTables = source => new Set([...source.matchAll(/\b(?:UPDATE|INSERT\s+(?:IGNORE\s+)?INTO|DELETE\s+FROM)\s+(ll_[a-z0-9_]+)/gi)].map(match => match[1]))
|
||
|
|
|
||
|
|
// 按完整注解比较 SQL,避免只改 SELECT 时把同一 Mapper 内未改的全部写语句扩大为全域回归。
|
||
|
|
function writeStatements(source) {
|
||
|
|
const result = []
|
||
|
|
for (const match of source.matchAll(/@(?:Insert|Update|Delete)\s*\(/g)) {
|
||
|
|
let depth = 1, quote = '', index = match.index + match[0].length
|
||
|
|
for (; index < source.length && depth; index++) {
|
||
|
|
if (quote) {
|
||
|
|
if (source.startsWith(quote, index)) { index += quote.length - 1; quote = '' }
|
||
|
|
else if (source[index] === '\\' && quote.length === 1) index++
|
||
|
|
} else if (source.startsWith('"""', index)) { quote = '"""'; index += 2 }
|
||
|
|
else if (source[index] === '"' || source[index] === "'") quote = source[index]
|
||
|
|
else if (source[index] === '(') depth++
|
||
|
|
else if (source[index] === ')') depth--
|
||
|
|
}
|
||
|
|
if (depth) throw new Error('Cannot parse SQL annotation; use MODULE test scope')
|
||
|
|
result.push(source.slice(match.index, index))
|
||
|
|
}
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
|
||
|
|
function changedWrites(node) {
|
||
|
|
if (node.previousSource === undefined) return writtenTables(node.source)
|
||
|
|
const before = writeStatements(node.previousSource), after = writeStatements(node.source)
|
||
|
|
const changed = [...before.filter(sql => !after.includes(sql)), ...after.filter(sql => !before.includes(sql))]
|
||
|
|
const oldRest = before.reduce((text, sql) => text.replace(sql, ''), node.previousSource)
|
||
|
|
const newRest = after.reduce((text, sql) => text.replace(sql, ''), node.source)
|
||
|
|
if (oldRest !== newRest) changed.push(oldRest, newRest)
|
||
|
|
return writtenTables(changed.join('\n'))
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 宁可扩大范围,不用文件名猜测唯一测试;未识别的输入保留完整模块门禁。 */
|
||
|
|
export function planTests({ modules, mode = 'AUTO', changes = [], sources = [], baseline = '' }) {
|
||
|
|
const architectureScript = `${moduleName}/scripts/analyze-lastleg-dependencies`
|
||
|
|
const staticChecks = changes.some(path => path.startsWith(architectureScript))
|
||
|
|
? [`${architectureScript}.test.mjs`] : []
|
||
|
|
const full = reason => ({ scope: 'MODULE', reason, modules, baseline, tests: [], staticChecks })
|
||
|
|
if (mode !== 'AUTO' && mode !== 'MODULE') throw new Error('TEST_SCOPE must be AUTO or MODULE')
|
||
|
|
if (mode === 'MODULE') return full('显式完整模块回归')
|
||
|
|
if (!baseline) return full('没有成功测试基线')
|
||
|
|
if (modules.length !== 1 || modules[0] !== moduleName) return full('多服务或尚未核对的服务采用完整模块回归')
|
||
|
|
const prefix = `${moduleName}/`
|
||
|
|
const relevant = changes.filter(path => path.startsWith(prefix))
|
||
|
|
const shared = changes.some(path => path !== 'scripts/agents-index-contract.ps1' && !path.startsWith(prefix)
|
||
|
|
&& /^(?:common-[^/]+\/|platform-bom\/|pom\.xml$|Jenkinsfile|deploy\/|config\/|scripts\/.*\.(?:mjs|sh|ps1)$)/.test(path))
|
||
|
|
if (shared) return full('公共能力、发布或测试基础设施变更')
|
||
|
|
if (!relevant.length) return full('补跑服务没有可验证的业务差异')
|
||
|
|
const java = `${prefix}src/main/java/com/baiyue/lastleg/`
|
||
|
|
const supported = path => path.startsWith(`${prefix}src/test/java/`)
|
||
|
|
|| path.startsWith(`${prefix}src/main/resources/openapi/`)
|
||
|
|
|| path.startsWith(architectureScript)
|
||
|
|
|| (path.startsWith(java) && path.slice(java.length).includes('/')
|
||
|
|
&& !/^(?:config|security|entity|dto|model|mapper|repository|service|client)\//.test(path.slice(java.length)))
|
||
|
|
|| /\.(?:md)$/.test(path)
|
||
|
|
if (relevant.some(path => !supported(path))) return full('共享模型、配置、SQL、资源或未识别路径变更')
|
||
|
|
|
||
|
|
const nodes = sources.map(({ path, source, previousSource }) => ({ path, source, previousSource,
|
||
|
|
symbol: basename(path, '.java'), tokens: words(source), tables: tables(source),
|
||
|
|
test: path.startsWith(`${prefix}src/test/java/`),
|
||
|
|
name: `${source.match(/\bpackage\s+([\w.]+)\s*;/)?.[1] || ''}.${basename(path, '.java')}` }))
|
||
|
|
const byPath = new Map(nodes.map(node => [node.path, node]))
|
||
|
|
const bySymbol = new Map()
|
||
|
|
for (const node of nodes) {
|
||
|
|
if (!bySymbol.has(node.symbol)) bySymbol.set(node.symbol, [])
|
||
|
|
bySymbol.get(node.symbol).push(node)
|
||
|
|
}
|
||
|
|
const reverse = new Map(nodes.map(node => [node.path, new Set()]))
|
||
|
|
for (const node of nodes) {
|
||
|
|
// 包含反射字符串、静态 import、同包引用及测试夹具;重名类全部纳入。
|
||
|
|
for (const token of node.tokens) for (const dependency of bySymbol.get(token) || []) {
|
||
|
|
if (dependency !== node) reverse.get(dependency.path).add(node.path)
|
||
|
|
}
|
||
|
|
// 实现变化也要覆盖只依赖接口/基类的调用者,不能仅沿显式 import 反查。
|
||
|
|
for (const match of node.source.matchAll(/\b(?:implements|extends)\s+([^{};]+)/g)) {
|
||
|
|
for (const token of words(match[1])) for (const contract of bySymbol.get(token) || []) {
|
||
|
|
if (contract !== node) reverse.get(node.path).add(contract.path)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const seeds = new Set()
|
||
|
|
const changedTables = new Set()
|
||
|
|
for (const path of relevant.filter(path => path.endsWith('.java'))) {
|
||
|
|
const node = byPath.get(path)
|
||
|
|
if (node) {
|
||
|
|
seeds.add(path)
|
||
|
|
// 读查询变化不改变其他查询的数据;潜在写入变化则纳入同表读写消费者。
|
||
|
|
if (!node.test) for (const table of changedWrites(node)) changedTables.add(table)
|
||
|
|
} else {
|
||
|
|
// 删除/移动旧类型时,仍按旧类名查现存消费者。
|
||
|
|
const symbol = basename(path, '.java')
|
||
|
|
const consumers = nodes.filter(consumer => consumer.tokens.has(symbol))
|
||
|
|
if (!consumers.length) return full('删除类型缺少可追踪的现存消费者')
|
||
|
|
for (const consumer of consumers) seeds.add(consumer.path)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (!seeds.size) return full('没有可追踪的 Java 行为差异')
|
||
|
|
for (const node of nodes) if ([...node.tables].some(table => changedTables.has(table))) seeds.add(node.path)
|
||
|
|
const affected = new Set(seeds)
|
||
|
|
const queue = [...seeds]
|
||
|
|
for (let index = 0; index < queue.length; index++) {
|
||
|
|
for (const consumer of reverse.get(queue[index]) || []) if (!affected.has(consumer)) {
|
||
|
|
affected.add(consumer); queue.push(consumer)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const allTests = nodes.filter(node => node.test && testClass.test(basename(node.path))
|
||
|
|
&& /@(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/.test(node.source))
|
||
|
|
const contractChange = relevant.some(path => path.startsWith(`${prefix}src/main/resources/openapi/`))
|
||
|
|
const candidates = allTests.filter(node => affected.has(node.path)
|
||
|
|
|| /(?:Security|Isolation|MapperRegistrationArchitecture|QueryBinding)/.test(node.symbol)
|
||
|
|
|| (contractChange && /(?:Contract|OpenApi|Schema|Architecture|Permission)/.test(node.symbol)))
|
||
|
|
// 显式依赖真实环境的类仍由 MODULE/专项授权入口负责,不能把它们默认跳过算作执行成功。
|
||
|
|
const optIn = node => /@(?:EnabledIf(?:SystemProperty|EnvironmentVariable)|Disabled(?:IfSystemProperty|IfEnvironmentVariable)?\b)/
|
||
|
|
.test(node.source.slice(0, node.source.indexOf(`class ${node.symbol}`)))
|
||
|
|
const tests = candidates.filter(node => !optIn(node))
|
||
|
|
if (!tests.some(node => affected.has(node.path))) return full('没有匹配到行为测试,拒绝零测试通过')
|
||
|
|
if (tests.some(node => !/^com\.[\w.$]+$/.test(node.name))) return full('测试类名无法安全传入 Maven')
|
||
|
|
return { scope: 'AFFECTED', reason: 'Java 反向引用、接口实现、测试夹具、共享 SQL 表和相关静态合同',
|
||
|
|
modules, baseline, tests: tests.map(node => node.name).sort(), totalTests: allTests.length, staticChecks,
|
||
|
|
optInTests: candidates.filter(optIn).map(node => node.name).sort() }
|
||
|
|
}
|
||
|
|
|
||
|
|
function walk(root) {
|
||
|
|
const files = []
|
||
|
|
for (const item of readdirSync(root, { withFileTypes: true })) {
|
||
|
|
const path = resolve(root, item.name)
|
||
|
|
if (item.isDirectory()) files.push(...walk(path))
|
||
|
|
else if (item.isFile() && item.name.endsWith('.java')) files.push(path)
|
||
|
|
}
|
||
|
|
return files
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 每个所选类必须有本轮实际执行的报告;允许上游零匹配不能变成整个门禁零测试。 */
|
||
|
|
export function verifyReports(plan, reports) {
|
||
|
|
if (plan.scope !== 'AFFECTED') return
|
||
|
|
for (const name of plan.tests) {
|
||
|
|
const matches = reports.filter(report => report.name === name || report.name.startsWith(`${name}$`))
|
||
|
|
if (!matches.length || matches.some(report => report.failures || report.errors
|
||
|
|
|| ['tests', 'skipped', 'failures', 'errors'].some(key => !Number.isInteger(report[key]) || report[key] < 0)
|
||
|
|
|| report.skipped > report.tests)
|
||
|
|
|| matches.reduce((sum, report) => sum + report.tests - report.skipped, 0) <= 0) {
|
||
|
|
throw new Error(`Missing, skipped or failed fresh test report: ${name}`)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function main(args) {
|
||
|
|
const [action, ...options] = args
|
||
|
|
const values = {}
|
||
|
|
for (let i = 0; i < options.length; i += 2) {
|
||
|
|
if (!options[i].startsWith('--') || options[i + 1] === undefined) throw new Error('Expected --name value')
|
||
|
|
values[options[i].slice(2)] = options[i + 1]
|
||
|
|
}
|
||
|
|
const root = process.cwd()
|
||
|
|
const planFile = values.plan
|
||
|
|
if (!planFile) throw new Error('--plan is required')
|
||
|
|
if (action === 'plan') {
|
||
|
|
const modules = (values.modules || '').split(',')
|
||
|
|
if (modules.some(module => !/^[a-z][a-z0-9-]+$/.test(module))) throw new Error('Invalid modules')
|
||
|
|
let baseline = values.base || ''
|
||
|
|
let changes = []
|
||
|
|
if (baseline) {
|
||
|
|
if (!/^[0-9a-f]{7,40}$/i.test(baseline)) throw new Error('Invalid baseline commit')
|
||
|
|
try {
|
||
|
|
execFileSync('git', ['merge-base', '--is-ancestor', baseline, 'HEAD'], { stdio: 'pipe' })
|
||
|
|
changes = execFileSync('git', ['diff', '--name-only', '-z', baseline, 'HEAD'], { encoding: 'utf8' }).split('\0').filter(Boolean)
|
||
|
|
} catch { baseline = '' }
|
||
|
|
}
|
||
|
|
const sources = modules.length === 1 && modules[0] === moduleName
|
||
|
|
? ['main', 'test'].flatMap(set => walk(resolve(root, moduleName, 'src', set, 'java')))
|
||
|
|
.map(path => {
|
||
|
|
const key = relative(root, path).replaceAll('\\', '/')
|
||
|
|
const entry = { path: key, source: readFileSync(path, 'utf8') }
|
||
|
|
if (baseline && changes.includes(key)) {
|
||
|
|
try { entry.previousSource = execFileSync('git', ['show', `${baseline}:${key}`], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }) }
|
||
|
|
catch { entry.previousSource = '' }
|
||
|
|
}
|
||
|
|
return entry
|
||
|
|
}) : []
|
||
|
|
const plan = { ...planTests({ modules, mode: values.mode || 'AUTO', baseline, changes, sources }), startedAt: Date.now() }
|
||
|
|
writeFileSync(planFile, JSON.stringify(plan))
|
||
|
|
console.log(`CI test scope: ${plan.scope}; ${plan.reason}`)
|
||
|
|
if (plan.scope === 'AFFECTED') console.log(`Selected ${plan.tests.length}/${plan.totalTests} test classes:\n${plan.tests.join('\n')}`)
|
||
|
|
if (plan.optInTests?.length) console.log(`Explicit environment tests remain opt-in (not counted as passed): ${plan.optInTests.join(',')}`)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
const plan = JSON.parse(readFileSync(planFile, 'utf8'))
|
||
|
|
if (action === 'args') {
|
||
|
|
if (plan.scope === 'AFFECTED') process.stdout.write(`-Dtest=${plan.tests.join(',')} -Dsurefire.failIfNoSpecifiedTests=false -Dci.sql-semantic.skip=true`)
|
||
|
|
} else if (action === 'checks') {
|
||
|
|
for (const check of plan.staticChecks || []) {
|
||
|
|
if (check !== `${moduleName}/scripts/analyze-lastleg-dependencies.test.mjs`) throw new Error('Unsupported static check')
|
||
|
|
execFileSync(process.execPath, ['--test', '--test-concurrency=2', check], { stdio: 'inherit' })
|
||
|
|
}
|
||
|
|
} else if (action === 'verify') {
|
||
|
|
const reports = []
|
||
|
|
if (plan.scope === 'AFFECTED') for (const module of plan.modules) {
|
||
|
|
const directory = resolve(root, module, 'target/surefire-reports')
|
||
|
|
for (const filename of readdirSync(directory)) {
|
||
|
|
if (!/^TEST-.*\.xml$/.test(filename)) continue
|
||
|
|
const path = resolve(directory, filename)
|
||
|
|
if (statSync(path).mtimeMs < plan.startedAt - 2000) continue
|
||
|
|
const xml = readFileSync(path, 'utf8').match(/<testsuite\b([^>]+)>/)?.[1]
|
||
|
|
if (!xml) continue
|
||
|
|
const attrs = Object.fromEntries([...xml.matchAll(/([\w-]+)="([^"]*)"/g)].map(match => [match[1], match[2]]))
|
||
|
|
reports.push({ name: attrs.name || '', ...Object.fromEntries(['tests', 'failures', 'errors', 'skipped'].map(key => [key, Number(attrs[key] || 0)])) })
|
||
|
|
}
|
||
|
|
}
|
||
|
|
verifyReports(plan, reports)
|
||
|
|
console.log(`CI test reports verified: ${plan.scope}`)
|
||
|
|
} else throw new Error(`Unknown action: ${action}`)
|
||
|
|
}
|
||
|
|
|
||
|
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||
|
|
try { main(process.argv.slice(2)) } catch (error) { console.error(error.message); process.exitCode = 1 }
|
||
|
|
}
|