#!/usr/bin/env node
* scan-hollow-implementations.mjs
*
* 启发式扫描 Java 源码中的空壳/未完成/静默跳过实现。
*
* 灵感来源:Plan 00 的 Anti-Hollow Rule (Rule #8, #9, #24),
* 以及历史中反复出现的空壳实现问题(如 nop-stream 的 CheckpointCoordinator,
* Plan 98 的 windowState UnsupportedOperationException,Plan 97 的空壳类等)。
*
* 用法:
* node ai-dev/tools/scan-hollow-implementations.mjs [options] [paths...]
*
* 选项:
* --src-only 只扫描 src/main,跳过测试代码(默认)
* --include-tests 同时扫描测试代码
* --severity <level> 最低严重级别:critical, high, medium, low, info(默认 medium)
* --format <fmt> 输出格式:json, markdown, summary(默认 summary)
* --output <file> 输出到文件(默认 stdout)
* --no-header 不输出标题头
* --module <name> 只扫描指定模块(如 nop-stream, nop-code)
*
* 检测模式:
* [P1] UnsupportedOperationException("not yet" / "not supported" / "minimal")
* [P2] 空方法体 / 单行 return null/0/false
* [P3] 空 catch 块(吞异常)
* [P4] TODO / FIXME 标记
* [P5] 未使用的 public 方法(仅在声明类中使用)
* [P6] "stub" / "placeholder" / "dummy" 注释或命名
* [P7] 抽象类/接口只有空实现(单一实现且所有方法都是 return null/空体)
*/
import fs from 'fs';
import path from 'path';
const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low', 'info'];
function parseArgs(argv) {
const args = {
paths: [],
srcOnly: true,
includeTests: false,
severity: 'medium',
format: 'summary',
output: null,
noHeader: false,
module: null,
};
const positional = [];
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--src-only') args.srcOnly = true;
else if (a === '--include-tests') { args.includeTests = true; args.srcOnly = false; }
else if (a === '--severity' && i + 1 < argv.length) args.severity = argv[++i];
else if (a === '--format' && i + 1 < argv.length) args.format = argv[++i];
else if (a === '--output' && i + 1 < argv.length) args.output = argv[++i];
else if (a === '--no-header') args.noHeader = true;
else if (a === '--module' && i + 1 < argv.length) args.module = argv[++i];
else if (!a.startsWith('--')) positional.push(a);
}
if (positional.length > 0) {
args.paths = positional;
} else {
const repoRoot = findRepoRoot();
if (args.module) {
args.paths = [path.join(repoRoot, args.module)];
} else {
args.paths = [repoRoot];
}
}
return args;
}
function findRepoRoot() {
let dir = process.cwd();
while (dir !== path.dirname(dir)) {
if (fs.existsSync(path.join(dir, 'pom.xml')) && fs.existsSync(path.join(dir, 'AGENTS.md'))) {
return dir;
}
dir = path.dirname(dir);
}
return process.cwd();
}
function walkJavaFiles(dir, srcOnly) {
const results = [];
if (!fs.existsSync(dir)) return results;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (srcOnly && entry.name === 'test' && dir.endsWith('src')) continue;
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'target' || entry.name === '_gen') continue;
results.push(...walkJavaFiles(fullPath, srcOnly));
} else if (entry.isFile() && entry.name.endsWith('.java')) {
results.push(fullPath);
}
}
return results;
}
function relativePath(fullPath) {
const repoRoot = findRepoRoot();
if (fullPath.startsWith(repoRoot)) {
return fullPath.slice(repoRoot.length + 1);
}
return fullPath;
}
function classifyPath(relPath) {
if (relPath.includes('/src/test/')) return 'test';
if (relPath.includes('/src/main/')) return 'main';
return 'unknown';
}
const PATTERNS = [
{
id: 'P1',
name: 'UnsupportedOperationException',
severity: 'high',
description: '抛出 UnsupportedOperationException,可能表示未实现的功能',
regex: /throw new UnsupportedOperationException\s*\(\s*"([^"]*)"\s*\)/g,
extract: (m) => m[1],
filter: (relPath, line) => {
return !relPath.includes('/src/test/');
},
rationale: 'Plan 00 Rule #8(空壳实现)和 Rule #24(静默跳过)明确要求未实现功能应使用语义明确的异常,而非 UnsupportedOperationException。历史计划 84/86/97/98 中多次修复此类问题。',
},
{
id: 'P2a',
name: '空方法体',
severity: 'high',
description: '方法体只有空花括号,无任何逻辑',
regex: /\{\s*\}[ \t]*$/g,
contextBefore: 1,
filter: (relPath, line, prevLines) => {
const prevLine = (prevLines && prevLines.length > 0) ? prevLines[prevLines.length - 1] : '';
if (/catch\s*\(/.test(line) || /catch\s*\(/.test(prevLine || '')) return false;
if (/interface\s+\w+/.test(prevLine || '')) return false;
if (/\bif\s*\(|else\s*\{|try\s*\{|finally\s*\{|synchronized\s*\(|switch\s*\(/.test(prevLine || '')) return false;
if (/enum\s*\{/.test(prevLine || '')) return false;
if (/\/\/\s*(empty|intentionally|deliberately|no-op|no op)/i.test(line)) return false;
const methodSig = prevLine || '';
return /(void|\w+)\s+\w+\s*\([^)]*\)\s*(throws\s+[\w\s,]+)?\s*\{?\s*$/.test(methodSig) || /Runnable|Callable|Supplier|Function|Consumer/.test(methodSig);
},
rationale: 'Plan 00 Rule #8:空壳实现的典型症状。历史中 CheckpointCoordinator 的方法曾经如此。',
},
{
id: 'P2b',
name: '单行 return null 作为方法体',
severity: 'medium',
description: '非空检查目的的 return null 可能是 placeholder',
regex: /return null;\s*$/g,
contextBefore: 3,
filter: (relPath, line, prevLines) => {
const ctx = (prevLines && prevLines.length > 0) ? prevLines.join('\n') : '';
if (/if\s*\(\s*\w+\s*==\s*null\s*\)/.test(ctx)) return false;
if (/if\s*\(\s*!\s*\w+/.test(ctx)) return false;
if (/null\s*\)\s*$/.test(prevLines ? prevLines[prevLines.length - 1] || '' : '')) return false;
if (/@Override/.test(ctx)) return false;
return true;
},
rationale: 'Plan 00 Rule #8:placeholder return 值。需人工判断是合理 null 返回还是未实现。',
},
{
id: 'P3',
name: '空 catch 块(吞异常)',
severity: 'high',
description: 'catch 块为空或只包含注释,异常被静默吞掉',
regex: /\{\s*\}\s*$/g,
contextBefore: 2,
filter: (relPath, line, prevLines) => {
if (relPath.includes('/src/test/')) return false;
const ctx = (prevLines && prevLines.length > 0) ? prevLines.join('\n') : '';
return /catch\s*\(/.test(ctx);
},
rationale: 'Plan 00 Rule #24:静默跳过的典型模式。历史计划中多次发现并修复。',
},
{
id: 'P3b',
name: 'catch 块只打印日志不重新抛出',
severity: 'medium',
description: 'catch 块只有 e.printStackTrace() 或 LOG 但不重新抛出',
regex: /catch\s*\([^)]+\)\s*\{[^}]*e\.printStackTrace\(\)|catch\s*\([^)]+\)\s*\{[^}]*LOG\.\w+\([^)]*e[^)]*\)[^}]*\}/g,
filter: () => true,
rationale: 'Plan 00 Rule #24 变体:日志打印不等于处理。需人工判断是否应该重新抛出。',
},
{
id: 'P4',
name: 'TODO/FIXME 标记',
severity: 'low',
description: '代码中的 TODO 或 FIXME 注释,可能标记未完成工作',
regex: /\/\/\s*(TODO|FIXME)\b[:\s]/gi,
extract: (m) => m[0].trim(),
filter: () => true,
rationale: 'Plan 00 Rule #24 明确禁止将 TODO/FIXME 标记的代码当作已完成。',
},
{
id: 'P6',
name: 'Stub/Placeholder/Dummy 命名',
severity: 'medium',
description: '类名或方法名包含 stub/placeholder/dummy/fake/mock(生产代码中)',
regex: /\b(class|interface|void|\w+)\s+(Stub|Placeholder|Dummy|Fake|Mock|Temp|Temporary)\w*\b/gi,
filter: (relPath) => {
return !relPath.includes('/src/test/');
},
rationale: '生产代码中不应该有 stub/placeholder 命名的类。历史计划 90/96 中发现过空壳模块。',
},
{
id: 'P6b',
name: '"not yet implemented" 注释',
severity: 'high',
description: '注释中出现 not yet implemented / minimal implementation 等措辞',
regex: /\/\/.*not yet implemented|\/\/.*minimal implementation|\/\/.*stub implementation|\/\/.*placeholder|\/\/.*temp\s/i,
filter: (relPath) => !relPath.includes('/src/test/'),
rationale: '这些注释明确标记了代码未完成,属于 Plan 00 Rule #8 中的空壳模式。',
},
{
id: 'P8',
name: 'continue 跳过循环体(疑似静默跳过)',
severity: 'medium',
description: '循环中的 continue 可能跳过了应处理的逻辑',
regex: /^\s*continue;\s*$/g,
contextBefore: 2,
filter: (relPath, line, prevLines) => {
const ctx = (prevLines && prevLines.length > 0) ? prevLines.join('\n') : '';
if (/null\s*check|null\s*\)|isEmpty|isBlank|\.isEmpty|filter|skip/i.test(ctx)) return false;
return true;
},
rationale: 'Plan 00 Rule #24:静默跳过模式。需人工判断是合理的过滤还是遗漏的逻辑。',
},
];
function scanFile(filePath, srcOnly) {
const relPath = relativePath(filePath);
const codeClass = classifyPath(relPath);
if (srcOnly && codeClass === 'test') return [];
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.split('\n');
const findings = [];
for (const pattern of PATTERNS) {
if (!pattern.filter(relPath, '')) continue;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
pattern.regex.lastIndex = 0;
const match = pattern.regex.exec(line);
if (!match) continue;
const prevLines = [];
const ctxCount = pattern.contextBefore || 0;
for (let j = Math.max(0, i - ctxCount); j < i; j++) {
prevLines.push(lines[j]);
}
if (!pattern.filter(relPath, line, prevLines)) continue;
findings.push({
patternId: pattern.id,
patternName: pattern.name,
severity: pattern.severity,
description: pattern.description,
file: relPath,
line: i + 1,
column: match.index + 1,
snippet: line.trim(),
extracted: pattern.extract ? pattern.extract(match) : null,
codeClass,
});
}
}
return findings;
}
function severityRank(s) {
return SEVERITY_ORDER.indexOf(s);
}
function generateSummary(findings, args) {
const byPattern = {};
const byModule = {};
let critical = 0, high = 0, medium = 0, low = 0, info = 0;
for (const f of findings) {
byPattern[f.patternId] = byPattern[f.patternId] || { name: f.patternName, items: [] };
byPattern[f.patternId].items.push(f);
const mod = f.file.split('/')[0];
byModule[mod] = byModule[mod] || 0;
byModule[mod]++;
switch (f.severity) {
case 'critical': critical++; break;
case 'high': high++; break;
case 'medium': medium++; break;
case 'low': low++; break;
default: info++;
}
}
const lines = [];
if (!args.noHeader) {
lines.push('# Hollow Implementation Scan Report');
lines.push(`Generated: ${new Date().toISOString().split('T')[0]}`);
lines.push(`Scan scope: ${args.srcOnly ? 'src/main only' : 'src/main + src/test'}`);
lines.push(`Min severity: ${args.severity}`);
lines.push('');
}
lines.push('## Summary');
lines.push('');
lines.push(`| Severity | Count |`);
lines.push(`|----------|-------|`);
lines.push(`| Critical | ${critical} |`);
lines.push(`| High | ${high} |`);
lines.push(`| Medium | ${medium} |`);
lines.push(`| Low | ${low} |`);
lines.push(`| **Total**| **${findings.length}** |`);
lines.push('');
if (Object.keys(byModule).length > 0) {
lines.push('## By Module');
lines.push('');
lines.push('| Module | Findings |');
lines.push('|--------|----------|');
const sorted = Object.entries(byModule).sort((a, b) => b[1] - a[1]);
for (const [mod, count] of sorted) {
lines.push(`| ${mod} | ${count} |`);
}
lines.push('');
}
lines.push('## By Pattern');
lines.push('');
for (const [pid, data] of Object.entries(byPattern)) {
const pattern = PATTERNS.find(p => p.id === pid);
lines.push(`### ${pid}: ${data.name} (${data.items.length} findings)`);
lines.push(`Severity: ${pattern.severity}`);
lines.push(`Rationale: ${pattern.rationale}`);
lines.push('');
for (const f of data.items) {
lines.push(`- \`${f.file}:${f.line}\` — ${f.snippet}`);
}
lines.push('');
}
lines.push('## Recommendation');
lines.push('');
lines.push('1. **High severity findings should be reviewed first** — they are most likely genuine hollow implementations or silent no-ops.');
lines.push('2. **Medium severity findings need human judgment** — some return null is legitimate (map lookup miss), some is placeholder.');
lines.push('3. **Low severity findings (TODO/FIXME)** are informational — track them but they don\'t indicate bugs.');
lines.push('');
lines.push('For each high-severity finding, ask:');
lines.push('- Is this method supposed to have real logic?');
lines.push('- Is there a test that exercises this code path?');
lines.push('- If this is intentionally unimplemented, does it throw a clear exception (not `return null`)?');
return lines.join('\n');
}
function generateJson(findings, args) {
const grouped = {};
for (const f of findings) {
grouped[f.patternId] = grouped[f.patternId] || { name: f.patternName, severity: f.severity, rationale: '', items: [] };
grouped[f.patternId].items.push({
file: f.file,
line: f.line,
snippet: f.snippet,
codeClass: f.codeClass,
});
}
for (const p of PATTERNS) {
if (grouped[p.id]) {
grouped[p.id].rationale = p.rationale;
}
}
return JSON.stringify({
generated: new Date().toISOString(),
scope: args.srcOnly ? 'src/main' : 'src/main + src/test',
minSeverity: args.severity,
totalFindings: findings.length,
patterns: grouped,
}, null, 2);
}
function generateMarkdown(findings, args) {
return generateSummary(findings, args);
}
async function main() {
const args = parseArgs(process.argv);
const minSev = severityRank(args.severity);
const javaFiles = [];
for (const scanPath of args.paths) {
javaFiles.push(...walkJavaFiles(scanPath, args.srcOnly));
}
const allFindings = [];
for (const f of javaFiles) {
allFindings.push(...scanFile(f, args.srcOnly));
}
const filtered = allFindings.filter(f => severityRank(f.severity) <= minSev);
filtered.sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || a.file.localeCompare(b.file) || a.line - b.line);
let output;
switch (args.format) {
case 'json':
output = generateJson(filtered, args);
break;
case 'markdown':
output = generateMarkdown(filtered, args);
break;
default:
output = generateSummary(filtered, args);
}
if (args.output) {
fs.writeFileSync(args.output, output, 'utf-8');
console.error(`Written to ${args.output} (${filtered.length} findings)`);
} else {
console.log(output);
}
const highCount = filtered.filter(f => f.severity === 'high' || f.severity === 'critical').length;
process.exit(highCount > 0 ? 1 : 0);
}
main().catch(e => { console.error(e); process.exit(2); });