mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-20 14:31:12 +02:00
fix(ios-qa): generate app-owned bridge accessors deterministically
This commit is contained in:
+659
-89
@@ -5,15 +5,16 @@
|
||||
// first time. Also exercised by tests so we can verify the cache + parse
|
||||
// behavior without a Swift toolchain.
|
||||
//
|
||||
// The TS port uses a stricter regex than the fork's original — it understands:
|
||||
// The TS fast path uses a lightweight Swift lexical scanner — it understands:
|
||||
// - @Observable class declarations
|
||||
// - @Snapshotable property markers (only marked fields are exported)
|
||||
// - `// @Snapshotable` property markers (only marked fields are exported)
|
||||
// - Legacy @Snapshotable attributes for existing integrations
|
||||
// - Multi-line type signatures (collapses whitespace before matching)
|
||||
// - Generic type parameters (matched as opaque text inside the type)
|
||||
// - JSON-native generic arrays and String-keyed dictionaries
|
||||
//
|
||||
// What it does NOT handle (deferred to the SwiftPM tool):
|
||||
// - Computed properties with bodies (regex can mis-parse braces)
|
||||
// - Property wrappers other than @Snapshotable
|
||||
// Invalid marked declarations (computed/immutable/inaccessible/untyped,
|
||||
// nested, duplicate-keyed, or non-JSON) fail generation instead of producing
|
||||
// Swift that only fails later in xcodebuild or at snapshot time.
|
||||
//
|
||||
// Composite cache key (codex-flagged): swift_version || tool_git_rev ||
|
||||
// platform_triple || source_content_hash. Source-only hash misses generator
|
||||
@@ -35,6 +36,16 @@ export interface AccessorSpec {
|
||||
fields: AccessorField[];
|
||||
}
|
||||
|
||||
export class AccessorGenerationError extends Error {
|
||||
readonly diagnostics: string[];
|
||||
|
||||
constructor(diagnostics: string[]) {
|
||||
super(`gen-accessors: invalid @Snapshotable declaration(s):\n${diagnostics.map(d => ` - ${d}`).join('\n')}`);
|
||||
this.name = 'AccessorGenerationError';
|
||||
this.diagnostics = diagnostics;
|
||||
}
|
||||
}
|
||||
|
||||
export interface GenInputs {
|
||||
inputDir: string;
|
||||
outputDir?: string;
|
||||
@@ -48,56 +59,105 @@ export interface GenInputs {
|
||||
export interface GenResult {
|
||||
outputPath: string;
|
||||
cacheKey: string;
|
||||
accessorHash: string;
|
||||
specs: AccessorSpec[];
|
||||
cacheHit: boolean;
|
||||
}
|
||||
|
||||
const FALLBACK_PLATFORM = process.platform === 'darwin' ? 'darwin-arm64' : `${process.platform}-${process.arch}`;
|
||||
const GENERATOR_FORMAT_VERSION = 'accessor-generator-v5';
|
||||
|
||||
export function collectSwiftFiles(dir: string, opts: { excludeGenerated?: boolean } = {}): string[] {
|
||||
const JSON_SCALAR_TYPES = new Set([
|
||||
'String',
|
||||
'Bool',
|
||||
'Int', 'Int8', 'Int16', 'Int32', 'Int64',
|
||||
'UInt', 'UInt8', 'UInt16', 'UInt32', 'UInt64',
|
||||
'Float', 'Double', 'CGFloat',
|
||||
]);
|
||||
|
||||
export function collectSwiftFiles(
|
||||
dir: string,
|
||||
opts: { excludeGenerated?: boolean; outputDir?: string } = {},
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
const excludeGenerated = opts.excludeGenerated ?? true;
|
||||
for (const name of readdirSync(dir)) {
|
||||
const full = join(dir, name);
|
||||
const s = statSync(full);
|
||||
if (s.isDirectory()) {
|
||||
// Skip generated output dir (when it lives under the input dir)
|
||||
if (excludeGenerated && name === 'DebugBridgeGenerated') continue;
|
||||
out.push(...collectSwiftFiles(full, opts));
|
||||
} else if (name.endsWith('.swift')) {
|
||||
// Skip the codegen output file. Otherwise the second run picks it up,
|
||||
// changes the cache key, and the cache never hits.
|
||||
if (excludeGenerated && name === 'StateAccessor.swift') continue;
|
||||
out.push(full);
|
||||
const root = resolve(dir);
|
||||
const outputDir = opts.outputDir ? resolve(opts.outputDir) : undefined;
|
||||
|
||||
function walk(currentDir: string): void {
|
||||
for (const name of readdirSync(currentDir)) {
|
||||
const full = join(currentDir, name);
|
||||
const s = statSync(full);
|
||||
if (s.isDirectory()) {
|
||||
// The generated subtree must never participate in its own cache key.
|
||||
// Keep the well-known-name guard for direct collectSwiftFiles callers,
|
||||
// and use the actual --output path when generate() supplies one.
|
||||
const isOutputSubtree = outputDir !== undefined
|
||||
&& outputDir !== root
|
||||
&& resolve(full) === outputDir;
|
||||
if (excludeGenerated && (name === 'DebugBridgeGenerated' || isOutputSubtree)) continue;
|
||||
walk(full);
|
||||
} else if (name.endsWith('.swift')) {
|
||||
// Skip generated accessor files wherever they live. Otherwise moving
|
||||
// an old copy outside the output directory poisons the next cache key.
|
||||
if (excludeGenerated && name === 'StateAccessor.swift') continue;
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(root);
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
export function parseSwift(source: string): AccessorSpec[] {
|
||||
const specs: AccessorSpec[] = [];
|
||||
const diagnostics: string[] = [];
|
||||
const masked = maskSwiftSource(source);
|
||||
// Find `@Observable\n(public )?(final )?class <Name>` followed by a brace
|
||||
// block. We then scan inside that block for @Snapshotable fields.
|
||||
const classPattern = /@Observable\s*(?:(?:public|internal|fileprivate|private)\s+)?(?:final\s+)?class\s+(\w+)[^{]*\{/g;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = classPattern.exec(source)) !== null) {
|
||||
for (const match of masked.matchAll(classPattern)) {
|
||||
const className = match[1]!;
|
||||
const startIdx = classPattern.lastIndex;
|
||||
const endIdx = findMatchingBrace(source, startIdx - 1);
|
||||
const matchOffset = match.index!;
|
||||
const openBraceOffset = matchOffset + match[0].lastIndexOf('{');
|
||||
const startIdx = openBraceOffset + 1;
|
||||
const endIdx = findMatchingBrace(masked, startIdx - 1);
|
||||
if (endIdx === -1) continue;
|
||||
const body = source.slice(startIdx, endIdx);
|
||||
const body = masked.slice(startIdx, endIdx);
|
||||
|
||||
const fields = parseFields(body);
|
||||
const parsed = parseFields(body, source, startIdx, className);
|
||||
if (braceDepthAt(masked, matchOffset) !== 0) {
|
||||
if (parsed.fields.length > 0 || parsed.diagnostics.length > 0) {
|
||||
const line = source.slice(0, matchOffset).split(/\r?\n/).length;
|
||||
diagnostics.push(`${className} (line ${line}): nested @Observable types are not supported; move the type to file scope`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
diagnostics.push(...parsed.diagnostics);
|
||||
const fields = parsed.fields;
|
||||
if (fields.length > 0) {
|
||||
specs.push({ className, fields });
|
||||
}
|
||||
}
|
||||
|
||||
if (diagnostics.length > 0) throw new AccessorGenerationError(diagnostics);
|
||||
return specs;
|
||||
}
|
||||
|
||||
function braceDepthAt(masked: string, offset: number): number {
|
||||
let depth = 0;
|
||||
for (let i = 0; i < offset; i++) {
|
||||
if (masked[i] === '{') depth++;
|
||||
else if (masked[i] === '}') depth = Math.max(0, depth - 1);
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
function findMatchingBrace(s: string, openIdx: number): number {
|
||||
// openIdx points at '{'. Return idx of matching '}', or -1.
|
||||
// Strings and comments have already been blanked by maskSwiftSource, so
|
||||
// braces here are syntax rather than prose or literal content.
|
||||
let depth = 0;
|
||||
for (let i = openIdx; i < s.length; i++) {
|
||||
const c = s[i];
|
||||
@@ -105,48 +165,305 @@ function findMatchingBrace(s: string, openIdx: number): number {
|
||||
else if (c === '}') {
|
||||
depth--;
|
||||
if (depth === 0) return i;
|
||||
} else if (c === '"' || c === "'") {
|
||||
// skip string literal
|
||||
const quote = c;
|
||||
i++;
|
||||
while (i < s.length && s[i] !== quote) {
|
||||
if (s[i] === '\\') i++;
|
||||
i++;
|
||||
}
|
||||
} else if (c === '/' && s[i + 1] === '/') {
|
||||
// skip line comment
|
||||
while (i < s.length && s[i] !== '\n') i++;
|
||||
} else if (c === '/' && s[i + 1] === '*') {
|
||||
i += 2;
|
||||
while (i < s.length - 1 && !(s[i] === '*' && s[i + 1] === '/')) i++;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function parseFields(body: string): AccessorField[] {
|
||||
// Look for @Snapshotable followed by var/let declarations. Allow attribute
|
||||
// ordering: `@Snapshotable var name: Type` OR `@Snapshotable\n var name: Type`.
|
||||
// Multi-line types are handled by greedy non-newline match in the type, but
|
||||
// we collapse adjacent whitespace first to avoid false negatives.
|
||||
const normalized = body.replace(/[\t ]*\r?\n[\t ]*/g, ' ');
|
||||
const fieldPattern = /@Snapshotable\s+(?:(?:public|internal|fileprivate|private)\s+)?(?:var|let)\s+(\w+)\s*:\s*([^={]+?)(?=\s*(?:=|\{|@Snapshotable|\bvar\b|\blet\b|\bfunc\b|\}|$))/g;
|
||||
const fields: AccessorField[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = fieldPattern.exec(normalized)) !== null) {
|
||||
// Codex catch: skip fields that have a computed body (`{ get ... }` or
|
||||
// `{ didSet ... }` after the type). The match boundary stops before `{`,
|
||||
// so we peek at what comes after the type in the original body.
|
||||
const afterMatchIdx = m.index + m[0].length;
|
||||
const afterMatch = normalized.slice(afterMatchIdx, afterMatchIdx + 4).trim();
|
||||
// If the next non-space character is `{`, this is a computed property.
|
||||
// We're conservative: snapshot-eligible fields must be stored properties
|
||||
// (initialized with `=` or just declared).
|
||||
if (afterMatch.startsWith('{')) continue;
|
||||
fields.push({ name: m[1]!, typeText: m[2]!.trim() });
|
||||
/**
|
||||
* Blank comments and string literals while preserving byte offsets/newlines.
|
||||
* A canonical standalone `// @Snapshotable` comment is rewritten to the
|
||||
* equivalent attribute token. This is intentionally lexical rather than a
|
||||
* whole-file regexp: markers inside nested block comments, normal/triple/raw
|
||||
* strings, trailing comments, and prose comments must never opt a field in.
|
||||
*/
|
||||
function maskSwiftSource(source: string): string {
|
||||
const out = source.split('');
|
||||
|
||||
const blank = (start: number, end: number): void => {
|
||||
for (let j = start; j < end; j++) {
|
||||
if (out[j] !== '\n' && out[j] !== '\r') out[j] = ' ';
|
||||
}
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
while (i < source.length) {
|
||||
if (source[i] === '/' && source[i + 1] === '/') {
|
||||
let end = i + 2;
|
||||
while (end < source.length && source[end] !== '\n' && source[end] !== '\r') end++;
|
||||
const lineStart = source.lastIndexOf('\n', i - 1) + 1;
|
||||
const standalone = source.slice(lineStart, i).trim().length === 0;
|
||||
const isMarker = standalone && source.slice(i + 2, end).trim() === '@Snapshotable';
|
||||
blank(i, end);
|
||||
if (isMarker) {
|
||||
const marker = '@Snapshotable';
|
||||
for (let j = 0; j < marker.length; j++) out[i + j] = marker[j]!;
|
||||
}
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source[i] === '/' && source[i + 1] === '*') {
|
||||
const start = i;
|
||||
i += 2;
|
||||
let depth = 1;
|
||||
while (i < source.length && depth > 0) {
|
||||
if (source[i] === '/' && source[i + 1] === '*') {
|
||||
depth++;
|
||||
i += 2;
|
||||
} else if (source[i] === '*' && source[i + 1] === '/') {
|
||||
depth--;
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
blank(start, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Swift strings may be ordinary, multiline, or raw (`#"..."#` and
|
||||
// `#"""..."""#`). Mask the entire literal, including interpolation;
|
||||
// declarations cannot legally originate inside a literal.
|
||||
let hashCount = 0;
|
||||
while (source[i + hashCount] === '#') hashCount++;
|
||||
const quoteIdx = i + hashCount;
|
||||
if (source[quoteIdx] === '"') {
|
||||
const start = i;
|
||||
const triple = source.slice(quoteIdx, quoteIdx + 3) === '"""';
|
||||
const quoteCount = triple ? 3 : 1;
|
||||
i = quoteIdx + quoteCount;
|
||||
while (i < source.length) {
|
||||
if (hashCount === 0 && source[i] === '\\') {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
const quoteRun = source.slice(i, i + quoteCount) === '"'.repeat(quoteCount);
|
||||
const hashesMatch = source.slice(i + quoteCount, i + quoteCount + hashCount) === '#'.repeat(hashCount);
|
||||
if (quoteRun && hashesMatch) {
|
||||
i += quoteCount + hashCount;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
blank(start, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
return fields;
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
const DECL_MODIFIERS = new Set([
|
||||
'public', 'internal', 'package', 'open', 'private', 'fileprivate',
|
||||
'final', 'static', 'class', 'lazy', 'weak', 'unowned', 'override',
|
||||
'nonisolated', 'isolated', 'borrowing', 'consuming',
|
||||
]);
|
||||
|
||||
function parseFields(
|
||||
body: string,
|
||||
fullSource: string,
|
||||
bodyOffset: number,
|
||||
className: string,
|
||||
): { fields: AccessorField[]; diagnostics: string[] } {
|
||||
const fields: AccessorField[] = [];
|
||||
const diagnostics: string[] = [];
|
||||
let braceDepth = 0;
|
||||
|
||||
const lineFor = (localOffset: number): number => {
|
||||
const absolute = bodyOffset + localOffset;
|
||||
let line = 1;
|
||||
for (let j = 0; j < absolute; j++) if (fullSource[j] === '\n') line++;
|
||||
return line;
|
||||
};
|
||||
const fail = (offset: number, message: string): void => {
|
||||
diagnostics.push(`${className} (line ${lineFor(offset)}): ${message}`);
|
||||
};
|
||||
const skipWhitespace = (start: number): number => {
|
||||
let at = start;
|
||||
while (at < body.length && /\s/.test(body[at]!)) at++;
|
||||
return at;
|
||||
};
|
||||
const identifierAt = (start: number): { text: string; end: number } | undefined => {
|
||||
const m = /^[A-Za-z_]\w*/.exec(body.slice(start));
|
||||
return m ? { text: m[0], end: start + m[0].length } : undefined;
|
||||
};
|
||||
const skipBalancedParens = (start: number): number => {
|
||||
if (body[start] !== '(') return start;
|
||||
let depth = 0;
|
||||
for (let at = start; at < body.length; at++) {
|
||||
if (body[at] === '(') depth++;
|
||||
else if (body[at] === ')' && --depth === 0) return at + 1;
|
||||
}
|
||||
return body.length;
|
||||
};
|
||||
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
if (body[i] === '{') {
|
||||
braceDepth++;
|
||||
continue;
|
||||
}
|
||||
if (body[i] === '}') {
|
||||
braceDepth--;
|
||||
continue;
|
||||
}
|
||||
if (braceDepth !== 0 || !body.startsWith('@Snapshotable', i)) continue;
|
||||
const before = i === 0 ? '' : body[i - 1]!;
|
||||
const after = body[i + '@Snapshotable'.length] ?? '';
|
||||
if (/\w/.test(before) || /\w/.test(after)) continue;
|
||||
|
||||
const markerOffset = i;
|
||||
let at = skipWhitespace(i + '@Snapshotable'.length);
|
||||
const modifiers: string[] = [];
|
||||
let bindingKind: 'var' | 'let' | undefined;
|
||||
|
||||
// Permit other Swift attributes between the marker and declaration. They
|
||||
// remain the compiler's responsibility; this scanner only owns the
|
||||
// snapshot contract. Parenthesized attribute arguments are skipped.
|
||||
while (body[at] === '@') {
|
||||
const attribute = identifierAt(at + 1);
|
||||
if (!attribute) break;
|
||||
at = skipWhitespace(attribute.end);
|
||||
if (body[at] === '(') at = skipBalancedParens(at);
|
||||
at = skipWhitespace(at);
|
||||
}
|
||||
|
||||
while (at < body.length) {
|
||||
const token = identifierAt(at);
|
||||
if (!token) break;
|
||||
if (token.text === 'var' || token.text === 'let') {
|
||||
bindingKind = token.text;
|
||||
at = token.end;
|
||||
break;
|
||||
}
|
||||
if (!DECL_MODIFIERS.has(token.text)) break;
|
||||
let modifier = token.text;
|
||||
at = skipWhitespace(token.end);
|
||||
if (body[at] === '(') {
|
||||
const end = skipBalancedParens(at);
|
||||
modifier += body.slice(at, end).replace(/\s+/g, '');
|
||||
at = skipWhitespace(end);
|
||||
}
|
||||
modifiers.push(modifier);
|
||||
}
|
||||
|
||||
if (!bindingKind) {
|
||||
fail(markerOffset, '@Snapshotable must immediately precede a stored property declaration');
|
||||
continue;
|
||||
}
|
||||
|
||||
at = skipWhitespace(at);
|
||||
const identifier = identifierAt(at);
|
||||
const fieldName = identifier?.text ?? '<unknown>';
|
||||
if (!identifier) {
|
||||
fail(markerOffset, '@Snapshotable only supports a single identifier binding');
|
||||
continue;
|
||||
}
|
||||
at = skipWhitespace(identifier.end);
|
||||
|
||||
if (bindingKind === 'let') {
|
||||
fail(markerOffset, `@Snapshotable field '${fieldName}' must be declared var, not let`);
|
||||
continue;
|
||||
}
|
||||
if (modifiers.some(m => /^(?:private|fileprivate)(?:\(set\))?$/.test(m))) {
|
||||
fail(markerOffset, `@Snapshotable field '${fieldName}' cannot be private, fileprivate, private(set), or fileprivate(set)`);
|
||||
continue;
|
||||
}
|
||||
if (modifiers.some(m => m === 'static' || m === 'class')) {
|
||||
fail(markerOffset, `@Snapshotable field '${fieldName}' must be an instance property`);
|
||||
continue;
|
||||
}
|
||||
if (body[at] !== ':') {
|
||||
fail(markerOffset, `@Snapshotable field '${fieldName}' requires an explicit type annotation`);
|
||||
continue;
|
||||
}
|
||||
|
||||
at = skipWhitespace(at + 1);
|
||||
const typeStart = at;
|
||||
let parens = 0;
|
||||
let brackets = 0;
|
||||
let angles = 0;
|
||||
let typeEnd = at;
|
||||
let delimiter = '';
|
||||
for (; at < body.length; at++) {
|
||||
const c = body[at]!;
|
||||
if (c === '(') parens++;
|
||||
else if (c === ')') parens--;
|
||||
else if (c === '[') brackets++;
|
||||
else if (c === ']') brackets--;
|
||||
else if (c === '<') angles++;
|
||||
else if (c === '>') angles = Math.max(0, angles - 1);
|
||||
const topLevel = parens === 0 && brackets === 0 && angles === 0;
|
||||
if (topLevel && (c === '=' || c === '{' || c === ',' || c === ';')) {
|
||||
delimiter = c;
|
||||
break;
|
||||
}
|
||||
if (topLevel && (c === '\n' || c === '\r')) {
|
||||
const soFar = body.slice(typeStart, at).trim();
|
||||
if (soFar.length > 0 && !/(?:->|[&.,])\s*$/.test(soFar)) break;
|
||||
}
|
||||
typeEnd = at + 1;
|
||||
}
|
||||
const typeText = body.slice(typeStart, typeEnd).replace(/\s+/g, ' ').trim();
|
||||
if (typeText.length === 0) {
|
||||
fail(markerOffset, `@Snapshotable field '${fieldName}' requires an explicit type annotation`);
|
||||
continue;
|
||||
}
|
||||
if (delimiter === '{') {
|
||||
fail(markerOffset, `@Snapshotable field '${fieldName}' must be stored and writable`);
|
||||
continue;
|
||||
}
|
||||
if (delimiter === ',') {
|
||||
fail(markerOffset, '@Snapshotable declarations must contain exactly one binding');
|
||||
continue;
|
||||
}
|
||||
if (delimiter === '=') {
|
||||
// A declaration such as `var a: Int = 1, b: Int = 2` reaches `=`
|
||||
// before its binding comma. Scan the initializer at syntax depth zero
|
||||
// so commas in arrays/calls/closures remain valid.
|
||||
let initializerAt = at + 1;
|
||||
let expressionParens = 0;
|
||||
let expressionBrackets = 0;
|
||||
let expressionBraces = 0;
|
||||
let hasSecondBinding = false;
|
||||
for (; initializerAt < body.length; initializerAt++) {
|
||||
const c = body[initializerAt]!;
|
||||
if (c === '(') expressionParens++;
|
||||
else if (c === ')') expressionParens--;
|
||||
else if (c === '[') expressionBrackets++;
|
||||
else if (c === ']') expressionBrackets--;
|
||||
else if (c === '{') expressionBraces++;
|
||||
else if (c === '}') {
|
||||
if (expressionBraces === 0) break;
|
||||
expressionBraces--;
|
||||
}
|
||||
const topLevel = expressionParens === 0 && expressionBrackets === 0 && expressionBraces === 0;
|
||||
if (topLevel && c === ',') {
|
||||
hasSecondBinding = true;
|
||||
break;
|
||||
}
|
||||
if (topLevel && (c === '\n' || c === '\r' || c === ';')) break;
|
||||
}
|
||||
if (hasSecondBinding) {
|
||||
fail(markerOffset, '@Snapshotable declarations must contain exactly one binding');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const wrappedOptional = optionalWrappedType(typeText);
|
||||
if (wrappedOptional !== undefined && optionalWrappedType(wrappedOptional) !== undefined) {
|
||||
fail(markerOffset, `@Snapshotable field '${fieldName}' cannot use a nested Optional type`);
|
||||
continue;
|
||||
}
|
||||
const typeIssue = snapshotTypeIssue(typeText);
|
||||
if (typeIssue !== undefined) {
|
||||
fail(markerOffset, `@Snapshotable field '${fieldName}' ${typeIssue}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
fields.push({ name: fieldName, typeText });
|
||||
}
|
||||
return { fields, diagnostics };
|
||||
}
|
||||
|
||||
export function computeCacheKey(inputs: {
|
||||
@@ -154,35 +471,263 @@ export function computeCacheKey(inputs: {
|
||||
swiftVersion: string;
|
||||
toolGitRev: string;
|
||||
platformTriple: string;
|
||||
buildId?: string;
|
||||
}): string {
|
||||
const h = createHash('sha256');
|
||||
h.update(`swift=${inputs.swiftVersion}|tool=${inputs.toolGitRev}|platform=${inputs.platformTriple}|`);
|
||||
h.update(`${GENERATOR_FORMAT_VERSION}|swift=${inputs.swiftVersion}|tool=${inputs.toolGitRev}|platform=${inputs.platformTriple}|build=${inputs.buildId ?? 'unknown'}|`);
|
||||
for (const f of inputs.swiftFiles) {
|
||||
const content = readFileSync(f);
|
||||
h.update(`${f}:${content.length}:`);
|
||||
// Cache identity is content-based. Absolute checkout paths must not make
|
||||
// equivalent source trees produce different generated output.
|
||||
h.update(`${content.length}:`);
|
||||
h.update(content);
|
||||
h.update('|');
|
||||
}
|
||||
return h.digest('hex');
|
||||
}
|
||||
|
||||
export function render(specs: AccessorSpec[], buildId: string, accessorHash: string): string {
|
||||
let out = '// AUTO-GENERATED — DO NOT EDIT. Regenerate with /ios-sync.\n';
|
||||
out += '#if DEBUG\nimport Foundation\nimport DebugBridge\n\n';
|
||||
/** Stable snapshot-schema fingerprint, deliberately independent of cache ABI,
|
||||
* source paths, app build provenance, and unmarked source. Field/class order is
|
||||
* source order because restore payload compatibility is an ordered contract. */
|
||||
export function computeAccessorHash(specs: AccessorSpec[]): string {
|
||||
let signature = 'snapshot-schema-v1\n';
|
||||
for (const spec of specs) {
|
||||
out += `@MainActor\npublic enum ${spec.className}Accessor {\n`;
|
||||
out += ` public static func register(_ state: ${spec.className}) {\n`;
|
||||
signature += `C${Buffer.byteLength(spec.className)}:${spec.className}\n`;
|
||||
for (const field of spec.fields) {
|
||||
signature += `F${Buffer.byteLength(field.name)}:${field.name}`;
|
||||
signature += `T${Buffer.byteLength(field.typeText)}:${field.typeText}\n`;
|
||||
}
|
||||
signature += 'E\n';
|
||||
}
|
||||
return createHash('sha256').update(signature).digest('hex');
|
||||
}
|
||||
|
||||
/** Return the wrapped type for one top-level Optional spelling. */
|
||||
export function optionalWrappedType(typeText: string): string | undefined {
|
||||
const type = typeText.trim();
|
||||
if (type.endsWith('?')) {
|
||||
const wrapped = type.slice(0, -1).trim();
|
||||
return wrapped.length > 0 ? wrapped : undefined;
|
||||
}
|
||||
|
||||
const optional = /^(?:Swift\.)?Optional\s*</.exec(type);
|
||||
if (!optional) return undefined;
|
||||
const open = type.indexOf('<', optional.index);
|
||||
let depth = 0;
|
||||
for (let i = open; i < type.length; i++) {
|
||||
if (type[i] === '<') depth++;
|
||||
else if (type[i] === '>') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
if (type.slice(i + 1).trim().length > 0) return undefined;
|
||||
const wrapped = type.slice(open + 1, i).trim();
|
||||
return wrapped.length > 0 ? wrapped : undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function splitTopLevel(type: string, delimiter: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let start = 0;
|
||||
let angle = 0;
|
||||
let square = 0;
|
||||
let paren = 0;
|
||||
for (let i = 0; i < type.length; i++) {
|
||||
const char = type[i]!;
|
||||
if (char === '<') angle++;
|
||||
else if (char === '>') angle--;
|
||||
else if (char === '[') square++;
|
||||
else if (char === ']') square--;
|
||||
else if (char === '(') paren++;
|
||||
else if (char === ')') paren--;
|
||||
else if (char === delimiter && angle === 0 && square === 0 && paren === 0) {
|
||||
parts.push(type.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
parts.push(type.slice(start));
|
||||
return parts;
|
||||
}
|
||||
|
||||
function genericArgument(type: string, name: string): string | undefined {
|
||||
const prefix = `${name}<`;
|
||||
if (!type.startsWith(prefix) || !type.endsWith('>')) return undefined;
|
||||
let depth = 0;
|
||||
for (let i = name.length; i < type.length; i++) {
|
||||
if (type[i] === '<') depth++;
|
||||
else if (type[i] === '>') {
|
||||
depth--;
|
||||
if (depth === 0 && i !== type.length - 1) return undefined;
|
||||
if (depth < 0) return undefined;
|
||||
}
|
||||
}
|
||||
return depth === 0 ? type.slice(prefix.length, -1) : undefined;
|
||||
}
|
||||
|
||||
/** Return a user-facing suffix when a Swift type cannot round-trip through
|
||||
* Foundation JSON without custom encoding. The accepted grammar is deliberately
|
||||
* narrow: JSON scalar values, arrays, string-keyed dictionaries, and Optional
|
||||
* compositions of those types. */
|
||||
export function snapshotTypeIssue(typeText: string): string | undefined {
|
||||
const type = typeText.replace(/\s+/g, '');
|
||||
if (type.length === 0) return 'requires a JSON-compatible type';
|
||||
if (type.endsWith('!')) {
|
||||
return 'cannot use an implicitly unwrapped Optional; use T? instead';
|
||||
}
|
||||
|
||||
if (type.endsWith('?')) {
|
||||
const wrapped = type.slice(0, -1);
|
||||
if (wrapped.endsWith('?')
|
||||
|| genericArgument(wrapped, 'Optional') !== undefined
|
||||
|| genericArgument(wrapped, 'Swift.Optional') !== undefined) {
|
||||
return 'cannot use a nested Optional type';
|
||||
}
|
||||
return snapshotTypeIssue(wrapped);
|
||||
}
|
||||
const optional = genericArgument(type, 'Optional') ?? genericArgument(type, 'Swift.Optional');
|
||||
if (optional !== undefined) {
|
||||
if (optional.endsWith('?')
|
||||
|| genericArgument(optional, 'Optional') !== undefined
|
||||
|| genericArgument(optional, 'Swift.Optional') !== undefined) {
|
||||
return 'cannot use a nested Optional type';
|
||||
}
|
||||
return snapshotTypeIssue(optional);
|
||||
}
|
||||
|
||||
const scalar = type === 'CoreGraphics.CGFloat'
|
||||
? 'CGFloat'
|
||||
: (type.startsWith('Swift.') ? type.slice('Swift.'.length) : type);
|
||||
if (JSON_SCALAR_TYPES.has(scalar)) return undefined;
|
||||
|
||||
if (type.startsWith('[') && type.endsWith(']')) {
|
||||
const inner = type.slice(1, -1);
|
||||
const dictionaryParts = splitTopLevel(inner, ':');
|
||||
if (dictionaryParts.length === 1) return snapshotTypeIssue(inner);
|
||||
if (dictionaryParts.length === 2) {
|
||||
const key = dictionaryParts[0]!.replace(/^Swift\./, '');
|
||||
if (key !== 'String') return 'must use String keys for snapshot dictionaries';
|
||||
return snapshotTypeIssue(dictionaryParts[1]!);
|
||||
}
|
||||
return 'requires a JSON-compatible array or dictionary type';
|
||||
}
|
||||
|
||||
const array = genericArgument(type, 'Array') ?? genericArgument(type, 'Swift.Array');
|
||||
if (array !== undefined) return snapshotTypeIssue(array);
|
||||
const dictionary = genericArgument(type, 'Dictionary') ?? genericArgument(type, 'Swift.Dictionary');
|
||||
if (dictionary !== undefined) {
|
||||
const parts = splitTopLevel(dictionary, ',');
|
||||
if (parts.length !== 2) return 'requires a JSON-compatible dictionary type';
|
||||
const key = parts[0]!.replace(/^Swift\./, '');
|
||||
if (key !== 'String') return 'must use String keys for snapshot dictionaries';
|
||||
return snapshotTypeIssue(parts[1]!);
|
||||
}
|
||||
|
||||
return `uses unsupported snapshot type '${typeText}'; use JSON scalar, array, or String-keyed dictionary types`;
|
||||
}
|
||||
|
||||
export function validateAccessorSpecs(specs: AccessorSpec[]): void {
|
||||
const owners = new Map<string, string>();
|
||||
const diagnostics: string[] = [];
|
||||
for (const spec of specs) {
|
||||
for (const field of spec.fields) {
|
||||
const previous = owners.get(field.name);
|
||||
if (previous !== undefined) {
|
||||
diagnostics.push(`snapshot key '${field.name}' is declared by both ${previous} and ${spec.className}; keys must be unique across @Observable types`);
|
||||
} else {
|
||||
owners.set(field.name, spec.className);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (diagnostics.length > 0) throw new AccessorGenerationError(diagnostics);
|
||||
}
|
||||
|
||||
function swiftStringLiteral(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function render(specs: AccessorSpec[], buildId: string, accessorHash: string): string {
|
||||
validateAccessorSpecs(specs);
|
||||
let out = '// AUTO-GENERATED — DO NOT EDIT. Regenerate with /ios-sync.\n';
|
||||
out += '#if DEBUG\nimport Foundation\nimport DebugBridgeCore\n\n';
|
||||
for (const spec of specs) {
|
||||
// Accessors compile in the app target beside its usually-internal state
|
||||
// types. Making this API public would make valid internal models fail
|
||||
// Swift type checking (a public signature cannot expose an internal type).
|
||||
out += `@MainActor\nenum ${spec.className}Accessor {\n`;
|
||||
out += ` private static func decodeSnapshotValue<T: Decodable>(_ value: Any, as type: T.Type) -> T? {\n`;
|
||||
out += ` guard JSONSerialization.isValidJSONObject(["value": value]),\n`;
|
||||
out += ` let data = try? JSONSerialization.data(withJSONObject: ["value": value]),\n`;
|
||||
out += ` let decoded = try? JSONDecoder().decode([String: T].self, from: data) else { return nil }\n`;
|
||||
out += ` return decoded["value"]\n`;
|
||||
out += ` }\n\n`;
|
||||
out += ` static func register(_ state: ${spec.className}) {\n`;
|
||||
out += ` StateServer.shared.register(\n`;
|
||||
out += ` buildId: "${buildId}",\n`;
|
||||
out += ` accessorHash: "${accessorHash}",\n`;
|
||||
out += ` atomicRestore: { _ in .ok }\n`;
|
||||
out += ` buildId: {\n`;
|
||||
out += ` let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String\n`;
|
||||
out += ` let bundleVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String\n`;
|
||||
out += ` if let shortVersion, let bundleVersion { return "\\(shortVersion) (\\(bundleVersion))" }\n`;
|
||||
out += ` return shortVersion ?? bundleVersion ?? ${swiftStringLiteral(buildId)}\n`;
|
||||
out += ` }(),\n`;
|
||||
out += ` accessorHash: ${swiftStringLiteral(accessorHash)},\n`;
|
||||
out += ` atomicRestore: { keys, apply in\n`;
|
||||
out += ` // Validate every key and value before assignment.\n`;
|
||||
out += ` // Successful assignments are sequential on MainActor.\n`;
|
||||
spec.fields.forEach((field, index) => {
|
||||
out += ` guard let raw${index} = keys["${field.name}"] else {\n`;
|
||||
out += ` return .missingKey("${field.name}")\n`;
|
||||
out += ` }\n`;
|
||||
const wrapped = optionalWrappedType(field.typeText);
|
||||
if (wrapped !== undefined) {
|
||||
out += ` let restored${index}: ${field.typeText}\n`;
|
||||
out += ` if raw${index} is NSNull {\n`;
|
||||
out += ` restored${index} = nil\n`;
|
||||
out += ` } else if let typed = Self.decodeSnapshotValue(raw${index}, as: ${wrapped}.self) {\n`;
|
||||
out += ` restored${index} = typed\n`;
|
||||
out += ` } else {\n`;
|
||||
out += ` return .typeMismatch("${field.name}")\n`;
|
||||
out += ` }\n`;
|
||||
} else {
|
||||
out += ` guard let restored${index} = Self.decodeSnapshotValue(raw${index}, as: ${field.typeText}.self) else {\n`;
|
||||
out += ` return .typeMismatch("${field.name}")\n`;
|
||||
out += ` }\n`;
|
||||
}
|
||||
});
|
||||
out += ` if apply {\n`;
|
||||
spec.fields.forEach((field, index) => {
|
||||
out += ` state.${field.name} = restored${index}\n`;
|
||||
});
|
||||
out += ` }\n`;
|
||||
out += ` return .ok\n`;
|
||||
out += ` }\n`;
|
||||
out += ` )\n`;
|
||||
for (const field of spec.fields) {
|
||||
const wrapped = optionalWrappedType(field.typeText);
|
||||
out += ` StateServer.shared.registerAccessor(\n`;
|
||||
out += ` key: "${field.name}",\n`;
|
||||
out += ` type: "${field.typeText}",\n`;
|
||||
out += ` read: { state.${field.name} as Any? },\n`;
|
||||
out += ` write: { _ in false }\n`;
|
||||
if (wrapped !== undefined) {
|
||||
out += ` read: {\n`;
|
||||
out += ` guard let value = state.${field.name} else { return NSNull() }\n`;
|
||||
out += ` return value as Any\n`;
|
||||
out += ` },\n`;
|
||||
} else {
|
||||
out += ` read: { state.${field.name} as Any? },\n`;
|
||||
}
|
||||
out += ` write: { value in\n`;
|
||||
if (wrapped !== undefined) {
|
||||
out += ` if value is NSNull {\n`;
|
||||
out += ` state.${field.name} = nil\n`;
|
||||
out += ` return true\n`;
|
||||
out += ` }\n`;
|
||||
out += ` guard let typed = Self.decodeSnapshotValue(value, as: ${wrapped}.self) else { return false }\n`;
|
||||
} else {
|
||||
out += ` guard let typed = Self.decodeSnapshotValue(value, as: ${field.typeText}.self) else { return false }\n`;
|
||||
}
|
||||
out += ` state.${field.name} = typed\n`;
|
||||
out += ` return true\n`;
|
||||
out += ` }\n`;
|
||||
out += ` )\n`;
|
||||
}
|
||||
out += ` }\n}\n\n`;
|
||||
@@ -215,6 +760,15 @@ function detectToolGitRev(): string {
|
||||
}
|
||||
}
|
||||
|
||||
function detectBuildId(): string {
|
||||
if (process.env.APP_BUILD_ID) return process.env.APP_BUILD_ID;
|
||||
const marketing = process.env.MARKETING_VERSION;
|
||||
const build = process.env.CURRENT_PROJECT_VERSION;
|
||||
if (marketing && build) return `${marketing} (${build})`;
|
||||
if (build) return build;
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function defaultCacheRoot(): string {
|
||||
return process.env.GSTACK_IOS_CACHE_ROOT ?? join(homedir(), '.gstack', 'cache', 'gen-accessors');
|
||||
}
|
||||
@@ -223,13 +777,25 @@ export function generate(inputs: GenInputs): GenResult {
|
||||
const inputDir = resolve(inputs.inputDir);
|
||||
const outputDir = resolve(inputs.outputDir ?? inputDir);
|
||||
const cacheRoot = inputs.cacheRoot ?? defaultCacheRoot();
|
||||
const swiftFiles = collectSwiftFiles(inputDir);
|
||||
const swiftFiles = collectSwiftFiles(inputDir, { outputDir });
|
||||
const buildId = inputs.buildId ?? detectBuildId();
|
||||
|
||||
// Parse before cache lookup. This keeps diagnostics deterministic even when
|
||||
// an older cache entry exists, and gives us the schema-only accessor hash.
|
||||
const allSpecs: AccessorSpec[] = [];
|
||||
for (const f of swiftFiles) {
|
||||
const src = readFileSync(f, 'utf-8');
|
||||
allSpecs.push(...parseSwift(src));
|
||||
}
|
||||
validateAccessorSpecs(allSpecs);
|
||||
const accessorHash = computeAccessorHash(allSpecs);
|
||||
|
||||
const cacheKey = computeCacheKey({
|
||||
swiftFiles,
|
||||
swiftVersion: inputs.swiftVersion ?? detectSwiftVersion(),
|
||||
toolGitRev: inputs.toolGitRev ?? detectToolGitRev(),
|
||||
platformTriple: inputs.platformTriple ?? FALLBACK_PLATFORM,
|
||||
buildId,
|
||||
});
|
||||
|
||||
const cachedOutput = join(cacheRoot, cacheKey, 'StateAccessor.swift');
|
||||
@@ -242,18 +808,13 @@ export function generate(inputs: GenInputs): GenResult {
|
||||
return {
|
||||
outputPath: finalOutput,
|
||||
cacheKey,
|
||||
specs: [], // intentionally empty on cache hit (no need to re-parse)
|
||||
accessorHash,
|
||||
specs: allSpecs,
|
||||
cacheHit: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse + render fresh
|
||||
const allSpecs: AccessorSpec[] = [];
|
||||
for (const f of swiftFiles) {
|
||||
const src = readFileSync(f, 'utf-8');
|
||||
allSpecs.push(...parseSwift(src));
|
||||
}
|
||||
const rendered = render(allSpecs, inputs.buildId ?? 'unknown', cacheKey);
|
||||
const rendered = render(allSpecs, buildId, accessorHash);
|
||||
writeFileSync(finalOutput, rendered);
|
||||
|
||||
// Populate cache (best-effort — cache failures don't break codegen).
|
||||
@@ -267,6 +828,7 @@ export function generate(inputs: GenInputs): GenResult {
|
||||
return {
|
||||
outputPath: finalOutput,
|
||||
cacheKey,
|
||||
accessorHash,
|
||||
specs: allSpecs,
|
||||
cacheHit: false,
|
||||
};
|
||||
@@ -300,10 +862,18 @@ if (import.meta.main) {
|
||||
const inputDir = args[inputIdx + 1]!;
|
||||
const outputIdx = args.indexOf('--output');
|
||||
const outputDir = outputIdx !== -1 ? args[outputIdx + 1] : undefined;
|
||||
const result = generate({ inputDir, outputDir });
|
||||
process.stdout.write(
|
||||
result.cacheHit
|
||||
? `gen-accessors: cache hit (${result.cacheKey.slice(0, 12)})\n`
|
||||
: `gen-accessors: wrote ${result.specs.length} accessor(s) to ${result.outputPath}\n`,
|
||||
);
|
||||
try {
|
||||
const result = generate({ inputDir, outputDir });
|
||||
process.stdout.write(
|
||||
result.cacheHit
|
||||
? `gen-accessors: cache hit (${result.cacheKey.slice(0, 12)})\n`
|
||||
: `gen-accessors: wrote ${result.specs.length} accessor(s) to ${result.outputPath}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof AccessorGenerationError) {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
process.exit(4);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user