// Local AdAttributionKit / SKAdNetwork scan of an iOS project (no network, no credentials, no config needed). // storebound ads scan --ios ios/App [--plist path/to/Info.plist] [--dry] [--json] // Reads every app Info.plist (Pods, build output, tests and frameworks left out) plus Podfile.lock / Package.resolved // and the Swift / Objective-C sources, then checks: ad network ids (SKAdNetworkItems, AdNetworkIdentifiers; // lowercase, format, ad SDKs without ids), postback copy endpoints (AttributionCopyEndpoint, the re-engagement opt-in, // NSAdvertisingAttributionReportEndpoint), AdAttributionKitConfigurations (attribution windows, cooldown) and that an // advertised app updates its conversion value. Exit 1 on a blocker. --dry is accepted and changes nothing (read-only). // Rules and Apple's documentation links: cli/lib/adattribution.ts; the ads-mediation-checks skill explains the fixes. import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { join, relative, resolve } from 'node:path'; import type { Args } from './lib/args'; import { type AakFinding, evaluateAdAttribution, sourceSignals } from './lib/adattribution'; import { done, printJson } from './lib/log'; import { parsePlist, type PlistValue } from './lib/plist'; const SKIP = new Set(['.git', 'build', 'DerivedData', 'node_modules', '.build', 'xcuserdata', 'Pods', 'Carthage', 'fastlane']); const SKIP_PLIST = /(Tests?|UITests|\.bundle|\.framework|\.xcframework|\.appex|Extension|Widget|Watch)(\/|$)/i; function walk(dir: string, visit: (p: string) => void, depth = 0): void { if (depth > 12) return; let names: string[] = []; try { names = readdirSync(dir); } catch { return; } for (const n of names) { if (SKIP.has(n)) continue; const p = join(dir, n); let st; try { st = statSync(p); } catch { continue; } if (st.isDirectory()) walk(p, visit, depth + 1); else visit(p); } } export async function run(args: Args): Promise { if (args._[0] !== 'scan') throw new Error('usage: storebound ads scan --ios [--plist Info.plist] [--json]'); const ios = args.get('ios'); if (!ios) throw new Error('--ios '); const root = resolve(ios); if (!existsSync(root)) throw new Error(`${root} does not exist`); const plists: string[] = []; const files: { path: string; text: string }[] = []; walk(root, p => { const rel = relative(root, p); if (p.endsWith('/Info.plist') && !SKIP_PLIST.test(rel)) plists.push(p); if (/(\.swift|\.m|\.mm|Podfile\.lock|Package\.resolved|Cartfile\.resolved|project\.pbxproj)$/.test(p)) { try { if (statSync(p).size < 4_000_000) files.push({ path: rel, text: readFileSync(p, 'utf8') }); } catch { /* unreadable */ } } }); // lock files usually sit next to the project folder (ios/Podfile.lock for ios/App) for (const extra of ['Podfile.lock', '../Podfile.lock', 'Package.resolved', '../Package.resolved']) { const p = join(root, extra); if (existsSync(p) && !files.some(f => resolve(root, f.path) === resolve(p))) files.push({ path: extra, text: readFileSync(p, 'utf8') }); } const chosen = args.get('plist') ? [resolve(args.get('plist')!)] : plists; const signals = sourceSignals(files); const results: { plist: string; findings: AakFinding[] }[] = []; for (const p of chosen) { let parsed: PlistValue; try { parsed = parsePlist(readFileSync(p)); } catch (e) { results.push({ plist: relative(process.cwd(), p), findings: [{ level: 'warn', code: 'plist-read', msg: (e as Error).message }] }); continue; } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || Buffer.isBuffer(parsed)) continue; results.push({ plist: relative(process.cwd(), p), findings: evaluateAdAttribution(parsed, signals) }); } if (!chosen.length) results.push({ plist: '(none)', findings: [{ level: signals.adSdks.length ? 'blocker' : 'info', code: 'no-plist', msg: `no app Info.plist under ${root}${signals.adSdks.length ? ` but ad SDKs (${signals.adSdks.join(', ')}) need SKAdNetworkItems / AdNetworkIdentifiers in one` : ''}` }] }); const blockers = results.flatMap(r => r.findings).filter(f => f.level === 'blocker').length; if (args.has('json')) printJson({ ios: root, signals, results, blockers }); else { console.log(`ad SDKs: ${signals.adSdks.join(', ') || 'none found'} · attribution SDKs: ${signals.mmpSdks.join(', ') || 'none'} · import AdAttributionKit: ${signals.aakImport ? 'yes' : 'no'} · conversion value updates in code: ${signals.conversionUpdates ? 'yes' : 'no'}`); const tag = { blocker: 'BLOCKER', warn: 'warn ', ok: 'ok ', info: 'info ' } as const; for (const r of results) { console.log(`\n${r.plist}`); for (const f of r.findings) console.log(` ${tag[f.level]} ${f.msg}`); } } done('ads', blockers ? `${blockers} blocker(s)` : 'no blocker'); if (blockers) process.exitCode = 1; }