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:
parent
34c2fa22e5
commit
7219ded3e8
27 changed files with 659 additions and 19 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'] : []),
|
||||
|
|
|
|||
|
|
@ -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) }];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue