@cardstack/boxel-cli 0.2.0-unstable.298 → 0.2.0-unstable.327

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.
@@ -0,0 +1,741 @@
1
+ import type { Command } from 'commander';
2
+ import { execFile } from 'node:child_process';
3
+ import {
4
+ mkdtempSync,
5
+ mkdirSync,
6
+ rmSync,
7
+ symlinkSync,
8
+ writeFileSync,
9
+ } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { dirname, join, resolve } from 'node:path';
12
+
13
+ import { ensureTrailingSlash } from '@cardstack/runtime-common/paths';
14
+ import { SupportedMimeType } from '@cardstack/runtime-common/supported-mime-type';
15
+
16
+ import {
17
+ getProfileManager,
18
+ NO_ACTIVE_PROFILE_ERROR,
19
+ type ProfileManager,
20
+ } from '../lib/profile-manager';
21
+ import { FG_RED, DIM, RESET } from '../lib/colors';
22
+ import { cliLog } from '../lib/cli-log';
23
+ import { findBoxelCliRoot } from '../lib/find-package-root';
24
+ import { validateRealmRelativePath } from '../lib/realm-relative-path';
25
+ import { search } from './search';
26
+
27
+ /**
28
+ * Inlined to avoid cascading the runtime-common index's URL-style
29
+ * imports (`https://cardstack.com/base/*`) into boxel-cli's
30
+ * tsconfig, which doesn't carry the monorepo path mappings the
31
+ * factory's tsconfig does. Equivalent to `specRef` in
32
+ * `@cardstack/runtime-common/constants`.
33
+ */
34
+ const SPEC_TYPE = {
35
+ module: 'https://cardstack.com/base/spec',
36
+ name: 'Spec',
37
+ } as const;
38
+
39
+ /**
40
+ * `boxel parse` runs glint (`ember-tsc`) over `.gts` / `.gjs` / `.ts`
41
+ * files in a realm and validates the document structure of any
42
+ * `.json` files linked as `Spec.linkedExamples`. Source is fetched
43
+ * from the realm; type-checking happens locally.
44
+ *
45
+ * Path resolution assumes a Boxel monorepo layout — `packages/base`,
46
+ * `packages/host`, `packages/boxel-ui`, and `@glint/ember-tsc` are
47
+ * discovered relative to this file. The published CLI installed
48
+ * outside the monorepo will not be able to run this command (the
49
+ * binary won't be present and the type-path mappings won't resolve);
50
+ * `boxel parse` is a factory-developer tool, not an end-user one.
51
+ *
52
+ * Lifted from `packages/software-factory/src/parse-execution.ts`
53
+ * during CS-11149 so the same engine is reachable from a
54
+ * subscription-billed Claude Code session via Bash.
55
+ */
56
+
57
+ const PARSEABLE_GTS_EXTENSIONS = ['.gts', '.gjs', '.ts'] as const;
58
+ const PARSEABLE_JSON_EXTENSION = '.json';
59
+
60
+ const BOXEL_CLI_PATH = findBoxelCliRoot(__dirname);
61
+ const PACKAGES_PATH = resolve(BOXEL_CLI_PATH, '..');
62
+ const BASE_PKG_PATH = join(PACKAGES_PATH, 'base');
63
+ const HOST_PKG_PATH = join(PACKAGES_PATH, 'host');
64
+ const BOXEL_UI_PATH = join(PACKAGES_PATH, 'boxel-ui', 'addon', 'src');
65
+ const NODE_MODULES_PATH = join(HOST_PKG_PATH, 'node_modules');
66
+
67
+ let cachedTsconfigContent: string | undefined;
68
+
69
+ export interface ParseError {
70
+ file: string;
71
+ line: number;
72
+ column: number;
73
+ message: string;
74
+ }
75
+
76
+ export interface ParseRealmResult {
77
+ status: 'passed' | 'failed' | 'error';
78
+ filesChecked: number;
79
+ filesWithErrors: number;
80
+ errorCount: number;
81
+ durationMs: number;
82
+ parseableFiles: string[];
83
+ errors: ParseError[];
84
+ errorMessage?: string;
85
+ }
86
+
87
+ export interface ParseRealmOptions {
88
+ /**
89
+ * Optional realm-relative path. When set, parses only this file.
90
+ * `.gts` / `.gjs` / `.ts` paths run through glint;
91
+ * `.json` paths are validated for card document structure.
92
+ */
93
+ path?: string;
94
+ profileManager?: ProfileManager;
95
+ }
96
+
97
+ interface SpecExampleInfo {
98
+ specId: string;
99
+ exampleUrls: string[];
100
+ }
101
+
102
+ /**
103
+ * Bounded-poll an async attempt until `needsRetry` is false or the
104
+ * deadline elapses. Used to absorb realm-side indexing latency when
105
+ * we search for Specs immediately after a push.
106
+ */
107
+ async function retryWithPoll<T>(
108
+ attempt: () => Promise<T>,
109
+ needsRetry: (result: T) => boolean,
110
+ options: { totalWaitMs?: number; pollMs?: number } = {},
111
+ ): Promise<T> {
112
+ let totalWaitMs = options.totalWaitMs ?? 30_000;
113
+ let pollMs = options.pollMs ?? 250;
114
+ let deadline = Date.now() + totalWaitMs;
115
+ let result = await attempt();
116
+ while (needsRetry(result) && Date.now() < deadline) {
117
+ await new Promise((r) => setTimeout(r, pollMs));
118
+ result = await attempt();
119
+ }
120
+ return result;
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Public entry point
125
+ // ---------------------------------------------------------------------------
126
+
127
+ export async function parseRealm(
128
+ realmUrl: string,
129
+ options?: ParseRealmOptions,
130
+ ): Promise<ParseRealmResult> {
131
+ let pm = options?.profileManager ?? getProfileManager();
132
+ let active = pm.getActiveProfile();
133
+ if (!active) {
134
+ return emptyErrorResult(NO_ACTIVE_PROFILE_ERROR);
135
+ }
136
+
137
+ let normalizedRealmUrl = ensureTrailingSlash(realmUrl);
138
+ let startedAt = Date.now();
139
+
140
+ let gtsFiles: string[] = [];
141
+ let jsonFiles: string[] = [];
142
+
143
+ if (options?.path) {
144
+ let path = options.path;
145
+ let pathError = validateRealmRelativePath(path);
146
+ if (pathError) {
147
+ return emptyErrorResult(pathError);
148
+ }
149
+ if (PARSEABLE_GTS_EXTENSIONS.some((ext) => path.endsWith(ext))) {
150
+ gtsFiles = [path];
151
+ } else if (path.endsWith(PARSEABLE_JSON_EXTENSION)) {
152
+ jsonFiles = [path];
153
+ } else {
154
+ return emptyErrorResult(
155
+ `Path "${path}" is not parseable — must end with one of ${PARSEABLE_GTS_EXTENSIONS.join(', ')}, or ${PARSEABLE_JSON_EXTENSION}`,
156
+ );
157
+ }
158
+ } else {
159
+ try {
160
+ [gtsFiles, jsonFiles] = await Promise.all([
161
+ discoverParseableGtsFiles(normalizedRealmUrl, pm),
162
+ discoverJsonExampleFiles(normalizedRealmUrl, pm),
163
+ ]);
164
+ } catch (err) {
165
+ return emptyErrorResult(
166
+ `Failed to discover parseable files: ${err instanceof Error ? err.message : String(err)}`,
167
+ );
168
+ }
169
+ }
170
+
171
+ let parseableFiles = [...gtsFiles, ...jsonFiles];
172
+
173
+ if (parseableFiles.length === 0) {
174
+ return {
175
+ status: 'passed',
176
+ filesChecked: 0,
177
+ filesWithErrors: 0,
178
+ errorCount: 0,
179
+ durationMs: Date.now() - startedAt,
180
+ parseableFiles: [],
181
+ errors: [],
182
+ };
183
+ }
184
+
185
+ let errors: ParseError[] = [];
186
+ let filesWithErrors = new Set<string>();
187
+
188
+ if (gtsFiles.length > 0) {
189
+ let gtsContents: { path: string; content: string }[] = [];
190
+ for (let file of gtsFiles) {
191
+ let readResult = await fetchSource(normalizedRealmUrl, file, pm);
192
+ if (!readResult.ok) {
193
+ errors.push({
194
+ file,
195
+ line: 0,
196
+ column: 0,
197
+ message: `Could not read ${file}: ${readResult.error}`,
198
+ });
199
+ filesWithErrors.add(file);
200
+ continue;
201
+ }
202
+ gtsContents.push({ path: file, content: readResult.content });
203
+ }
204
+
205
+ if (gtsContents.length > 0) {
206
+ try {
207
+ let glintErrors = await runGlintCheck(gtsContents);
208
+ for (let e of glintErrors) {
209
+ errors.push(e);
210
+ filesWithErrors.add(e.file);
211
+ }
212
+ } catch (err) {
213
+ let firstFile = gtsContents[0].path;
214
+ errors.push({
215
+ file: firstFile,
216
+ line: 0,
217
+ column: 0,
218
+ message: `Glint check failed: ${err instanceof Error ? err.message : String(err)}`,
219
+ });
220
+ filesWithErrors.add(firstFile);
221
+ }
222
+ }
223
+ }
224
+
225
+ for (let jsonUrl of jsonFiles) {
226
+ let readResult = await fetchSource(normalizedRealmUrl, jsonUrl, pm);
227
+ if (!readResult.ok) {
228
+ errors.push({
229
+ file: jsonUrl,
230
+ line: 0,
231
+ column: 0,
232
+ message: `Could not read ${jsonUrl}: ${readResult.error}`,
233
+ });
234
+ filesWithErrors.add(jsonUrl);
235
+ continue;
236
+ }
237
+ let jsonErrors = parseJsonFile(jsonUrl, readResult.content);
238
+ for (let e of jsonErrors) {
239
+ errors.push(e);
240
+ filesWithErrors.add(e.file);
241
+ }
242
+ }
243
+
244
+ return {
245
+ status: errors.length === 0 ? 'passed' : 'failed',
246
+ filesChecked: parseableFiles.length,
247
+ filesWithErrors: filesWithErrors.size,
248
+ errorCount: errors.length,
249
+ durationMs: Date.now() - startedAt,
250
+ parseableFiles,
251
+ errors,
252
+ };
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // File discovery
257
+ // ---------------------------------------------------------------------------
258
+
259
+ async function discoverParseableGtsFiles(
260
+ realmUrl: string,
261
+ pm: ProfileManager,
262
+ ): Promise<string[]> {
263
+ let mtimesUrl = `${realmUrl}_mtimes`;
264
+ let response = await pm.authedRealmFetch(mtimesUrl, {
265
+ method: 'GET',
266
+ headers: { Accept: SupportedMimeType.Mtimes },
267
+ });
268
+ if (!response.ok) {
269
+ let body = await response.text().catch(() => '(no body)');
270
+ throw new Error(
271
+ `_mtimes returned HTTP ${response.status}: ${body.slice(0, 300)}`,
272
+ );
273
+ }
274
+ let json = (await response.json()) as {
275
+ data?: { attributes?: { mtimes?: Record<string, number> } };
276
+ };
277
+ let mtimes =
278
+ json?.data?.attributes?.mtimes ??
279
+ (json as unknown as Record<string, number>);
280
+
281
+ let filenames: string[] = [];
282
+ for (let fullUrl of Object.keys(mtimes)) {
283
+ if (!fullUrl.startsWith(realmUrl)) continue;
284
+ let relativePath = fullUrl.slice(realmUrl.length);
285
+ if (!relativePath || relativePath.endsWith('/')) continue;
286
+ if (PARSEABLE_GTS_EXTENSIONS.some((ext) => relativePath.endsWith(ext))) {
287
+ filenames.push(relativePath);
288
+ }
289
+ }
290
+ return filenames.sort();
291
+ }
292
+
293
+ async function discoverJsonExampleFiles(
294
+ realmUrl: string,
295
+ pm: ProfileManager,
296
+ ): Promise<string[]> {
297
+ // The realm's source POST returns once writes are durable, but the
298
+ // search index settles asynchronously. Right after a `boxel realm
299
+ // push`, a search for Spec cards may still see the pre-push state
300
+ // and return zero results, which would make us silently skip the
301
+ // freshly-pushed linkedExamples. Bounded-poll for up to ~30s while
302
+ // the result is OK but empty so the index has a chance to catch up.
303
+ let searchResult = await retryWithPoll(
304
+ () =>
305
+ search(realmUrl, { filter: { type: SPEC_TYPE } }, { profileManager: pm }),
306
+ (r) => r.ok && (r.data?.length ?? 0) === 0,
307
+ );
308
+ if (!searchResult.ok) {
309
+ return [];
310
+ }
311
+
312
+ let specs: SpecExampleInfo[] = [];
313
+ for (let card of searchResult.data ?? []) {
314
+ let specId = (card as Record<string, unknown>).id as string | undefined;
315
+ if (!specId) continue;
316
+
317
+ let attributes = (card as Record<string, unknown>).attributes as
318
+ | Record<string, unknown>
319
+ | undefined;
320
+ if (!attributes) continue;
321
+ let specType = attributes.specType as string | undefined;
322
+ if (specType === 'field') continue;
323
+
324
+ let relationships = (card as Record<string, unknown>).relationships as
325
+ | Record<string, unknown>
326
+ | undefined;
327
+ let rawExampleUrls = extractLinkedExamples(relationships);
328
+ let specCardUrl = new URL(specId, realmUrl).href;
329
+ let exampleUrls: string[] = [];
330
+ for (let rawUrl of rawExampleUrls) {
331
+ let absoluteUrl = new URL(rawUrl, specCardUrl).href;
332
+ if (absoluteUrl.startsWith(realmUrl)) {
333
+ exampleUrls.push(absoluteUrl.slice(realmUrl.length));
334
+ }
335
+ }
336
+ specs.push({ specId, exampleUrls });
337
+ }
338
+
339
+ let urls: string[] = [];
340
+ for (let spec of specs) {
341
+ for (let url of spec.exampleUrls) {
342
+ let normalized = url.endsWith(PARSEABLE_JSON_EXTENSION)
343
+ ? url
344
+ : `${url}${PARSEABLE_JSON_EXTENSION}`;
345
+ if (!urls.includes(normalized)) urls.push(normalized);
346
+ }
347
+ }
348
+ return urls.sort();
349
+ }
350
+
351
+ function extractLinkedExamples(
352
+ relationships: Record<string, unknown> | undefined,
353
+ ): string[] {
354
+ if (!relationships) return [];
355
+ let urls: string[] = [];
356
+ for (let i = 0; ; i++) {
357
+ let entry = relationships[`linkedExamples.${i}`] as
358
+ | { links?: { self?: string } }
359
+ | undefined;
360
+ if (!entry?.links?.self) break;
361
+ urls.push(entry.links.self);
362
+ }
363
+ if (urls.length === 0) {
364
+ let examples = relationships['linkedExamples'] as
365
+ | { links?: { self?: string } }
366
+ | undefined;
367
+ if (examples?.links?.self) urls.push(examples.links.self);
368
+ }
369
+ return urls;
370
+ }
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // Source reading
374
+ // ---------------------------------------------------------------------------
375
+
376
+ async function fetchSource(
377
+ realmUrl: string,
378
+ path: string,
379
+ pm: ProfileManager,
380
+ ): Promise<{ ok: true; content: string } | { ok: false; error: string }> {
381
+ try {
382
+ let readUrl = new URL(path, realmUrl).href;
383
+ let response = await pm.authedRealmFetch(readUrl, {
384
+ method: 'GET',
385
+ headers: { Accept: SupportedMimeType.CardSource },
386
+ });
387
+ if (!response.ok) {
388
+ let body = await response.text().catch(() => '(no body)');
389
+ return {
390
+ ok: false,
391
+ error: `HTTP ${response.status}: ${body.slice(0, 300)}`,
392
+ };
393
+ }
394
+ return { ok: true, content: await response.text() };
395
+ } catch (err) {
396
+ return {
397
+ ok: false,
398
+ error: err instanceof Error ? err.message : String(err),
399
+ };
400
+ }
401
+ }
402
+
403
+ // ---------------------------------------------------------------------------
404
+ // Glint (ember-tsc) type checking
405
+ // ---------------------------------------------------------------------------
406
+
407
+ /**
408
+ * Run `ember-tsc --noEmit` against a set of `.gts` / `.gjs` / `.ts`
409
+ * files in a temp dir. Symlinks the host package's node_modules and
410
+ * writes a tsconfig with the same monorepo path mappings the realm
411
+ * uses at runtime, then parses TS diagnostics from stdout.
412
+ */
413
+ async function runGlintCheck(
414
+ files: { path: string; content: string }[],
415
+ ): Promise<ParseError[]> {
416
+ let tempDir = mkdtempSync(join(tmpdir(), 'boxel-parse-'));
417
+
418
+ try {
419
+ for (let file of files) {
420
+ let pathError = validateRealmRelativePath(file.path);
421
+ if (pathError) {
422
+ throw new Error(pathError);
423
+ }
424
+ let normalized = join(tempDir, file.path);
425
+ let resolved = resolve(normalized);
426
+ if (!resolved.startsWith(tempDir + '/')) {
427
+ throw new Error(
428
+ `Path "${file.path}" resolves outside the parse workspace and was rejected.`,
429
+ );
430
+ }
431
+ mkdirSync(dirname(resolved), { recursive: true });
432
+ writeFileSync(resolved, file.content, 'utf8');
433
+ }
434
+
435
+ if (!cachedTsconfigContent) {
436
+ let tsconfig = {
437
+ compilerOptions: {
438
+ target: 'es2022',
439
+ allowJs: true,
440
+ moduleResolution: 'bundler',
441
+ allowSyntheticDefaultImports: true,
442
+ noEmit: true,
443
+ baseUrl: '.',
444
+ module: 'es2022',
445
+ strict: true,
446
+ experimentalDecorators: true,
447
+ skipLibCheck: true,
448
+ noUnusedLocals: false,
449
+ noUnusedParameters: false,
450
+ types: ['qunit-dom', '@cardstack/local-types'],
451
+ paths: {
452
+ 'https://cardstack.com/base/*': [`${BASE_PKG_PATH}/*`],
453
+ '@cardstack/host/tests/*': [`${HOST_PKG_PATH}/tests/*`],
454
+ '@cardstack/host/*': [`${HOST_PKG_PATH}/app/*`],
455
+ '@cardstack/boxel-host/commands/*': [
456
+ `${HOST_PKG_PATH}/app/commands/*`,
457
+ ],
458
+ '@cardstack/boxel-ui/*': [`${BOXEL_UI_PATH}/*`],
459
+ '*': [`${HOST_PKG_PATH}/types/*`],
460
+ },
461
+ },
462
+ include: ['**/*.ts', '**/*.gts', '**/*.gjs'],
463
+ exclude: ['node_modules'],
464
+ };
465
+ cachedTsconfigContent = JSON.stringify(tsconfig, null, 2);
466
+ }
467
+ writeFileSync(
468
+ join(tempDir, 'tsconfig.json'),
469
+ cachedTsconfigContent,
470
+ 'utf8',
471
+ );
472
+
473
+ symlinkSync(NODE_MODULES_PATH, join(tempDir, 'node_modules'));
474
+
475
+ let emberTscBin = join(BOXEL_CLI_PATH, 'node_modules', '.bin', 'ember-tsc');
476
+
477
+ let { output, exitedWithError } = await new Promise<{
478
+ output: string;
479
+ exitedWithError: boolean;
480
+ }>((resolvePromise, reject) => {
481
+ let child = execFile(
482
+ emberTscBin,
483
+ ['--noEmit', '--project', join(tempDir, 'tsconfig.json')],
484
+ {
485
+ cwd: tempDir,
486
+ timeout: 120_000,
487
+ maxBuffer: 10 * 1024 * 1024,
488
+ },
489
+ (error, stdout, stderr) => {
490
+ if (error && !stdout && !stderr) {
491
+ reject(new Error(`ember-tsc execution failed: ${error.message}`));
492
+ return;
493
+ }
494
+ if (child.killed || error?.killed) {
495
+ reject(new Error('ember-tsc was killed (timeout or signal)'));
496
+ return;
497
+ }
498
+ resolvePromise({
499
+ output: stdout + stderr,
500
+ exitedWithError: !!error,
501
+ });
502
+ },
503
+ );
504
+ });
505
+
506
+ let errors: ParseError[] = [];
507
+ let totalDiagnosticLines = 0;
508
+ for (let line of output.split('\n')) {
509
+ let match = line.match(
510
+ /^(.+?)\((\d+),(\d+)\):\s*error\s+(TS\d+):\s*(.+)/,
511
+ );
512
+ if (!match) continue;
513
+
514
+ totalDiagnosticLines++;
515
+
516
+ let [, filePath, lineStr, colStr, tsCode, message] = match;
517
+ let absolutePath = resolve(tempDir, filePath);
518
+ if (!absolutePath.startsWith(tempDir)) continue;
519
+
520
+ if (tsCode === 'TS2353' && message.includes("'scoped'")) continue;
521
+
522
+ let realmPath = absolutePath.slice(tempDir.length + 1);
523
+ let originalFile = files.find((f) => f.path === realmPath);
524
+ if (!originalFile) continue;
525
+
526
+ errors.push({
527
+ file: originalFile.path,
528
+ line: parseInt(lineStr, 10),
529
+ column: parseInt(colStr, 10),
530
+ message,
531
+ });
532
+ }
533
+
534
+ if (exitedWithError && errors.length === 0 && totalDiagnosticLines === 0) {
535
+ let truncatedOutput = output.slice(0, 500).trim();
536
+ errors.push({
537
+ file: files[0]?.path ?? 'unknown',
538
+ line: 0,
539
+ column: 0,
540
+ message: `ember-tsc exited with errors but produced no TS diagnostics. Check the tsconfig paths and node_modules symlink. Output: ${truncatedOutput}`,
541
+ });
542
+ }
543
+
544
+ return errors;
545
+ } finally {
546
+ try {
547
+ rmSync(tempDir, { recursive: true, force: true });
548
+ } catch {
549
+ // best-effort cleanup
550
+ }
551
+ }
552
+ }
553
+
554
+ // ---------------------------------------------------------------------------
555
+ // JSON document validation
556
+ // ---------------------------------------------------------------------------
557
+
558
+ function parseJsonFile(filename: string, source: string): ParseError[] {
559
+ let parsed: unknown;
560
+ try {
561
+ parsed = JSON.parse(source);
562
+ } catch (err) {
563
+ let message = err instanceof Error ? err.message : String(err);
564
+ return [
565
+ {
566
+ file: filename,
567
+ line: 0,
568
+ column: 0,
569
+ message: `Invalid JSON: ${message}`,
570
+ },
571
+ ];
572
+ }
573
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
574
+ return [
575
+ {
576
+ file: filename,
577
+ line: 0,
578
+ column: 0,
579
+ message: 'Card document must be a JSON object',
580
+ },
581
+ ];
582
+ }
583
+ return validateCardDocumentStructure(
584
+ filename,
585
+ parsed as { data: Record<string, unknown> },
586
+ );
587
+ }
588
+
589
+ function validateCardDocumentStructure(
590
+ filename: string,
591
+ doc: { data: Record<string, unknown> },
592
+ ): ParseError[] {
593
+ let errors: ParseError[] = [];
594
+ let data = doc.data;
595
+ if (typeof data !== 'object' || data === null || Array.isArray(data)) {
596
+ errors.push({
597
+ file: filename,
598
+ line: 0,
599
+ column: 0,
600
+ message: 'Card document must have a "data" object',
601
+ });
602
+ return errors;
603
+ }
604
+ let dataObj = data as Record<string, unknown>;
605
+ if (typeof dataObj.type !== 'string') {
606
+ errors.push({
607
+ file: filename,
608
+ line: 0,
609
+ column: 0,
610
+ message: 'Card document "data.type" must be a string',
611
+ });
612
+ }
613
+ let meta = dataObj.meta as Record<string, unknown> | undefined;
614
+ if (typeof meta !== 'object' || meta === null) {
615
+ errors.push({
616
+ file: filename,
617
+ line: 0,
618
+ column: 0,
619
+ message: 'Card document must have a "data.meta" object',
620
+ });
621
+ } else {
622
+ let adoptsFrom = meta.adoptsFrom as Record<string, unknown> | undefined;
623
+ if (typeof adoptsFrom !== 'object' || adoptsFrom === null) {
624
+ errors.push({
625
+ file: filename,
626
+ line: 0,
627
+ column: 0,
628
+ message:
629
+ 'Card document must have a "data.meta.adoptsFrom" object with "module" and "name"',
630
+ });
631
+ } else {
632
+ if (typeof adoptsFrom.module !== 'string') {
633
+ errors.push({
634
+ file: filename,
635
+ line: 0,
636
+ column: 0,
637
+ message: '"data.meta.adoptsFrom.module" must be a string',
638
+ });
639
+ }
640
+ if (typeof adoptsFrom.name !== 'string') {
641
+ errors.push({
642
+ file: filename,
643
+ line: 0,
644
+ column: 0,
645
+ message: '"data.meta.adoptsFrom.name" must be a string',
646
+ });
647
+ }
648
+ }
649
+ }
650
+ return errors;
651
+ }
652
+
653
+ // ---------------------------------------------------------------------------
654
+ // Helpers
655
+ // ---------------------------------------------------------------------------
656
+
657
+ function emptyErrorResult(message: string): ParseRealmResult {
658
+ return {
659
+ status: 'error',
660
+ filesChecked: 0,
661
+ filesWithErrors: 0,
662
+ errorCount: 0,
663
+ durationMs: 0,
664
+ parseableFiles: [],
665
+ errors: [],
666
+ errorMessage: message,
667
+ };
668
+ }
669
+
670
+ // ---------------------------------------------------------------------------
671
+ // CLI surface
672
+ // ---------------------------------------------------------------------------
673
+
674
+ interface ParseCliOptions {
675
+ realm: string;
676
+ json?: boolean;
677
+ }
678
+
679
+ export function registerParseCommand(program: Command): void {
680
+ program
681
+ .command('parse')
682
+ .description(
683
+ "Type-check every .gts / .gjs / .ts file in a realm with glint, plus validate the document structure of any .json files linked as Spec.linkedExamples. Pass a realm-relative path to parse a single file. Monorepo-only (relies on packages/base, packages/host, packages/boxel-ui, and @glint/ember-tsc resolvable from this CLI's location).",
684
+ )
685
+ .argument(
686
+ '[path]',
687
+ 'Optional realm-relative file path. When omitted, parses every parseable file (gts/gjs/ts + Spec linkedExamples JSON) in the realm.',
688
+ )
689
+ .requiredOption('--realm <realm-url>', 'The realm URL to parse against')
690
+ .option('--json', 'Output structured JSON result')
691
+ .action(async (path: string | undefined, opts: ParseCliOptions) => {
692
+ let result: ParseRealmResult;
693
+ try {
694
+ result = await parseRealm(opts.realm, path ? { path } : {});
695
+ } catch (err) {
696
+ console.error(
697
+ `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
698
+ );
699
+ process.exit(1);
700
+ }
701
+
702
+ if (opts.json) {
703
+ cliLog.output(JSON.stringify(result, null, 2));
704
+ if (result.status !== 'passed') {
705
+ process.exit(1);
706
+ }
707
+ return;
708
+ }
709
+
710
+ if (result.errorMessage) {
711
+ console.error(`${FG_RED}Error:${RESET} ${result.errorMessage}`);
712
+ process.exit(1);
713
+ }
714
+
715
+ if (result.errors.length === 0) {
716
+ console.log(
717
+ `${DIM}No parse errors (${result.filesChecked} file(s) checked).${RESET}`,
718
+ );
719
+ return;
720
+ }
721
+
722
+ let currentFile: string | undefined;
723
+ for (let e of result.errors) {
724
+ if (e.file !== currentFile) {
725
+ currentFile = e.file;
726
+ console.log(`\n${DIM}${e.file}${RESET}`);
727
+ }
728
+ console.log(
729
+ ` ${FG_RED}error${RESET} ${e.line}:${e.column} ${e.message}`,
730
+ );
731
+ }
732
+
733
+ console.log(
734
+ `\n${DIM}${result.errorCount} error(s) across ${result.filesWithErrors} of ${result.filesChecked} file(s)${RESET}`,
735
+ );
736
+
737
+ if (result.errorCount > 0) {
738
+ process.exit(1);
739
+ }
740
+ });
741
+ }