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('