Skill Tracer
Skills › gstack › ios-qa › gen-accessors.ts

gen-accessors.ts

880 lines · ios-qa/scripts/gen-accessors.ts at 3f79d81

1#!/usr/bin/env -S bun --no-env-file --config=/dev/null
2//
3// gen-accessors (TS port). Mirrors the SwiftPM tool's logic for the cases
4// where a user doesn't want to wait 2-5min for swift-syntax to build the
5// first time. Also exercised by tests so we can verify the cache + parse
6// behavior without a Swift toolchain.
7//
8// The TS fast path uses a lightweight Swift lexical scanner — it understands:
9// - @Observable class declarations
10// - `// @Snapshotable` property markers (only marked fields are exported)
11// - Legacy @Snapshotable attributes for existing integrations
12// - Multi-line type signatures (collapses whitespace before matching)
13// - JSON-native generic arrays and String-keyed dictionaries
14//
15// Invalid marked declarations (computed/immutable/inaccessible/untyped,
16// nested, duplicate-keyed, or non-JSON) fail generation instead of producing
17// Swift that only fails later in xcodebuild or at snapshot time.
18//
19// Composite cache key (codex-flagged): swift_version || tool_git_rev ||
20// platform_triple || source_content_hash. Source-only hash misses generator
21// logic changes.
23import { readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, existsSync, copyFileSync, rmSync } from 'fs';
24import { join, resolve, dirname } from 'path';
25import { createHash } from 'crypto';
26import { execSync } from 'child_process';
27import { resolveStateRoot } from '../../lib/state-root';
29export interface AccessorField {
30 name: string;
31 typeText: string;
34export interface AccessorSpec {
35 className: string;
36 fields: AccessorField[];
39export class AccessorGenerationError extends Error {
40 readonly diagnostics: string[];
42 constructor(diagnostics: string[]) {
43 super(`gen-accessors: invalid @Snapshotable declaration(s):\n${diagnostics.map(d => ` - ${d}`).join('\n')}`);
44 this.name = 'AccessorGenerationError';
45 this.diagnostics = diagnostics;
46 }
49export interface GenInputs {
50 inputDir: string;
51 outputDir?: string;
52 buildId?: string;
53 cacheRoot?: string;
54 swiftVersion?: string;
55 toolGitRev?: string;
56 platformTriple?: string;
59export interface GenResult {
60 outputPath: string;
61 cacheKey: string;
62 accessorHash: string;
63 specs: AccessorSpec[];
64 cacheHit: boolean;
67const FALLBACK_PLATFORM = process.platform === 'darwin' ? 'darwin-arm64' : `${process.platform}-${process.arch}`;
68const GENERATOR_FORMAT_VERSION = 'accessor-generator-v5';
70const JSON_SCALAR_TYPES = new Set([
71 'String',
72 'Bool',
73 'Int', 'Int8', 'Int16', 'Int32', 'Int64',
74 'UInt', 'UInt8', 'UInt16', 'UInt32', 'UInt64',
75 'Float', 'Double', 'CGFloat',
76]);
78export function collectSwiftFiles(
79 dir: string,
80 opts: { excludeGenerated?: boolean; outputDir?: string } = {},
81): string[] {
82 const out: string[] = [];
83 const excludeGenerated = opts.excludeGenerated ?? true;
84 const root = resolve(dir);
85 const outputDir = opts.outputDir ? resolve(opts.outputDir) : undefined;
87 function walk(currentDir: string): void {
88 for (const name of readdirSync(currentDir)) {
89 const full = join(currentDir, name);
90 const s = statSync(full);
91 if (s.isDirectory()) {
92 // The generated subtree must never participate in its own cache key.
93 // Keep the well-known-name guard for direct collectSwiftFiles callers,
94 // and use the actual --output path when generate() supplies one.
95 const isOutputSubtree = outputDir !== undefined
96 && outputDir !== root
97 && resolve(full) === outputDir;
98 if (excludeGenerated && (name === 'DebugBridgeGenerated' || isOutputSubtree)) continue;
99 walk(full);
100 } else if (name.endsWith('.swift')) {
101 // Skip generated accessor files wherever they live. Otherwise moving
102 // an old copy outside the output directory poisons the next cache key.
103 if (excludeGenerated && name === 'StateAccessor.swift') continue;
104 out.push(full);
105 }
106 }
107 }
109 walk(root);
110 return out.sort();
113export function parseSwift(source: string): AccessorSpec[] {
114 const specs: AccessorSpec[] = [];
115 const diagnostics: string[] = [];
116 const masked = maskSwiftSource(source);
117 // Find `@Observable\n(public )?(final )?class <Name>` followed by a brace
118 // block. We then scan inside that block for @Snapshotable fields.
119 const classPattern = /@Observable\s*(?:(?:public|internal|fileprivate|private)\s+)?(?:final\s+)?class\s+(\w+)[^{]*\{/g;
121 for (const match of masked.matchAll(classPattern)) {
122 const className = match[1]!;
123 const matchOffset = match.index!;
124 const openBraceOffset = matchOffset + match[0].lastIndexOf('{');
125 const startIdx = openBraceOffset + 1;
126 const endIdx = findMatchingBrace(masked, startIdx - 1);
127 if (endIdx === -1) continue;
128 const body = masked.slice(startIdx, endIdx);
130 const parsed = parseFields(body, source, startIdx, className);
131 if (braceDepthAt(masked, matchOffset) !== 0) {
132 if (parsed.fields.length > 0 || parsed.diagnostics.length > 0) {
133 const line = source.slice(0, matchOffset).split(/\r?\n/).length;
134 diagnostics.push(`${className} (line ${line}): nested @Observable types are not supported; move the type to file scope`);
135 }
136 continue;
137 }
138 diagnostics.push(...parsed.diagnostics);
139 const fields = parsed.fields;
140 if (fields.length > 0) {
141 specs.push({ className, fields });
142 }
143 }
145 if (diagnostics.length > 0) throw new AccessorGenerationError(diagnostics);
146 return specs;
149function braceDepthAt(masked: string, offset: number): number {
150 let depth = 0;
151 for (let i = 0; i < offset; i++) {
152 if (masked[i] === '{') depth++;
153 else if (masked[i] === '}') depth = Math.max(0, depth - 1);
154 }
155 return depth;
158function findMatchingBrace(s: string, openIdx: number): number {
159 // Strings and comments have already been blanked by maskSwiftSource, so
160 // braces here are syntax rather than prose or literal content.
161 let depth = 0;
162 for (let i = openIdx; i < s.length; i++) {
163 const c = s[i];
164 if (c === '{') depth++;
165 else if (c === '}') {
166 depth--;
167 if (depth === 0) return i;
168 }
169 }
170 return -1;
173/**
174 * Blank comments and string literals while preserving byte offsets/newlines.
175 * A canonical standalone `// @Snapshotable` comment is rewritten to the
176 * equivalent attribute token. This is intentionally lexical rather than a
177 * whole-file regexp: markers inside nested block comments, normal/triple/raw
178 * strings, trailing comments, and prose comments must never opt a field in.
179 */
180function maskSwiftSource(source: string): string {
181 const out = source.split('');
183 const blank = (start: number, end: number): void => {
184 for (let j = start; j < end; j++) {
185 if (out[j] !== '\n' && out[j] !== '\r') out[j] = ' ';
186 }
187 };
189 let i = 0;
190 while (i < source.length) {
191 if (source[i] === '/' && source[i + 1] === '/') {
192 let end = i + 2;
193 while (end < source.length && source[end] !== '\n' && source[end] !== '\r') end++;
194 const lineStart = source.lastIndexOf('\n', i - 1) + 1;
195 const standalone = source.slice(lineStart, i).trim().length === 0;
196 const isMarker = standalone && source.slice(i + 2, end).trim() === '@Snapshotable';
197 blank(i, end);
198 if (isMarker) {
199 const marker = '@Snapshotable';
200 for (let j = 0; j < marker.length; j++) out[i + j] = marker[j]!;
201 }
202 i = end;
203 continue;
204 }
206 if (source[i] === '/' && source[i + 1] === '*') {
207 const start = i;
208 i += 2;
209 let depth = 1;
210 while (i < source.length && depth > 0) {
211 if (source[i] === '/' && source[i + 1] === '*') {
212 depth++;
213 i += 2;
214 } else if (source[i] === '*' && source[i + 1] === '/') {
215 depth--;
216 i += 2;
217 } else {
218 i++;
219 }
220 }
221 blank(start, i);
222 continue;
223 }
225 // Swift strings may be ordinary, multiline, or raw (`#"..."#` and
226 // `#"""..."""#`). Mask the entire literal, including interpolation;
227 // declarations cannot legally originate inside a literal.
228 let hashCount = 0;
229 while (source[i + hashCount] === '#') hashCount++;
230 const quoteIdx = i + hashCount;
231 if (source[quoteIdx] === '"') {
232 const start = i;
233 const triple = source.slice(quoteIdx, quoteIdx + 3) === '"""';
234 const quoteCount = triple ? 3 : 1;
235 i = quoteIdx + quoteCount;
236 while (i < source.length) {
237 if (hashCount === 0 && source[i] === '\\') {
238 i += 2;
239 continue;
240 }
241 const quoteRun = source.slice(i, i + quoteCount) === '"'.repeat(quoteCount);
242 const hashesMatch = source.slice(i + quoteCount, i + quoteCount + hashCount) === '#'.repeat(hashCount);
243 if (quoteRun && hashesMatch) {
244 i += quoteCount + hashCount;
245 break;
246 }
247 i++;
248 }
249 blank(start, i);
250 continue;
251 }
253 i++;
254 }
255 return out.join('');
258const DECL_MODIFIERS = new Set([
259 'public', 'internal', 'package', 'open', 'private', 'fileprivate',
260 'final', 'static', 'class', 'lazy', 'weak', 'unowned', 'override',
261 'nonisolated', 'isolated', 'borrowing', 'consuming',
262]);
264function parseFields(
265 body: string,
266 fullSource: string,
267 bodyOffset: number,
268 className: string,
269): { fields: AccessorField[]; diagnostics: string[] } {
270 const fields: AccessorField[] = [];
271 const diagnostics: string[] = [];
272 let braceDepth = 0;
274 const lineFor = (localOffset: number): number => {
275 const absolute = bodyOffset + localOffset;
276 let line = 1;
277 for (let j = 0; j < absolute; j++) if (fullSource[j] === '\n') line++;
278 return line;
279 };
280 const fail = (offset: number, message: string): void => {
281 diagnostics.push(`${className} (line ${lineFor(offset)}): ${message}`);
282 };
283 const skipWhitespace = (start: number): number => {
284 let at = start;
285 while (at < body.length && /\s/.test(body[at]!)) at++;
286 return at;
287 };
288 const identifierAt = (start: number): { text: string; end: number } | undefined => {
289 const m = /^[A-Za-z_]\w*/.exec(body.slice(start));
290 return m ? { text: m[0], end: start + m[0].length } : undefined;
291 };
292 const skipBalancedParens = (start: number): number => {
293 if (body[start] !== '(') return start;
294 let depth = 0;
295 for (let at = start; at < body.length; at++) {
296 if (body[at] === '(') depth++;
297 else if (body[at] === ')' && --depth === 0) return at + 1;
298 }
299 return body.length;
300 };
302 for (let i = 0; i < body.length; i++) {
303 if (body[i] === '{') {
304 braceDepth++;
305 continue;
306 }
307 if (body[i] === '}') {
308 braceDepth--;
309 continue;
310 }
311 if (braceDepth !== 0 || !body.startsWith('@Snapshotable', i)) continue;
312 const before = i === 0 ? '' : body[i - 1]!;
313 const after = body[i + '@Snapshotable'.length] ?? '';
314 if (/\w/.test(before) || /\w/.test(after)) continue;
316 const markerOffset = i;
317 let at = skipWhitespace(i + '@Snapshotable'.length);
318 const modifiers: string[] = [];
319 let bindingKind: 'var' | 'let' | undefined;
321 // Permit other Swift attributes between the marker and declaration. They
322 // remain the compiler's responsibility; this scanner only owns the
323 // snapshot contract. Parenthesized attribute arguments are skipped.
324 while (body[at] === '@') {
325 const attribute = identifierAt(at + 1);
326 if (!attribute) break;
327 at = skipWhitespace(attribute.end);
328 if (body[at] === '(') at = skipBalancedParens(at);
329 at = skipWhitespace(at);
330 }
332 while (at < body.length) {
333 const token = identifierAt(at);
334 if (!token) break;
335 if (token.text === 'var' || token.text === 'let') {
336 bindingKind = token.text;
337 at = token.end;
338 break;
339 }
340 if (!DECL_MODIFIERS.has(token.text)) break;
341 let modifier = token.text;
342 at = skipWhitespace(token.end);
343 if (body[at] === '(') {
344 const end = skipBalancedParens(at);
345 modifier += body.slice(at, end).replace(/\s+/g, '');
346 at = skipWhitespace(end);
347 }
348 modifiers.push(modifier);
349 }
351 if (!bindingKind) {
352 fail(markerOffset, '@Snapshotable must immediately precede a stored property declaration');
353 continue;
354 }
356 at = skipWhitespace(at);
357 const identifier = identifierAt(at);
358 const fieldName = identifier?.text ?? '<unknown>';
359 if (!identifier) {
360 fail(markerOffset, '@Snapshotable only supports a single identifier binding');
361 continue;
362 }
363 at = skipWhitespace(identifier.end);
365 if (bindingKind === 'let') {
366 fail(markerOffset, `@Snapshotable field '${fieldName}' must be declared var, not let`);
367 continue;
368 }
369 if (modifiers.some(m => /^(?:private|fileprivate)(?:\(set\))?$/.test(m))) {
370 fail(markerOffset, `@Snapshotable field '${fieldName}' cannot be private, fileprivate, private(set), or fileprivate(set)`);
371 continue;
372 }
373 if (modifiers.some(m => m === 'static' || m === 'class')) {
374 fail(markerOffset, `@Snapshotable field '${fieldName}' must be an instance property`);
375 continue;
376 }
377 if (body[at] !== ':') {
378 fail(markerOffset, `@Snapshotable field '${fieldName}' requires an explicit type annotation`);
379 continue;
380 }
382 at = skipWhitespace(at + 1);
383 const typeStart = at;
384 let parens = 0;
385 let brackets = 0;
386 let angles = 0;
387 let typeEnd = at;
388 let delimiter = '';
389 for (; at < body.length; at++) {
390 const c = body[at]!;
391 if (c === '(') parens++;
392 else if (c === ')') parens--;
393 else if (c === '[') brackets++;
394 else if (c === ']') brackets--;
395 else if (c === '<') angles++;
396 else if (c === '>') angles = Math.max(0, angles - 1);
397 const topLevel = parens === 0 && brackets === 0 && angles === 0;
398 if (topLevel && (c === '=' || c === '{' || c === ',' || c === ';')) {
399 delimiter = c;
400 break;
401 }
402 if (topLevel && (c === '\n' || c === '\r')) {
403 const soFar = body.slice(typeStart, at).trim();
404 if (soFar.length > 0 && !/(?:->|[&.,])\s*$/.test(soFar)) break;
405 }
406 typeEnd = at + 1;
407 }
408 const typeText = body.slice(typeStart, typeEnd).replace(/\s+/g, ' ').trim();
409 if (typeText.length === 0) {
410 fail(markerOffset, `@Snapshotable field '${fieldName}' requires an explicit type annotation`);
411 continue;
412 }
413 if (delimiter === '{') {
414 fail(markerOffset, `@Snapshotable field '${fieldName}' must be stored and writable`);
415 continue;
416 }
417 if (delimiter === ',') {
418 fail(markerOffset, '@Snapshotable declarations must contain exactly one binding');
419 continue;
420 }
421 if (delimiter === '=') {
422 // A declaration such as `var a: Int = 1, b: Int = 2` reaches `=`
423 // before its binding comma. Scan the initializer at syntax depth zero
424 // so commas in arrays/calls/closures remain valid.
425 let initializerAt = at + 1;
426 let expressionParens = 0;
427 let expressionBrackets = 0;
428 let expressionBraces = 0;
429 let hasSecondBinding = false;
430 for (; initializerAt < body.length; initializerAt++) {
431 const c = body[initializerAt]!;
432 if (c === '(') expressionParens++;
433 else if (c === ')') expressionParens--;
434 else if (c === '[') expressionBrackets++;
435 else if (c === ']') expressionBrackets--;
436 else if (c === '{') expressionBraces++;
437 else if (c === '}') {
438 if (expressionBraces === 0) break;
439 expressionBraces--;
440 }
441 const topLevel = expressionParens === 0 && expressionBrackets === 0 && expressionBraces === 0;
442 if (topLevel && c === ',') {
443 hasSecondBinding = true;
444 break;
445 }
446 if (topLevel && (c === '\n' || c === '\r' || c === ';')) break;
447 }
448 if (hasSecondBinding) {
449 fail(markerOffset, '@Snapshotable declarations must contain exactly one binding');
450 continue;
451 }
452 }
453 const wrappedOptional = optionalWrappedType(typeText);
454 if (wrappedOptional !== undefined && optionalWrappedType(wrappedOptional) !== undefined) {
455 fail(markerOffset, `@Snapshotable field '${fieldName}' cannot use a nested Optional type`);
456 continue;
457 }
458 const typeIssue = snapshotTypeIssue(typeText);
459 if (typeIssue !== undefined) {
460 fail(markerOffset, `@Snapshotable field '${fieldName}' ${typeIssue}`);
461 continue;
462 }
464 fields.push({ name: fieldName, typeText });
465 }
466 return { fields, diagnostics };
469export function computeCacheKey(inputs: {
470 swiftFiles: string[];
471 swiftVersion: string;
472 toolGitRev: string;
473 platformTriple: string;
474 buildId?: string;
475}): string {
476 const h = createHash('sha256');
477 h.update(`${GENERATOR_FORMAT_VERSION}|swift=${inputs.swiftVersion}|tool=${inputs.toolGitRev}|platform=${inputs.platformTriple}|build=${inputs.buildId ?? 'unknown'}|`);
478 for (const f of inputs.swiftFiles) {
479 const content = readFileSync(f);
480 // Cache identity is content-based. Absolute checkout paths must not make
481 // equivalent source trees produce different generated output.
482 h.update(`${content.length}:`);
483 h.update(content);
484 h.update('|');
485 }
486 return h.digest('hex');
489/** Stable snapshot-schema fingerprint, deliberately independent of cache ABI,
490 * source paths, app build provenance, and unmarked source. Field/class order is
491 * source order because restore payload compatibility is an ordered contract. */
492export function computeAccessorHash(specs: AccessorSpec[]): string {
493 let signature = 'snapshot-schema-v1\n';
494 for (const spec of specs) {
495 signature += `C${Buffer.byteLength(spec.className)}:${spec.className}\n`;
496 for (const field of spec.fields) {
497 signature += `F${Buffer.byteLength(field.name)}:${field.name}`;
498 signature += `T${Buffer.byteLength(field.typeText)}:${field.typeText}\n`;
499 }
500 signature += 'E\n';
501 }
502 return createHash('sha256').update(signature).digest('hex');
505/** Return the wrapped type for one top-level Optional spelling. */
506export function optionalWrappedType(typeText: string): string | undefined {
507 const type = typeText.trim();
508 if (type.endsWith('?')) {
509 const wrapped = type.slice(0, -1).trim();
510 return wrapped.length > 0 ? wrapped : undefined;
511 }
513 const optional = /^(?:Swift\.)?Optional\s*</.exec(type);
514 if (!optional) return undefined;
515 const open = type.indexOf('<', optional.index);
516 let depth = 0;
517 for (let i = open; i < type.length; i++) {
518 if (type[i] === '<') depth++;
519 else if (type[i] === '>') {
520 depth--;
521 if (depth === 0) {
522 if (type.slice(i + 1).trim().length > 0) return undefined;
523 const wrapped = type.slice(open + 1, i).trim();
524 return wrapped.length > 0 ? wrapped : undefined;
525 }
526 }
527 }
528 return undefined;
531function splitTopLevel(type: string, delimiter: string): string[] {
532 const parts: string[] = [];
533 let start = 0;
534 let angle = 0;
535 let square = 0;
536 let paren = 0;
537 for (let i = 0; i < type.length; i++) {
538 const char = type[i]!;
539 if (char === '<') angle++;
540 else if (char === '>') angle--;
541 else if (char === '[') square++;
542 else if (char === ']') square--;
543 else if (char === '(') paren++;
544 else if (char === ')') paren--;
545 else if (char === delimiter && angle === 0 && square === 0 && paren === 0) {
546 parts.push(type.slice(start, i));
547 start = i + 1;
548 }
549 }
550 parts.push(type.slice(start));
551 return parts;
554function genericArgument(type: string, name: string): string | undefined {
555 const prefix = `${name}<`;
556 if (!type.startsWith(prefix) || !type.endsWith('>')) return undefined;
557 let depth = 0;
558 for (let i = name.length; i < type.length; i++) {
559 if (type[i] === '<') depth++;
560 else if (type[i] === '>') {
561 depth--;
562 if (depth === 0 && i !== type.length - 1) return undefined;
563 if (depth < 0) return undefined;
564 }
565 }
566 return depth === 0 ? type.slice(prefix.length, -1) : undefined;
569/** Return a user-facing suffix when a Swift type cannot round-trip through
570 * Foundation JSON without custom encoding. The accepted grammar is deliberately
571 * narrow: JSON scalar values, arrays, string-keyed dictionaries, and Optional
572 * compositions of those types. */
573export function snapshotTypeIssue(typeText: string): string | undefined {
574 const type = typeText.replace(/\s+/g, '');
575 if (type.length === 0) return 'requires a JSON-compatible type';
576 if (type.endsWith('!')) {
577 return 'cannot use an implicitly unwrapped Optional; use T? instead';
578 }
580 if (type.endsWith('?')) {
581 const wrapped = type.slice(0, -1);
582 if (wrapped.endsWith('?')
583 || genericArgument(wrapped, 'Optional') !== undefined
584 || genericArgument(wrapped, 'Swift.Optional') !== undefined) {
585 return 'cannot use a nested Optional type';
586 }
587 return snapshotTypeIssue(wrapped);
588 }
589 const optional = genericArgument(type, 'Optional') ?? genericArgument(type, 'Swift.Optional');
590 if (optional !== undefined) {
591 if (optional.endsWith('?')
592 || genericArgument(optional, 'Optional') !== undefined
593 || genericArgument(optional, 'Swift.Optional') !== undefined) {
594 return 'cannot use a nested Optional type';
595 }
596 return snapshotTypeIssue(optional);
597 }
599 const scalar = type === 'CoreGraphics.CGFloat'
600 ? 'CGFloat'
601 : (type.startsWith('Swift.') ? type.slice('Swift.'.length) : type);
602 if (JSON_SCALAR_TYPES.has(scalar)) return undefined;
604 if (type.startsWith('[') && type.endsWith(']')) {
605 const inner = type.slice(1, -1);
606 const dictionaryParts = splitTopLevel(inner, ':');
607 if (dictionaryParts.length === 1) return snapshotTypeIssue(inner);
608 if (dictionaryParts.length === 2) {
609 const key = dictionaryParts[0]!.replace(/^Swift\./, '');
610 if (key !== 'String') return 'must use String keys for snapshot dictionaries';
611 return snapshotTypeIssue(dictionaryParts[1]!);
612 }
613 return 'requires a JSON-compatible array or dictionary type';
614 }
616 const array = genericArgument(type, 'Array') ?? genericArgument(type, 'Swift.Array');
617 if (array !== undefined) return snapshotTypeIssue(array);
618 const dictionary = genericArgument(type, 'Dictionary') ?? genericArgument(type, 'Swift.Dictionary');
619 if (dictionary !== undefined) {
620 const parts = splitTopLevel(dictionary, ',');
621 if (parts.length !== 2) return 'requires a JSON-compatible dictionary type';
622 const key = parts[0]!.replace(/^Swift\./, '');
623 if (key !== 'String') return 'must use String keys for snapshot dictionaries';
624 return snapshotTypeIssue(parts[1]!);
625 }
627 return `uses unsupported snapshot type '${typeText}'; use JSON scalar, array, or String-keyed dictionary types`;
630export function validateAccessorSpecs(specs: AccessorSpec[]): void {
631 const owners = new Map<string, string>();
632 const diagnostics: string[] = [];
633 for (const spec of specs) {
634 for (const field of spec.fields) {
635 const previous = owners.get(field.name);
636 if (previous !== undefined) {
637 diagnostics.push(`snapshot key '${field.name}' is declared by both ${previous} and ${spec.className}; keys must be unique across @Observable types`);
638 } else {
639 owners.set(field.name, spec.className);
640 }
641 }
642 }
643 if (diagnostics.length > 0) throw new AccessorGenerationError(diagnostics);
646function swiftStringLiteral(value: string): string {
647 return JSON.stringify(value);
650export function render(specs: AccessorSpec[], buildId: string, accessorHash: string): string {
651 validateAccessorSpecs(specs);
652 let out = '// AUTO-GENERATED — DO NOT EDIT. Regenerate with /ios-sync.\n';
653 out += '#if DEBUG\nimport Foundation\nimport DebugBridgeCore\n\n';
654 for (const spec of specs) {
655 // Accessors compile in the app target beside its usually-internal state
656 // types. Making this API public would make valid internal models fail
657 // Swift type checking (a public signature cannot expose an internal type).
658 out += `@MainActor\nenum ${spec.className}Accessor {\n`;
659 out += ` private static func decodeSnapshotValue<T: Decodable>(_ value: Any, as type: T.Type) -> T? {\n`;
660 out += ` guard JSONSerialization.isValidJSONObject(["value": value]),\n`;
661 out += ` let data = try? JSONSerialization.data(withJSONObject: ["value": value]),\n`;
662 out += ` let decoded = try? JSONDecoder().decode([String: T].self, from: data) else { return nil }\n`;
663 out += ` return decoded["value"]\n`;
664 out += ` }\n\n`;
665 out += ` static func register(_ state: ${spec.className}) {\n`;
666 out += ` StateServer.shared.register(\n`;
667 out += ` buildId: {\n`;
668 out += ` let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String\n`;
669 out += ` let bundleVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String\n`;
670 out += ` if let shortVersion, let bundleVersion { return "\\(shortVersion) (\\(bundleVersion))" }\n`;
671 out += ` return shortVersion ?? bundleVersion ?? ${swiftStringLiteral(buildId)}\n`;
672 out += ` }(),\n`;
673 out += ` accessorHash: ${swiftStringLiteral(accessorHash)},\n`;
674 out += ` atomicRestore: { keys, apply in\n`;
675 out += ` // Validate every key and value before assignment.\n`;
676 out += ` // Successful assignments are sequential on MainActor.\n`;
677 spec.fields.forEach((field, index) => {
678 out += ` guard let raw${index} = keys["${field.name}"] else {\n`;
679 out += ` return .missingKey("${field.name}")\n`;
680 out += ` }\n`;
681 const wrapped = optionalWrappedType(field.typeText);
682 if (wrapped !== undefined) {
683 out += ` let restored${index}: ${field.typeText}\n`;
684 out += ` if raw${index} is NSNull {\n`;
685 out += ` restored${index} = nil\n`;
686 out += ` } else if let typed = Self.decodeSnapshotValue(raw${index}, as: ${wrapped}.self) {\n`;
687 out += ` restored${index} = typed\n`;
688 out += ` } else {\n`;
689 out += ` return .typeMismatch("${field.name}")\n`;
690 out += ` }\n`;
691 } else {
692 out += ` guard let restored${index} = Self.decodeSnapshotValue(raw${index}, as: ${field.typeText}.self) else {\n`;
693 out += ` return .typeMismatch("${field.name}")\n`;
694 out += ` }\n`;
695 }
696 });
697 out += ` if apply {\n`;
698 spec.fields.forEach((field, index) => {
699 out += ` state.${field.name} = restored${index}\n`;
700 });
701 out += ` }\n`;
702 out += ` return .ok\n`;
703 out += ` }\n`;
704 out += ` )\n`;
705 for (const field of spec.fields) {
706 const wrapped = optionalWrappedType(field.typeText);
707 out += ` StateServer.shared.registerAccessor(\n`;
708 out += ` key: "${field.name}",\n`;
709 out += ` type: "${field.typeText}",\n`;
710 if (wrapped !== undefined) {
711 out += ` read: {\n`;
712 out += ` guard let value = state.${field.name} else { return NSNull() }\n`;
713 out += ` return value as Any\n`;
714 out += ` },\n`;
715 } else {
716 out += ` read: { state.${field.name} as Any? },\n`;
717 }
718 out += ` write: { value in\n`;
719 if (wrapped !== undefined) {
720 out += ` if value is NSNull {\n`;
721 out += ` state.${field.name} = nil\n`;
722 out += ` return true\n`;
723 out += ` }\n`;
724 out += ` guard let typed = Self.decodeSnapshotValue(value, as: ${wrapped}.self) else { return false }\n`;
725 } else {
726 out += ` guard let typed = Self.decodeSnapshotValue(value, as: ${field.typeText}.self) else { return false }\n`;
727 }
728 out += ` state.${field.name} = typed\n`;
729 out += ` return true\n`;
730 out += ` }\n`;
731 out += ` )\n`;
732 }
733 out += ` }\n}\n\n`;
734 }
735 out += '#endif\n';
736 return out;
739function detectSwiftVersion(): string {
740 if (process.env.SWIFT_VERSION) return process.env.SWIFT_VERSION;
741 try {
742 const out = execSync('swift --version', { stdio: ['ignore', 'pipe', 'ignore'], timeout: 30_000 }).toString();
743 const m = out.match(/Apple Swift version (\d+\.\d+\.\d+)/);
744 if (m) return m[1]!;
745 } catch {
746 /* swift not installed */
747 }
748 return 'unknown';
751function detectToolGitRev(): string {
752 if (process.env.GEN_ACCESSORS_REV) return process.env.GEN_ACCESSORS_REV;
753 try {
754 return execSync('git rev-parse --short HEAD', {
755 cwd: dirname(new URL(import.meta.url).pathname),
756 stdio: ['ignore', 'pipe', 'ignore'],
757 timeout: 30_000,
758 }).toString().trim();
759 } catch {
760 return 'dev';
761 }
764function detectBuildId(): string {
765 if (process.env.APP_BUILD_ID) return process.env.APP_BUILD_ID;
766 const marketing = process.env.MARKETING_VERSION;
767 const build = process.env.CURRENT_PROJECT_VERSION;
768 if (marketing && build) return `${marketing} (${build})`;
769 if (build) return build;
770 return 'unknown';
773export function defaultCacheRoot(): string {
774 return process.env.GSTACK_IOS_CACHE_ROOT ?? join(resolveStateRoot(), 'cache', 'gen-accessors');
777export function generate(inputs: GenInputs): GenResult {
778 const inputDir = resolve(inputs.inputDir);
779 const outputDir = resolve(inputs.outputDir ?? inputDir);
780 const cacheRoot = inputs.cacheRoot ?? defaultCacheRoot();
781 const swiftFiles = collectSwiftFiles(inputDir, { outputDir });
782 const buildId = inputs.buildId ?? detectBuildId();
784 // Parse before cache lookup. This keeps diagnostics deterministic even when
785 // an older cache entry exists, and gives us the schema-only accessor hash.
786 const allSpecs: AccessorSpec[] = [];
787 for (const f of swiftFiles) {
788 const src = readFileSync(f, 'utf-8');
789 allSpecs.push(...parseSwift(src));
790 }
791 validateAccessorSpecs(allSpecs);
792 const accessorHash = computeAccessorHash(allSpecs);
794 const cacheKey = computeCacheKey({
795 swiftFiles,
796 swiftVersion: inputs.swiftVersion ?? detectSwiftVersion(),
797 toolGitRev: inputs.toolGitRev ?? detectToolGitRev(),
798 platformTriple: inputs.platformTriple ?? FALLBACK_PLATFORM,
799 buildId,
800 });
802 const cachedOutput = join(cacheRoot, cacheKey, 'StateAccessor.swift');
803 const finalOutput = join(outputDir, 'StateAccessor.swift');
804 mkdirSync(outputDir, { recursive: true });
806 if (existsSync(cachedOutput)) {
807 copyFileSync(cachedOutput, finalOutput);
808 // Parse for return value but use cached content as truth.
809 return {
810 outputPath: finalOutput,
811 cacheKey,
812 accessorHash,
813 specs: allSpecs,
814 cacheHit: true,
815 };
816 }
818 const rendered = render(allSpecs, buildId, accessorHash);
819 writeFileSync(finalOutput, rendered);
821 // Populate cache (best-effort — cache failures don't break codegen).
822 try {
823 mkdirSync(join(cacheRoot, cacheKey), { recursive: true });
824 writeFileSync(cachedOutput, rendered);
825 } catch {
826 // best-effort
827 }
829 return {
830 outputPath: finalOutput,
831 cacheKey,
832 accessorHash,
833 specs: allSpecs,
834 cacheHit: false,
835 };
838export function pruneCache(cacheRoot: string = defaultCacheRoot(), maxAgeDays = 30): { pruned: string[] } {
839 const pruned: string[] = [];
840 if (!existsSync(cacheRoot)) return { pruned };
841 const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
842 for (const name of readdirSync(cacheRoot)) {
843 const full = join(cacheRoot, name);
844 try {
845 const s = statSync(full);
846 if (s.isDirectory() && s.mtimeMs < cutoff) {
847 rmSync(full, { recursive: true, force: true });
848 pruned.push(full);
849 }
850 } catch { /* ignore */ }
851 }
852 return { pruned };
855// CLI entry
856if (import.meta.main) {
857 const args = process.argv.slice(2);
858 const inputIdx = args.indexOf('--input');
859 if (inputIdx === -1) {
860 process.stderr.write('usage: gen-accessors --input <dir> [--output <dir>]\n');
861 process.exit(2);
862 }
863 const inputDir = args[inputIdx + 1]!;
864 const outputIdx = args.indexOf('--output');
865 const outputDir = outputIdx !== -1 ? args[outputIdx + 1] : undefined;
866 try {
867 const result = generate({ inputDir, outputDir });
868 process.stdout.write(
869 result.cacheHit
870 ? `gen-accessors: cache hit (${result.cacheKey.slice(0, 12)})\n`
871 : `gen-accessors: wrote ${result.specs.length} accessor(s) to ${result.outputPath}\n`,
872 );
873 } catch (error) {
874 if (error instanceof AccessorGenerationError) {
875 process.stderr.write(`${error.message}\n`);
876 process.exit(4);
877 }
878 throw error;
879 }