template compliance answers never reach a store: init writes unanswered (dataSafety, appPrivacy, reviewNotes, ageRating, accounts, reviewAccess), older configs are recognised by the template values; check / validate / scan block, play data-safety (also --dry), console plan / fill, apple review-details and appinfo refuse. onboard --declarations replaces them on a live app: name and copyright from the stores (a copyright with a private term is left out), the rows the code proves (scan proposal, sign-up methods from auth code and login SDKs), the rest as one owner question list. play data-safety --dry prints the answers that change (old -> new, account and deletion first) against the last uploaded CSV; pitfall 60; tests

This commit is contained in:
CRTkafa 2026-10-14 11:36:30 +08:00
parent 34c2fa22e5
commit 7219ded3e8
27 changed files with 659 additions and 19 deletions

View file

@ -132,3 +132,72 @@ function depthAt(s: string, at: number): number {
}
return d;
}
/** End of the value that starts at `i` in `s`: the comma or closing bracket at depth 0. */
function valueEnd(s: string, i: number): number {
let d = 0;
for (; i < s.length; i++) {
const ch = s[i]!;
if (ch === '/' && s[i + 1] === '/') { const n = s.indexOf('\n', i); if (n < 0) return s.length; i = n; continue; }
if (ch === '/' && s[i + 1] === '*') { const n = s.indexOf('*/', i + 2); if (n < 0) return s.length; i = n + 1; continue; }
if (ch === '"' || ch === '\'' || ch === '`') { for (i++; i < s.length && s[i] !== ch; i++) if (s[i] === '\\') i++; continue; }
if (ch === '{' || ch === '[' || ch === '(') d++;
else if (ch === '}' || ch === ']' || ch === ')') { if (d === 0) return i; d--; }
else if (ch === ',' && d === 0) return i;
}
return s.length;
}
/**
* The config text without the property at `path` (['dataSafety'], ['apple', 'info', 'reviewNotes']): the key, its
* value (any expression), its trailing comma and the comment lines right above it. Unchanged when the path is not
* there. .json is parsed and written back.
*/
export function removeKeyPath(text: string, file: string, path: string[]): string {
if (file.endsWith('.json')) {
const j = JSON.parse(text) as Record<string, unknown>;
let o: Record<string, unknown> | undefined = j;
for (const k of path.slice(0, -1)) o = o?.[k] as Record<string, unknown> | undefined;
if (!o || typeof o !== 'object' || !(path.at(-1)! in o)) return text;
delete o[path.at(-1)!];
return JSON.stringify(j, null, 2) + '\n';
}
let from = 0, to = text.length;
for (let n = 0; n < path.length; n++) {
const region = text.slice(from, to);
const re = new RegExp(`(^|[{,\\s])${path[n]}\\s*:\\s*`, 'gm');
let hit: { at: number; val: number } | null = null, bestInd = Infinity;
for (let m = re.exec(region); m; m = re.exec(region)) {
const at = m.index + m[1]!.length;
const lineStart = region.lastIndexOf('\n', at - 1) + 1;
if (region.slice(lineStart, at).includes('//')) continue; // in a comment
if (n > 0) { if (depthAt(region, at) !== 0) continue; hit = { at: from + at, val: from + m.index + m[0].length }; break; }
if (at - lineStart >= bestInd) continue;
hit = { at: from + at, val: from + m.index + m[0].length }; bestInd = at - lineStart;
}
if (!hit) return text;
if (n < path.length - 1) {
if (text[hit.val] !== '{') return text; // the parent is not an object literal
const close = matchBrace(text, hit.val);
if (close < 0) return text;
from = hit.val + 1; to = close;
continue;
}
let end = valueEnd(text, hit.val);
if (text[end] === ',') end++;
let start = hit.at;
const ls = text.lastIndexOf('\n', start - 1) + 1;
if (!text.slice(ls, start).trim()) {
start = ls;
while (start > 0) {
const prev = text.lastIndexOf('\n', start - 2) + 1;
if (!/^\s*\/\//.test(text.slice(prev, start - 1))) break;
start = prev;
}
const nl = /^[ \t]*\n/.exec(text.slice(end));
if (nl) end += nl[0].length;
}
return text.slice(0, start) + text.slice(end);
}
return text;
}

View file

@ -1,5 +1,6 @@
// `storebound scan` engine: detect the project, run the rejection-risk rules, propose the declarations. Offline and
// read-only (the only write is `--write`, done by the command, never here).
import { withoutTemplate } from '../template-answers';
import { existsSync, readFileSync } from 'node:fs';
import { join, relative, resolve } from 'node:path';
import { CONFIG_NAMES, loadConfig, type ShipConfig } from '../config';
@ -53,7 +54,7 @@ export async function runScan(dir: string, o: { config?: string } = {}): Promise
captions: readRel(config?.shots?.copy ?? 'store/shots/copy.json'),
};
const configFile = configPath ? { path: rel(configPath), text: readFileSync(configPath, 'utf8') } : null;
const declarations = proposeDeclarations(facts, config, configFile);
const declarations = proposeDeclarations(facts, config ? withoutTemplate(config) : null, configFile);
const dataMerges = config?.dataSafety ? declarations.diff.filter(d => d.change === 'merge' && d.before).map(d => {
const b = d.before!;
const missing = [...d.after.collect.filter(x => !b.collect.includes(x)).map(x => `collected for ${x}`), ...(d.after.shared && !b.shared ? ['shared'] : []),

View file

@ -33,6 +33,7 @@
// error of Play Console (no policy page; developer.android.com/build/configure-app-module on choosing the id).
// The other Google Play rules (sensitive permissions, prominent disclosure, Families, advertising ID, 16 KB pages,
// testing for personal accounts, Play metadata, ads, payments, impersonation) are in play-rules.ts with their sources.
import { templateBlockers } from '../template-answers';
import { BETA_WORDS, competitorRe, OTHER_PLATFORMS, PLACEHOLDER_MARKERS, PLACEHOLDER_TEXT, PRICE_CLAIMS } from '../listing-rules';
import type { ShipConfig } from '../config';
import { allSubscriptions } from '../subscriptions';
@ -560,6 +561,10 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
playRules(inp, { add: f => add({ ...f, store: 'play' }), cfg, sdkHits, listingHits, creates, restorable, paySdk });
// ---- owner-accepted findings ----
// compliance answers that are still the template's (storebound init): never the owner's, never pushed
if (c) for (const msg of templateBlockers(c)) for (const store of (['apple', 'play'] as const).filter(x => (x === 'apple' ? c.apple : c.play)))
add({ rule: 'template-answers', guideline: store === 'apple' ? '5.1.2 / 2.1' : 'Play: User Data', store, severity: 'blocker', title: `${msg.split(' still holds ')[0]} still holds template answers`,
evidence: cfgHits(inp, /unanswered\s*:|dataSafety\s*:|reviewNotes\s*:/), fix: msg, skill: 'store-declarations' });
applyIgnore(out, c?.scan?.ignore ?? []);
return sortScan(out);
}
@ -595,3 +600,11 @@ const RANK: Record<Severity, number> = { blocker: 0, high: 1, medium: 2, low: 3
export function sortScan(f: ScanFinding[]): ScanFinding[] {
return f.map((x, i) => [x, i] as const).sort(([a, i], [b, j]) => Number(!!a.accepted) - Number(!!b.accepted) || RANK[a.severity] - RANK[b.severity] || i - j).map(([x]) => x);
}
/** The first config line matching `re`, as evidence. */
function cfgHits(inp: ScanInput, re: RegExp): Hit[] {
const f = inp.configFile;
if (!f) return [];
const line = f.text.split('\n').findIndex(l => re.test(l));
return line < 0 ? [] : [{ file: f.path, line: line + 1, text: f.text.split('\n')[line]!.trim().slice(0, 100) }];
}