@stag-build/phonebook 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +283 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +82 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/commands/doctor.d.ts +33 -0
  7. package/dist/commands/doctor.js +690 -0
  8. package/dist/commands/doctor.js.map +1 -0
  9. package/dist/commands/init.d.ts +27 -0
  10. package/dist/commands/init.js +415 -0
  11. package/dist/commands/init.js.map +1 -0
  12. package/dist/config.d.ts +33 -0
  13. package/dist/config.js +21 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/engines/android.d.ts +70 -0
  16. package/dist/engines/android.js +265 -0
  17. package/dist/engines/android.js.map +1 -0
  18. package/dist/engines/git.d.ts +4 -0
  19. package/dist/engines/git.js +13 -0
  20. package/dist/engines/git.js.map +1 -0
  21. package/dist/engines/ios.d.ts +55 -0
  22. package/dist/engines/ios.js +195 -0
  23. package/dist/engines/ios.js.map +1 -0
  24. package/dist/errors.d.ts +12 -0
  25. package/dist/errors.js +69 -0
  26. package/dist/errors.js.map +1 -0
  27. package/dist/gradle/catalog.d.ts +110 -0
  28. package/dist/gradle/catalog.js +413 -0
  29. package/dist/gradle/catalog.js.map +1 -0
  30. package/dist/ios/snapshotTestClass.d.ts +59 -0
  31. package/dist/ios/snapshotTestClass.js +195 -0
  32. package/dist/ios/snapshotTestClass.js.map +1 -0
  33. package/dist/manifest.d.ts +32 -0
  34. package/dist/manifest.js +23 -0
  35. package/dist/manifest.js.map +1 -0
  36. package/dist/mcp/server.d.ts +1 -0
  37. package/dist/mcp/server.js +226 -0
  38. package/dist/mcp/server.js.map +1 -0
  39. package/dist/naming.d.ts +18 -0
  40. package/dist/naming.js +34 -0
  41. package/dist/naming.js.map +1 -0
  42. package/dist/scan/android.d.ts +7 -0
  43. package/dist/scan/android.js +222 -0
  44. package/dist/scan/android.js.map +1 -0
  45. package/dist/scan/hints.d.ts +25 -0
  46. package/dist/scan/hints.js +338 -0
  47. package/dist/scan/hints.js.map +1 -0
  48. package/dist/scan/ios.d.ts +7 -0
  49. package/dist/scan/ios.js +201 -0
  50. package/dist/scan/ios.js.map +1 -0
  51. package/dist/scan/types.d.ts +52 -0
  52. package/dist/scan/types.js +7 -0
  53. package/dist/scan/types.js.map +1 -0
  54. package/dist/site/build.d.ts +17 -0
  55. package/dist/site/build.js +0 -0
  56. package/dist/site/build.js.map +1 -0
  57. package/dist/versions.d.ts +71 -0
  58. package/dist/versions.js +334 -0
  59. package/dist/versions.js.map +1 -0
  60. package/package.json +54 -0
