@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.
- package/dist/index.js +156 -97
- package/package.json +5 -3
- package/src/build-program.ts +6 -0
- package/src/commands/lint.ts +285 -0
- package/src/commands/parse.ts +741 -0
- package/src/commands/test.ts +728 -0
- package/src/lib/find-package-root.ts +34 -0
- package/src/lib/realm-relative-path.ts +46 -0
|
@@ -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
|
+
}
|