@playcraft/build 0.0.45 → 0.0.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -30,6 +30,8 @@ export declare class BaseBuilder {
30
30
  * 根据构建工具路由到对应适配器
31
31
  */
32
32
  build(): Promise<BaseBuildOutput>;
33
+ private runPrebuildValidation;
34
+ private isPhaserValidationTarget;
33
35
  /**
34
36
  * 解析引擎类型和构建工具
35
37
  * 优先级:
@@ -1,4 +1,5 @@
1
1
  import { EngineDetector, PlayCanvasAdapter, GenericAdapter, PlayableScriptsAdapter } from './engines/index.js';
2
+ import { formatValidationSummary, hasBlockingValidationErrors, runPlayableValidation, saveValidationReport, } from './validation/index.js';
2
3
  /**
3
4
  * 基础构建器 - 路由到不同构建工具适配器
4
5
  *
@@ -33,6 +34,7 @@ export class BaseBuilder {
33
34
  // 1. 确定引擎类型和构建工具
34
35
  const { engine, buildTool } = await this.resolveDetection();
35
36
  console.log(`[BaseBuilder] 使用引擎: ${engine}, 构建工具: ${buildTool}`);
37
+ await this.runPrebuildValidation(engine, buildTool);
36
38
  // 2. 根据构建工具路由到对应适配器
37
39
  let result;
38
40
  switch (buildTool) {
@@ -57,6 +59,48 @@ export class BaseBuilder {
57
59
  this._metadata = result.metadata;
58
60
  return result;
59
61
  }
62
+ async runPrebuildValidation(engine, buildTool) {
63
+ const validationOptions = this.options.validation;
64
+ if (validationOptions?.enabled === false || validationOptions?.sourcePrechecked || !validationOptions?.channel) {
65
+ return;
66
+ }
67
+ if (!this.isPhaserValidationTarget(engine, buildTool)) {
68
+ return;
69
+ }
70
+ const requestedStages = validationOptions.stages;
71
+ if (requestedStages && !requestedStages.includes('prebuild-source')) {
72
+ return;
73
+ }
74
+ const stages = ['prebuild-source'];
75
+ const report = await runPlayableValidation({
76
+ projectRoot: this.projectDir,
77
+ channel: validationOptions.channel,
78
+ engine,
79
+ buildTool,
80
+ buildChain: buildTool === 'playable-scripts'
81
+ ? 'devkit'
82
+ : buildTool === 'playcanvas-native'
83
+ ? 'playcanvas-native'
84
+ : 'generic',
85
+ sourceGlobs: validationOptions.sourceGlobs,
86
+ }, stages);
87
+ const summary = formatValidationSummary(report);
88
+ if (report.summary.status === 'pass') {
89
+ console.log(summary);
90
+ }
91
+ else {
92
+ console.warn(summary);
93
+ }
94
+ if (validationOptions.reportPath) {
95
+ await saveValidationReport(report, validationOptions.reportPath);
96
+ }
97
+ if (validationOptions.failOnError && hasBlockingValidationErrors(report)) {
98
+ throw new Error(`Prebuild validation failed for ${validationOptions.channel}`);
99
+ }
100
+ }
101
+ isPhaserValidationTarget(engine, buildTool) {
102
+ return engine === 'phaser' || buildTool === 'playable-scripts';
103
+ }
60
104
  /**
61
105
  * 解析引擎类型和构建工具
62
106
  * 优先级:
@@ -155,6 +155,11 @@ export declare class PlayableScriptsAdapter {
155
155
  * 获取平台默认的文件名格式
156
156
  */
157
157
  private getPlatformFilename;
158
+ private runDevkitOutputValidation;
159
+ private getValidationChannels;
160
+ private isSupportedValidationChannel;
161
+ private pickArtifactForChannel;
162
+ private reportPathForChannel;
158
163
  private saveBuildMetadata;
159
164
  /**
160
165
  * 执行命令并设置超时
@@ -3,6 +3,7 @@ import path from 'path';
3
3
  import { spawn } from 'child_process';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { createRequire } from 'module';
6
+ import { formatValidationSummary, hasBlockingValidationErrors, runPlayableValidation, saveValidationReport, } from '../validation/index.js';
6
7
  // ESM 兼容:获取当前模块的目录路径
7
8
  const __filename = fileURLToPath(import.meta.url);
8
9
  const __dirname = path.dirname(__filename);
@@ -92,6 +93,7 @@ export class PlayableScriptsAdapter {
92
93
  const outputs = await this.collectOutputs();
93
94
  // 7. 复制产物到 PlayCraft outputDir
94
95
  const files = await this.copyToOutputDir(outputs);
96
+ await this.runDevkitOutputValidation(files);
95
97
  // 7. 构建元数据
96
98
  const metadata = {
97
99
  mode: 'classic',
@@ -1236,6 +1238,94 @@ export class PlayableScriptsAdapter {
1236
1238
  return platformFileNames[channel] || 'index';
1237
1239
  }
1238
1240
  // ==================== 9. 保存元数据 ====================
1241
+ async runDevkitOutputValidation(files) {
1242
+ const validationOptions = this.options.validation;
1243
+ if (validationOptions?.enabled === false) {
1244
+ return;
1245
+ }
1246
+ const channels = this.getValidationChannels();
1247
+ const artifacts = files.assets.filter((file) => file.endsWith('.html') || file.endsWith('.zip'));
1248
+ if (channels.length === 0 || artifacts.length === 0) {
1249
+ return;
1250
+ }
1251
+ const requestedStages = validationOptions?.stages;
1252
+ for (const channel of channels) {
1253
+ const artifact = this.pickArtifactForChannel(channel, artifacts);
1254
+ if (!artifact) {
1255
+ continue;
1256
+ }
1257
+ const isZip = artifact.toLowerCase().endsWith('.zip');
1258
+ const stages = requestedStages ?? (isZip ? ['delivery-package'] : ['html-artifact']);
1259
+ const report = await runPlayableValidation({
1260
+ projectRoot: this.projectDir,
1261
+ channel,
1262
+ engine: 'phaser',
1263
+ buildTool: 'playable-scripts',
1264
+ buildChain: 'devkit',
1265
+ htmlPath: isZip ? undefined : artifact,
1266
+ packagePath: isZip ? artifact : undefined,
1267
+ outputDir: this.options.outputDir,
1268
+ sourceGlobs: validationOptions?.sourceGlobs,
1269
+ }, stages);
1270
+ const summary = formatValidationSummary(report);
1271
+ if (report.summary.status === 'pass') {
1272
+ console.log(summary);
1273
+ }
1274
+ else {
1275
+ console.warn(summary);
1276
+ }
1277
+ if (validationOptions?.reportPath) {
1278
+ await saveValidationReport(report, this.reportPathForChannel(validationOptions.reportPath, channel, channels.length));
1279
+ }
1280
+ if (validationOptions?.failOnError && hasBlockingValidationErrors(report)) {
1281
+ throw new Error(`Playable validation failed for ${channel}`);
1282
+ }
1283
+ }
1284
+ }
1285
+ getValidationChannels() {
1286
+ if (this.options.validation?.channel) {
1287
+ return [this.options.validation.channel];
1288
+ }
1289
+ const channels = this.config?.channels ?? [];
1290
+ return channels.filter((channel) => this.isSupportedValidationChannel(channel));
1291
+ }
1292
+ isSupportedValidationChannel(channel) {
1293
+ const supported = [
1294
+ 'playcraft',
1295
+ 'facebook',
1296
+ 'snapchat',
1297
+ 'ironsource',
1298
+ 'applovin',
1299
+ 'google',
1300
+ 'tiktok',
1301
+ 'unity',
1302
+ 'liftoff',
1303
+ 'moloco',
1304
+ 'bigo',
1305
+ 'inmobi',
1306
+ 'adikteev',
1307
+ 'remerge',
1308
+ 'mintegral',
1309
+ ];
1310
+ return supported.includes(channel);
1311
+ }
1312
+ pickArtifactForChannel(channel, artifacts) {
1313
+ if (artifacts.length === 1) {
1314
+ return artifacts[0];
1315
+ }
1316
+ const direct = artifacts.find((artifact) => {
1317
+ const normalized = artifact.replace(/\\/g, '/').toLowerCase();
1318
+ return normalized.includes(`/${channel}/`) || normalized.includes(`-${channel}.`);
1319
+ });
1320
+ return direct ?? artifacts[0];
1321
+ }
1322
+ reportPathForChannel(reportPath, channel, channelCount) {
1323
+ if (channelCount <= 1) {
1324
+ return reportPath;
1325
+ }
1326
+ const parsed = path.parse(reportPath);
1327
+ return path.join(parsed.dir, `${parsed.name}.${channel}${parsed.ext || '.json'}`);
1328
+ }
1239
1329
  async saveBuildMetadata(metadata) {
1240
1330
  // 不再保存 .build-metadata.json 文件,元数据通过返回值传递
1241
1331
  console.log(`[PlayableScriptsAdapter] 构建完成(buildTool: playable-scripts, skipChannelBuild: true)`);
package/dist/index.d.ts CHANGED
@@ -25,4 +25,5 @@ export { PLATFORM_CONFIGS } from './vite/platform-configs.js';
25
25
  export type { PlatformViteConfig } from './vite/platform-configs.js';
26
26
  export { AudioOptimizer } from './audio-optimizer.js';
27
27
  export type { AudioAssetInfo, AudioOptimizationReport, AudioOptimizerOptions } from './audio-optimizer.js';
28
+ export * from './validation/index.js';
28
29
  export * from './types.js';
package/dist/index.js CHANGED
@@ -23,4 +23,5 @@ export { PLATFORM_CONFIGS } from './vite/platform-configs.js';
23
23
  // 导出音频优化器
24
24
  export { AudioOptimizer } from './audio-optimizer.js';
25
25
  // 导出类型
26
+ export * from './validation/index.js';
26
27
  export * from './types.js';
package/dist/types.d.ts CHANGED
@@ -7,6 +7,16 @@ export interface StoreUrls {
7
7
  ios?: string;
8
8
  android?: string;
9
9
  }
10
+ export interface ComplianceValidationOptions {
11
+ enabled?: boolean;
12
+ failOnError?: boolean;
13
+ channel?: Platform;
14
+ reportPath?: string;
15
+ sourcePrechecked?: boolean;
16
+ stages?: Array<'prebuild-source' | 'base-output' | 'html-artifact' | 'delivery-package' | 'runtime-manual' | 'external-validator'>;
17
+ sourceGlobs?: string[];
18
+ sourceDebug?: unknown;
19
+ }
10
20
  export type BuildMode = 'classic' | 'esm';
11
21
  export type ESMMode = 'auto' | 'enabled' | 'disabled';
12
22
  export interface BuildOptions {
@@ -48,6 +58,7 @@ export interface BuildOptions {
48
58
  preserveESM?: boolean;
49
59
  forceIIFE?: boolean;
50
60
  storeUrls?: StoreUrls;
61
+ validation?: ComplianceValidationOptions;
51
62
  tracking?: {
52
63
  enabled?: boolean;
53
64
  adNetwork?: string;
@@ -208,6 +219,7 @@ export interface BaseBuildOptions {
208
219
  engine?: EngineType;
209
220
  /** 渠道产物格式(playable-scripts 阶段;与 Vite 阶段 format 对齐) */
210
221
  format?: OutputFormat;
222
+ validation?: ComplianceValidationOptions;
211
223
  }
