import fs from 'node:fs' import path from 'node:path' import vm from 'node:vm' import { createRequire } from 'node:module' import { fileURLToPath } from 'node:url' import { createNativeManifest, createNativeOutputGuard, listFiles, sha256 } from './tang-detective-native-plugin.mjs' import { isMediaFile, loadCosMediaManifest } from './tang-detective-cos-media.mjs' const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') export function validateNativeOutput(outputDirectory, { mediaManifest, mediaManifestPath } = {}) { const outputGuard = createNativeOutputGuard(outputDirectory) const output = outputGuard.root const nativeRoot = outputGuard.path('tang-detective', 'directory') const sourceRoot = path.join(projectRoot, 'native/tang-detective') const sourceApp = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'app.json'), 'utf8')) const manifest = createNativeManifest(sourceApp) const hostApp = JSON.parse(fs.readFileSync(outputGuard.path('app.json'), 'utf8')) const imported = JSON.parse(fs.readFileSync(outputGuard.path('tang-detective/.native-import-state.json'), 'utf8')) const sourceManifest = JSON.parse(fs.readFileSync(path.join(projectRoot, 'build/tang-detective-source-manifest.json'), 'utf8')) const localRequire = createRequire(path.join(output, 'native-validation.cjs')) const errors = [] const check = (condition, message) => { if (!condition) errors.push(message) } const remoteMode = imported.media?.mode === 'cos' let media = null if (remoteMode) { try { media = loadCosMediaManifest({ sourceDirectory: sourceRoot, mediaManifest, mediaManifestPath }) check(Boolean(media), 'COS output requires its verified media manifest') if (media) { check(imported.media.manifestSha256 === media.manifestSha256, 'COS manifest changed after import') check(imported.media.sourceManifestSha256 === media.sourceManifestSha256, 'COS source manifest changed after import') } } catch (error) { errors.push(error.message) } } const allRoutes = [...hostApp.pages, ...(hostApp.subPackages || hostApp.subpackages || []).flatMap(item => item.pages.map(page => `${item.root}/${page}`))] const packageOwner = relative => manifest.subPackages.find(item => relative.startsWith(`${item.root}/`))?.root || 'main' let mediaFiles = 0 for (const file of sourceManifest.files) { check(sha256(fs.readFileSync(path.join(sourceRoot, file.path))) === file.sha256, `Source snapshot drift: ${file.path}`) if (!isMediaFile(file.path)) continue mediaFiles += 1 outputGuard.path(`tang-detective/${file.path}`) if (remoteMode) { check(!fs.existsSync(path.join(nativeRoot, file.path)), `Packaged media remains in COS mode: ${file.path}`) check(media?.entries.get(file.path)?.sha256 === file.sha256, `Remote media mapping changed: ${file.path}`) } else { check(sha256(fs.readFileSync(path.join(nativeRoot, file.path))) === file.sha256, `Media changed: ${file.path}`) } } for (const file of imported.files) { check(sha256(fs.readFileSync(outputGuard.path(`tang-detective/${file.path}`))) === file.sha256, `Native output changed after import: ${file.path}`) } const packagedMediaFiles = listFiles(nativeRoot).filter(isMediaFile).length if (remoteMode) check(packagedMediaFiles === 0, 'Unexpected native packaged media in COS mode') for (const page of manifest.allPages) { check(allRoutes.filter(route => route === page).length === 1, `Page registration missing or duplicated: ${page}`) for (const extension of ['.js', '.json', '.wxml', '.wxss']) check(fs.existsSync(path.join(output, `${page}${extension}`)), `Missing page file: ${page}${extension}`) const config = JSON.parse(fs.readFileSync(path.join(output, `${page}.json`), 'utf8')) check(config.navigationStyle === 'custom' && config.pageOrientation === 'landscape', `Native display config missing: ${page}`) const script = fs.readFileSync(path.join(output, `${page}.js`), 'utf8') check(!/^Page\(\{/m.test(script) && /require\(["']\.\.\/(?:\.\.\/)*utils\/tangPage\.js["']\)\(\{/.test(script), `Page lifecycle wrapper missing: ${page}`) const style = fs.readFileSync(path.join(output, `${page}.wxss`), 'utf8') check(/@import\s+["']\/tang-detective\/shared\.wxss["'];/.test(style), `Native style import missing: ${page}`) } let dependencies = 0 for (const relative of listFiles(nativeRoot).filter(file => file.endsWith('.js'))) { const filename = path.join(nativeRoot, relative) const script = fs.readFileSync(filename, 'utf8') try { new vm.Script(script, { filename: relative }) } catch (error) { errors.push(error.message) } for (const [, specifier] of script.matchAll(/require\(['"]([^'"]+)['"]\)/g)) { dependencies += 1 if (!specifier.startsWith('.')) { errors.push(`Non-relative native dependency: ${relative} -> ${specifier}`); continue } try { const resolved = localRequire.resolve(path.resolve(path.dirname(filename), specifier)) const dependency = path.relative(output, resolved).split(path.sep).join('/') check(dependency.startsWith('tang-detective/'), `Dependency escaped namespace: ${relative} -> ${specifier}`) check(packageOwner(dependency) === 'main' || packageOwner(dependency) === packageOwner(`tang-detective/${relative}`), `Illegal sibling package import: ${relative} -> ${specifier}`) } catch (error) { errors.push(`Unresolved require: ${relative} -> ${specifier}: ${error.message}`) } } } const outputSizes = { totalFileBytes: 0, mainPackageFileBytes: 0, subPackages: {} } const outputPackages = hostApp.subPackages || hostApp.subpackages || [] for (const relative of listFiles(output)) { const bytes = fs.statSync(path.join(output, relative)).size outputSizes.totalFileBytes += bytes const subpackage = outputPackages.find(item => relative.startsWith(`${item.root}/`)) if (subpackage) outputSizes.subPackages[subpackage.root] = (outputSizes.subPackages[subpackage.root] || 0) + bytes else outputSizes.mainPackageFileBytes += bytes } return { passed: errors.length === 0, nativePages: manifest.allPages.length, mediaFiles, packagedMediaFiles, mediaMode: remoteMode ? 'cos' : 'local', relativeDependencies: dependencies, nativeSizes: imported.sizes, outputSizes, errors, boundary: 'Static source/asset/dependency/manifest verification only. Raw file sizes are not WeChat upload package sizes; no simulator, device, audio listening, upload, or release acceptance was performed.', } } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const report = validateNativeOutput(path.resolve(projectRoot, process.argv[2] || 'dist/build/mp-weixin')) if (process.argv[3]) fs.writeFileSync(path.resolve(projectRoot, process.argv[3]), `${JSON.stringify(report, null, 2)}\n`) console.log(JSON.stringify(report, null, 2)) if (!report.passed) process.exitCode = 1 }