Skip to content

Commit d7877af

Browse files
authored
Merge pull request #4881 from cardstack/cs-11149a-boxel-cli-validators
feat(boxel-cli): add lint, parse, test validator commands
2 parents 6c96568 + 7b8859e commit d7877af

9 files changed

Lines changed: 1861 additions & 1 deletion

File tree

packages/boxel-cli/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@
3939
"@cardstack/local-types": "workspace:*",
4040
"@cardstack/postgres": "workspace:*",
4141
"@cardstack/runtime-common": "workspace:*",
42+
"@glint/ember-tsc": "catalog:",
43+
"@playwright/test": "catalog:",
4244
"content-tag": "catalog:",
4345
"@types/jsonwebtoken": "catalog:",
4446
"@types/node": "catalog:",

packages/boxel-cli/scripts/build.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,19 @@ const commonConfig = {
1010
platform: 'node' as const,
1111
target: 'node18',
1212
format: 'cjs' as const,
13-
external: nodeBuiltins,
13+
external: [
14+
...nodeBuiltins,
15+
// Playwright (drives `boxel test`) and its native-module transitive
16+
// deps (fsevents on macOS, etc.) can't be bundled by esbuild — they
17+
// contain `.node` files and runtime `require.resolve` calls. boxel-cli
18+
// keeps them as runtime requires; they're picked up from node_modules
19+
// when `boxel test` actually runs. Monorepo-only by consequence —
20+
// matches `boxel test`'s existing monorepo-only constraint.
21+
'@playwright/test',
22+
'playwright',
23+
'playwright-core',
24+
'fsevents',
25+
],
1426
sourcemap: false,
1527
minify: true,
1628
metafile: true,

packages/boxel-cli/src/build-program.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { Command } from 'commander';
22
import { profileCommand } from './commands/profile';
33
import { registerConsolidateWorkspacesCommand } from './commands/consolidate-workspaces';
4+
import { registerLintCommand } from './commands/lint';
5+
import { registerParseCommand } from './commands/parse';
46
import { registerReadTranspiledCommand } from './commands/read-transpiled';
57
import { registerRealmCommand } from './commands/realm/index';
68
import { registerFileCommand } from './commands/file/index';
79
import { registerRunCommand } from './commands/run-command';
810
import { registerSearchCommand } from './commands/search';
11+
import { registerTestCommand } from './commands/test';
912
import { setQuiet } from './lib/cli-log';
1013
import { warnIfMisplacedLocalRealmDirs } from './lib/realm-local-paths';
1114

@@ -85,9 +88,12 @@ Environment variables (for 'add'):
8588
);
8689

