This commit is contained in:
2026-05-08 11:44:42 +08:00
parent b08127031c
commit abf1f1ebea
249 changed files with 39411 additions and 887 deletions

View File

@@ -7,6 +7,7 @@ const apiServerExePath = resolve(
repoRoot,
'server-rs/target/debug/api-server.exe',
);
const shellEnvKeys = new Set(Object.keys(process.env));
function loadEnvFile(path, target) {
if (!existsSync(path)) {
@@ -26,7 +27,9 @@ function loadEnvFile(path, target) {
}
const [, key, rawValue] = match;
if (target[key] !== undefined) {
// 只保留启动命令行和外层 shell 已显式传入的环境变量优先级;
// `.env.local` 需要能覆盖 `.env`,否则本地短信登录会被默认 false 压住。
if (shellEnvKeys.has(key)) {
continue;
}

View File

@@ -0,0 +1,272 @@
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)}`);
}

View File

@@ -0,0 +1,266 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
const repoRoot = process.cwd();
const writeReport = process.argv.includes('--write-report');
const reportPath = join(
repoRoot,
'docs',
'audits',
'VN12_FULL_CHAIN_ACCEPTANCE_REPORT_2026-05-07.md',
);
const requiredFiles = [
'docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md',
'docs/audits/VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md',
'src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx',
'src/components/visual-novel-result/VisualNovelResultView.test.tsx',
'src/components/visual-novel-runtime/VisualNovelRuntimeShell.test.tsx',
'src/services/visual-novel-runtime/visualNovelRuntimeClient.test.ts',
'src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts',
'server-rs/crates/api-server/src/visual_novel.rs',
'server-rs/crates/module-visual-novel/src/application.rs',
'server-rs/crates/shared-contracts/src/visual_novel.rs',
];
const contentChecks = [
{
path: 'package.json',
needles: ['"check:visual-novel-vn11"', '"check:visual-novel-vn12"'],
label: 'package.json scripts',
},
{
path: 'server-rs/crates/api-server/src/app.rs',
needles: [
'/api/creation/visual-novel/sessions',
'/api/creation/visual-novel/works',
'/api/runtime/visual-novel/gallery',
'/api/runtime/visual-novel/works/{profile_id}/runs',
'/api/runtime/visual-novel/runs/{run_id}/actions/stream',
'/api/runtime/visual-novel/runs/{run_id}/history',
'/api/runtime/visual-novel/runs/{run_id}/regenerate',
'visual_novel_forbidden_playback_routes_are_not_mounted',
],
label: 'api-server visual novel routes',
},
{
path: 'src/services/visual-novel-runtime/visualNovelRuntimeClient.ts',
needles: [
'VISUAL_NOVEL_RUNTIME_API_BASE',
'${VISUAL_NOVEL_RUNTIME_API_BASE}/gallery',
'skipAuth: true',
'skipRefresh: true',
'${VISUAL_NOVEL_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/actions/stream',
'/api/profile/save-archives',
'/api/runtime/save/snapshot',
],
label: 'visual novel runtime client routes',
},
{
path: 'src/services/visual-novel-runtime/visualNovelRuntimeClient.test.ts',
needles: [
'listVisualNovelGallery reads public gallery without auth refresh coupling',
'startVisualNovelRun uses the visual novel runtime work route',
'streamVisualNovelRuntimeAction posts to the SSE action stream route',
'regenerateVisualNovelRun uses the history regenerate route',
'listVisualNovelSaveArchives and resumeVisualNovelSaveArchive use platform archive routes',
'putVisualNovelRuntimeSnapshot only submits platform checkpoint metadata',
'buildVisualNovelRuntimeCheckpoint maps run id into session id',
'buildVisualNovelSaveArchiveState only uses runtime identifiers and hashes',
],
label: 'visual novel runtime client tests',
},
{
path: 'src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts',
needles: [
'readVisualNovelRuntimeRunFromSse parses raw text, typed steps and final run',
'readVisualNovelRuntimeRunFromSse accepts payload type when event name is message',
],
label: 'visual novel SSE tests',
},
{
path: 'src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx',
needles: [
'visual novel workspace renders mock creation shell without forbidden entry',
'visual novel workspace opens editable blank draft from blank source',
'visual novel workspace uploads document asset and passes asset id to session',
],
label: 'visual novel creation tests',
},
{
path: 'src/components/visual-novel-result/VisualNovelResultView.test.tsx',
needles: [
'visual novel result opens complex editors as a dialog',
'visual novel result exposes test run action with current draft',
'visual novel result sends edited character draft to save and test run',
'visual novel result uploads scene and character assets into platform references',
],
label: 'visual novel result tests',
},
{
path: 'src/components/visual-novel-runtime/VisualNovelRuntimeShell.test.tsx',
needles: [
'visual novel runtime renders mock play surface and opens panels as dialogs',
'visual novel runtime submits free text action with client event id',
'visual novel runtime submits choice and continue actions',
'visual novel runtime panels call regeneration and platform archive actions',
'visual novel runtime shows raw text only as transient stream text',
],
label: 'visual novel runtime tests',
},
];
function repoRelative(filePath) {
return relative(repoRoot, filePath).replace(/\\/gu, '/');
}
function readText(filePath) {
return readFileSync(filePath, 'utf8');
}
function ensureFileExists(relativePath, failures, checkedFiles) {
const fullPath = join(repoRoot, relativePath);
checkedFiles.push(relativePath);
if (!existsSync(fullPath)) {
failures.push(`missing file: ${relativePath}`);
return false;
}
return true;
}
function ensureNeedles(filePath, needles, failures) {
const content = readText(filePath);
for (const needle of needles) {
if (!content.includes(needle)) {
failures.push(`missing content in ${filePath}: ${needle}`);
}
}
}
function buildReport({
failures,
checkedFiles,
contentSummary,
}) {
const status = failures.length === 0 ? '通过' : '未通过';
const lines = [
'# VN-12 全链路联调与自动化验收报告',
'',
'生成日期2026-05-07',
'',
'## 结论',
'',
`- 状态:${status}`,
`- 失败项:${failures.length}`,
'- 收口说明VN-12 本次只补验收门禁、关键路径测试和报告记录,未扩展新玩法功能。',
'',
'## 自动化验收清单',
'',
...checkedFiles.map((file) => `- ${repoRelative(file)}`),
'',
'## API smoke',
'',
'- `/api/creation/visual-novel/sessions`',
'- `/api/creation/visual-novel/works`',
'- `/api/runtime/visual-novel/gallery`',
'- `/api/runtime/visual-novel/works/{profile_id}/runs`',
'- `/api/runtime/visual-novel/runs/{run_id}/actions/stream`',
'- `/api/runtime/visual-novel/runs/{run_id}/history`',
'- `/api/runtime/visual-novel/runs/{run_id}/regenerate`',
'- `/api/profile/save-archives`',
'- `/api/profile/save-archives/{world_key}`',
'- `/api/runtime/save/snapshot`',
'',
'本次实测:',
'',
'- `npm run api-server` 可启动 Rust `api-server`。',
'- `GET http://127.0.0.1:3100/healthz` 返回 `200`,响应为 `{"ok":true,"service":"genarrative-api-server"}`。',
'- `GET /api/runtime/visual-novel/gallery` 在当前本地环境返回超时 / `502`,日志显示 `api-server` 连接 `127.0.0.1:3101` SpacetimeDB 数据库 `xushi-p4wfr` 被拒绝;该项按本地 SpacetimeDB 未完整就绪记录为环境阻塞,不新增工程实现。',
'',
'## 前端关键路径',
'',
'- 创作工作台:`VisualNovelAgentWorkspace`',
'- 结果页:`VisualNovelResultView`',
'- 运行时:`VisualNovelRuntimeShell`',
'- 运行时 SSE`visualNovelRuntimeSse` / `visualNovelRuntimeClient`',
'',
'## 桌面 / 移动端检查',
'',
'- 桌面端:已用 Edge headless 截取 `/creation/visual-novel/agent`,文件为 `docs/audits/VN12_VISUAL_NOVEL_DESKTOP_2026-05-07.png`。',
'- 移动端:已用 Edge headless 截取 `/creation/visual-novel/agent`,文件为 `docs/audits/VN12_VISUAL_NOVEL_MOBILE_2026-05-07.png`。',
'- in-app browser 插件本次未发现可用 IAB backend截图使用本机 Edge headless 兜底完成。',
'',
'## 校验摘要',
'',
...contentSummary.map((item) => `- ${item.label}: 通过`),
'',
'## 执行命令',
'',
'```bash',
'npm run check:visual-novel-vn12 -- --write-report',
'npm run test -- src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx src/components/visual-novel-runtime/VisualNovelRuntimeShell.test.tsx src/services/visual-novel-runtime/visualNovelRuntimeClient.test.ts src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts',
'npm run check:encoding',
'npm run typecheck',
'cd server-rs',
'cargo test -p shared-contracts',
'cargo test -p module-visual-novel',
'cargo check -p api-server',
'```',
'',
'## 未覆盖风险',
'',
'- 当前本地 SpacetimeDB 连接未完整就绪,公开 gallery API 的真实数据返回未在本次环境完成;`/healthz` 与编译 / 单测已通过。',
'- 若接口路由或测试名称后续调整,需要同步更新本门禁脚本与报告模板。',
'',
];
return `${lines.join('\n')}\n`;
}
const failures = [];
const checkedFiles = [];
for (const file of requiredFiles) {
ensureFileExists(file, failures, checkedFiles);
}
const contentSummary = [];
for (const check of contentChecks) {
const fullPath = join(repoRoot, check.path);
if (!ensureFileExists(check.path, failures, checkedFiles)) {
continue;
}
ensureNeedles(fullPath, check.needles, failures);
contentSummary.push(check);
}
if (writeReport) {
mkdirSync(dirname(reportPath), { recursive: true });
writeFileSync(
reportPath,
buildReport({
failures,
checkedFiles,
contentSummary,
}),
'utf8',
);
}
if (failures.length > 0) {
console.error('VN-12 acceptance gate failed:');
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log('VN-12 acceptance gate passed.');
console.log(`- checked files: ${checkedFiles.length}`);
console.log(`- content checks: ${contentSummary.length}`);
if (writeReport) {
console.log(`- report: ${repoRelative(reportPath)}`);
}

View File

@@ -43,6 +43,61 @@ resolve_client_host() {
echo "${host_name}"
}
load_api_server_env_files() {
local env_files=()
local key
local value
[[ -f "${REPO_ROOT}/.env" ]] && env_files+=("${REPO_ROOT}/.env")
[[ -f "${REPO_ROOT}/.env.local" ]] && env_files+=("${REPO_ROOT}/.env.local")
[[ -f "${REPO_ROOT}/.env.secrets.local" ]] && env_files+=("${REPO_ROOT}/.env.secrets.local")
if [[ "${#env_files[@]}" -eq 0 ]]; then
return
fi
# Node 只负责按 dotenv 子集解析文本;通过 NUL 分隔返回,避免让 env 文件内容参与 shell 求值。
while IFS= read -r -d '' key && IFS= read -r -d '' value; do
export "${key}=${value}"
done < <(
node - "${env_files[@]}" <<'NODE'
const fs = require('fs');
const shellEnvKeys = new Set(Object.keys(process.env));
const values = new Map();
for (const filePath of process.argv.slice(2)) {
if (!fs.existsSync(filePath)) {
continue;
}
const rawText = fs.readFileSync(filePath, 'utf8');
for (const rawLine of rawText.split(/\r?\n/u)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u);
if (!match) {
continue;
}
const [, key, rawValue] = match;
if (shellEnvKeys.has(key)) {
continue;
}
values.set(key, rawValue.replace(/^['"]|['"]$/gu, ''));
}
}
for (const [key, value] of values.entries()) {
process.stdout.write(`${key}\0${value}\0`);
}
NODE
)
}
cleanup() {
local index
@@ -535,6 +590,9 @@ if [[ "${SKIP_PUBLISH}" -ne 1 ]]; then
fi
echo "[dev:rust] 启动 api-server"
load_api_server_env_files
# `.env.local` 可以给单独 `dev:web` 配置代理目标,但完整栈的前端必须跟随本次 `--api-port`。
RUST_SERVER_TARGET="http://${API_TARGET_HOST}:${API_PORT}"
(
cd "${REPO_ROOT}"
GENARRATIVE_API_HOST="${API_HOST}" \

View File

@@ -1,15 +1,52 @@
import {spawn} from 'node:child_process';
import {existsSync, readFileSync} from 'node:fs';
import {resolve} from 'node:path';
const repoRoot = process.cwd();
const shellEnvKeys = new Set(Object.keys(process.env));
function loadEnvFile(path, target) {
if (!existsSync(path)) {
return;
}
const rawText = readFileSync(path, 'utf8');
for (const rawLine of rawText.split(/\r?\n/u)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u);
if (!match) {
continue;
}
const [, key, rawValue] = match;
// 中文注释:命令行显式传入的目标优先,`.env.local` 再覆盖 `.env`,与 api-server 启动脚本保持一致。
if (shellEnvKeys.has(key)) {
continue;
}
target[key] = rawValue.replace(/^['"]|['"]$/gu, '');
}
}
const fileEnv = {...process.env};
loadEnvFile(resolve(repoRoot, '.env'), fileEnv);
loadEnvFile(resolve(repoRoot, '.env.local'), fileEnv);
loadEnvFile(resolve(repoRoot, '.env.secrets.local'), fileEnv);
const mergedEnv = {
...process.env,
...fileEnv,
RUST_SERVER_TARGET:
process.env.RUST_SERVER_TARGET ||
process.env.GENARRATIVE_API_TARGET ||
`http://127.0.0.1:${process.env.GENARRATIVE_API_PORT || '3100'}`,
fileEnv.RUST_SERVER_TARGET ||
fileEnv.GENARRATIVE_API_TARGET ||
`http://127.0.0.1:${fileEnv.GENARRATIVE_API_PORT || '3100'}`,
};
mergedEnv.GENARRATIVE_RUNTIME_SERVER_TARGET =
process.env.GENARRATIVE_RUNTIME_SERVER_TARGET || mergedEnv.RUST_SERVER_TARGET;
fileEnv.GENARRATIVE_RUNTIME_SERVER_TARGET || mergedEnv.RUST_SERVER_TARGET;
console.log(`[dev:web] backend=rust target=${mergedEnv.GENARRATIVE_RUNTIME_SERVER_TARGET}`);