273 lines
6.8 KiB
JavaScript
273 lines
6.8 KiB
JavaScript
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||
import { basename, extname, join, relative } from 'node:path';
|
||
|
||
const repoRoot = process.cwd();
|
||
const writeReport = process.argv.includes('--write-report');
|
||
const reportPath = join(repoRoot, 'docs', 'audits', 'VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md');
|
||
|
||
const codeTargets = [
|
||
'src',
|
||
'packages/shared/src',
|
||
'server-rs/crates',
|
||
];
|
||
|
||
const documentTargets = [
|
||
'docs',
|
||
'.hermes/shared-memory',
|
||
];
|
||
|
||
const visualNovelImplementationTargets = [
|
||
'src/components/visual-novel-creation',
|
||
'src/components/visual-novel-result',
|
||
'src/components/visual-novel-runtime',
|
||
'src/services/visual-novel-creation',
|
||
'src/services/visual-novel-runtime',
|
||
'src/services/visual-novel-works',
|
||
'packages/shared/src/contracts/visualNovel.ts',
|
||
'server-rs/crates/shared-contracts/src/visual_novel.rs',
|
||
'server-rs/crates/module-visual-novel',
|
||
'server-rs/crates/api-server/src/visual_novel.rs',
|
||
'server-rs/crates/api-server/src/prompt/visual_novel.rs',
|
||
'server-rs/crates/spacetime-module/src/visual_novel.rs',
|
||
'server-rs/crates/spacetime-client/src/visual_novel.rs',
|
||
];
|
||
|
||
const textExtensions = new Set([
|
||
'.cjs',
|
||
'.controller',
|
||
'.css',
|
||
'.html',
|
||
'.js',
|
||
'.json',
|
||
'.jsx',
|
||
'.md',
|
||
'.mjs',
|
||
'.ps1',
|
||
'.py',
|
||
'.rs',
|
||
'.scss',
|
||
'.sh',
|
||
'.toml',
|
||
'.ts',
|
||
'.tsx',
|
||
'.txt',
|
||
'.yaml',
|
||
'.yml',
|
||
]);
|
||
|
||
const textFileNames = new Set([
|
||
'AGENTS.md',
|
||
'README.md',
|
||
]);
|
||
|
||
const excludedPrefixes = [
|
||
'.git/',
|
||
'dist/',
|
||
'node_modules/',
|
||
'server-rs/target/',
|
||
'server-rs/target-',
|
||
];
|
||
|
||
const legacyPlaybackTerms = [
|
||
're' + 'play',
|
||
'Re' + 'play',
|
||
'回放',
|
||
'分享回放',
|
||
'录制',
|
||
'复盘',
|
||
];
|
||
|
||
const externalPlatformPatterns = [
|
||
/订单/u,
|
||
/会员/u,
|
||
/促销/u,
|
||
/后台/u,
|
||
/公开市场/u,
|
||
/私有存档/u,
|
||
/独立存档/u,
|
||
/商城/u,
|
||
/支付/u,
|
||
/订阅/u,
|
||
/活动配置/u,
|
||
/小游戏平台/u,
|
||
/公开游戏市场/u,
|
||
/server-node/u,
|
||
/Cloudflare Worker/u,
|
||
/\bExpress\b/u,
|
||
/\bD1\b/u,
|
||
/\bR2\b/u,
|
||
];
|
||
|
||
function normalizePath(filePath) {
|
||
return filePath.replace(/\\/gu, '/');
|
||
}
|
||
|
||
function repoRelative(filePath) {
|
||
return normalizePath(relative(repoRoot, filePath));
|
||
}
|
||
|
||
function shouldInspect(filePath) {
|
||
const normalized = repoRelative(filePath);
|
||
|
||
if (excludedPrefixes.some((prefix) => normalized.startsWith(prefix))) {
|
||
return false;
|
||
}
|
||
|
||
const fileName = basename(filePath);
|
||
if (textFileNames.has(fileName)) {
|
||
return true;
|
||
}
|
||
|
||
return textExtensions.has(extname(fileName).toLowerCase());
|
||
}
|
||
|
||
function listTextFiles(targetPath) {
|
||
const fullPath = join(repoRoot, targetPath);
|
||
|
||
if (!existsSync(fullPath)) {
|
||
return [];
|
||
}
|
||
|
||
const stat = statSync(fullPath);
|
||
if (stat.isFile()) {
|
||
return shouldInspect(fullPath) ? [fullPath] : [];
|
||
}
|
||
|
||
const files = [];
|
||
const walk = (dir) => {
|
||
for (const name of readdirSync(dir)) {
|
||
const child = join(dir, name);
|
||
const childStat = statSync(child);
|
||
|
||
if (childStat.isDirectory()) {
|
||
walk(child);
|
||
continue;
|
||
}
|
||
|
||
if (shouldInspect(child)) {
|
||
files.push(child);
|
||
}
|
||
}
|
||
};
|
||
|
||
walk(fullPath);
|
||
return files;
|
||
}
|
||
|
||
function collectFiles(targets) {
|
||
return [...new Set(targets.flatMap(listTextFiles))].sort();
|
||
}
|
||
|
||
function collectLineHits(files, matcher) {
|
||
const hits = [];
|
||
|
||
for (const file of files) {
|
||
const lines = readFileSync(file, 'utf8').split(/\r?\n/u);
|
||
|
||
lines.forEach((line, index) => {
|
||
if (matcher(line)) {
|
||
hits.push({
|
||
file: repoRelative(file),
|
||
lineNumber: index + 1,
|
||
text: line.trim(),
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
return hits;
|
||
}
|
||
|
||
function hasLegacyPlaybackMarker(line) {
|
||
return legacyPlaybackTerms.some((term) => line.includes(term));
|
||
}
|
||
|
||
function hasExternalPlatformMarker(line) {
|
||
return externalPlatformPatterns.some((pattern) => pattern.test(line));
|
||
}
|
||
|
||
function formatHits(hits) {
|
||
return hits.map((hit) => ` - ${hit.file}:${hit.lineNumber} ${hit.text}`);
|
||
}
|
||
|
||
const codeFiles = collectFiles(codeTargets);
|
||
const documentFiles = collectFiles(documentTargets);
|
||
const visualNovelFiles = collectFiles(visualNovelImplementationTargets);
|
||
|
||
const codePlaybackHits = collectLineHits(codeFiles, hasLegacyPlaybackMarker);
|
||
const documentPlaybackHits = collectLineHits(documentFiles, hasLegacyPlaybackMarker);
|
||
const externalPlatformHits = collectLineHits(
|
||
visualNovelFiles,
|
||
hasExternalPlatformMarker,
|
||
);
|
||
|
||
const reportLines = [
|
||
'# VN-11 负向扫描报告',
|
||
'',
|
||
'生成日期:2026-05-07',
|
||
'',
|
||
'## 扫描范围',
|
||
'',
|
||
'- 工程代码:`src/`、`packages/shared/src/`、`server-rs/crates/`',
|
||
'- 文档与共享记忆:`docs/`、`.hermes/shared-memory/`',
|
||
'- 外部平台误入复核:视觉小说前端、service、shared contracts、Rust contracts、module、api-server、SpacetimeDB schema 与 facade 路径',
|
||
'',
|
||
'## 扫描结论',
|
||
'',
|
||
`- 工程代码回放类直出命中:${codePlaybackHits.length}`,
|
||
`- 文档 / 共享记忆回放类命中:${documentPlaybackHits.length}`,
|
||
`- 视觉小说实现路径外部平台能力疑似误入命中:${externalPlatformHits.length}`,
|
||
'',
|
||
'## 处理记录',
|
||
'',
|
||
'- 已将 `storyEngine` 回归工具的命名从 replay 语义收口为 rerun / 复测语义。',
|
||
'- 已将技能效果预览按钮的内部状态与文案从重播语义收口为重新预览语义。',
|
||
'- 已确认视觉小说工程路径未新增回放路由、DTO、表、按钮、文案、外部平台账号 / 订单 / 会员 / 促销 / 后台 / 公开市场或私有存档能力。',
|
||
'',
|
||
'## 文档命中说明',
|
||
'',
|
||
'- 文档命中来自历史旧文档、设计复盘、禁止语境、负向验收或本报告记录。VN-11 工程门禁只阻断代码路径新增能力。',
|
||
'',
|
||
'## 门禁命令',
|
||
'',
|
||
'```bash',
|
||
'npm run check:visual-novel-vn11',
|
||
'```',
|
||
'',
|
||
];
|
||
|
||
if (writeReport) {
|
||
writeFileSync(reportPath, `${reportLines.join('\n')}\n`, 'utf8');
|
||
}
|
||
|
||
const failures = [];
|
||
if (codePlaybackHits.length > 0) {
|
||
failures.push('工程代码仍存在回放类直出命中。');
|
||
}
|
||
|
||
if (externalPlatformHits.length > 0) {
|
||
failures.push('视觉小说实现路径仍存在疑似外部平台能力误入。');
|
||
}
|
||
|
||
if (failures.length > 0) {
|
||
console.error('VN-11 negative scan failed:');
|
||
for (const failure of failures) {
|
||
console.error(`- ${failure}`);
|
||
}
|
||
|
||
console.error('');
|
||
for (const hit of [...codePlaybackHits, ...externalPlatformHits]) {
|
||
console.error(`- ${hit.file}:${hit.lineNumber} ${hit.text}`);
|
||
}
|
||
|
||
process.exit(1);
|
||
}
|
||
|
||
console.log('VN-11 negative scan passed.');
|
||
console.log(`- code playback hits: ${codePlaybackHits.length}`);
|
||
console.log(`- document playback hits: ${documentPlaybackHits.length}`);
|
||
console.log(`- external platform hits in visual novel implementation: ${externalPlatformHits.length}`);
|
||
if (writeReport) {
|
||
console.log(`- report: ${repoRelative(reportPath)}`);
|
||
}
|