212
224
  /**
213
225
  * 基础构建输出
@@ -268,4 +280,5 @@ export interface BuildOptions {
268
280
  forceIIFE?: boolean;
269
281
  engine?: EngineType;
270
282
  storeUrls?: StoreUrls;
283
+ validation?: ComplianceValidationOptions;
271
284
  }
@@ -0,0 +1,2 @@
1
+ import type { ChannelRuleProfile, ValidationContext, ValidationResult } from './types.js';
2
+ export declare function validateHtmlArtifact(context: ValidationContext, profile?: ChannelRuleProfile): Promise<ValidationResult[]>;
@@ -0,0 +1,140 @@
1
+ import fs from 'fs/promises';
2
+ import { getRulesForStage } from './profiles.js';
3
+ const RESOURCE_ATTR_RE = /\b(?:src|href|data)\s*=\s*["']([^"']+)["']/gi;
4
+ const CSS_URL_RE = /url\(\s*["']?([^"')]+)["']?\s*\)/gi;
5
+ export async function validateHtmlArtifact(context, profile) {
6
+ const rules = profile?.rules.filter((rule) => rule.stage === 'html-artifact')
7
+ ?? getRulesForStage(context.channel, 'html-artifact');
8
+ if (!context.htmlPath) {
9
+ return [missingHtmlResult(context.channel)];
10
+ }
11
+ let artifact;
12
+ try {
13
+ const [content, stat] = await Promise.all([
14
+ fs.readFile(context.htmlPath, 'utf-8'),
15
+ fs.stat(context.htmlPath),
16
+ ]);
17
+ artifact = {
18
+ path: context.htmlPath,
19
+ content,
20
+ size: stat.size,
21
+ };
22
+ }
23
+ catch (error) {
24
+ return [missingHtmlResult(context.channel, context.htmlPath, error)];
25
+ }
26
+ const results = [];
27
+ for (const rule of rules) {
28
+ if (!rule.detectable || !rule.check) {
29
+ continue;
30
+ }
31
+ results.push(evaluateHtmlRule(rule, artifact));
32
+ }
33
+ return results;
34
+ }
35
+ function missingHtmlResult(channel, htmlPath, error) {
36
+ return {
37
+ ruleId: `${channel}.html.entry.exists`,
38
+ channel,
39
+ stage: 'html-artifact',
40
+ severity: 'error',
41
+ status: 'fail',
42
+ message: htmlPath
43
+ ? `HTML artifact is not readable: ${htmlPath}`
44
+ : 'HTML artifact path is missing.',
45
+ recommendation: 'Pass the final channel HTML artifact path to the validation context.',
46
+ file: htmlPath,
47
+ evidence: error instanceof Error ? error.message : undefined,
48
+ owner: 'playcraft-build',
49
+ confidence: 'high',
50
+ };
51
+ }
52
+ function evaluateHtmlRule(rule, artifact) {
53
+ const check = rule.check;
54
+ switch (check.kind) {
55
+ case 'max-size':
56
+ return resultFromBoolean(rule, artifact.size <= check.maxBytes, artifact.path, `actual=${formatBytes(artifact.size)}, limit=${formatBytes(check.maxBytes)}`);
57
+ case 'external-resources': {
58
+ const externalResources = collectExternalResources(artifact.content)
59
+ .filter((url) => !isAllowedExternalResource(url, check.allowedPatterns));
60
+ return resultFromBoolean(rule, externalResources.length === 0, artifact.path, externalResources.slice(0, 20).join(', '));
61
+ }
62
+ case 'required-pattern': {
63
+ const regex = new RegExp(check.pattern, check.flags);
64
+ return resultFromBoolean(rule, regex.test(artifact.content), artifact.path, check.evidence ?? check.pattern);
65
+ }
66
+ case 'forbidden-pattern': {
67
+ const regex = new RegExp(check.pattern, check.flags);
68
+ const match = artifact.content.match(regex);
69
+ return resultFromBoolean(rule, !match, artifact.path, match?.[0] ?? check.evidence ?? check.pattern);
70
+ }
71
+ case 'single-html':
72
+ return resultFromBoolean(rule, collectLocalResourceReferences(artifact.content).length === 0, artifact.path, collectLocalResourceReferences(artifact.content).slice(0, 20).join(', '));
73
+ }
74
+ }
75
+ function resultFromBoolean(rule, passed, file, evidence) {
76
+ return {
77
+ ruleId: rule.id,
78
+ channel: rule.channel,
79
+ stage: rule.stage,
80
+ severity: rule.severity,
81
+ status: passed ? 'pass' : (rule.severity === 'error' ? 'fail' : 'warning'),
82
+ message: passed ? `${rule.title}: passed` : rule.title,
83
+ recommendation: rule.recommendation,
84
+ file,
85
+ evidence: evidence || undefined,
86
+ owner: rule.owner,
87
+ confidence: rule.confidence,
88
+ };
89
+ }
90
+ function collectExternalResources(html) {
91
+ return collectUrls(html).filter((url) => /^https?:\/\//i.test(url) || /^\/\//.test(url));
92
+ }
93
+ function collectLocalResourceReferences(html) {
94
+ return collectUrls(html).filter((url) => {
95
+ if (isInlineOrSpecialUrl(url))
96
+ return false;
97
+ if (/^https?:\/\//i.test(url) || /^\/\//.test(url))
98
+ return false;
99
+ return !url.startsWith('#');
100
+ });
101
+ }
102
+ function collectUrls(html) {
103
+ const urls = new Set();
104
+ for (const match of html.matchAll(RESOURCE_ATTR_RE)) {
105
+ if (match[1])
106
+ urls.add(match[1].trim());
107
+ }
108
+ for (const match of html.matchAll(CSS_URL_RE)) {
109
+ if (match[1])
110
+ urls.add(match[1].trim());
111
+ }
112
+ return Array.from(urls);
113
+ }
114
+ function isInlineOrSpecialUrl(url) {
115
+ return /^(data:|blob:|javascript:|mailto:|tel:|about:)/i.test(url);
116
+ }
117
+ function isAllowedExternalResource(url, allowedPatterns) {
118
+ if (!allowedPatterns || allowedPatterns.length === 0) {
119
+ return false;
120
+ }
121
+ return allowedPatterns.some((pattern) => {
122
+ if (url.includes(pattern))
123
+ return true;
124
+ try {
125
+ return new RegExp(pattern, 'i').test(url);
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ });
131
+ }
132
+ function formatBytes(bytes) {
133
+ if (!Number.isFinite(bytes))
134
+ return String(bytes);
135
+ const mb = bytes / 1024 / 1024;
136
+ if (mb >= 1)
137
+ return `${mb.toFixed(2)} MB`;
138
+ const kb = bytes / 1024;
139
+ return `${kb.toFixed(1)} KB`;
140
+ }
@@ -0,0 +1,8 @@
1
+ export * from './types.js';
2
+ export * from './profiles.js';
3
+ export * from './html-validator.js';
4
+ export * from './package-validator.js';
5
+ export * from './reporter.js';
6
+ export * from './source-scanner.js';
7
+ export * from './validation-runner.js';
8
+ export * from './zip-reader.js';
@@ -0,0 +1,8 @@
1
+ export * from './types.js';
2
+ export * from './profiles.js';
3
+ export * from './html-validator.js';
4
+ export * from './package-validator.js';
5
+ export * from './reporter.js';
6
+ export * from './source-scanner.js';
7
+ export * from './validation-runner.js';
8
+ export * from './zip-reader.js';
@@ -0,0 +1,2 @@
1
+ import type { ChannelRuleProfile, ValidationContext, ValidationResult } from './types.js';
2
+ export declare function validateDeliveryPackage(context: ValidationContext, profile?: ChannelRuleProfile): Promise<ValidationResult[]>;
@@ -0,0 +1,198 @@
1
+ import fs from 'fs/promises';
2
+ import fsSync from 'fs';
3
+ import path from 'path';
4
+ import { getChannelRuleProfile } from './profiles.js';
5
+ import { SimpleZipReader } from './zip-reader.js';
6
+ const FIVE_MB = 5 * 1024 * 1024;
7
+ export async function validateDeliveryPackage(context, profile = getChannelRuleProfile(context.channel)) {
8
+ if (!context.packagePath) {
9
+ return [{
10
+ ruleId: `${context.channel}.package.path.required`,
11
+ channel: context.channel,
12
+ stage: 'delivery-package',
13
+ severity: 'error',
14
+ status: 'fail',
15
+ message: 'Delivery package path is missing.',
16
+ recommendation: 'Pass the ZIP file or delivery directory path to the validation context.',
17
+ owner: 'playcraft-delivery',
18
+ confidence: 'high',
19
+ }];
20
+ }
21
+ let inventory;
22
+ try {
23
+ inventory = await readPackageInventory(context.packagePath);
24
+ }
25
+ catch (error) {
26
+ return [{
27
+ ruleId: `${context.channel}.package.readable.required`,
28
+ channel: context.channel,
29
+ stage: 'delivery-package',
30
+ severity: 'error',
31
+ status: 'fail',
32
+ message: `Delivery package is not readable: ${context.packagePath}`,
33
+ recommendation: 'Verify the generated ZIP or delivery directory exists and is readable.',
34
+ file: context.packagePath,
35
+ evidence: error instanceof Error ? error.message : String(error),
36
+ owner: 'playcraft-delivery',
37
+ confidence: 'high',
38
+ }];
39
+ }
40
+ const results = [
41
+ boolResult(context.channel, 'package.size.max-5mb', inventory.size <= FIVE_MB, 'Delivery package should be smaller than 5 MB.', 'Reduce artifact size or compression output.', inventory.packagePath, `actual=${inventory.size}, limit=${FIVE_MB}`),
42
+ ];
43
+ if (profile.deliveryPackageFormat === 'zip' || profile.deliveryMode === 'zip' || profile.deliveryMode === 'html-backed-zip') {
44
+ results.push(...validateZipLikePackage(context.channel, inventory));
45
+ }
46
+ switch (context.channel) {
47
+ case 'google':
48
+ results.push(boolResult('google', 'package.file-count.max-512', fileEntries(inventory).length <= 512, 'Google ZIP should contain no more than 512 files.', 'Reduce generated files or inline assets.', inventory.packagePath, `files=${fileEntries(inventory).length}`));
49
+ break;
50
+ case 'tiktok':
51
+ results.push(...validateJsonField(context.channel, inventory, 'config.json', 'playable_orientation', [0, 1, 2]));
52
+ break;
53
+ case 'bigo':
54
+ results.push(...validateJsonField(context.channel, inventory, 'config.json', 'orientation', [0, 1, 2]));
55
+ break;
56
+ case 'snapchat':
57
+ results.push(...validateJsonField(context.channel, inventory, 'config.json', 'orientation', [1]));
58
+ break;
59
+ case 'adikteev':
60
+ results.push(...validateAdikteevPackage(inventory));
61
+ break;
62
+ }
63
+ return results;
64
+ }
65
+ async function readPackageInventory(packagePath) {
66
+ const stat = await fs.stat(packagePath);
67
+ if (stat.isDirectory()) {
68
+ const entries = await readDirectoryEntries(packagePath);
69
+ return {
70
+ packagePath,
71
+ entries,
72
+ readText: (entryPath) => readTextFromDirectorySyncUnsupported(packagePath, entryPath),
73
+ size: await calculateDirectorySize(packagePath),
74
+ };
75
+ }
76
+ const buffer = await fs.readFile(packagePath);
77
+ const zip = new SimpleZipReader(buffer);
78
+ return {
79
+ packagePath,
80
+ entries: zip.listEntries(),
81
+ readText: (entryPath) => zip.readEntryText(entryPath),
82
+ size: buffer.length,
83
+ };
84
+ }
85
+ async function readDirectoryEntries(dir) {
86
+ const entries = [];
87
+ const walk = async (current) => {
88
+ const dirEntries = await fs.readdir(current, { withFileTypes: true });
89
+ for (const entry of dirEntries) {
90
+ const fullPath = path.join(current, entry.name);
91
+ const relative = path.relative(dir, fullPath).replace(/\\/g, '/');
92
+ if (entry.isDirectory()) {
93
+ entries.push({
94
+ path: `${relative}/`,
95
+ compressedSize: 0,
96
+ uncompressedSize: 0,
97
+ compressionMethod: 0,
98
+ isDirectory: true,
99
+ });
100
+ await walk(fullPath);
101
+ }
102
+ else {
103
+ const stat = await fs.stat(fullPath);
104
+ entries.push({
105
+ path: relative,
106
+ compressedSize: stat.size,
107
+ uncompressedSize: stat.size,
108
+ compressionMethod: 0,
109
+ isDirectory: false,
110
+ });
111
+ }
112
+ }
113
+ };
114
+ await walk(dir);
115
+ return entries;
116
+ }
117
+ function readTextFromDirectorySyncUnsupported(_packagePath, _entryPath) {
118
+ const filePath = path.join(_packagePath, _entryPath);
119
+ try {
120
+ return fsSync.readFileSync(filePath, 'utf-8');
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }
126
+ async function calculateDirectorySize(dir) {
127
+ let total = 0;
128
+ const walk = async (current) => {
129
+ const entries = await fs.readdir(current, { withFileTypes: true });
130
+ for (const entry of entries) {
131
+ const fullPath = path.join(current, entry.name);
132
+ if (entry.isDirectory()) {
133
+ await walk(fullPath);
134
+ }
135
+ else {
136
+ total += (await fs.stat(fullPath)).size;
137
+ }
138
+ }
139
+ };
140
+ await walk(dir);
141
+ return total;
142
+ }
143
+ function validateZipLikePackage(channel, inventory) {
144
+ const paths = new Set(inventory.entries.map((entry) => entry.path));
145
+ return [
146
+ boolResult(channel, 'package.index-html.root-required', paths.has('index.html'), 'Delivery package should contain root index.html.', 'Place the playable entry file at the root of the delivery package.', inventory.packagePath),
147
+ ];
148
+ }
149
+ function validateJsonField(channel, inventory, entryPath, fieldName, allowedValues) {
150
+ const paths = new Set(inventory.entries.map((entry) => entry.path));
151
+ const results = [
152
+ boolResult(channel, `package.${entryPath}.root-required`, paths.has(entryPath), `Delivery package should contain root ${entryPath}.`, `Generate ${entryPath} at the package root.`, inventory.packagePath),
153
+ ];
154
+ if (!paths.has(entryPath)) {
155
+ return results;
156
+ }
157
+ try {
158
+ const raw = inventory.readText(entryPath);
159
+ if (!raw) {
160
+ return results;
161
+ }
162
+ const json = JSON.parse(raw);
163
+ results.push(boolResult(channel, `package.${fieldName}.valid`, allowedValues.includes(json[fieldName]), `${entryPath} should contain valid ${fieldName}.`, `Set ${fieldName} to one of: ${allowedValues.join(', ')}.`, inventory.packagePath, `${fieldName}=${JSON.stringify(json[fieldName])}`));
164
+ }
165
+ catch (error) {
166
+ results.push(boolResult(channel, `package.${entryPath}.json-valid`, false, `${entryPath} should be valid JSON.`, `Fix ${entryPath} so it can be parsed as JSON.`, inventory.packagePath, error instanceof Error ? error.message : String(error)));
167
+ }
168
+ return results;
169
+ }
170
+ function validateAdikteevPackage(inventory) {
171
+ const files = fileEntries(inventory);
172
+ const hasHtml = files.some((entry) => entry.path.endsWith('.html') || entry.path.endsWith('.htm'));
173
+ const hasJs = files.some((entry) => entry.path.endsWith('.js'));
174
+ const hasCss = files.some((entry) => entry.path.endsWith('.css'));
175
+ return [
176
+ boolResult('adikteev', 'package.fragment.html.required', hasHtml, 'Adikteev package should contain an HTML fragment/file.', 'Include the HTML fragment required by Adikteev.', inventory.packagePath),
177
+ boolResult('adikteev', 'package.fragment-js.required', hasJs, 'Adikteev package should contain separated JavaScript.', 'Separate JavaScript for CDN upload.', inventory.packagePath),
178
+ boolResult('adikteev', 'package.fragment-css.required', hasCss, 'Adikteev package should contain separated CSS.', 'Separate CSS for CDN upload.', inventory.packagePath),
179
+ ];
180
+ }
181
+ function fileEntries(inventory) {
182
+ return inventory.entries.filter((entry) => !entry.isDirectory);
183
+ }
184
+ function boolResult(channel, idSuffix, passed, message, recommendation, file, evidence) {
185
+ return {
186
+ ruleId: `${channel}.${idSuffix}`,
187
+ channel,
188
+ stage: 'delivery-package',
189
+ severity: 'error',
190
+ status: passed ? 'pass' : 'fail',
191
+ message: passed ? `${message}: passed` : message,
192
+ recommendation,
193
+ file,
194
+ evidence,
195
+ owner: 'playcraft-delivery',
196
+ confidence: 'high',
197
+ };
198
+ }
@@ -0,0 +1,5 @@
1
+ import type { Platform } from '../types.js';
2
+ import type { ChannelRuleProfile, ValidationRule, ValidationStage } from './types.js';
3
+ export declare const CHANNEL_RULE_PROFILES: Record<Platform, ChannelRuleProfile>;
4
+ export declare function getChannelRuleProfile(channel: Platform): ChannelRuleProfile;
5
+ export declare function getRulesForStage(channel: Platform, stage: ValidationStage): ValidationRule[];