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;
}