@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.
- package/dist/base-builder.d.ts +2 -0
- package/dist/base-builder.js +44 -0
- package/dist/engines/playable-scripts-adapter.d.ts +5 -0
- package/dist/engines/playable-scripts-adapter.js +90 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/types.d.ts +13 -0
- package/dist/validation/html-validator.d.ts +2 -0
- package/dist/validation/html-validator.js +140 -0
- package/dist/validation/index.d.ts +8 -0
- package/dist/validation/index.js +8 -0
- package/dist/validation/package-validator.d.ts +2 -0
- package/dist/validation/package-validator.js +198 -0
- package/dist/validation/profiles.d.ts +5 -0
- package/dist/validation/profiles.js +222 -0
- package/dist/validation/reporter.d.ts +4 -0
- package/dist/validation/reporter.js +30 -0
- package/dist/validation/source-scanner.d.ts +4 -0
- package/dist/validation/source-scanner.js +271 -0
- package/dist/validation/types.d.ts +112 -0
- package/dist/validation/types.js +1 -0
- package/dist/validation/validation-runner.d.ts +5 -0
- package/dist/validation/validation-runner.js +96 -0
- package/dist/validation/zip-reader.d.ts +17 -0
- package/dist/validation/zip-reader.js +84 -0
- package/dist/vite-builder.d.ts +1 -0
- package/dist/vite-builder.js +43 -0
- package/package.json +2 -2
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
const FIVE_MB = 5 * 1024 * 1024;
|
|
2
|
+
const SOURCE_SPEC = 'temp-file/md/各渠道试玩广告规格-gemini.md';
|
|
3
|
+
function sizeRule(channel) {
|
|
4
|
+
return {
|
|
5
|
+
id: `${channel}.artifact.size.max-5mb`,
|
|
6
|
+
title: 'Playable artifact must be smaller than 5 MB',
|
|
7
|
+
channel,
|
|
8
|
+
stage: 'html-artifact',
|
|
9
|
+
severity: 'error',
|
|
10
|
+
owner: 'playcraft-build',
|
|
11
|
+
detectable: true,
|
|
12
|
+
confidence: 'high',
|
|
13
|
+
description: 'Most playable ad channels cap the final creative artifact at 5 MB.',
|
|
14
|
+
recommendation: 'Compress or inline assets more aggressively, or remove unused media.',
|
|
15
|
+
evidenceType: 'file',
|
|
16
|
+
source: SOURCE_SPEC,
|
|
17
|
+
check: { kind: 'max-size', maxBytes: FIVE_MB },
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function externalResourceRule(channel, allowedPatterns) {
|
|
21
|
+
return {
|
|
22
|
+
id: `${channel}.html.external-resource.forbidden`,
|
|
23
|
+
title: 'External runtime resources should not be loaded',
|
|
24
|
+
channel,
|
|
25
|
+
stage: 'html-artifact',
|
|
26
|
+
severity: 'error',
|
|
27
|
+
owner: 'playcraft-build',
|
|
28
|
+
detectable: true,
|
|
29
|
+
confidence: 'high',
|
|
30
|
+
description: 'Playable ads should not depend on external scripts, styles, images, media, or frames unless the channel explicitly provides that SDK.',
|
|
31
|
+
recommendation: 'Inline resources into the HTML artifact, or add a channel-specific allowlist when the SDK is officially required.',
|
|
32
|
+
evidenceType: 'pattern',
|
|
33
|
+
source: SOURCE_SPEC,
|
|
34
|
+
check: { kind: 'external-resources', allowedPatterns },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function forbiddenPatternRule(channel, idSuffix, title, pattern, recommendation, severity = 'error') {
|
|
38
|
+
return {
|
|
39
|
+
id: `${channel}.${idSuffix}`,
|
|
40
|
+
title,
|
|
41
|
+
channel,
|
|
42
|
+
stage: 'html-artifact',
|
|
43
|
+
severity,
|
|
44
|
+
owner: 'playcraft-build',
|
|
45
|
+
detectable: true,
|
|
46
|
+
confidence: 'high',
|
|
47
|
+
description: title,
|
|
48
|
+
recommendation,
|
|
49
|
+
evidenceType: 'pattern',
|
|
50
|
+
source: SOURCE_SPEC,
|
|
51
|
+
check: { kind: 'forbidden-pattern', pattern, flags: 'i' },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function requiredPatternRule(channel, idSuffix, title, pattern, recommendation, severity = 'error') {
|
|
55
|
+
return {
|
|
56
|
+
id: `${channel}.${idSuffix}`,
|
|
57
|
+
title,
|
|
58
|
+
channel,
|
|
59
|
+
stage: 'html-artifact',
|
|
60
|
+
severity,
|
|
61
|
+
owner: 'playcraft-build',
|
|
62
|
+
detectable: true,
|
|
63
|
+
confidence: 'high',
|
|
64
|
+
description: title,
|
|
65
|
+
recommendation,
|
|
66
|
+
evidenceType: 'pattern',
|
|
67
|
+
source: SOURCE_SPEC,
|
|
68
|
+
check: { kind: 'required-pattern', pattern, flags: 'i' },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function sourcePresenceRule(channel, idSuffix, title, recommendation) {
|
|
72
|
+
return {
|
|
73
|
+
id: `${channel}.${idSuffix}`,
|
|
74
|
+
title,
|
|
75
|
+
channel,
|
|
76
|
+
stage: 'prebuild-source',
|
|
77
|
+
severity: 'error',
|
|
78
|
+
owner: 'playcraft-build',
|
|
79
|
+
detectable: true,
|
|
80
|
+
confidence: 'high',
|
|
81
|
+
description: title,
|
|
82
|
+
recommendation,
|
|
83
|
+
evidenceType: 'pattern',
|
|
84
|
+
source: SOURCE_SPEC,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function manualRule(channel, idSuffix, title, recommendation) {
|
|
88
|
+
return {
|
|
89
|
+
id: `${channel}.${idSuffix}`,
|
|
90
|
+
title,
|
|
91
|
+
channel,
|
|
92
|
+
stage: 'runtime-manual',
|
|
93
|
+
severity: 'info',
|
|
94
|
+
owner: 'manual',
|
|
95
|
+
detectable: false,
|
|
96
|
+
confidence: 'medium',
|
|
97
|
+
description: title,
|
|
98
|
+
recommendation,
|
|
99
|
+
evidenceType: 'runtime',
|
|
100
|
+
source: SOURCE_SPEC,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function externalValidatorRule(channel, title, recommendation) {
|
|
104
|
+
return {
|
|
105
|
+
id: `${channel}.external-validator.review.required`,
|
|
106
|
+
title,
|
|
107
|
+
channel,
|
|
108
|
+
stage: 'external-validator',
|
|
109
|
+
severity: 'info',
|
|
110
|
+
owner: 'external',
|
|
111
|
+
detectable: false,
|
|
112
|
+
confidence: 'medium',
|
|
113
|
+
description: title,
|
|
114
|
+
recommendation,
|
|
115
|
+
evidenceType: 'external',
|
|
116
|
+
source: SOURCE_SPEC,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function profile(channel, displayName, deliveryMode, rules, options) {
|
|
120
|
+
return {
|
|
121
|
+
channel,
|
|
122
|
+
displayName,
|
|
123
|
+
defaultStages: ['prebuild-source', 'html-artifact'],
|
|
124
|
+
optionalStages: options?.optionalStages ?? [],
|
|
125
|
+
deliveryMode,
|
|
126
|
+
internalBuildFormat: options?.internalBuildFormat ?? 'html',
|
|
127
|
+
deliveryPackageFormat: options?.deliveryPackageFormat,
|
|
128
|
+
devkitOwned: options?.devkitOwned,
|
|
129
|
+
rules: [sizeRule(channel), ...rules],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
export const CHANNEL_RULE_PROFILES = {
|
|
133
|
+
playcraft: {
|
|
134
|
+
channel: 'playcraft',
|
|
135
|
+
displayName: 'PlayCraft',
|
|
136
|
+
defaultStages: ['prebuild-source', 'html-artifact'],
|
|
137
|
+
optionalStages: [],
|
|
138
|
+
deliveryMode: 'mixed',
|
|
139
|
+
internalBuildFormat: 'html',
|
|
140
|
+
deliveryPackageFormat: 'mixed',
|
|
141
|
+
rules: [],
|
|
142
|
+
},
|
|
143
|
+
applovin: profile('applovin', 'AppLovin', 'single-html', [
|
|
144
|
+
externalResourceRule('applovin'),
|
|
145
|
+
requiredPatternRule('applovin', 'cta.mraid-open.required', 'CTA should use mraid.open()', '\\bmraid\\.open\\s*\\(', 'Route store jumps through mraid.open().'),
|
|
146
|
+
manualRule('applovin', 'runtime.orientation-and-cta.manual', 'Landscape, portrait, and CTA visibility need runtime review.', 'Verify both orientations and that CTA stays visible throughout the playable.'),
|
|
147
|
+
externalValidatorRule('applovin', 'AppLovin preview should be used before delivery.', 'Upload the final creative to AppLovin Preview or Preview Tool.'),
|
|
148
|
+
], { optionalStages: ['runtime-manual', 'external-validator'] }),
|
|
149
|
+
google: profile('google', 'Google', 'html-backed-zip', [
|
|
150
|
+
externalResourceRule('google'),
|
|
151
|
+
forbiddenPatternRule('google', 'network.fetch.forbidden', 'Google creatives should not make runtime network requests.', '\\b(fetch|XMLHttpRequest|WebSocket|EventSource)\\b', 'Remove runtime network calls or mark approved analytics separately.'),
|
|
152
|
+
manualRule('google', 'runtime.audio-and-gesture.manual', 'Audio before interaction and download button animation need runtime review.', 'Confirm the creative is silent before interaction and CTA gestures do not scale improperly.'),
|
|
153
|
+
externalValidatorRule('google', 'Google H5 Validator should be used before delivery.', 'Validate the delivery package with Google H5 Validator.'),
|
|
154
|
+
], { optionalStages: ['delivery-package', 'runtime-manual', 'external-validator'], deliveryPackageFormat: 'zip' }),
|
|
155
|
+
facebook: profile('facebook', 'Facebook / Meta', 'mixed', [
|
|
156
|
+
externalResourceRule('facebook'),
|
|
157
|
+
forbiddenPatternRule('facebook', 'sdk.mraid-js.forbidden', 'Facebook creatives should not include mraid.js.', 'mraid\\.js', 'Remove mraid.js and use Facebook playable APIs.'),
|
|
158
|
+
requiredPatternRule('facebook', 'cta.fb-on-click.recommended', 'CTA should call FbPlayableAd.onCTAClick().', 'FbPlayableAd\\.onCTAClick\\s*\\(', 'Route CTA clicks through FbPlayableAd.onCTAClick().', 'warning'),
|
|
159
|
+
manualRule('facebook', 'runtime.portrait-and-cta.manual', 'Portrait display and CTA clickability need runtime review.', 'Verify portrait layout and CTA behavior in Meta preview.'),
|
|
160
|
+
], { optionalStages: ['delivery-package', 'runtime-manual'], deliveryPackageFormat: 'mixed' }),
|
|
161
|
+
tiktok: profile('tiktok', 'TikTok / Pangle', 'zip', [
|
|
162
|
+
forbiddenPatternRule('tiktok', 'sdk.mraid-js.forbidden', 'TikTok/Pangle creatives should not include mraid.js.', 'mraid\\.js', 'Remove mraid.js; Pangle integration belongs to the channel SDK.'),
|
|
163
|
+
forbiddenPatternRule('tiktok', 'redirect.javascript.forbidden', 'TikTok/Pangle creatives should not contain JavaScript redirects.', '\\blocation\\.(href|assign|replace)\\b|window\\.open\\s*\\(', 'Remove automatic redirects and delegate store jumps to Pangle.'),
|
|
164
|
+
], { optionalStages: ['delivery-package'], deliveryPackageFormat: 'zip', devkitOwned: true, internalBuildFormat: 'delegated' }),
|
|
165
|
+
unity: profile('unity', 'Unity Ads', 'single-html', [
|
|
166
|
+
sourcePresenceRule('unity', 'mraid.viewable-change.required', 'Unity requires MRAID viewableChange before gameplay starts.', 'Add a viewableChange listener before starting playable content.'),
|
|
167
|
+
externalResourceRule('unity'),
|
|
168
|
+
requiredPatternRule('unity', 'cta.mraid-open.required', 'Unity CTA should use mraid.open().', '\\bmraid\\.open\\s*\\(', 'Route the CTA directly to the store through mraid.open().'),
|
|
169
|
+
requiredPatternRule('unity', 'mraid.viewable-change.required', 'Unity playable should wait for MRAID viewableChange.', 'viewableChange', 'Wait for the MRAID viewableChange event before starting gameplay.'),
|
|
170
|
+
manualRule('unity', 'runtime.orientation-and-start.manual', 'Orientation support and gameplay start timing need runtime review.', 'Verify portrait/landscape support and that gameplay waits for viewability.'),
|
|
171
|
+
], { optionalStages: ['runtime-manual'] }),
|
|
172
|
+
ironsource: profile('ironsource', 'ironSource', 'mixed', [
|
|
173
|
+
forbiddenPatternRule('ironsource', 'redirect.auto.forbidden', 'ironSource creatives should not auto-redirect to the store.', '\\blocation\\.(href|assign|replace)\\b', 'Keep store jumps behind a user CTA.'),
|
|
174
|
+
manualRule('ironsource', 'runtime.loading-audio-controls.manual', 'Loading time, mute, close, and info buttons need runtime review.', 'Verify load time, audio behavior, mute button, close button, and info button.'),
|
|
175
|
+
], { optionalStages: ['runtime-manual'], deliveryPackageFormat: 'mixed' }),
|
|
176
|
+
liftoff: profile('liftoff', 'Liftoff', 'zip', [
|
|
177
|
+
manualRule('liftoff', 'package.entry.manual', 'ZIP entry and folder structure need delivery review.', 'Confirm the ZIP has a single entry folder and a reachable root entry file.'),
|
|
178
|
+
externalValidatorRule('liftoff', 'Liftoff validator should be used before delivery.', 'Validate with Liftoff Interactive Ad Validator or Creative Verifier.'),
|
|
179
|
+
], { optionalStages: ['delivery-package', 'external-validator'], deliveryPackageFormat: 'zip' }),
|
|
180
|
+
moloco: profile('moloco', 'Moloco', 'single-html', [
|
|
181
|
+
externalResourceRule('moloco'),
|
|
182
|
+
forbiddenPatternRule('moloco', 'sdk.mraid-js.forbidden', 'Moloco creatives should not include mraid.js.', 'mraid\\.js', 'Remove mraid.js and use Facebook-compatible CTA handling.'),
|
|
183
|
+
requiredPatternRule('moloco', 'cta.fb-on-click.required', 'Moloco CTA should use FbPlayableAd.onCTAClick().', 'FbPlayableAd\\.onCTAClick\\s*\\(', 'Route CTA clicks through FbPlayableAd.onCTAClick().'),
|
|
184
|
+
manualRule('moloco', 'runtime.portrait-responsive.manual', 'Portrait responsive behavior needs runtime review.', 'Verify portrait layout and CTA visibility.'),
|
|
185
|
+
], { optionalStages: ['runtime-manual'] }),
|
|
186
|
+
bigo: profile('bigo', 'BIGO', 'zip', [
|
|
187
|
+
sourcePresenceRule('bigo', 'lifecycle.game-end.required', 'BIGO requires window.BGY_MRAID.gameEnd() when gameplay ends.', 'Call window.BGY_MRAID.gameEnd() when gameplay ends.'),
|
|
188
|
+
externalResourceRule('bigo', ['static-web.likeevideo.com']),
|
|
189
|
+
requiredPatternRule('bigo', 'cta.bgy-open.required', 'BIGO CTA should use window.BGY_MRAID.open().', 'window\\.BGY_MRAID\\.open\\s*\\(', 'Route CTA clicks through window.BGY_MRAID.open().'),
|
|
190
|
+
requiredPatternRule('bigo', 'lifecycle.game-end.required', 'BIGO creatives should call window.BGY_MRAID.gameEnd().', 'window\\.BGY_MRAID\\.gameEnd\\s*\\(', 'Call gameEnd() when gameplay ends.'),
|
|
191
|
+
], { optionalStages: ['delivery-package'], deliveryPackageFormat: 'zip', devkitOwned: true, internalBuildFormat: 'delegated' }),
|
|
192
|
+
inmobi: profile('inmobi', 'InMobi', 'mixed', [
|
|
193
|
+
requiredPatternRule('inmobi', 'sdk.mraid-js.required', 'InMobi creatives should include mraid.js or MRAID support.', 'mraid', 'Include the channel-required MRAID integration.'),
|
|
194
|
+
requiredPatternRule('inmobi', 'cta.mraid-open.required', 'InMobi redirects should use mraid.open(url).', '\\bmraid\\.open\\s*\\(', 'Route store redirects through mraid.open(url).'),
|
|
195
|
+
], { optionalStages: ['delivery-package', 'runtime-manual'], deliveryPackageFormat: 'mixed' }),
|
|
196
|
+
adikteev: profile('adikteev', 'Adikteev', 'fragment-assets', [
|
|
197
|
+
requiredPatternRule('adikteev', 'cta.mraid-open.required', 'Adikteev CTA should use mraid.open().', '\\bmraid\\.open\\s*\\(', 'Route CTA clicks through mraid.open().'),
|
|
198
|
+
requiredPatternRule('adikteev', 'click-pixel-url.required', 'Adikteev requires AK_CLICK_PIXEL_URL exposure.', 'AK_CLICK_PIXEL_URL|AK CLICK PIXEL URL', 'Expose the Adikteev click pixel URL variable.'),
|
|
199
|
+
manualRule('adikteev', 'runtime.cdn-responsive.manual', 'CDN resource loading and responsive layout need runtime review.', 'Verify separated assets load from Adikteev CDN and remain responsive.'),
|
|
200
|
+
], { optionalStages: ['delivery-package', 'runtime-manual'], deliveryPackageFormat: 'fragment-assets' }),
|
|
201
|
+
remerge: profile('remerge', 'Remerge', 'single-html', [
|
|
202
|
+
externalResourceRule('remerge'),
|
|
203
|
+
forbiddenPatternRule('remerge', 'redirect.auto.forbidden', 'Remerge creatives should not auto-redirect to the store.', '\\blocation\\.(href|assign|replace)\\b', 'Keep store jumps behind a user CTA.'),
|
|
204
|
+
]),
|
|
205
|
+
mintegral: profile('mintegral', 'Mintegral', 'mixed', [
|
|
206
|
+
sourcePresenceRule('mintegral', 'lifecycle.sdk-finish.required', 'Mintegral requires sdk.finish() after gameplay ends.', 'Call sdk.finish() when gameplay reaches win or end state.'),
|
|
207
|
+
requiredPatternRule('mintegral', 'lifecycle.sdk-finish.required', 'Mintegral creatives should call sdk.finish() after win or end.', '\\bsdk\\.finish\\s*\\(', 'Call sdk.finish() at the end of gameplay.'),
|
|
208
|
+
forbiddenPatternRule('mintegral', 'redirect.auto.forbidden', 'Mintegral creatives should not auto-redirect to the store.', '\\blocation\\.(href|assign|replace)\\b', 'Keep store jumps behind a user CTA.'),
|
|
209
|
+
externalValidatorRule('mintegral', 'Mindworks testing should be used before delivery.', 'Validate through the Mintegral/Mindworks testing entry.'),
|
|
210
|
+
], { optionalStages: ['external-validator'], deliveryPackageFormat: 'mixed' }),
|
|
211
|
+
snapchat: profile('snapchat', 'Snapchat', 'zip', [
|
|
212
|
+
forbiddenPatternRule('snapchat', 'sdk.mraid-js.forbidden', 'Snapchat creatives should not include mraid.js.', 'mraid\\.js', 'Remove mraid.js and use Snapchat playable APIs.'),
|
|
213
|
+
requiredPatternRule('snapchat', 'cta.sc-on-click.required', 'Snapchat CTA should call ScPlayableAd.onCTAClick().', 'ScPlayableAd\\.onCTAClick\\s*\\(', 'Route CTA clicks through ScPlayableAd.onCTAClick().'),
|
|
214
|
+
manualRule('snapchat', 'runtime.portrait-and-cta.manual', 'Portrait display and CTA availability need runtime review.', 'Verify portrait layout and CTA behavior.'),
|
|
215
|
+
], { optionalStages: ['delivery-package', 'runtime-manual'], deliveryPackageFormat: 'zip' }),
|
|
216
|
+
};
|
|
217
|
+
export function getChannelRuleProfile(channel) {
|
|
218
|
+
return CHANNEL_RULE_PROFILES[channel];
|
|
219
|
+
}
|
|
220
|
+
export function getRulesForStage(channel, stage) {
|
|
221
|
+
return getChannelRuleProfile(channel).rules.filter((rule) => rule.stage === stage);
|
|
222
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ValidationReport } from './types.js';
|
|
2
|
+
export declare function saveValidationReport(report: ValidationReport, reportPath: string): Promise<void>;
|
|
3
|
+
export declare function formatValidationSummary(report: ValidationReport): string;
|
|
4
|
+
export declare function hasBlockingValidationErrors(report: ValidationReport): boolean;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
export async function saveValidationReport(report, reportPath) {
|
|
4
|
+
await fs.mkdir(path.dirname(reportPath), { recursive: true });
|
|
5
|
+
await fs.writeFile(reportPath, JSON.stringify(report, null, 2), 'utf-8');
|
|
6
|
+
}
|
|
7
|
+
export function formatValidationSummary(report) {
|
|
8
|
+
const lines = [
|
|
9
|
+
`[Validation] channel=${report.channel}, chain=${report.buildChain}, status=${report.summary.status}`,
|
|
10
|
+
`[Validation] errors=${report.summary.errorCount}, warnings=${report.summary.warningCount}, manual=${report.summary.manualCount}, external=${report.summary.externalCount}`,
|
|
11
|
+
];
|
|
12
|
+
const failed = report.results.filter((result) => result.status === 'fail');
|
|
13
|
+
const warnings = report.results.filter((result) => result.status === 'warning');
|
|
14
|
+
for (const result of [...failed, ...warnings].slice(0, 10)) {
|
|
15
|
+
lines.push(` - ${result.status.toUpperCase()} ${result.ruleId}: ${result.message}`);
|
|
16
|
+
if (result.file) {
|
|
17
|
+
lines.push(` file=${result.file}${result.line ? `:${result.line}` : ''}`);
|
|
18
|
+
}
|
|
19
|
+
if (result.evidence) {
|
|
20
|
+
lines.push(` evidence=${result.evidence}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (failed.length + warnings.length > 10) {
|
|
24
|
+
lines.push(` ... ${failed.length + warnings.length - 10} more validation results`);
|
|
25
|
+
}
|
|
26
|
+
return lines.join('\n');
|
|
27
|
+
}
|
|
28
|
+
export function hasBlockingValidationErrors(report) {
|
|
29
|
+
return report.results.some((result) => result.status === 'fail' && result.severity === 'error');
|
|
30
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Platform } from '../types.js';
|
|
2
|
+
import type { ValidationContext, ValidationResult } from './types.js';
|
|
3
|
+
export declare function scanSourceForCompliance(context: ValidationContext): Promise<ValidationResult[]>;
|
|
4
|
+
export declare function scanSourceForChannels(context: Omit<ValidationContext, 'channel'>, channels: Platform[]): Promise<ValidationResult[]>;
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { getRulesForStage } from './profiles.js';
|
|
4
|
+
const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.html', '.css']);
|
|
5
|
+
const IGNORED_DIRS = new Set([
|
|
6
|
+
'.git',
|
|
7
|
+
'.playcraft-temp',
|
|
8
|
+
'coverage',
|
|
9
|
+
'dist',
|
|
10
|
+
'build',
|
|
11
|
+
'node_modules',
|
|
12
|
+
'out',
|
|
13
|
+
]);
|
|
14
|
+
const COMMON_SOURCE_PATTERNS = [
|
|
15
|
+
{
|
|
16
|
+
ruleId: 'common.redirect.location.forbidden',
|
|
17
|
+
pattern: /\blocation\.(?:href|assign|replace)\b/g,
|
|
18
|
+
message: 'Source contains a direct location redirect.',
|
|
19
|
+
recommendation: 'Move store jumps behind the channel CTA API and an explicit user action.',
|
|
20
|
+
severity: 'warning',
|
|
21
|
+
confidence: 'medium',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
ruleId: 'common.redirect.window-open.review',
|
|
25
|
+
pattern: /\bwindow\.open\s*\(/g,
|
|
26
|
+
message: 'Source contains window.open().',
|
|
27
|
+
recommendation: 'Confirm the call is only triggered by an explicit CTA click.',
|
|
28
|
+
severity: 'warning',
|
|
29
|
+
confidence: 'medium',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
ruleId: 'common.network.fetch.review',
|
|
33
|
+
pattern: /\b(?:fetch|XMLHttpRequest|WebSocket|EventSource)\b/g,
|
|
34
|
+
message: 'Source contains a runtime network API.',
|
|
35
|
+
recommendation: 'Remove runtime network dependency unless the channel allows analytics-only calls.',
|
|
36
|
+
severity: 'warning',
|
|
37
|
+
confidence: 'medium',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
ruleId: 'common.audio.autoplay.review',
|
|
41
|
+
pattern: /<audio\b[^>]*\bautoplay\b|new\s+Audio\s*\([^)]*\)\.play\s*\(|AudioContext\s*\([^)]*\).*?resume\s*\(/gs,
|
|
42
|
+
message: 'Source contains a possible audio autoplay pattern.',
|
|
43
|
+
recommendation: 'Start audio only after user interaction and stop it when the ad is not viewable.',
|
|
44
|
+
severity: 'warning',
|
|
45
|
+
confidence: 'low',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
ruleId: 'common.layout.fixed-size.review',
|
|
49
|
+
pattern: /\b(?:canvas|body|#?app|#?root)[^{]{0,80}\{[^}]*\b(?:width|height)\s*:\s*\d{3,4}px/gs,
|
|
50
|
+
message: 'Source contains a possible fixed-size layout.',
|
|
51
|
+
recommendation: 'Use responsive sizing so the playable can adapt to mobile viewports.',
|
|
52
|
+
severity: 'warning',
|
|
53
|
+
confidence: 'low',
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
const CHANNEL_SOURCE_PATTERNS = {
|
|
57
|
+
unity: [
|
|
58
|
+
{
|
|
59
|
+
ruleId: 'unity.mraid.viewable-change.required',
|
|
60
|
+
pattern: /viewableChange/g,
|
|
61
|
+
message: 'Unity requires waiting for MRAID viewableChange before starting playable content.',
|
|
62
|
+
recommendation: 'Add a viewableChange listener and start gameplay only after the ad is viewable.',
|
|
63
|
+
severity: 'error',
|
|
64
|
+
confidence: 'high',
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
mintegral: [
|
|
68
|
+
{
|
|
69
|
+
ruleId: 'mintegral.lifecycle.sdk-finish.required',
|
|
70
|
+
pattern: /\bsdk\.finish\s*\(/g,
|
|
71
|
+
message: 'Mintegral requires sdk.finish() after gameplay ends.',
|
|
72
|
+
recommendation: 'Call sdk.finish() when the win or end state is reached.',
|
|
73
|
+
severity: 'error',
|
|
74
|
+
confidence: 'high',
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
bigo: [
|
|
78
|
+
{
|
|
79
|
+
ruleId: 'bigo.lifecycle.game-end.required',
|
|
80
|
+
pattern: /window\.BGY_MRAID\.gameEnd\s*\(/g,
|
|
81
|
+
message: 'BIGO requires window.BGY_MRAID.gameEnd() when gameplay ends.',
|
|
82
|
+
recommendation: 'Call window.BGY_MRAID.gameEnd() at the end of gameplay.',
|
|
83
|
+
severity: 'error',
|
|
84
|
+
confidence: 'high',
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
export async function scanSourceForCompliance(context) {
|
|
89
|
+
const sourceFiles = await collectSourceFiles(context.projectRoot, context.sourceGlobs);
|
|
90
|
+
return scanSourceFilesForChannel(context, sourceFiles, true);
|
|
91
|
+
}
|
|
92
|
+
export async function scanSourceForChannels(context, channels) {
|
|
93
|
+
const sourceFiles = await collectSourceFiles(context.projectRoot, context.sourceGlobs);
|
|
94
|
+
const results = [];
|
|
95
|
+
for (const filePath of sourceFiles) {
|
|
96
|
+
let content;
|
|
97
|
+
try {
|
|
98
|
+
content = await fs.readFile(filePath, 'utf-8');
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
for (const pattern of COMMON_SOURCE_PATTERNS) {
|
|
104
|
+
results.push(...evaluateSourcePatternForChannel(context, 'common', filePath, content, pattern));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
for (const channel of unique(channels)) {
|
|
108
|
+
results.push(...await scanSourceFilesForChannel({ ...context, channel }, sourceFiles, false));
|
|
109
|
+
}
|
|
110
|
+
return results;
|
|
111
|
+
}
|
|
112
|
+
async function scanSourceFilesForChannel(context, sourceFiles, includeCommonPatterns) {
|
|
113
|
+
const results = [];
|
|
114
|
+
const requiredPatterns = CHANNEL_SOURCE_PATTERNS[context.channel] ?? [];
|
|
115
|
+
const matchedRequiredRuleIds = new Set();
|
|
116
|
+
for (const filePath of sourceFiles) {
|
|
117
|
+
let content;
|
|
118
|
+
try {
|
|
119
|
+
content = await fs.readFile(filePath, 'utf-8');
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (includeCommonPatterns) {
|
|
125
|
+
for (const pattern of COMMON_SOURCE_PATTERNS) {
|
|
126
|
+
results.push(...evaluateSourcePattern(context, filePath, content, pattern, false));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const pattern of requiredPatterns) {
|
|
130
|
+
if (matchedRequiredRuleIds.has(pattern.ruleId)) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const match = content.match(pattern.pattern);
|
|
134
|
+
if (match) {
|
|
135
|
+
matchedRequiredRuleIds.add(pattern.ruleId);
|
|
136
|
+
results.push({
|
|
137
|
+
ruleId: pattern.ruleId,
|
|
138
|
+
channel: context.channel,
|
|
139
|
+
stage: 'prebuild-source',
|
|
140
|
+
severity: pattern.severity,
|
|
141
|
+
status: 'pass',
|
|
142
|
+
message: `${pattern.message}: passed`,
|
|
143
|
+
recommendation: pattern.recommendation,
|
|
144
|
+
file: filePath,
|
|
145
|
+
line: lineOfIndex(content, content.search(pattern.pattern)),
|
|
146
|
+
evidence: trimEvidence(match[0]),
|
|
147
|
+
owner: ownerForContext(context),
|
|
148
|
+
confidence: pattern.confidence,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
results.push(...missingRequiredPatternResults(context, matchedRequiredRuleIds));
|
|
154
|
+
return results;
|
|
155
|
+
}
|
|
156
|
+
function evaluateSourcePatternForChannel(context, channel, filePath, content, sourcePattern) {
|
|
157
|
+
const matches = Array.from(content.matchAll(sourcePattern.pattern));
|
|
158
|
+
return matches.slice(0, 10).map((match) => ({
|
|
159
|
+
ruleId: sourcePattern.ruleId,
|
|
160
|
+
channel,
|
|
161
|
+
stage: 'prebuild-source',
|
|
162
|
+
severity: sourcePattern.severity,
|
|
163
|
+
status: sourcePattern.severity === 'error' ? 'fail' : 'warning',
|
|
164
|
+
message: sourcePattern.message,
|
|
165
|
+
recommendation: sourcePattern.recommendation,
|
|
166
|
+
file: filePath,
|
|
167
|
+
line: lineOfIndex(content, match.index ?? 0),
|
|
168
|
+
evidence: trimEvidence(match[0]),
|
|
169
|
+
owner: ownerForContext(context),
|
|
170
|
+
confidence: sourcePattern.confidence,
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
async function collectSourceFiles(projectRoot, explicitGlobs) {
|
|
174
|
+
if (explicitGlobs && explicitGlobs.length > 0) {
|
|
175
|
+
const files = [];
|
|
176
|
+
for (const item of explicitGlobs) {
|
|
177
|
+
const fullPath = path.isAbsolute(item) ? item : path.join(projectRoot, item);
|
|
178
|
+
try {
|
|
179
|
+
const stat = await fs.stat(fullPath);
|
|
180
|
+
if (stat.isFile() && SOURCE_EXTENSIONS.has(path.extname(fullPath).toLowerCase())) {
|
|
181
|
+
files.push(fullPath);
|
|
182
|
+
}
|
|
183
|
+
else if (stat.isDirectory()) {
|
|
184
|
+
files.push(...await walkSourceFiles(fullPath));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
// Ignore missing explicit entries; the report stays focused on detected evidence.
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return unique(files);
|
|
192
|
+
}
|
|
193
|
+
return walkSourceFiles(projectRoot);
|
|
194
|
+
}
|
|
195
|
+
async function walkSourceFiles(dir) {
|
|
196
|
+
const files = [];
|
|
197
|
+
let entries;
|
|
198
|
+
try {
|
|
199
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return files;
|
|
203
|
+
}
|
|
204
|
+
for (const entry of entries) {
|
|
205
|
+
const fullPath = path.join(dir, entry.name);
|
|
206
|
+
if (entry.isDirectory()) {
|
|
207
|
+
if (!IGNORED_DIRS.has(entry.name)) {
|
|
208
|
+
files.push(...await walkSourceFiles(fullPath));
|
|
209
|
+
}
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (entry.isFile() && SOURCE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
|
|
213
|
+
files.push(fullPath);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return files;
|
|
217
|
+
}
|
|
218
|
+
function evaluateSourcePattern(context, filePath, content, sourcePattern, requiredPresence) {
|
|
219
|
+
const matches = Array.from(content.matchAll(sourcePattern.pattern));
|
|
220
|
+
if (requiredPresence) {
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
return matches.slice(0, 10).map((match) => ({
|
|
224
|
+
ruleId: sourcePattern.ruleId,
|
|
225
|
+
channel: context.channel,
|
|
226
|
+
stage: 'prebuild-source',
|
|
227
|
+
severity: sourcePattern.severity,
|
|
228
|
+
status: sourcePattern.severity === 'error' ? 'fail' : 'warning',
|
|
229
|
+
message: sourcePattern.message,
|
|
230
|
+
recommendation: sourcePattern.recommendation,
|
|
231
|
+
file: filePath,
|
|
232
|
+
line: lineOfIndex(content, match.index ?? 0),
|
|
233
|
+
evidence: trimEvidence(match[0]),
|
|
234
|
+
owner: ownerForContext(context),
|
|
235
|
+
confidence: sourcePattern.confidence,
|
|
236
|
+
}));
|
|
237
|
+
}
|
|
238
|
+
function missingRequiredPatternResults(context, matchedRequiredRuleIds) {
|
|
239
|
+
const channelPatterns = CHANNEL_SOURCE_PATTERNS[context.channel] ?? [];
|
|
240
|
+
const results = [];
|
|
241
|
+
const sourceRuleIds = new Set(getRulesForStage(context.channel, 'prebuild-source').map((rule) => rule.id));
|
|
242
|
+
for (const pattern of channelPatterns) {
|
|
243
|
+
if (matchedRequiredRuleIds.has(pattern.ruleId) || !sourceRuleIds.has(pattern.ruleId)) {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
results.push({
|
|
247
|
+
ruleId: pattern.ruleId,
|
|
248
|
+
channel: context.channel,
|
|
249
|
+
stage: 'prebuild-source',
|
|
250
|
+
severity: pattern.severity,
|
|
251
|
+
status: pattern.severity === 'error' ? 'fail' : 'warning',
|
|
252
|
+
message: pattern.message,
|
|
253
|
+
recommendation: pattern.recommendation,
|
|
254
|
+
owner: ownerForContext(context),
|
|
255
|
+
confidence: pattern.confidence,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return results;
|
|
259
|
+
}
|
|
260
|
+
function ownerForContext(context) {
|
|
261
|
+
return context.buildChain === 'devkit' ? 'devkit' : 'playcraft-build';
|
|
262
|
+
}
|
|
263
|
+
function lineOfIndex(content, index) {
|
|
264
|
+
return content.slice(0, index).split(/\r?\n/).length;
|
|
265
|
+
}
|
|
266
|
+
function trimEvidence(value) {
|
|
267
|
+
return value.replace(/\s+/g, ' ').trim().slice(0, 200);
|
|
268
|
+
}
|
|
269
|
+
function unique(items) {
|
|
270
|
+
return Array.from(new Set(items));
|
|
271
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { BuildTool, EngineType, OutputFormat, Platform } from '../types.js';
|
|
2
|
+
export type ValidationStage = 'prebuild-source' | 'base-output' | 'html-artifact' | 'delivery-package' | 'runtime-manual' | 'external-validator';
|
|
3
|
+
export type RuleSeverity = 'error' | 'warning' | 'info';
|
|
4
|
+
export type RuleStatus = 'pass' | 'fail' | 'warning' | 'manual' | 'external' | 'not-applicable';
|
|
5
|
+
export type RuleConfidence = 'high' | 'medium' | 'low';
|
|
6
|
+
export type RuleOwner = 'playcraft-build' | 'playcraft-delivery' | 'devkit' | 'manual' | 'external';
|
|
7
|
+
export type DeliveryMode = 'single-html' | 'zip' | 'html-backed-zip' | 'fragment-assets' | 'mixed';
|
|
8
|
+
export type BuildChain = 'generic' | 'devkit' | 'playcanvas-native';
|
|
9
|
+
export type EvidenceType = 'file' | 'pattern' | 'package' | 'runtime' | 'external';
|
|
10
|
+
export type AutoCheck = {
|
|
11
|
+
kind: 'max-size';
|
|
12
|
+
maxBytes: number;
|
|
13
|
+
} | {
|
|
14
|
+
kind: 'external-resources';
|
|
15
|
+
allowedPatterns?: string[];
|
|
16
|
+
} | {
|
|
17
|
+
kind: 'required-pattern';
|
|
18
|
+
pattern: string;
|
|
19
|
+
flags?: string;
|
|
20
|
+
evidence?: string;
|
|
21
|
+
} | {
|
|
22
|
+
kind: 'forbidden-pattern';
|
|
23
|
+
pattern: string;
|
|
24
|
+
flags?: string;
|
|
25
|
+
evidence?: string;
|
|
26
|
+
} | {
|
|
27
|
+
kind: 'single-html';
|
|
28
|
+
};
|
|
29
|
+
export interface ValidationRule {
|
|
30
|
+
id: string;
|
|
31
|
+
title: string;
|
|
32
|
+
channel: Platform | 'common';
|
|
33
|
+
stage: ValidationStage;
|
|
34
|
+
severity: RuleSeverity;
|
|
35
|
+
owner: RuleOwner;
|
|
36
|
+
detectable: boolean;
|
|
37
|
+
confidence: RuleConfidence;
|
|
38
|
+
description: string;
|
|
39
|
+
recommendation: string;
|
|
40
|
+
evidenceType: EvidenceType;
|
|
41
|
+
source: string;
|
|
42
|
+
check?: AutoCheck;
|
|
43
|
+
}
|
|
44
|
+
export interface ChannelRuleProfile {
|
|
45
|
+
channel: Platform;
|
|
46
|
+
displayName: string;
|
|
47
|
+
defaultStages: ValidationStage[];
|
|
48
|
+
optionalStages: ValidationStage[];
|
|
49
|
+
deliveryMode: DeliveryMode;
|
|
50
|
+
internalBuildFormat: OutputFormat | 'delegated';
|
|
51
|
+
deliveryPackageFormat?: OutputFormat | 'fragment-assets' | 'mixed';
|
|
52
|
+
devkitOwned?: boolean;
|
|
53
|
+
rules: ValidationRule[];
|
|
54
|
+
}
|
|
55
|
+
export interface ValidationContext {
|
|
56
|
+
projectRoot: string;
|
|
57
|
+
channel: Platform;
|
|
58
|
+
engine: EngineType | 'unknown';
|
|
59
|
+
buildTool?: BuildTool;
|
|
60
|
+
buildChain: BuildChain;
|
|
61
|
+
htmlPath?: string;
|
|
62
|
+
packagePath?: string;
|
|
63
|
+
outputDir?: string;
|
|
64
|
+
sourceGlobs?: string[];
|
|
65
|
+
metadata?: Record<string, unknown>;
|
|
66
|
+
}
|
|
67
|
+
export interface ManualChecklistItem {
|
|
68
|
+
ruleId: string;
|
|
69
|
+
channel: Platform | 'common';
|
|
70
|
+
stage: ValidationStage;
|
|
71
|
+
message: string;
|
|
72
|
+
recommendation: string;
|
|
73
|
+
owner: RuleOwner;
|
|
74
|
+
}
|
|
75
|
+
export interface ExternalChecklistItem {
|
|
76
|
+
ruleId: string;
|
|
77
|
+
channel: Platform | 'common';
|
|
78
|
+
stage: ValidationStage;
|
|
79
|
+
message: string;
|
|
80
|
+
recommendation: string;
|
|
81
|
+
owner: RuleOwner;
|
|
82
|
+
}
|
|
83
|
+
export interface ValidationResult {
|
|
84
|
+
ruleId: string;
|
|
85
|
+
channel: Platform | 'common';
|
|
86
|
+
stage: ValidationStage;
|
|
87
|
+
severity: RuleSeverity;
|
|
88
|
+
status: RuleStatus;
|
|
89
|
+
message: string;
|
|
90
|
+
recommendation: string;
|
|
91
|
+
file?: string;
|
|
92
|
+
line?: number;
|
|
93
|
+
evidence?: string;
|
|
94
|
+
owner: RuleOwner;
|
|
95
|
+
confidence: RuleConfidence;
|
|
96
|
+
}
|
|
97
|
+
export interface ValidationSummary {
|
|
98
|
+
status: 'pass' | 'failed' | 'warning' | 'manual-required';
|
|
99
|
+
errorCount: number;
|
|
100
|
+
warningCount: number;
|
|
101
|
+
manualCount: number;
|
|
102
|
+
externalCount: number;
|
|
103
|
+
}
|
|
104
|
+
export interface ValidationReport {
|
|
105
|
+
version: string;
|
|
106
|
+
channel: Platform;
|
|
107
|
+
buildChain: BuildChain;
|
|
108
|
+
summary: ValidationSummary;
|
|
109
|
+
results: ValidationResult[];
|
|
110
|
+
manualChecklist: ManualChecklistItem[];
|
|
111
|
+
externalChecklist: ExternalChecklistItem[];
|
|
112
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Platform } from '../types.js';
|
|
2
|
+
import type { ValidationContext, ValidationReport, ValidationStage } from './types.js';
|
|
3
|
+
export declare const VALIDATION_REPORT_VERSION = "0.1.0";
|
|
4
|
+
export declare function runPlayableValidation(context: ValidationContext, stages?: ValidationStage[]): Promise<ValidationReport>;
|
|
5
|
+
export declare function runPlayableSourceValidationForChannels(context: Omit<ValidationContext, 'channel'>, channels: Platform[]): Promise<ValidationReport>;
|