@@ -0,0 +1,690 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
3
+ import { join, resolve } from 'node:path';
4
+ import { loadConfig } from '../config.js';
5
+ import { diagnoseGradleFailure, diagnoseXcodebuildFailure } from '../errors.js';
6
+ import { runGradle } from '../engines/android.js';
7
+ import { canRead, detectKotlinVersion, fetchKotlinMetadataVersion, lookupFallbackMetadata, resolveMaxCompatible, } from '../versions.js';
8
+ import { findLibrary, findPlugin, loadVersionCatalog, UNIT_TEST_CONFIGURATIONS, } from '../gradle/catalog.js';
9
+ import { detectAndroidPackage, IOS_SNAPSHOT_TEST_CLASS_SNIPPET } from './init.js';
10
+ import { findMissingTestHostNote, findSnapshotTestClassLocation, findSnapshotTestSubclass, } from '../ios/snapshotTestClass.js';
11
+ export { findMissingTestHostNote, findSnapshotTestClassLocation, findSnapshotTestSubclass };
12
+ /** Runs a command and captures stdout+stderr, never throwing on a non-zero exit. */
13
+ function runCapture(cmd, args, opts = {}) {
14
+ return new Promise((res) => {
15
+ const child = spawn(cmd, args, { cwd: opts.cwd, env: opts.env ?? process.env });
16
+ let stdout = '';
17
+ let stderr = '';
18
+ child.stdout?.on('data', (d) => (stdout += d));
19
+ child.stderr?.on('data', (d) => (stderr += d));
20
+ child.on('error', (err) => res({ code: null, stdout, stderr: String(err.message) }));
21
+ child.on('close', (code) => res({ code, stdout, stderr }));
22
+ });
23
+ }
24
+ async function exists(path) {
25
+ try {
26
+ await stat(path);
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ /** Parses the major version number out of `java -version`'s output (printed to stderr). */
34
+ export function parseJavaMajorVersion(versionOutput) {
35
+ const match = versionOutput.match(/version "(\d+)(?:\.(\d+))?/);
36
+ if (!match)
37
+ return undefined;
38
+ const first = Number(match[1]);
39
+ // Old scheme: "1.8.0_292" -> major is the second component.
40
+ if (first === 1 && match[2] !== undefined)
41
+ return Number(match[2]);
42
+ return first;
43
+ }
44
+ /** Parses the "Schemes:" section out of `xcodebuild -list` output. */
45
+ export function parseXcodeSchemes(listOutput) {
46
+ const lines = listOutput.split('\n');
47
+ const start = lines.findIndex((l) => l.trim() === 'Schemes:');
48
+ if (start === -1)
49
+ return [];
50
+ const schemes = [];
51
+ for (let i = start + 1; i < lines.length; i++) {
52
+ const line = lines[i];
53
+ if (line.trim() === '')
54
+ break;
55
+ schemes.push(line.trim());
56
+ }
57
+ return schemes;
58
+ }
59
+ /** Parses device names out of `xcrun simctl list devices available` output. */
60
+ export function parseAvailableSimulatorNames(simctlOutput) {
61
+ const names = [];
62
+ for (const line of simctlOutput.split('\n')) {
63
+ // e.g. " iPhone 17 Pro (3D3E4A9B-...) (Shutdown)"
64
+ const match = line.match(/^\s{4}(.+?) \([0-9A-F-]{36}\) \((?:Shutdown|Booted)\)\s*$/i);
65
+ if (match)
66
+ names.push(match[1]);
67
+ }
68
+ return names;
69
+ }
70
+ /**
71
+ * A single doctor check line plus whether the check passed. Collected instead of
72
+ * printed directly so the same core logic can back both the CLI command
73
+ * (byte-identical console output) and the MCP `check_setup` tool.
74
+ */
75
+ function formatLine(name, result) {
76
+ if (result.note !== undefined) {
77
+ return { line: `note ${name}: ${result.note}`, ok: true };
78
+ }
79
+ return { line: `${result.ok ? 'ok' : 'FAIL'} ${name}: ${result.detail}`, ok: result.ok };
80
+ }
81
+ /**
82
+ * Runs the same checks as `phonebook doctor` and returns the printed lines plus
83
+ * overall pass/fail, without writing to stdout. Shared by the CLI command and
84
+ * the MCP `check_setup` tool.
85
+ */
86
+ export async function collectDoctorChecks(dir, options = {}) {
87
+ const lines = [];
88
+ const print = (name, result) => {
89
+ const { line, ok } = formatLine(name, result);
90
+ lines.push(line);
91
+ return ok;
92
+ };
93
+ const projectDir = resolve(dir);
94
+ let config;
95
+ try {
96
+ const loaded = await loadConfig(projectDir);
97
+ config = loaded.config;
98
+ }
99
+ catch (err) {
100
+ print('config', { ok: false, detail: `${err.message}` });
101
+ return { lines, ok: false };
102
+ }
103
+ print('config', { ok: true, detail: 'phonebook.config.json is valid' });
104
+ const deep = options.deep ?? false;
105
+ const ok = config.platform === 'android'
106
+ ? await runAndroidChecks(config, projectDir, print, deep)
107
+ : await runIosChecks(config, projectDir, print, deep);
108
+ return { lines, ok };
109
+ }
110
+ export async function runDoctor(dir, options = {}) {
111
+ const { lines, ok } = await collectDoctorChecks(dir, options);
112
+ for (const line of lines)
113
+ console.log(line);
114
+ return ok;
115
+ }
116
+ async function gradleFileTexts(projectDir, modules) {
117
+ const candidates = new Set([join(projectDir, 'build.gradle'), join(projectDir, 'build.gradle.kts')]);
118
+ for (const module of modules) {
119
+ const moduleDir = join(projectDir, ...module.split(':').filter(Boolean));
120
+ candidates.add(join(moduleDir, 'build.gradle'));
121
+ candidates.add(join(moduleDir, 'build.gradle.kts'));
122
+ }
123
+ const texts = [];
124
+ for (const path of candidates) {
125
+ try {
126
+ texts.push(await readFile(path, 'utf8'));
127
+ }
128
+ catch {
129
+ // File doesn't exist under this name; that's fine.
130
+ }
131
+ }
132
+ return texts.join('\n');
133
+ }
134
+ const ROBORAZZI_DEP_PATTERN = /io\.github\.takahirom\.roborazzi["']?(?:roborazzi[^:]*)?:?([0-9]+\.[0-9]+\.[0-9]+)/;
135
+ const ROBORAZZI_PLUGIN_PATTERN = /(?:id\(\s*["']io\.github\.takahirom\.roborazzi["']\s*\)|id\s+["']io\.github\.takahirom\.roborazzi["'])\s+version\s+["']([0-9]+\.[0-9]+\.[0-9]+)["']/;
136
+ const ROBORAZZI_GROUP = 'io.github.takahirom.roborazzi';
137
+ const ROBORAZZI_ARTIFACT = 'roborazzi';
138
+ const CPS_GROUP = 'io.github.sergio-sastre.ComposablePreviewScanner';
139
+ const CPS_ARTIFACT = 'android';
140
+ /**
141
+ * Message used when a dependency/plugin check can't find its coordinate in
142
+ * the module build files but the project has a build-logic/buildSrc
143
+ * convention-plugin setup — in that case a FAIL would likely be a false
144
+ * negative (the dependency may be applied by a convention plugin we can't
145
+ * see), so the check degrades to a note pointing at `doctor --deep` instead.
146
+ */
147
+ function indirectionNote(coordinate) {
148
+ return (`could not find ${coordinate} in the module build files; this repo has build-logic/buildSrc, ` +
149
+ 'so it may be applied by a convention plugin — run `phonebook doctor --deep` to verify by compiling');
150
+ }
151
+ /** Message for a dependency found, but declared under a configuration that never reaches the unit-test compile classpath. */
152
+ function wrongConfigurationDetail(group, name, foundConfiguration) {
153
+ return `${group}:${name} is declared as ${foundConfiguration}, which is not on the unit-test compile classpath — add it as testImplementation`;
154
+ }
155
+ async function runAndroidChecks(config, projectDir, print, deep) {
156
+ let allOk = true;
157
+ const gradlewName = process.platform === 'win32' ? 'gradlew.bat' : 'gradlew';
158
+ const hasGradlew = await exists(join(projectDir, gradlewName));
159
+ allOk = print('gradlew', hasGradlew ? { ok: true, detail: `found ${gradlewName}` } : {
160
+ ok: false,
161
+ detail: `${gradlewName} not found in ${projectDir}`,
162
+ }) && allOk;
163
+ const javaHome = process.env.JAVA_HOME;
164
+ const javaBin = javaHome ? join(javaHome, 'bin', 'java') : 'java';
165
+ const javaResult = await runCapture(javaBin, ['-version']);
166
+ const versionOutput = javaResult.stderr || javaResult.stdout;
167
+ const majorVersion = javaResult.code === 0 ? parseJavaMajorVersion(versionOutput) : undefined;
168
+ if (majorVersion !== undefined && majorVersion >= 17) {
169
+ allOk = print('java', { ok: true, detail: `JDK ${majorVersion} (${javaBin})` }) && allOk;
170
+ }
171
+ else {
172
+ allOk = print('java', {
173
+ ok: false,
174
+ detail: (majorVersion !== undefined ? `JDK ${majorVersion} found, need 17+` : `could not run ${javaBin} -version`) +
175
+ '. AGP requires JDK 17+; set JAVA_HOME (note: /usr/libexec/java_home may return an old JDK ' +
176
+ 'even when newer ones are installed via Homebrew)',
177
+ }) && allOk;
178
+ }
179
+ const modules = config.android?.modules ?? [':app'];
180
+ const gradleText = await gradleFileTexts(projectDir, modules);
181
+ const catalogs = await loadVersionCatalog(projectDir);
182
+ const catalogList = [...catalogs.values()];
183
+ const hasIndirection = (await exists(join(projectDir, 'build-logic'))) || (await exists(join(projectDir, 'buildSrc')));
184
+ const roborazziPlugin = findPlugin(gradleText, catalogList, ROBORAZZI_GROUP);
185
+ if (roborazziPlugin.found) {
186
+ allOk = print('roborazzi-plugin', {
187
+ ok: true,
188
+ detail: `io.github.takahirom.roborazzi plugin found${roborazziPlugin.via === 'catalog' ? ' (via version catalog)' : ''}`,
189
+ }) && allOk;
190
+ }
191
+ else if (hasIndirection) {
192
+ print('roborazzi-plugin', { ok: true, detail: '', note: indirectionNote(ROBORAZZI_GROUP) });
193
+ }
194
+ else {
195
+ allOk = print('roborazzi-plugin', {
196
+ ok: false,
197
+ detail: 'io.github.takahirom.roborazzi plugin not found in build.gradle(.kts); run `phonebook init` for setup instructions',
198
+ }) && allOk;
199
+ }
200
+ const cpsLib = findLibrary(gradleText, catalogList, CPS_GROUP, CPS_ARTIFACT, {
201
+ configurations: UNIT_TEST_CONFIGURATIONS,
202
+ });
203
+ const hasGenerateBlock = gradleText.includes('generateComposePreviewRobolectricTests');
204
+ const hasPreviewScanner = cpsLib.found && hasGenerateBlock;
205
+ if (hasPreviewScanner) {
206
+ allOk = print('preview-scanner', {
207
+ ok: true,
208
+ detail: `ComposablePreviewScanner + generateComposePreviewRobolectricTests configured${cpsLib.via === 'catalog' ? ' (via version catalog)' : ''}`,
209
+ }) && allOk;
210
+ }
211
+ else if (cpsLib.via === 'wrong-configuration' && cpsLib.foundConfiguration) {
212
+ allOk = print('preview-scanner', {
213
+ ok: false,
214
+ detail: wrongConfigurationDetail(CPS_GROUP, CPS_ARTIFACT, cpsLib.foundConfiguration),
215
+ }) && allOk;
216
+ }
217
+ else if (hasIndirection) {
218
+ print('preview-scanner', { ok: true, detail: '', note: indirectionNote(`${CPS_GROUP}:${CPS_ARTIFACT}`) });
219
+ }
220
+ else {
221
+ allOk = print('preview-scanner', {
222
+ ok: false,
223
+ detail: 'ComposablePreviewScanner / generateComposePreviewRobolectricTests not found; run `phonebook init` for setup instructions',
224
+ }) && allOk;
225
+ }
226
+ if (hasGenerateBlock) {
227
+ allOk = (await runPreviewPackagesCheck(projectDir, modules, print)) && allOk;
228
+ }
229
+ if (hasGenerateBlock) {
230
+ const uiTestJunit4 = findLibrary(gradleText, catalogList, 'androidx.compose.ui', 'ui-test-junit4', {
231
+ configurations: UNIT_TEST_CONFIGURATIONS,
232
+ });
233
+ if (uiTestJunit4.found) {
234
+ allOk = print('compose-test-deps', {
235
+ ok: true,
236
+ detail: `testImplementation("androidx.compose.ui:ui-test-junit4") found${uiTestJunit4.via === 'catalog' ? ' (via version catalog)' : ''}`,
237
+ }) && allOk;
238
+ }
239
+ else if (uiTestJunit4.via === 'wrong-configuration' && uiTestJunit4.foundConfiguration) {
240
+ allOk = print('compose-test-deps', {
241
+ ok: false,
242
+ detail: wrongConfigurationDetail('androidx.compose.ui', 'ui-test-junit4', uiTestJunit4.foundConfiguration),
243
+ }) && allOk;
244
+ }
245
+ else if (hasIndirection) {
246
+ print('compose-test-deps', { ok: true, detail: '', note: indirectionNote('androidx.compose.ui:ui-test-junit4') });
247
+ }
248
+ else {
249
+ allOk = print('compose-test-deps', {
250
+ ok: false,
251
+ detail: 'missing testImplementation("androidx.compose.ui:ui-test-junit4") — the generated Roborazzi test ' +
252
+ 'requires it (add testImplementation(platform("androidx.compose:compose-bom:<version>")) too if the ' +
253
+ 'version comes from the BOM)',
254
+ }) && allOk;
255
+ }
256
+ }
257
+ if (!gradleText.includes('includePrivatePreviews')) {
258
+ print('private-previews', {
259
+ ok: true,
260
+ detail: '',
261
+ note: 'includePrivatePreviews not set; private @Previews will be skipped unless includePrivatePreviews = true',
262
+ });
263
+ }
264
+ allOk = (await runKotlinCompatCheck(projectDir, gradleText, catalogList, print)) && allOk;
265
+ if (deep) {
266
+ allOk = (await runAndroidDeepCheck(config, projectDir, modules, print)) && allOk;
267
+ }
268
+ return allOk;
269
+ }
270
+ /**
271
+ * Extracts the values configured in a `packages = listOf(...)` (Kotlin DSL)
272
+ * or `packages = ["a", "b"]` (Groovy) block. Returns undefined if no
273
+ * `packages = ...` assignment is present at all (as opposed to an empty list).
274
+ */
275
+ export function extractConfiguredPackages(gradleText) {
276
+ const match = gradleText.match(/\bpackages\s*=\s*(?:listOf\(([^)]*)\)|\[([^\]]*)\])/);
277
+ if (!match)
278
+ return undefined;
279
+ const body = match[1] ?? match[2] ?? '';
280
+ return [...body.matchAll(/["']([^"']*)["']/g)].map((m) => m[1]);
281
+ }
282
+ /** True for placeholder package values left over from the `phonebook init` setup instructions. */
283
+ function isPlaceholderPackage(pkg) {
284
+ return /^<.*>$/.test(pkg) || pkg.includes('REPLACE_ME') || pkg === 'your.package' || pkg === '<your package>';
285
+ }
286
+ /** Recursively scans a module's src/main/java + src/main/kotlin trees for `package ...` declarations and @Preview annotations. */
287
+ async function scanModuleSources(projectDir, module) {
288
+ const moduleDir = join(projectDir, ...module.split(':').filter(Boolean));
289
+ const packages = new Set();
290
+ const previewCountByPackage = new Map();
291
+ async function scanDir(dir) {
292
+ let entries;
293
+ try {
294
+ entries = await readdir(dir, { withFileTypes: true });
295
+ }
296
+ catch {
297
+ return;
298
+ }
299
+ for (const entry of entries) {
300
+ const full = join(dir, entry.name);
301
+ if (entry.isDirectory()) {
302
+ await scanDir(full);
303
+ continue;
304
+ }
305
+ if (!entry.isFile() || !(entry.name.endsWith('.kt') || entry.name.endsWith('.java')))
306
+ continue;
307
+ const text = await readTextIfExists(full);
308
+ const packageMatch = text.match(/^\s*package\s+([\w.]+)/m);
309
+ if (!packageMatch)
310
+ continue;
311
+ const pkg = packageMatch[1];
312
+ packages.add(pkg);
313
+ const previewMatches = text.match(/@Preview\b/g);
314
+ if (previewMatches) {
315
+ previewCountByPackage.set(pkg, (previewCountByPackage.get(pkg) ?? 0) + previewMatches.length);
316
+ }
317
+ }
318
+ }
319
+ for (const srcRoot of ['java', 'kotlin']) {
320
+ await scanDir(join(moduleDir, 'src', 'main', srcRoot));
321
+ }
322
+ return { packages, previewCountByPackage };
323
+ }
324
+ /**
325
+ * Checks the `packages = listOf(...)` values configured for
326
+ * `generateComposePreviewRobolectricTests`, per module: flags a missing
327
+ * packages list, a leftover setup-instructions placeholder, and a configured
328
+ * package that doesn't actually exist in the module's sources — the three
329
+ * ways `packages = listOf("<your package>")` silently records zero previews.
330
+ */
331
+ async function runPreviewPackagesCheck(projectDir, modules, print) {
332
+ let allOk = true;
333
+ for (const module of modules) {
334
+ const moduleText = await gradleFileTexts(projectDir, [module]);
335
+ if (!moduleText.includes('generateComposePreviewRobolectricTests'))
336
+ continue;
337
+ const packages = extractConfiguredPackages(moduleText);
338
+ if (!packages || packages.length === 0) {
339
+ allOk =
340
+ print('preview-packages', {
341
+ ok: false,
342
+ detail: 'generateComposePreviewRobolectricTests has no packages = listOf(...) — the scanner will find no previews',
343
+ }) && allOk;
344
+ continue;
345
+ }
346
+ const placeholder = packages.find(isPlaceholderPackage);
347
+ if (placeholder !== undefined) {
348
+ const detected = await detectAndroidPackage(projectDir, module);
349
+ allOk =
350
+ print('preview-packages', {
351
+ ok: false,
352
+ detail: `packages = listOf("${placeholder}") is a placeholder from the setup instructions — replace it with ` +
353
+ `your app package (detected: ${detected ?? 'unknown'})`,
354
+ }) && allOk;
355
+ continue;
356
+ }
357
+ const scan = await scanModuleSources(projectDir, module);
358
+ const sourcePackages = [...scan.packages];
359
+ const missing = packages.find((pkg) => !sourcePackages.some((p) => p === pkg || p.startsWith(`${pkg}.`)));
360
+ if (missing !== undefined) {
361
+ const detected = await detectAndroidPackage(projectDir, module);
362
+ allOk =
363
+ print('preview-packages', {
364
+ ok: false,
365
+ detail: `package "${missing}" was not found in ${module} sources (detected: ${detected ?? 'unknown'})`,
366
+ }) && allOk;
367
+ continue;
368
+ }
369
+ let previewCount = 0;
370
+ for (const pkg of packages) {
371
+ for (const p of sourcePackages) {
372
+ if (p === pkg || p.startsWith(`${pkg}.`))
373
+ previewCount += scan.previewCountByPackage.get(p) ?? 0;
374
+ }
375
+ }
376
+ allOk =
377
+ print('preview-packages', {
378
+ ok: true,
379
+ detail: `packages = listOf(${packages.map((p) => `"${p}"`).join(', ')})` +
380
+ (previewCount > 0 ? ` — ${previewCount} @Preview annotation(s) found` : ''),
381
+ }) && allOk;
382
+ }
383
+ return allOk;
384
+ }
385
+ /**
386
+ * Checks that the project's detected Kotlin compiler can read the declared
387
+ * Roborazzi version's real (bytecode) metadata. A library's POM understates
388
+ * its Kotlin requirement, so this uses FALLBACK / fetchKotlinMetadataVersion
389
+ * instead of trusting the POM.
390
+ */
391
+ async function runKotlinCompatCheck(projectDir, gradleText, catalogList, print) {
392
+ const detected = await detectKotlinVersion(projectDir);
393
+ let declaredVersion = gradleText.match(ROBORAZZI_DEP_PATTERN)?.[1] ?? gradleText.match(ROBORAZZI_PLUGIN_PATTERN)?.[1];
394
+ if (!declaredVersion) {
395
+ // Catalog library version: io.github.takahirom.roborazzi:roborazzi or any roborazzi-* artifact
396
+ // (roborazzi-compose, roborazzi-compose-preview-scanner-support, ...), actually referenced from
397
+ // a unit-test-reaching configuration.
398
+ outer: for (const cat of catalogList) {
399
+ for (const lib of cat.libraries) {
400
+ if (lib.group !== ROBORAZZI_GROUP || !lib.version)
401
+ continue;
402
+ if (lib.name !== ROBORAZZI_ARTIFACT && !lib.name.startsWith('roborazzi'))
403
+ continue;
404
+ const result = findLibrary(gradleText, cat, lib.group, lib.name, { configurations: UNIT_TEST_CONFIGURATIONS });
405
+ if (result.found) {
406
+ declaredVersion = lib.version;
407
+ break outer;
408
+ }
409
+ }
410
+ }
411
+ }
412
+ if (!declaredVersion) {
413
+ // Catalog plugin version for the roborazzi plugin id.
414
+ const pluginResult = findPlugin(gradleText, catalogList, ROBORAZZI_GROUP);
415
+ if (pluginResult.found && pluginResult.via === 'catalog' && pluginResult.version) {
416
+ declaredVersion = pluginResult.version;
417
+ }
418
+ }
419
+ if (!declaredVersion) {
420
+ print('kotlin-compat', {
421
+ ok: true,
422
+ detail: '',
423
+ note: 'could not determine the declared Roborazzi version from a literal coordinate, the version catalog ' +
424
+ 'library, or the version catalog plugin; skipping compatibility check',
425
+ });
426
+ return true;
427
+ }
428
+ if (!detected) {
429
+ print('kotlin-compat', {
430
+ ok: true,
431
+ detail: '',
432
+ note: 'could not detect the project Kotlin version; skipping compatibility check',
433
+ });
434
+ return true;
435
+ }
436
+ let metadata = lookupFallbackMetadata(ROBORAZZI_GROUP, ROBORAZZI_ARTIFACT, declaredVersion);
437
+ if (!metadata) {
438
+ metadata = await fetchKotlinMetadataVersion(ROBORAZZI_GROUP, ROBORAZZI_ARTIFACT, declaredVersion);
439
+ }
440
+ if (!metadata) {
441
+ print('kotlin-compat', {
442
+ ok: true,
443
+ detail: '',
444
+ note: `could not determine Roborazzi ${declaredVersion}'s Kotlin metadata version (network unavailable); skipping compatibility check`,
445
+ });
446
+ return true;
447
+ }
448
+ if (canRead(detected.version, metadata)) {
449
+ return print('kotlin-compat', {
450
+ ok: true,
451
+ detail: `Kotlin ${detected.version.major}.${detected.version.minor} can read Roborazzi ${declaredVersion}`,
452
+ });
453
+ }
454
+ const { best } = await resolveMaxCompatible(ROBORAZZI_GROUP, ROBORAZZI_ARTIFACT, detected.version);
455
+ const metaLabel = `${metadata[0]}.${metadata[1]}.${metadata[2]}`;
456
+ const needed = metadata[0] === 1 && metadata[1] === 9 && metadata[2] === 9999 ? '1.9' : `${metadata[0]}.${Math.max(0, metadata[1] - 1)}`;
457
+ return print('kotlin-compat', {
458
+ ok: false,
459
+ detail: `Kotlin ${detected.version.major}.${detected.version.minor} cannot read Roborazzi ${declaredVersion} ` +
460
+ `(compiled with Kotlin ${metaLabel} metadata). Use Roborazzi ${best ?? '(unknown)'} or upgrade Kotlin to >= ${needed}.`,
461
+ });
462
+ }
463
+ /**
464
+ * Extracts compiler `e:` error lines from raw build output, dropping
465
+ * stack-frame continuation lines (indented, starting with "at ").
466
+ */
467
+ export function extractCompilerErrors(output) {
468
+ return output
469
+ .split('\n')
470
+ .filter((line) => line.trimStart().startsWith('e:') && !/^\s*at\s/.test(line));
471
+ }
472
+ /** Writes the full captured output of a `doctor --deep` compile to a log file, returning its path. */
473
+ async function writeDeepCompileLog(projectDir, output) {
474
+ const logsDir = join(projectDir, 'phonebook-out', 'logs');
475
+ await mkdir(logsDir, { recursive: true });
476
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
477
+ const logPath = join(logsDir, `deep-compile-${timestamp}.log`);
478
+ await writeFile(logPath, output);
479
+ return logPath;
480
+ }
481
+ /** `doctor --deep`: actually compiles the unit test sources via Gradle. */
482
+ async function runAndroidDeepCheck(config, projectDir, modules, print) {
483
+ const variant = config.android?.variant ?? 'debug';
484
+ const variantCap = variant[0].toUpperCase() + variant.slice(1);
485
+ const tasks = modules.map((m) => `${m}:compile${variantCap}UnitTestKotlin`);
486
+ console.log('deep: compiling test sources (this may take a while)...');
487
+ let fullOutput = '';
488
+ try {
489
+ await runGradle(projectDir, tasks, true, {
490
+ dumpTailOnFailure: false,
491
+ onOutput: (output) => {
492
+ fullOutput = output;
493
+ },
494
+ });
495
+ const logPath = await writeDeepCompileLog(projectDir, fullOutput);
496
+ return print('deep-compile', { ok: true, detail: `compiled: ${tasks.join(', ')} (full log: ${logPath})` });
497
+ }
498
+ catch {
499
+ const logPath = await writeDeepCompileLog(projectDir, fullOutput);
500
+ const compilerErrors = extractCompilerErrors(fullOutput).slice(0, 3);
501
+ const diagnosis = diagnoseGradleFailure(fullOutput);
502
+ const detailLines = [
503
+ `Gradle failed compiling: ${tasks.join(', ')}`,
504
+ ...compilerErrors,
505
+ ...diagnosis,
506
+ `full log: ${logPath}`,
507
+ ];
508
+ return print('deep-compile', { ok: false, detail: detailLines.join('\n') });
509
+ }
510
+ }
511
+ async function runIosChecks(config, projectDir, print, deep) {
512
+ let allOk = true;
513
+ const ios = config.ios;
514
+ if (!ios?.scheme || (!ios.project && !ios.workspace)) {
515
+ print('config', { ok: false, detail: 'phonebook.config.json: ios.scheme and ios.project/workspace are required' });
516
+ return false;
517
+ }
518
+ const xcodebuildResult = await runCapture('xcodebuild', ['-version']);
519
+ const hasXcodebuild = xcodebuildResult.code === 0;
520
+ allOk = print('xcodebuild', hasXcodebuild ? {
521
+ ok: true,
522
+ detail: xcodebuildResult.stdout.split('\n')[0] || 'available',
523
+ } : {
524
+ ok: false,
525
+ detail: 'xcodebuild not found on PATH; install Xcode command line tools',
526
+ }) && allOk;
527
+ const projectPath = ios.project ? resolve(projectDir, ios.project) : undefined;
528
+ const workspacePath = ios.workspace ? resolve(projectDir, ios.workspace) : undefined;
529
+ const configuredPath = workspacePath ?? projectPath;
530
+ const pathExists = await exists(configuredPath);
531
+ allOk = print('project-path', pathExists ? {
532
+ ok: true,
533
+ detail: configuredPath,
534
+ } : {
535
+ ok: false,
536
+ detail: `${configuredPath} does not exist`,
537
+ }) && allOk;
538
+ if (hasXcodebuild && pathExists) {
539
+ const listArgs = workspacePath
540
+ ? ['-list', '-workspace', workspacePath]
541
+ : ['-list', '-project', projectPath];
542
+ const listResult = await runCapture('xcodebuild', listArgs, { cwd: projectDir });
543
+ const schemes = parseXcodeSchemes(listResult.stdout);
544
+ const hasScheme = schemes.includes(ios.scheme);
545
+ allOk = print('scheme', hasScheme ? {
546
+ ok: true,
547
+ detail: `"${ios.scheme}" found`,
548
+ } : {
549
+ ok: false,
550
+ detail: `"${ios.scheme}" not found; available schemes: ${schemes.join(', ') || '(none)'}`,
551
+ }) && allOk;
552
+ }
553
+ else {
554
+ print('scheme', { ok: false, detail: 'skipped (xcodebuild or project path unavailable)' });
555
+ allOk = false;
556
+ }
557
+ const pbxprojTexts = [];
558
+ if (projectPath && (await exists(projectPath))) {
559
+ pbxprojTexts.push(await readTextIfExists(join(projectPath, 'project.pbxproj')));
560
+ }
561
+ else {
562
+ try {
563
+ for (const entry of await readdir(projectDir)) {
564
+ if (entry.endsWith('.xcodeproj')) {
565
+ pbxprojTexts.push(await readTextIfExists(join(projectDir, entry, 'project.pbxproj')));
566
+ }
567
+ }
568
+ }
569
+ catch {
570
+ // projectDir unreadable; leave pbxprojTexts empty.
571
+ }
572
+ }
573
+ const pbxprojOnlyText = pbxprojTexts.join('\n');
574
+ const packageResolvedPaths = [
575
+ projectPath ? join(projectPath, 'project.xcworkspace', 'xcshareddata', 'swiftpm', 'Package.resolved') : undefined,
576
+ workspacePath ? join(workspacePath, 'xcshareddata', 'swiftpm', 'Package.resolved') : undefined,
577
+ ].filter((p) => Boolean(p));
578
+ for (const p of packageResolvedPaths) {
579
+ pbxprojTexts.push(await readTextIfExists(p));
580
+ }
581
+ const wiringText = pbxprojTexts.join('\n');
582
+ const hasSnapshotPreviews = wiringText.includes('SnapshotPreviews') || wiringText.includes('SnapshottingTests');
583
+ allOk = print('snapshot-previews', hasSnapshotPreviews ? {
584
+ ok: true,
585
+ detail: 'SnapshotPreviews / SnapshottingTests found in project wiring',
586
+ } : {
587
+ ok: false,
588
+ detail: 'SnapshotPreviews / SnapshottingTests not found in .pbxproj or Package.resolved; run `phonebook init` for setup instructions',
589
+ }) && allOk;
590
+ if (hasSnapshotPreviews) {
591
+ const subclass = await findSnapshotTestSubclass(projectDir);
592
+ if (subclass && !subclass.importsSnapshottingTests) {
593
+ allOk = print('snapshot-test-class', {
594
+ ok: false,
595
+ detail: `${subclass.relativePath} subclasses SnapshotTest but does not import SnapshottingTests — ` +
596
+ 'the base class lives in that module, so the file will not compile. ' +
597
+ 'Change its import line to: import SnapshottingTests',
598
+ }) && allOk;
599
+ }
600
+ else if (subclass) {
601
+ allOk = print('snapshot-test-class', {
602
+ ok: true,
603
+ detail: `SnapshotTest subclass found: ${subclass.className} (${subclass.relativePath})`,
604
+ }) && allOk;
605
+ }
606
+ else {
607
+ const preamble = 'SnapshotPreviews is linked but no SnapshotTest subclass exists — without it the test target records nothing. ';
608
+ const location = findSnapshotTestClassLocation(pbxprojOnlyText);
609
+ let detail;
610
+ if (location?.synchronizedFolder) {
611
+ detail =
612
+ preamble +
613
+ `Add the file ${location.synchronizedFolder}/PhonebookSnapshots.swift (this project uses synchronized ` +
614
+ `groups, so creating the file is enough):\n\n${IOS_SNAPSHOT_TEST_CLASS_SNIPPET}\n\n` +
615
+ `or run: phonebook init --write-snapshot-class -C ${projectDir}`;
616
+ }
617
+ else if (location) {
618
+ detail =
619
+ preamble +
620
+ `Create the file and add it to the ${location.targetName} target in Xcode (File > Add Files, check ` +
621
+ `the ${location.targetName} box):\n\n${IOS_SNAPSHOT_TEST_CLASS_SNIPPET}`;
622
+ }
623
+ else {
624
+ detail = preamble + `Add to your test target:\n\n${IOS_SNAPSHOT_TEST_CLASS_SNIPPET}`;
625
+ }
626
+ allOk = print('snapshot-test-class', { ok: false, detail }) && allOk;
627
+ }
628
+ const testHostNote = findMissingTestHostNote(pbxprojOnlyText);
629
+ if (testHostNote) {
630
+ print('test-host', { ok: true, detail: '', note: testHostNote });
631
+ }
632
+ }
633
+ const simulator = ios.simulator ?? 'iPhone 17 Pro';
634
+ const simctlResult = await runCapture('xcrun', ['simctl', 'list', 'devices', 'available']);
635
+ const availableNames = parseAvailableSimulatorNames(simctlResult.stdout);
636
+ const hasSimulator = availableNames.includes(simulator);
637
+ if (hasSimulator) {
638
+ allOk = print('simulator', { ok: true, detail: `"${simulator}" available` }) && allOk;
639
+ }
640
+ else {
641
+ const suggestions = availableNames.filter((n) => n.startsWith('iPhone')).slice(0, 3);
642
+ allOk = print('simulator', {
643
+ ok: false,
644
+ detail: `"${simulator}" not found; available iPhone simulators include: ${suggestions.join(', ') || '(none)'}`,
645
+ }) && allOk;
646
+ }
647
+ if (deep && hasXcodebuild && pathExists) {
648
+ allOk = (await runIosDeepCheck(projectDir, ios, projectPath, workspacePath, simulator, print)) && allOk;
649
+ }
650
+ return allOk;
651
+ }
652
+ /** `doctor --deep`: actually builds-for-testing via xcodebuild. */
653
+ async function runIosDeepCheck(projectDir, ios, projectPath, workspacePath, simulator, print) {
654
+ console.log('deep: compiling test sources (this may take a while)...');
655
+ const args = [
656
+ 'build-for-testing',
657
+ ...(workspacePath ? ['-workspace', workspacePath] : ['-project', projectPath]),
658
+ '-scheme',
659
+ ios.scheme,
660
+ '-destination',
661
+ `platform=iOS Simulator,name=${simulator}`,
662
+ ];
663
+ const result = await runCapture('xcodebuild', args, { cwd: projectDir });
664
+ const output = `${result.stdout}\n${result.stderr}`;
665
+ const logPath = await writeDeepCompileLog(projectDir, output);
666
+ if (result.code === 0) {
667
+ return print('deep-compile', {
668
+ ok: true,
669
+ detail: `build-for-testing succeeded for scheme "${ios.scheme}" (full log: ${logPath})`,
670
+ });
671
+ }
672
+ const compilerErrors = extractCompilerErrors(output).slice(0, 3);
673
+ const diagnosis = diagnoseXcodebuildFailure(output);
674
+ const detailLines = [
675
+ `xcodebuild build-for-testing failed (exit ${result.code}) for scheme "${ios.scheme}"`,
676
+ ...compilerErrors,
677
+ ...diagnosis,
678
+ `full log: ${logPath}`,
679
+ ];
680
+ return print('deep-compile', { ok: false, detail: detailLines.join('\n') });
681
+ }
682
+ async function readTextIfExists(path) {
683
+ try {
684
+ return await readFile(path, 'utf8');
685
+ }
686
+ catch {
687
+ return '';
688
+ }
689
+ }
690
+ //# sourceMappingURL=doctor.js.map