#!/usr/bin/env node import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { createRequire } from "node:module"; import { execFileSync } from "node:child_process"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; function parseArgs(args) { const result = { projectRoot: process.cwd(), toolchainRoot: null, expectation: "fixed", skipRuntime: false, }; for (let index = 0; index < args.length; index += 1) { const argument = args[index]; if (argument === "--project-root") result.projectRoot = args[++index]; else if (argument.startsWith("--project-root=")) result.projectRoot = argument.slice("--project-root=".length); else if (argument === "--toolchain-root") result.toolchainRoot = args[++index]; else if (argument.startsWith("--toolchain-root=")) result.toolchainRoot = argument.slice("--toolchain-root=".length); else if (argument === "--expect") result.expectation = args[++index]; else if (argument.startsWith("--expect=")) result.expectation = argument.slice("--expect=".length); else if (argument === "--skip-runtime") result.skipRuntime = true; else if (argument === "--help" || argument === "-h") { console.log("Usage: node scripts/check-wow-talent-gate-types.mjs [--project-root DIR] [--toolchain-root DIR] [--expect baseline|fixed] [--skip-runtime]"); process.exit(0); } else { throw new Error(`Unknown argument: ${argument}`); } } assert.ok(["baseline", "fixed"].includes(result.expectation), `invalid --expect value: ${result.expectation}`); result.projectRoot = path.resolve(result.projectRoot); result.toolchainRoot = path.resolve(result.toolchainRoot ?? result.projectRoot); return result; } function typecheckTalentCatalog(projectRoot, toolchainRoot) { const requireFromToolchain = createRequire(path.join(toolchainRoot, "package.json")); const ts = requireFromToolchain("typescript"); const catalogPath = path.join(projectRoot, "src", "game", "wow335AbilityCatalog.ts"); const viteClientPathCandidates = [ path.join(projectRoot, "node_modules", "vite", "client.d.ts"), path.join(toolchainRoot, "node_modules", "vite", "client.d.ts"), ]; const viteClientPath = viteClientPathCandidates.find(existsSync); assert.ok(viteClientPath, "strict catalog check requires Vite's client.d.ts in the project or toolchain dependency tree"); const configPath = path.join(projectRoot, "tsconfig.app.json"); let parsedOptions; if (existsSync(configPath)) { const configText = readFileSync(configPath, "utf8"); const parsedConfig = ts.parseConfigFileTextToJson(configPath, configText); assert.equal(parsedConfig.error, undefined, `could not parse ${configPath}`); const parsed = ts.parseJsonConfigFileContent(parsedConfig.config, ts.sys, projectRoot, undefined, configPath); assert.equal(parsed.errors.length, 0, `could not resolve ${configPath}: ${parsed.errors.map((error) => ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("; ")}`); parsedOptions = parsed.options; } else { parsedOptions = { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler, jsx: ts.JsxEmit.ReactJSX, resolveJsonModule: true, allowImportingTsExtensions: true, paths: {}, }; } const shimDirectory = mkdtempSync(path.join(os.tmpdir(), "wow-talent-gate-types-")); const aliasDeclarationPath = path.join(shimDirectory, "dungeonAssetModules.d.ts"); writeFileSync(aliasDeclarationPath, [ 'declare module "@dungeonAssetModules" {', ' export const dungeonModules: Readonly>;', '}', '', ].join("\n")); try { const compilerOptions = { ...parsedOptions, strict: true, noEmit: true, skipLibCheck: true, incremental: false, composite: false, types: ["node"], baseUrl: projectRoot, typeRoots: [ path.join(projectRoot, "node_modules", "@types"), path.join(toolchainRoot, "node_modules", "@types"), ], paths: { ...(parsedOptions.paths ?? {}) }, }; delete compilerOptions.paths["@dungeonAssetModules"]; const rootNames = [catalogPath, viteClientPath, aliasDeclarationPath]; const program = ts.createProgram({ rootNames, options: compilerOptions }); const diagnostics = ts.getPreEmitDiagnostics(program).filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error); const simplified = diagnostics.map((diagnostic) => { const location = diagnostic.file && diagnostic.start !== undefined ? diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start) : undefined; const sourceLine = diagnostic.file && location ? diagnostic.file.text.split(/\r?\n/u)[location.line] : undefined; return { code: diagnostic.code, file: diagnostic.file ? path.relative(projectRoot, diagnostic.file.fileName).split(path.sep).join("/") : null, line: location ? location.line + 1 : null, message: ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), sourceLine: sourceLine?.trim() ?? null, }; }); return { typescriptVersion: ts.version, diagnostics: simplified }; } finally { rmSync(shimDirectory, { recursive: true, force: true }); } } function runTalentRuntimeRegressions(projectRoot, toolchainRoot) { assert.ok(existsSync(path.join(projectRoot, "vite.config.ts")), "focused runtime checks require vite.config.ts; use --skip-runtime for a source-only mirror without it"); assert.ok(existsSync(path.join(projectRoot, "src", "game", "wow335AbilityCatalog.cold-start.fixture.ts")), "focused runtime checks require the cold-start fixture; use --skip-runtime when it is not present"); const requireFromToolchain = createRequire(path.join(toolchainRoot, "package.json")); const vitestPath = requireFromToolchain.resolve("vitest/vitest.mjs"); const testPath = path.join("src", "game", "wow335AbilityCatalog.test.ts"); const matchingTests = [ "preserves talent gates across a cold native import and a later talent preload", "uses legal talent gates for learned builds and leaves source-only nodes unlocked", "uses the same legal talent-node set as the full catalog", ]; const testPattern = matchingTests.join("|"); try { const output = execFileSync(process.execPath, [ vitestPath, "run", testPath, "--config", "vite.config.ts", "--maxWorkers=1", "--testTimeout=15000", "--testNamePattern", testPattern, ], { cwd: projectRoot, encoding: "utf8", stdio: "pipe", maxBuffer: 8 * 1024 * 1024, }); const passedMatch = output.match(/Tests\s+(\d+)\s+passed/u); assert.ok(passedMatch, `Vitest did not report a passing test count:\n${output}`); assert.equal(Number(passedMatch[1]), matchingTests.length, `expected all focused talent gate tests to run:\n${output}`); return { passed: Number(passedMatch[1]), tests: matchingTests, output: output.trim() }; } catch (error) { const stdout = error.stdout?.toString() ?? ""; const stderr = error.stderr?.toString() ?? ""; throw new Error(`focused talent runtime tests failed\n${stdout}\n${stderr}`, { cause: error }); } } const args = parseArgs(process.argv.slice(2)); const projectRoot = args.projectRoot; const toolchainRoot = args.toolchainRoot; const catalogPath = path.join(projectRoot, "src", "game", "wow335AbilityCatalog.ts"); const catalogText = readFileSync(catalogPath, "utf8"); assert.match(catalogText, /SOURCE_WOW335_TALENT_NODE_IDS\.has\(chain\.talentNodeId\)/u, "the actual source catalog must retain the legal-node gate"); const typecheck = typecheckTalentCatalog(projectRoot, toolchainRoot); const expectedDiagnostic = typecheck.diagnostics.length === 1 && typecheck.diagnostics[0].code === 2345 && typecheck.diagnostics[0].file === "src/game/wow335AbilityCatalog.ts" && typecheck.diagnostics[0].sourceLine?.includes("SOURCE_WOW335_TALENT_NODE_IDS.has(chain.talentNodeId)"); if (args.expectation === "baseline") { assert.ok(expectedDiagnostic, `expected exactly the pre-fix TS2345 at the actual talent Set gate; got ${JSON.stringify(typecheck.diagnostics, null, 2)}`); } else { assert.deepEqual(typecheck.diagnostics, [], `the corrected catalog and its focused dependency graph must type-check cleanly`); } const runtime = args.skipRuntime ? null : runTalentRuntimeRegressions(projectRoot, toolchainRoot); const result = { projectRoot, toolchainRoot, catalogSha256: createHash("sha256").update(catalogText).digest("hex"), expectation: args.expectation, typecheck: { typescriptVersion: typecheck.typescriptVersion, strict: true, diagnostics: typecheck.diagnostics, result: expectedDiagnostic ? "expected-baseline-ts2345" : "clean", }, runtime, limits: [ "Strict-checks the actual catalog and its imported TypeScript dependency graph, with only the bundler's dungeon asset glob import given its declared Readonly> type.", "Runs only the three legal talent gate, source-only exemption, and cold/warm preload regressions; it does not run a full project build or balance sweep.", ], }; console.log(JSON.stringify(result, null, 2));