#!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { spawnSync } from "node:child_process"; import { builtinModules } from "node:module"; import { fileURLToPath } from "node:url"; const filename = fileURLToPath(import.meta.url); const dirname = path.dirname(filename); const root = path.resolve(dirname, "../.."); const args = new Set(process.argv.slice(2)); const strict = args.has("--strict"); const runCommands = args.has("--run-commands"); if (args.has("--help") || args.has("-h")) { console.log(`Fan Passport release preflight Usage: node scripts/qa/release-preflight.mjs [--strict] [--run-commands] Checks performed: - Required demo, QA, documentation, and release files exist. - Workspace manifests parse as JSON and expose expected scripts. - Environment samples do not contain obvious secret values. - Source and documentation files do not contain merge-conflict markers. - Relative source imports resolve to delivered files. - Bare package imports are declared in workspace manifests or root dev tooling. - README and release documentation cover the public demo path. Options: --strict Treat advisory wording findings as release blockers. --run-commands Run selected local QA scripts after static checks. `); process.exit(0); } const results = []; function record(level, title, detail = "", options = {}) { results.push({ level, title, detail, strict: Boolean(options.strict) }); } function pass(title, detail = "") { record("pass", title, detail); } function warn(title, detail = "", options = {}) { record("warn", title, detail, options); } function fail(title, detail = "") { record("fail", title, detail); } function resolveRoot(relativePath) { return path.resolve(root, relativePath); } function relativeToRoot(absolutePath) { return path.relative(root, absolutePath).split(path.sep).join("/"); } function existsRelative(relativePath) { try { return fs.statSync(resolveRoot(relativePath)).isFile(); } catch { return false; } } function existsAbsolute(absolutePath) { try { return fs.statSync(absolutePath).isFile(); } catch { return false; } } function directoryExistsAbsolute(absolutePath) { try { return fs.statSync(absolutePath).isDirectory(); } catch { return false; } } function readText(relativePath) { const absolutePath = resolveRoot(relativePath); if (!existsAbsolute(absolutePath)) return ""; return fs.readFileSync(absolutePath, "utf8"); } function readTextAbsolute(absolutePath) { return fs.readFileSync(absolutePath, "utf8"); } function formatList(items, max = 12) { if (items.length === 0) return ""; const visible = items.slice(0, max).map((item) => `- ${item}`).join("\n"); if (items.length <= max) return visible; return `${visible}\n- ...and ${items.length - max} more`; } const requiredFiles = [ ["package.json", "root npm workspace manifest"], ["tsconfig.base.json", "shared TypeScript compiler baseline"], [".env.example", "root environment sample"], ["README.md", "local setup and demo guide"], ["playwright.config.ts", "browser automation configuration"], ["tests/e2e/demo.spec.ts", "end-to-end demo test"], ["packages/shared/package.json", "shared package manifest"], ["packages/shared/src/index.ts", "shared cross-stack types"], ["apps/api/package.json", "API package manifest"], ["apps/api/.env.example", "API environment sample"], ["apps/api/src/app.ts", "API application entry"], ["apps/api/src/routes.ts", "API route wiring"], ["apps/api/src/gamification.ts", "gamification engine"], ["apps/api/src/store.ts", "demo data store"], ["apps/api/src/__tests__/passport.integration.test.ts", "API integration tests"], ["apps/web/package.json", "web package manifest"], ["apps/web/.env.example", "web environment sample"], ["apps/web/src/main.tsx", "web app entry"], ["apps/web/src/App.tsx", "integrated web experience"], ["apps/web/src/api/client.ts", "frontend API client"], ["apps/web/src/styles.css", "responsive demo styling"], ["apps/web/src/__tests__/App.test.tsx", "web component tests"], ["docs/demo-script.md", "live demo script"], ["docs/qa-plan.md", "QA plan"], ["docs/deployment.md", "deployment guide"], ["docs/release-checklist.md", "release checklist"], ["docs/crowdfunding-update.md", "public update copy"], ["docs/full-stack-acceptance-matrix.md", "acceptance matrix"], ["docs/manual-qa-scenarios.md", "manual QA scenarios"], ["docs/accessibility-responsive-audit.md", "accessibility and responsive audit"], ["docs/edge-case-and-failure-modes.md", "edge-case catalogue"], ["docs/demo-release-runbook.md", "demo release runbook"], ["docs/docker-demo.md", "containerized demo guide"], ["docs/observability-and-analytics.md", "analytics and observability plan"], ["docs/support-triage-playbook.md", "support triage playbook"], ["docs/demo-video-shot-list.md", "crowdfunding video capture plan"], ["docs/final-self-audit.md", "final build hygiene self-audit"], ["scripts/qa/http-smoke.mjs", "HTTP smoke test"], ["scripts/qa/a11y-static-check.mjs", "static accessibility check"], ["scripts/qa/release-preflight.mjs", "release preflight check"], [".github/workflows/ci.yml", "CI workflow"], ["Dockerfile.demo", "demo container build"], ["docker-compose.demo.yml", "local container orchestration"], [".dockerignore", "container build ignore rules"] ]; for (const [file, reason] of requiredFiles) { if (existsRelative(file)) { pass(`Required file present: ${file}`, reason); } else { fail(`Required file missing: ${file}`, reason); } } const dependencyFields = [ "dependencies", "devDependencies", "peerDependencies", "optionalDependencies" ]; function readJson(relativePath) { if (!existsRelative(relativePath)) { fail(`Cannot parse JSON: ${relativePath}`, "File is missing."); return null; } try { const parsed = JSON.parse(readText(relativePath)); pass(`Valid JSON: ${relativePath}`); return parsed; } catch (error) { fail(`Invalid JSON: ${relativePath}`, error instanceof Error ? error.message : String(error)); return null; } } function collectDependencies(manifest) { const dependencies = new Map(); for (const field of dependencyFields) { const values = manifest?.[field]; if (!values || typeof values !== "object" || Array.isArray(values)) continue; for (const [name, version] of Object.entries(values)) { dependencies.set(name, String(version)); } } return dependencies; } const manifestFiles = [ "package.json", "packages/shared/package.json", "apps/api/package.json", "apps/web/package.json" ]; const manifests = new Map(); for (const relativePath of manifestFiles) { const manifest = readJson(relativePath); if (manifest) { manifests.set(resolveRoot(path.dirname(relativePath)), { relativePath, manifest, dependencies: collectDependencies(manifest) }); } } const rootManifestRecord = manifests.get(root); const rootManifest = rootManifestRecord?.manifest ?? null; const localPackageNames = new Set(); for (const { manifest } of manifests.values()) { if (typeof manifest.name === "string" && manifest.name.length > 0) { localPackageNames.add(manifest.name); } } function workspacePatternsFromManifest(manifest) { if (!manifest) return []; if (Array.isArray(manifest.workspaces)) return manifest.workspaces; if (Array.isArray(manifest.workspaces?.packages)) return manifest.workspaces.packages; return []; } const workspacePatterns = workspacePatternsFromManifest(rootManifest); if (workspacePatterns.length > 0) { pass("Root manifest declares npm workspaces", workspacePatterns.join(", ")); const hasApps = workspacePatterns.some((pattern) => pattern.includes("apps")); const hasPackages = workspacePatterns.some((pattern) => pattern.includes("packages")); if (!hasApps || !hasPackages) { warn( "Workspace patterns should include apps and packages", `Current patterns: ${workspacePatterns.join(", ")}` ); } } else { warn("Root manifest does not declare npm workspaces", "The demo is expected to install API, web, and shared packages together."); } function hasAnyScript(manifest, candidates) { const scripts = manifest?.scripts ?? {}; return candidates.some((candidate) => Object.prototype.hasOwnProperty.call(scripts, candidate)); } const recommendedScripts = [ ["package.json", ["dev"], "root local development command"], ["package.json", ["build"], "root build command"], ["package.json", ["test"], "root test command"], ["package.json", ["e2e", "test:e2e"], "root end-to-end command"], ["apps/api/package.json", ["dev", "start"], "API runnable command"], ["apps/api/package.json", ["test"], "API integration test command"], ["apps/web/package.json", ["dev"], "web development command"], ["apps/web/package.json", ["build"], "web production build command"], ["apps/web/package.json", ["preview"], "web preview command"], ["apps/web/package.json", ["test"], "web test command"], ["packages/shared/package.json", ["build", "typecheck"], "shared package validation command"] ]; for (const [manifestPath, candidates, reason] of recommendedScripts) { const recordForManifest = manifests.get(resolveRoot(path.dirname(manifestPath))); if (!recordForManifest) continue; if (hasAnyScript(recordForManifest.manifest, candidates)) { pass(`Recommended script available in ${manifestPath}`, `${reason}: ${candidates.join(" or ")}`); } else { warn( `Recommended script missing in ${manifestPath}`, `${reason}; expected one of: ${candidates.join(", ")}` ); } } function isAdvisoryVersion(name, version) { if (localPackageNames.has(name)) return false; if (/^(workspace|file|link|portal|npm):/.test(version)) return false; if (/^(latest|next)$/i.test(version)) return true; return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version); } const versionWarnings = []; for (const { relativePath, dependencies } of manifests.values()) { for (const [name, version] of dependencies.entries()) { if (isAdvisoryVersion(name, version)) { versionWarnings.push(`${relativePath}: ${name}@${version}`); } } } if (versionWarnings.length > 0) { warn( "Dependency version ranges should be flexible", `Exact or moving versions found; prefer caret/major-compatible ranges for public demo builds.\n${formatList(versionWarnings)}` ); } else { pass("Dependency version ranges look flexible"); } const envFiles = [ ".env.example", "apps/api/.env.example", "apps/web/.env.example" ]; const secretPatterns = [ ["OpenAI-style token", /sk-[A-Za-z0-9_-]{20,}/], ["Google API key shape", /AIza[0-9A-Za-z_-]{20,}/], ["private key block", /-----BEGIN (?:RSA |EC |OPENSSH |)PRIVATE KEY-----/], ["JWT-looking bearer token", /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/], ["AWS access key", /AKIA[0-9A-Z]{16}/] ]; for (const envFile of envFiles) { const text = readText(envFile); const leaks = []; for (const [label, pattern] of secretPatterns) { if (pattern.test(text)) leaks.push(label); } if (leaks.length > 0) { fail(`Environment sample contains secret-shaped value: ${envFile}`, leaks.join(", ")); } else if (text.trim().length > 0) { pass(`Environment sample is safe to publish: ${envFile}`); } else { warn(`Environment sample is empty: ${envFile}`, "Keep expected variables documented even when values are optional."); } } const ignoredDirectories = new Set([ ".git", "node_modules", "dist", "build", "coverage", "playwright-report", "test-results", ".vite", ".turbo", ".cache" ]); const textExtensions = new Set([ ".cjs", ".css", ".env", ".example", ".html", ".js", ".json", ".jsx", ".mjs", ".md", ".svg", ".toml", ".ts", ".tsx", ".txt", ".yaml", ".yml" ]); function isTextCandidate(absolutePath) { const base = path.basename(absolutePath); if (base.startsWith("Dockerfile")) return true; if (base === ".dockerignore" || base === ".env" || base.endsWith(".env.example")) return true; return textExtensions.has(path.extname(absolutePath)); } function walkFiles(absoluteDirectory, collected = []) { if (!directoryExistsAbsolute(absoluteDirectory)) return collected; const entries = fs.readdirSync(absoluteDirectory, { withFileTypes: true }); for (const entry of entries) { const absolutePath = path.join(absoluteDirectory, entry.name); if (entry.isDirectory()) { if (!ignoredDirectories.has(entry.name)) { walkFiles(absolutePath, collected); } continue; } if (entry.isFile() && isTextCandidate(absolutePath)) { collected.push(absolutePath); } } return collected; } const scanRoots = [ "apps", "packages", "tests", "docs", "scripts", ".github" ]; const explicitScanFiles = [ "package.json", "tsconfig.base.json", "playwright.config.ts", "Dockerfile.demo", "docker-compose.demo.yml", ".dockerignore", ".env.example" ]; const scanFileSet = new Set(); for (const scanRoot of scanRoots) { for (const file of walkFiles(resolveRoot(scanRoot))) { scanFileSet.add(file); } } for (const file of explicitScanFiles) { const absolutePath = resolveRoot(file); if (existsAbsolute(absolutePath)) scanFileSet.add(absolutePath); } const scanFiles = Array.from(scanFileSet).sort(); const conflictFiles = []; for (const file of scanFiles) { const text = readTextAbsolute(file); if (/(^|\n)<<<<<<< .+/.test(text) || /(^|\n)>>>>>>> .+/.test(text)) { conflictFiles.push(relativeToRoot(file)); } } if (conflictFiles.length > 0) { fail("Merge conflict markers found", formatList(conflictFiles)); } else { pass("No merge conflict markers found"); } const advisoryPatterns = [ ["unresolved TODO marker", /\bTODO\b/i], ["unresolved FIXME marker", /\bFIXME\b/i], ["exercise wording", /\bleft as an exercise\b/i], ["sample filler text", /\blorem ipsum\b/i], ["explicit placeholder wording", /\bPLACEHOLDER\b/i] ]; const advisoryWordingFindings = []; for (const file of scanFiles) { const relativePath = relativeToRoot(file); if (relativePath === "scripts/qa/release-preflight.mjs") continue; const text = readTextAbsolute(file); for (const [label, pattern] of advisoryPatterns) { const match = pattern.exec(text); if (match) { const beforeMatch = text.slice(0, match.index); const line = beforeMatch.split("\n").length; advisoryWordingFindings.push(`${relativePath}:${line} (${label})`); break; } } } if (advisoryWordingFindings.length > 0) { warn( "Advisory wording found", `Review these before a public release.\n${formatList(advisoryWordingFindings)}`, { strict: true } ); } else { pass("No unresolved advisory wording found"); } const codeExtensions = new Set([".cjs", ".js", ".jsx", ".mjs", ".ts", ".tsx"]); const codeFiles = scanFiles.filter((file) => codeExtensions.has(path.extname(file))); function stripComments(source) { return source .replace(/\/\*[\s\S]*?\*\//g, "") .replace(/^\s*\/\/.*$/gm, ""); } function collectImportSpecs(source) { const specs = new Set(); const withoutComments = stripComments(source); const patterns = [ /import\s+(?:type\s+)?(?:[^'"]*?\s+from\s+)?["']([^"']+)["']/g, /export\s+(?:type\s+)?[^'"]*?\s+from\s+["']([^"']+)["']/g, /import\(\s*["']([^"']+)["']\s*\)/g ]; for (const pattern of patterns) { pattern.lastIndex = 0; let match = pattern.exec(withoutComments); while (match) { specs.add(match[1]); match = pattern.exec(withoutComments); } } return Array.from(specs).sort(); } const localImportExtensions = [ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".css", ".svg", ".png", ".jpg", ".jpeg", ".webp" ]; const localImportIndexes = [ "index.ts", "index.tsx", "index.js", "index.jsx", "index.mjs", "index.cjs", "index.json" ]; function removeImportDecorators(spec) { return spec.split("?")[0].split("#")[0]; } function resolveLocalImport(fromFile, rawSpec) { const spec = removeImportDecorators(rawSpec); const absoluteBase = path.resolve(path.dirname(fromFile), spec); const candidates = []; if (path.extname(absoluteBase)) { candidates.push(absoluteBase); } else { for (const extension of localImportExtensions) { candidates.push(`${absoluteBase}${extension}`); } } for (const indexFile of localImportIndexes) { candidates.push(path.join(absoluteBase, indexFile)); } return candidates.find((candidate) => existsAbsolute(candidate)) ?? null; } const builtinRoots = new Set(); for (const moduleName of builtinModules) { const clean = moduleName.replace(/^node:/, ""); builtinRoots.add(clean); builtinRoots.add(clean.split("/")[0]); } function isBuiltinSpec(spec) { const clean = spec.replace(/^node:/, ""); return builtinRoots.has(clean) || builtinRoots.has(clean.split("/")[0]); } function packageNameFromSpec(spec) { const clean = removeImportDecorators(spec); if (clean.startsWith("@")) { const parts = clean.split("/"); return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : clean; } return clean.split("/")[0]; } function findNearestManifest(absoluteFile) { let current = path.dirname(absoluteFile); while (current.startsWith(root)) { if (manifests.has(current)) return manifests.get(current); const parent = path.dirname(current); if (parent === current) break; current = parent; } return rootManifestRecord ?? null; } function dependencyNamesForFile(absoluteFile) { const names = new Set(localPackageNames); const nearest = findNearestManifest(absoluteFile); if (rootManifestRecord) { for (const name of rootManifestRecord.dependencies.keys()) names.add(name); } if (nearest) { for (const name of nearest.dependencies.keys()) names.add(name); } return names; } const localImportFailures = []; const dependencyFailures = []; const aliasWarnings = []; for (const file of codeFiles) { const source = readTextAbsolute(file); const specs = collectImportSpecs(source); const relativeFile = relativeToRoot(file); for (const spec of specs) { if (spec.startsWith(".")) { const resolved = resolveLocalImport(file, spec); if (!resolved) { localImportFailures.push(`${relativeFile} imports ${spec}`); } continue; } if (spec.startsWith("/") || spec.startsWith("@/") || spec.startsWith("~/") || spec.startsWith("#")) { aliasWarnings.push(`${relativeFile} imports ${spec}`); continue; } if (spec.startsWith("data:") || spec.startsWith("http:") || spec.startsWith("https:") || spec.startsWith("virtual:")) { continue; } if (isBuiltinSpec(spec)) { continue; } const packageName = packageNameFromSpec(spec); const allowedNames = dependencyNamesForFile(file); if (!allowedNames.has(packageName)) { dependencyFailures.push(`${relativeFile} imports ${spec}; ${packageName} is not declared in a visible manifest`); } } } if (localImportFailures.length > 0) { fail("Relative imports resolve to delivered files", formatList(localImportFailures, 20)); } else { pass("Relative imports resolve to delivered files"); } if (dependencyFailures.length > 0) { fail("Bare package imports are declared", formatList(dependencyFailures, 20)); } else { pass("Bare package imports are declared in manifests or local workspaces"); } if (aliasWarnings.length > 0) { warn( "Path-alias imports found", `The static preflight does not resolve tsconfig/Vite aliases; ensure these are configured before release.\n${formatList(aliasWarnings)}` ); } const readme = readText("README.md"); if (/npm\s+install/.test(readme) && /npm\s+run\s+/.test(readme)) { pass("README includes npm setup and run commands"); } else { warn("README should include verbatim npm setup and run commands"); } if (/trivia/i.test(readme) && /prediction/i.test(readme) && /leaderboard/i.test(readme)) { pass("README references primary demo gameplay loops"); } else { warn("README should mention trivia, predictions, and leaderboard flows"); } const releaseDocs = [ "docs/demo-script.md", "docs/deployment.md", "docs/release-checklist.md", "docs/crowdfunding-update.md", "docs/demo-release-runbook.md", "docs/docker-demo.md", "docs/final-self-audit.md" ]; const missingReleaseDocs = releaseDocs.filter((file) => !existsRelative(file)); if (missingReleaseDocs.length === 0) { pass("Release documentation package is complete"); } else { fail("Release documentation package is incomplete", formatList(missingReleaseDocs)); } const qaAssets = [ "apps/api/src/__tests__/passport.integration.test.ts", "apps/web/src/__tests__/App.test.tsx", "tests/e2e/demo.spec.ts", "scripts/qa/http-smoke.mjs", "scripts/qa/a11y-static-check.mjs", "docs/qa-plan.md", "docs/manual-qa-scenarios.md", "docs/accessibility-responsive-audit.md", "docs/edge-case-and-failure-modes.md" ]; const missingQaAssets = qaAssets.filter((file) => !existsRelative(file)); if (missingQaAssets.length === 0) { pass("QA asset set covers unit, integration, e2e, smoke, accessibility, and manual review"); } else { fail("QA asset set is incomplete", formatList(missingQaAssets)); } if (runCommands) { const commandChecks = [ { label: "static accessibility script", command: process.execPath, commandArgs: ["scripts/qa/a11y-static-check.mjs"] } ]; for (const check of commandChecks) { const outcome = spawnSync(check.command, check.commandArgs, { cwd: root, stdio: "inherit", shell: false }); if (outcome.status === 0) { pass(`Command passed: ${check.label}`); } else { fail(`Command failed: ${check.label}`, `${check.command} ${check.commandArgs.join(" ")}`); } } } for (const result of results) { const icon = result.level === "pass" ? "✓" : result.level === "warn" ? "!" : "✕"; console.log(`${icon} ${result.title}`); if (result.detail) { const indented = result.detail .split("\n") .map((line) => ` ${line}`) .join("\n"); console.log(indented); } } const failures = results.filter((result) => result.level === "fail"); const warnings = results.filter((result) => result.level === "warn"); const strictWarnings = strict ? warnings.filter((result) => result.strict) : []; console.log(""); console.log(`Release preflight summary: ${results.filter((result) => result.level === "pass").length} passed, ${warnings.length} warnings, ${failures.length} failures.`); if (strictWarnings.length > 0) { console.log(`Strict mode promoted ${strictWarnings.length} advisory warning(s) to release blockers.`); } if (failures.length > 0 || strictWarnings.length > 0) { process.exit(1); } console.log("Release preflight passed.");