8790
registerFileCommand(program);
91+
registerLintCommand(program);
92+
registerParseCommand(program);
8893
registerRealmCommand(program);
8994
registerRunCommand(program);
9095
registerSearchCommand(program);
96+
registerTestCommand(program);
9197
registerReadTranspiledCommand(program);
9298
registerConsolidateWorkspacesCommand(program);
9399

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
import type { Command } from 'commander';
2+
import { ensureTrailingSlash } from '@cardstack/runtime-common/paths';
3+
import { SupportedMimeType } from '@cardstack/runtime-common/supported-mime-type';
4+
import {
5+
getProfileManager,
6+
NO_ACTIVE_PROFILE_ERROR,
7+
type ProfileManager,
8+
} from '../lib/profile-manager';
9+
import { FG_RED, FG_YELLOW, DIM, RESET } from '../lib/colors';
10+
import { cliLog } from '../lib/cli-log';
11+
import { validateRealmRelativePath } from '../lib/realm-relative-path';
12+
import { lint as lintSingleFile, type LintMessage } from './file/lint';
13+
import { listFiles } from './file/list';
14+
15+
const LINTABLE_EXTENSIONS = ['.gts', '.gjs', '.ts', '.js'] as const;
16+
17+
export interface LintRealmViolation {
18+
rule: string | null;
19+
file: string;
20+
line: number;
21+
column: number;
22+
message: string;
23+
severity: 'error' | 'warning';
24+
}
25+
26+
export interface LintRealmResult {
27+
status: 'passed' | 'failed' | 'error';
28+
filesChecked: number;
29+
filesWithErrors: number;
30+
errorCount: number;
31+
warningCount: number;
32+
durationMs: number;
33+
lintableFiles: string[];
34+
violations: LintRealmViolation[];
35+
errorMessage?: string;
36+
}
37+
38+
export interface LintRealmOptions {
39+
/** Optional realm-relative path. When set, lints only that file. */
40+
path?: string;
41+
profileManager?: ProfileManager;
42+
}
43+
44+
/**
45+
* Lint every lintable file (`.gts`, `.gjs`, `.ts`, `.js`) in a realm,
46+
* or a single file when `options.path` is set. Source is fetched from
47+
* the realm; the realm's `_lint` endpoint runs ESLint + Prettier with
48+
* the `@cardstack/boxel` rules.
49+
*/
50+
export async function lintRealm(
51+
realmUrl: string,
52+
options?: LintRealmOptions,
53+
): Promise<LintRealmResult> {
54+
let pm = options?.profileManager ?? getProfileManager();
55+
let active = pm.getActiveProfile();
56+
if (!active) {
57+
return emptyErrorResult(NO_ACTIVE_PROFILE_ERROR);
58+
}
59+
60+
let normalizedRealmUrl = ensureTrailingSlash(realmUrl);
61+
let startedAt = Date.now();
62+
63+
let lintableFiles: string[];
64+
if (options?.path) {
65+
let path = options.path;
66+
let pathError = validateRealmRelativePath(path);
67+
if (pathError) {
68+
return emptyErrorResult(pathError);
69+
}
70+
if (!LINTABLE_EXTENSIONS.some((ext) => path.endsWith(ext))) {
71+
return emptyErrorResult(
72+
`Path "${path}" is not lintable — must end with one of ${LINTABLE_EXTENSIONS.join(', ')}`,
73+
);
74+
}
75+
lintableFiles = [path];
76+
} else {
77+
let listResult = await listFiles(normalizedRealmUrl, {
78+
profileManager: pm,
79+
});
80+
if (listResult.error) {
81+
return emptyErrorResult(
82+
`Failed to list realm files: ${listResult.error}`,
83+
);
84+
}
85+
lintableFiles = listResult.filenames.filter((f) =>
86+
LINTABLE_EXTENSIONS.some((ext) => f.endsWith(ext)),
87+
);
88+
}
89+
90+
if (lintableFiles.length === 0) {
91+
return {
92+
status: 'passed',
93+
filesChecked: 0,
94+
filesWithErrors: 0,
95+
errorCount: 0,
96+
warningCount: 0,
97+
durationMs: Date.now() - startedAt,
98+
lintableFiles: [],
99+
violations: [],
100+
};
101+
}
102+
103+
let violations: LintRealmViolation[] = [];
104+
let filesWithErrors = 0;
105+
let errorCount = 0;
106+
let warningCount = 0;
107+
108+
for (let file of lintableFiles) {
109+
let source: string;
110+
try {
111+
let readUrl = new URL(file, normalizedRealmUrl).href;
112+
let response = await pm.authedRealmFetch(readUrl, {
113+
method: 'GET',
114+
headers: { Accept: SupportedMimeType.CardSource },
115+
});
116+
if (!response.ok) {
117+
let body = await response.text().catch(() => '(no body)');
118+
recordReadError(
119+
file,
120+
`HTTP ${response.status}: ${body.slice(0, 300)}`,
121+
violations,
122+
);
123+
filesWithErrors += 1;
124+
errorCount += 1;
125+
continue;
126+
}
127+
source = await response.text();
128+
} catch (err) {
129+
recordReadError(
130+
file,
131+
err instanceof Error ? err.message : String(err),
132+
violations,
133+
);
134+
filesWithErrors += 1;
135+
errorCount += 1;
136+
continue;
137+
}
138+
139+
let result = await lintSingleFile(normalizedRealmUrl, source, file, {
140+
profileManager: pm,
141+
});
142+
143+
if (!result.ok) {
144+
recordReadError(file, result.error ?? 'lint failed', violations);
145+
filesWithErrors += 1;
146+
errorCount += 1;
147+
continue;
148+
}
149+
150+
let fileHasError = false;
151+
for (let msg of result.messages ?? []) {
152+
let severity: 'error' | 'warning' =
153+
msg.severity === 2 ? 'error' : 'warning';
154+
violations.push({
155+
rule: msg.ruleId,
156+
file,
157+
line: msg.line,
158+
column: msg.column,
159+
message: msg.message,
160+
severity,
161+
});
162+
if (severity === 'error') {
163+
errorCount += 1;
164+
fileHasError = true;
165+
} else {
166+
warningCount += 1;
167+
}
168+
}
169+
if (fileHasError) filesWithErrors += 1;
170+
}
171+
172+
return {
173+
status: errorCount === 0 ? 'passed' : 'failed',
174+
filesChecked: lintableFiles.length,
175+
filesWithErrors,
176+
errorCount,
177+
warningCount,
178+
durationMs: Date.now() - startedAt,
179+
lintableFiles,
180+
violations,
181+
};
182+
}
183+
184+
function recordReadError(
185+
file: string,
186+
detail: string,
187+
violations: LintRealmViolation[],
188+
): void {
189+
violations.push({
190+
rule: 'lint-error',
191+
file,
192+
line: 0,
193+
column: 0,
194+
message: detail,
195+
severity: 'error',
196+
});
197+
}
198+
199+
function emptyErrorResult(message: string): LintRealmResult {
200+
return {
201+
status: 'error',
202+
filesChecked: 0,
203+
filesWithErrors: 0,
204+
errorCount: 0,
205+
warningCount: 0,
206+
durationMs: 0,
207+
lintableFiles: [],
208+
violations: [],
209+
errorMessage: message,
210+
};
211+
}
212+
213+
interface LintCliOptions {
214+
realm: string;
215+
json?: boolean;
216+
}
217+
218+
export function registerLintCommand(program: Command): void {
219+
program
220+
.command('lint')
221+
.description(
222+
'Lint every lintable (.gts/.gjs/.ts/.js) file in a realm via the realm lint endpoint. Pass a realm-relative path to lint a single file.',
223+
)
224+
.argument(
225+
'[path]',
226+
'Optional realm-relative file path. When omitted, lints every lintable file in the realm.',
227+
)
228+
.requiredOption('--realm <realm-url>', 'The realm URL to lint against')
229+
.option('--json', 'Output structured JSON result')
230+
.action(async (path: string | undefined, opts: LintCliOptions) => {
231+
let result: LintRealmResult;
232+
try {
233+
result = await lintRealm(opts.realm, path ? { path } : {});
234+
} catch (err) {
235+
console.error(
236+
`${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
237+
);
238+
process.exit(1);
239+
}
240+
241+
if (opts.json) {
242+
cliLog.output(JSON.stringify(result, null, 2));
243+
if (result.status !== 'passed') {
244+
process.exit(1);
245+
}
246+
return;
247+
}
248+
249+
if (result.errorMessage) {
250+
console.error(`${FG_RED}Error:${RESET} ${result.errorMessage}`);
251+
process.exit(1);
252+
}
253+
254+
if (result.violations.length === 0) {
255+
console.log(
256+
`${DIM}No lint issues found (${result.filesChecked} file(s) checked).${RESET}`,
257+
);
258+
return;
259+
}
260+
261+
let currentFile: string | undefined;
262+
for (let v of result.violations) {
263+
if (v.file !== currentFile) {
264+
currentFile = v.file;
265+
console.log(`\n${DIM}${v.file}${RESET}`);
266+
}
267+
let color = v.severity === 'error' ? FG_RED : FG_YELLOW;
268+
let rule = v.rule ? ` (${v.rule})` : '';
269+
console.log(
270+
` ${color}${v.severity}${RESET} ${v.line}:${v.column} ${v.message}${DIM}${rule}${RESET}`,
271+
);
272+
}
273+
274+
console.log(
275+
`\n${DIM}${result.errorCount} error(s), ${result.warningCount} warning(s) across ${result.filesChecked} file(s)${RESET}`,
276+
);
277+
278+
if (result.errorCount > 0) {
279+
process.exit(1);
280+
}
281+
});
282+
}
283+
284+
// Re-export for callers that want the type alongside the function.
285+
export type { LintMessage };

0 commit comments

Comments
 (0)