import fs from 'node:fs'; const packageConfig = JSON.parse( fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'), ); const tauriConfig = JSON.parse( fs.readFileSync( new URL('../src-tauri/tauri.conf.json', import.meta.url), 'utf8', ), ); const defaultAppConfig = JSON.parse( fs.readFileSync( new URL('../game-creator.config.json', import.meta.url), 'utf8', ), ); const rootPackageConfig = JSON.parse( fs.readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'), ); const viteConfigSource = fs.readFileSync( new URL('../vite.config.ts', import.meta.url), 'utf8', ); const appInvokeSource = fs.readFileSync( new URL('../src/App.tsx', import.meta.url), 'utf8', ); const tauriHandlerSource = fs.readFileSync( new URL('../src-tauri/src/main.rs', import.meta.url), 'utf8', ); const tauriRustSource = readSourceTree( new URL('../src-tauri/src/', import.meta.url), '.rs', ); const sharedContractSource = fs.readFileSync( new URL( '../../../packages/shared/src/contracts/gameCreationApp.ts', import.meta.url, ), 'utf8', ); const rustSharedContractSource = fs.readFileSync( new URL( '../../../server-rs/crates/shared-contracts/src/game_creation_app.rs', import.meta.url, ), 'utf8', ); const allowedUncalledTauriCommands = []; const sourceExtensions = new Set([ '.json', '.md', '.mjs', '.rs', '.toml', '.ts', '.tsx', ]); function collectFiles(path) { const stat = fs.statSync(path); if (stat.isDirectory()) { return fs.readdirSync(path, { withFileTypes: true }).flatMap((entry) => { if (entry.name === 'node_modules' || entry.name === 'target') { return []; } return collectFiles( new URL(`${entry.name}${entry.isDirectory() ? '/' : ''}`, path), ); }); } if (sourceExtensions.has(pathnameExtension(path.pathname))) { return [path]; } return []; } function readSourceTree(path, extension) { const stat = fs.statSync(path); if (stat.isDirectory()) { return fs .readdirSync(path, { withFileTypes: true }) .sort((left, right) => left.name.localeCompare(right.name)) .map((entry) => readSourceTree( new URL(`${entry.name}${entry.isDirectory() ? '/' : ''}`, path), extension, ), ) .join('\n'); } if (pathnameExtension(path.pathname) !== extension) { return ''; } return fs.readFileSync(path, 'utf8'); } function pathnameExtension(pathname) { const index = pathname.lastIndexOf('.'); return index === -1 ? '' : pathname.slice(index); } function assertNoOpenAiApiKeys(paths) { const secretPattern = /sk-[A-Za-z0-9_-]{20,}/; for (const path of paths.flatMap((entry) => collectFiles(entry))) { const source = fs.readFileSync(path, 'utf8'); if (secretPattern.test(source)) { throw new Error( `AI game creator shell source must not contain API keys: ${path.pathname}`, ); } } } function assertNoEnvironmentConfigFallbacks(paths) { const allowedDevCheck = 'import.meta.env.DEV'; for (const path of paths.flatMap((entry) => collectFiles(entry))) { const source = fs .readFileSync(path, 'utf8') .replaceAll(allowedDevCheck, '') .replaceAll('game-creator.config.local.json', ''); if (/\bprocess\.env\b|\bdotenv\b/.test(source)) { throw new Error( `AI game creator shell must use runtime config, not environment config: ${path.pathname}`, ); } } } function assertNoNativeBrowserConfirm(paths) { for (const path of paths.flatMap((entry) => collectFiles(entry))) { const source = fs.readFileSync(path, 'utf8'); if (/\bwindow\.confirm\b/.test(source)) { throw new Error( `AI game creator shell confirmations must use in-app UI: ${path.pathname}`, ); } } } function extractConstArrayBlock(source, name) { const start = source.indexOf(`const ${name}`); if (start === -1) { throw new Error(`Missing contract array: ${name}`); } const end = source.indexOf('];', start); if (end === -1) { throw new Error(`Missing contract array end: ${name}`); } return source.slice(start, end + 2); } function parseTsCommands(source) { const block = extractConstArrayBlock(source, 'GAME_CREATION_APP_COMMANDS'); return Array.from( block.matchAll( /\{\s*id:\s*'([^']+)',\s*permission:\s*'([^']+)'\s*\}/g, ), ([, id, permission]) => ({ id, permission }), ); } function parseRustCommands(source) { const block = extractConstArrayBlock(source, 'GAME_CREATION_APP_COMMANDS'); const permissionNames = { Auto: 'auto', Confirm: 'confirm', Deny: 'deny', }; return Array.from( block.matchAll( /command\(\s*"([^"]+)",\s*GameCreationAppPermission::(Auto|Confirm|Deny)\s*\)/g, ), ([, id, permission]) => ({ id, permission: permissionNames[permission] }), ); } function parseTsCapabilities(source) { const block = extractConstArrayBlock( source, 'GAME_CREATION_AGENT_CAPABILITIES', ); return Array.from( block.matchAll( /\{\s*id:\s*'([^']+)',\s*area:\s*'([^']+)',\s*title:\s*'([^']+)',?\s*\}/g, ), ([, id, area, title]) => ({ id, area, title }), ); } function parseRustCapabilities(source) { const block = extractConstArrayBlock( source, 'GAME_CREATION_AGENT_CAPABILITIES', ); return Array.from( block.matchAll( /capability\(\s*"([^"]+)",\s*"([^"]+)",\s*"([^"]+)",?\s*\)/g, ), ([, id, area, title]) => ({ id, area, title }), ); } function assertContractRecordsMatch(label, leftRecords, rightRecords) { const normalize = (records) => records .map((record) => JSON.stringify(record)) .sort((left, right) => left.localeCompare(right)); const left = normalize(leftRecords); const right = normalize(rightRecords); if (left.length === 0 || right.length === 0) { throw new Error(`${label} parser returned no records`); } if (JSON.stringify(left) !== JSON.stringify(right)) { throw new Error( `${label} drifted between TypeScript and Rust contracts\nTS=${left.join( '\n', )}\nRust=${right.join('\n')}`, ); } } function parseAppInvokeCommandNames(source) { return Array.from( source.matchAll(/invoke(?:<[^>]*>)?\(\s*['"]([a-z0-9_]+)['"]/g), ([, command]) => command, ); } function parseTauriHandlerCommandNames(source) { const match = source.match(/tauri::generate_handler!\[([\s\S]*?)\]/); if (!match) { throw new Error('AI game creator shell Tauri handler list is missing'); } return Array.from( match[1].matchAll(/\b([a-z][a-z0-9_]+)\b/g), ([, command]) => command, ); } function parseRustFunctionNames(source) { return Array.from( source.matchAll(/\b(?:async\s+)?fn\s+([a-z][a-z0-9_]*)\s*\(/g), ([, name]) => name, ); } function assertCommandNamesSubset(label, leftNames, rightNames) { const right = new Set(rightNames); const missing = Array.from(new Set(leftNames)) .filter((name) => !right.has(name)) .sort((left, rightName) => left.localeCompare(rightName)); if (missing.length > 0) { throw new Error(`${label} missing commands: ${missing.join(', ')}`); } } assertNoOpenAiApiKeys([ new URL('../src/', import.meta.url), new URL('../scripts/', import.meta.url), new URL('../game-creator.config.json', import.meta.url), new URL('../src-tauri/src/', import.meta.url), new URL('../package.json', import.meta.url), new URL('../vite.config.ts', import.meta.url), new URL('../src-tauri/Cargo.toml', import.meta.url), new URL('../src-tauri/tauri.conf.json', import.meta.url), new URL( '../../../packages/shared/src/contracts/gameCreationApp.ts', import.meta.url, ), new URL( '../../../packages/shared/src/contracts/gameCreationApp.test.ts', import.meta.url, ), new URL( '../../../server-rs/crates/platform-agent/src/game_creation.rs', import.meta.url, ), new URL( '../../../server-rs/crates/shared-contracts/src/game_creation_app.rs', import.meta.url, ), new URL( '../../../docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md', import.meta.url, ), ]); assertNoEnvironmentConfigFallbacks([ new URL('../src/', import.meta.url), new URL('../scripts/run-cli-with-config.mjs', import.meta.url), new URL('../scripts/smoke-agent-run-local-provider.mjs', import.meta.url), new URL('../scripts/start-dev-server.mjs', import.meta.url), new URL('../src-tauri/src/', import.meta.url), new URL('../package.json', import.meta.url), new URL('../vite.config.ts', import.meta.url), new URL('../src-tauri/Cargo.toml', import.meta.url), new URL('../src-tauri/tauri.conf.json', import.meta.url), ]); assertNoNativeBrowserConfirm([new URL('../src/', import.meta.url)]); assertContractRecordsMatch( 'AI game creator shell command contract', parseTsCommands(sharedContractSource), parseRustCommands(rustSharedContractSource), ); assertContractRecordsMatch( 'AI game creator shell capability contract', parseTsCapabilities(sharedContractSource), parseRustCapabilities(rustSharedContractSource), ); assertCommandNamesSubset( 'AI game creator shell Tauri handler', parseAppInvokeCommandNames(appInvokeSource), parseTauriHandlerCommandNames(tauriHandlerSource), ); assertCommandNamesSubset( 'AI game creator shell Tauri command implementation', parseTauriHandlerCommandNames(tauriHandlerSource), parseRustFunctionNames(tauriRustSource), ); assertCommandNamesSubset( 'AI game creator shell App invoke or explicit native-only allowlist', parseTauriHandlerCommandNames(tauriHandlerSource), [ ...parseAppInvokeCommandNames(appInvokeSource), ...allowedUncalledTauriCommands, ], ); assertCommandNamesSubset( 'AI game creator shell explicit native-only allowlist', allowedUncalledTauriCommands, parseTauriHandlerCommandNames(tauriHandlerSource), ); if (packageConfig.name !== '@genarrative/ai-game-creator-shell') { throw new Error('AI game creator shell package name drifted'); } if ( packageConfig.scripts?.['llm-status'] !== 'node scripts/run-cli-with-config.mjs --llm-status' ) { throw new Error( 'AI game creator shell llm-status must use client config before checking LLM config', ); } if ( packageConfig.scripts?.['agent-run'] !== 'node scripts/run-cli-with-config.mjs --agent-run' ) { throw new Error( 'AI game creator shell agent-run must use client config before running the provider path', ); } if (tauriConfig.productName !== 'Genarrative AI Game Creator') { throw new Error('AI game creator shell productName drifted'); } if (tauriConfig.identifier !== 'world.genarrative.ai-game-creator') { throw new Error('AI game creator shell identifier drifted'); } if (tauriConfig.app?.withGlobalTauri !== true) { throw new Error( 'AI game creator shell must expose window.__TAURI__ for local commands', ); } if ( !Array.isArray(tauriConfig.app?.windows) || tauriConfig.app.windows.length !== 1 || tauriConfig.app.windows[0]?.label !== 'launcher' || tauriConfig.app.windows[0]?.url !== 'index.html?launcher' ) { throw new Error( 'AI game creator shell must start with only the launcher window', ); } if (defaultAppConfig.llm?.apiKey !== '') { throw new Error('AI game creator shell default llm.apiKey must stay empty'); } if (defaultAppConfig.editorApi?.apiKey !== '') { throw new Error( 'AI game creator shell default editorApi.apiKey must stay empty', ); } if ( defaultAppConfig.llm?.requestTimeoutMs < 1000 || defaultAppConfig.llm?.maxRetries < 0 || defaultAppConfig.llm?.retryBackoffMs < 1 ) { throw new Error('AI game creator shell default LLM timing config is invalid'); } const windows = tauriConfig.app?.windows ?? []; if ( windows.length !== 1 || windows[0]?.label !== 'launcher' || windows[0]?.url !== 'index.html?launcher' ) { throw new Error( 'AI game creator shell release config must expose only the launcher window', ); } const launcherWindow = windows[0]; if ( launcherWindow.width !== 820 || launcherWindow.height !== 640 || launcherWindow.minWidth !== 720 || launcherWindow.minHeight !== 520 ) { throw new Error('AI game creator shell launcher window must stay compact'); } if (tauriConfig.build?.devUrl !== 'http://127.0.0.1:3080/') { throw new Error( 'AI game creator shell Tauri devUrl must stay on the fixed Vite dev port', ); } if (!viteConfigSource.includes("host: '127.0.0.1'")) { throw new Error( 'AI game creator shell Vite dev server must bind to localhost', ); } if (!viteConfigSource.includes('port: 3080')) { throw new Error( 'AI game creator shell Vite dev port must match Tauri devUrl', ); } if (!viteConfigSource.includes('strictPort: true')) { throw new Error( 'AI game creator shell Vite dev server must not drift away from Tauri devUrl', ); } if ( !( tauriConfig.build?.beforeDevCommand?.includes( 'run ai-game-creator-shell:dev-server', ) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve') ) ) { throw new Error( 'AI game creator shell beforeDevCommand must reuse or start the fixed Vite dev server', ); } if ( !tauriConfig.build?.beforeBuildCommand?.includes('--config vite.config.ts') ) { throw new Error( 'AI game creator shell beforeBuildCommand must resolve vite config from app root', ); } const devServerSource = fs.readFileSync( new URL('../scripts/start-dev-server.mjs', import.meta.url), 'utf8', ); const runCliWithConfigSource = fs.readFileSync( new URL('../scripts/run-cli-with-config.mjs', import.meta.url), 'utf8', ); for (const snippet of [ 'const port = 3080', "response.body.includes('AI 游戏创作')", 'function isPortListening()', 'reuse existing Vite dev server', 'non-HTTP or unrecognized server', "'--config', 'vite.config.ts'", ]) { if (!devServerSource.includes(snippet)) { throw new Error( `AI game creator shell dev server wrapper drifted: ${snippet}`, ); } } for (const snippet of [ "new URL('..', import.meta.url)", "'--manifest-path'", "'src-tauri/Cargo.toml'", ]) { if (!runCliWithConfigSource.includes(snippet)) { throw new Error( `AI game creator shell config CLI wrapper drifted: ${snippet}`, ); } } for (const snippet of [ 'const GAME_CREATOR_CONFIG_FILE_NAME: &str = "game-creator.config.json"', 'const GAME_CREATOR_LOCAL_CONFIG_FILE_NAME: &str = "game-creator.config.local.json"', 'const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json")', 'fn configure_game_creator_runtime_config_dir(', 'app.path().app_config_dir()?', 'fn load_game_creator_app_config()', 'fn read_game_creator_app_config()', 'fn write_game_creator_app_config(', 'fn writable_game_creator_config_path()', 'fn normalize_game_creator_app_config(', 'fn merge_game_creator_config_file(', '.join("apps")', '.join("ai-game-creator-shell")', 'configure_game_creator_runtime_config_dir(app.handle())?', 'read_game_creator_app_config,', 'write_game_creator_app_config,', 'resolve_game_creator_llm_config_for_agent(app_config, "planner")', 'resolve_game_creator_llm_config_for_agent(app_config, "generator")', 'build_game_creator_llm_client_from_llm_config(&planner_llm, "agentLlm.planner")?', 'build_game_creator_llm_client_from_llm_config(&generator_llm, "agentLlm.generator")?', 'agentLlm.{agent_id}', 'let app_config = match load_game_creator_app_config()', 'fn append_local_permission_log_at(', '"command.auto"', 'GameCreationAppPermission::Auto', ]) { if (!tauriRustSource.includes(snippet)) { throw new Error( `AI game creator shell developer window guardrail drifted: ${snippet}`, ); } } for (const snippet of [ 'open_developer_window(app)?;', 'tauri::WebviewWindowBuilder::new(app, "developer"', ]) { if (tauriRustSource.includes(snippet)) { throw new Error( `AI game creator shell must not auto-open developer windows: ${snippet}`, ); } } const smokeAgentRunSource = fs.readFileSync( new URL('./smoke-agent-run-local-provider.mjs', import.meta.url), 'utf8', ); for (const snippet of [ 'agentLlm', 'planner-smoke-model', 'generator-smoke-model', 'global-smoke-model-unused', ]) { if (!smokeAgentRunSource.includes(snippet)) { throw new Error( `AI game creator local-provider smoke lost per-agent LLM coverage: ${snippet}`, ); } } const appSource = fs.readFileSync( new URL('../src/App.tsx', import.meta.url), 'utf8', ); for (const snippet of [ "'game.run_local'", "'read_game_creator_app_config'", "'write_game_creator_app_config'", 'aria-label="运行时配置"', 'LLM API Key', '画板 API Key', 'runtime_config.save', "'/run:运行自检,启动本地 HTTP 预览并交给外部浏览器'", 'async function openPreviewInExternalBrowser', "'open_local_game_preview'", '已交给外部浏览器打开。', 'async function executeRunLocal', 'function needsInitializedChatProject', 'function resolvePendingCommandProjectPath', 'resolveChatProjectPath(localProject) ?? draftProjectPath', '`permission.cancel ${command.id} missing-project`', "'/remember [short|long|blackboard] 内容:追加短期、长期或黑板记忆'", "'/memory-set [short|long|blackboard] 内容:覆盖保存对应记忆'", 'function parseRememberInput', "'/trace 或 /loop:查看最近一次 Agent loop trace'", 'async function executeAgentTraceChat', "relativePath: '.agent/logs/command.log'", "'permission.pending'", "'permission.confirm'", "'permission.cancel'", "'command.auto'", "'agent.run_status'", 'function summarizeAgentRunTrace', '工具调用:${agentRunTrace.toolCallCount}/${agentRunTrace.maxToolCalls}', 'agentRunTrace.error ?', 'className="trace-error"', 'agentRunTrace.taskGraph.repairRoutes.map', "in: ${step.inputPaths.join(', ') || 'none'}", "out: ${step.outputPaths.join(', ') || 'none'}", ]) { if (!appSource.includes(snippet)) { throw new Error( `AI game creator shell trace panel guardrail drifted: ${snippet}`, ); } } for (const script of [ 'ai-game-creator-shell:dev', 'ai-game-creator-shell:dev-server', 'ai-game-creator-shell:build', 'ai-game-creator-shell:typecheck', 'ai-game-creator-shell:agent-run:smoke', 'ai-game-creator-shell:check', ]) { if (!rootPackageConfig.scripts?.[script]) { throw new Error(`root package missing ${script}`); } } if ( rootPackageConfig.scripts?.['ai-game-creator-shell:build'] !== 'npm --prefix apps/ai-game-creator-shell run build --' ) { throw new Error( 'root ai-game-creator-shell:build script must forward build args', ); } const agentRunSmokeSource = fs.readFileSync( new URL('../scripts/smoke-agent-run-local-provider.mjs', import.meta.url), 'utf8', ); for (const snippet of [ "const smokeAssetMarker = 'SMOKE_LOCAL_ASSET:chef'", "const smokeAudioAssetPath = 'assets/uploads/smoke-bounce.mp3'", "mediaType: 'audio/mpeg'", 'document.body.dataset.smokeFrame = String(frameCount)', "ctx.fillStyle = '#ff00ff'", 'ctx.fillRect(4, 4, 8, 24)', 'const sample = ctx.getImageData(4, 4, 24, 24).data', 'chef.complete && chef.naturalWidth > 0', 'document.body.dataset.smokeCanvasPixels = String(litPixels)', 'document.body.dataset.smokeCanvasColors = String(colors.size)', 'readBrowserDom(previewUrl)', "extractDomNumber(previewDom, 'smoke-canvas-pixels')", '--dump-dom', 'readHttpHead(new URL(smokeAssetPath, previewUrl).toString())', 'readHttpHead(new URL(smokeAudioAssetPath, previewUrl).toString())', 'function writeStreamingChatCompletion', 'requestJson?.stream === true', `requestBodies.every((body) => body.includes('"stream":true'))`, "const localConfigPath = path.join(appRoot, 'game-creator.config.local.json')", "apiKind: 'openai_chat'", 'stream: true', 'await restoreOptionalFile(localConfigPath, previousLocalConfig)', "method: 'HEAD'", 'previewAssetHead.contentLength === String(smokeAssetBytes.length)', "previewAudioHead.contentType === 'audio/mpeg'", "previewAssetHead.body === ''", 'trace missing professional group ${group}', ]) { if (!agentRunSmokeSource.includes(snippet)) { throw new Error( `AI game creator shell agent-run smoke drifted: ${snippet}`, ); } }