#!/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(/]+)>/)?.[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 } }