Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 124 additions & 1 deletion packages/boxel-cli/scripts/build-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@
* --declaration --emitDeclarationOnly`).
* Source `.d.ts` files in `base/types`
* are passed through as-is.
* - `bundled-types/runtime-common/` — auto-generated `.d.ts` for
* `packages/runtime-common`. See
* `buildRuntimeCommonDts()`. Backs the
* `@cardstack/runtime-common` path
* alias; base's `.d.ts` import its
* `primitive`/`realmURL` symbols and
* field/query types, on which card
* field-value typing depends.
* - `bundled-types/host-types/` — `packages/host/types/*` ambient
* `.d.ts` files, referenced via the
* `'*': ['<host>/types/*']` fallback
Expand Down Expand Up @@ -303,8 +311,108 @@ function annotateInferredTypes(code: string): string {
);
}

/**
* Generate `.d.ts` for every module in `packages/runtime-common` and
* copy them into the destination. Runtime-common is plain `.ts` (no
* `<template>`), so unlike `base/` there's no content-tag preprocess and
* no `: any` annotation — the declarations emit faithfully.
*
* That fidelity is the point: `base/`'s bundled `.d.ts` imports the
* `primitive` / `realmURL` `unique symbol`s and the field / query types
* from `@cardstack/runtime-common`, and card-api's field-value mapping
* (`@model.someNumberField` → `number`, via `T extends { [primitive]:
* infer P }`) only resolves when those symbol identities are present.
* Card code reaches these through the `@cardstack/runtime-common` path
* alias in parse.ts's tsconfig.
*/
function buildRuntimeCommonDts(rcSrc: string, rcDst: string): void {
let tmpDir = mkdtempSync(join(tmpdir(), 'boxel-cli-rc-dts-'));
try {
// Copy `.ts` source into tmpDir (skip node_modules/dist/tests).
// Pre-existing `.d.ts` are passed through; tsc emits the rest.
let preexistingDts: string[] = [];
let walk = (dir: string): void => {
for (let entry of readdirSync(dir, { withFileTypes: true })) {
if (
entry.name === 'node_modules' ||
entry.name === 'dist' ||
entry.name.startsWith('.cache')
) {
continue;
}
let full = join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
continue;
}
let rel = relative(rcSrc, full);
if (!rel.endsWith('.ts')) continue;
// Tests aren't part of the type surface and drag in test deps.
if (rel.endsWith('.test.ts') || rel.startsWith('tests/')) continue;
let dst = join(tmpDir, rel);
mkdirSync(dirname(dst), { recursive: true });
writeFileSync(dst, readFileSync(full, 'utf8'), 'utf8');
if (rel.endsWith('.d.ts')) preexistingDts.push(rel);
}
};
walk(rcSrc);

// Link host's node_modules so runtime-common's imports resolve.
let nodeModulesLink = join(tmpDir, 'node_modules');
if (!existsSync(nodeModulesLink)) {
execFileSync(
'ln',
[
'-sf',
join(MONOREPO_PACKAGES, 'host', 'node_modules'),
'node_modules',
],
{ cwd: tmpDir },
);
}

let outDir = join(tmpDir, '.dts-out');
let tsconfig = {
extends: join(MONOREPO_PACKAGES, 'host', 'tsconfig.json'),
compilerOptions: {
noEmit: false,
declaration: true,
emitDeclarationOnly: true,
outDir,
inlineSourceMap: false,
inlineSources: false,
noUnusedLocals: false,
noUnusedParameters: false,
skipLibCheck: true,
rootDir: tmpDir,
},
include: ['**/*.ts'],
};
let tsconfigPath = join(tmpDir, 'tsconfig.json');
writeFileSync(tsconfigPath, JSON.stringify(tsconfig, null, 2), 'utf8');

// Same sandboxed emit as base/: declarations for imports that reach
// outside rootDir (host, base) are dropped rather than written to
// their source locations.
runTscEmitOnly(tsconfigPath, outDir);

rmSync(rcDst, { recursive: true, force: true });
mkdirSync(rcDst, { recursive: true });
if (existsSync(outDir)) {
cpSync(outDir, rcDst, { recursive: true });
}
for (let rel of preexistingDts) {
let to = join(rcDst, rel);
mkdirSync(dirname(to), { recursive: true });
cpSync(join(tmpDir, rel), to);
}
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
}

// ---------------------------------------------------------------------------
// Simple-copy vendors (everything except `base/`)
// Simple-copy vendors (everything except `base/` and `runtime-common/`)
// ---------------------------------------------------------------------------

interface Vendor {
Expand Down Expand Up @@ -483,6 +591,21 @@ function main(): void {
total += baseSize;
console.log(`${(baseSize / 1024 / 1024).toFixed(2)} MB`);

// runtime-common runs through the .d.ts pipeline too: base's bundled
// declarations import its symbols and types, and card field-value
// typing depends on them.
let rcSrc = join(MONOREPO_PACKAGES, 'runtime-common');
let rcDst = join(outRoot, 'runtime-common');
if (!safeIsDirectory(rcSrc)) {
console.error(`Missing packages/runtime-common at ${rcSrc}`);
process.exit(1);
}
process.stdout.write(' runtime-common … (running tsc --declaration) ');
buildRuntimeCommonDts(rcSrc, rcDst);
let rcSize = dirSize(rcDst);
total += rcSize;
console.log(`${(rcSize / 1024 / 1024).toFixed(2)} MB`);

// Other vendors are simple copies with filters.
for (let vendor of VENDORS) {
if (!safeIsDirectory(vendor.from)) {
Expand Down
10 changes: 10 additions & 0 deletions packages/boxel-cli/src/commands/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ const BOXEL_UI_PATH = BUNDLED_TYPES_DIR
const LOCAL_TYPES_PATH = BUNDLED_TYPES_DIR
? join(BUNDLED_TYPES_DIR, 'local-types')
: join(PACKAGES_PATH, 'local-types');
// `@cardstack/runtime-common` is a devDependency, so `npm install` of a
// published CLI never installs it. Bundle its generated `.d.ts` and
// alias the bare + subpath specifiers to it; base's bundled `.d.ts`
// import its `primitive` symbol and field/query types, which card
// field-value typing depends on.
const RUNTIME_COMMON_PATH = BUNDLED_TYPES_DIR
? join(BUNDLED_TYPES_DIR, 'runtime-common')
: join(PACKAGES_PATH, 'runtime-common');
// Ambient module decls for paths boxel-cli doesn't ship full types
// for (e.g. `@cardstack/boxel-icons/*` — 130MB if shipped). Generated
// by `scripts/build-types.ts`. Only present in published / built
Expand Down Expand Up @@ -592,6 +600,8 @@ async function runGlintCheck(
paths: {
'@cardstack/base/*': [`${BASE_PKG_PATH}/*`],
'https://cardstack.com/base/*': [`${BASE_PKG_PATH}/*`],
'@cardstack/runtime-common': [`${RUNTIME_COMMON_PATH}/index`],
'@cardstack/runtime-common/*': [`${RUNTIME_COMMON_PATH}/*`],
'@cardstack/host/tests/*': [`${HOST_TESTS_PATH}/*`],
'@cardstack/host/*': [`${HOST_APP_PATH}/*`],
'@cardstack/boxel-host/commands/*': [`${HOST_APP_PATH}/commands/*`],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import NumberField from 'https://cardstack.com/base/number';
import TextAreaField from 'https://cardstack.com/base/text-area';
import { formatDateTime } from '@cardstack/boxel-ui/helpers';

// Exercises the common template shapes: the positional
// `formatDateTime` helper call, and direct interpolation of
// Exercises the common template shapes: a `formatDateTime` helper call
// with its `format` named arg, and direct interpolation of
// `contains(NumberField)` / `contains(TextAreaField)` values.
export class Event extends CardDef {
static displayName = 'Event';
Expand All @@ -19,7 +19,7 @@ export class Event extends CardDef {
@field notes = contains(TextAreaField);
static isolated = class extends Component<typeof Event> {
<template>
<time>{{formatDateTime @model.when 'MMM D'}}</time>
<time>{{formatDateTime @model.when format='MMM D'}}</time>
<span>{{@model.capacity}}</span>
<p>{{@model.notes}}</p>
</template>
Expand Down
87 changes: 49 additions & 38 deletions packages/boxel-cli/tests/integration/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,22 +33,47 @@ async function parseFixture(name: string): Promise<ParseRealmResult> {

// ---------------------------------------------------------------------------
// Primary behavior: glint runs against an npm install and the CLI's
// tsconfig aliases resolve, so real cards type-check clean.
// tsconfig aliases + bundled types resolve, so real cards type-check
// clean. Each fixture pins a distinct resolution surface:
// - plain-glimmer: field value types (`@model.someNumberField` →
// `number`), which resolve through the `primitive` symbol bundled
// from @cardstack/runtime-common.
// - runtime-common: the bare `@cardstack/runtime-common` import
// (the `realmURL` symbol, the `Query` type).
// - boxel-host-tools: the `@cardstack/boxel-host/tools/*` alias + the
// bundled `tools/` source.
// - helpers-and-fields: a `@cardstack/boxel-ui/helpers` call
// (`formatDateTime` with its `format` named arg) plus direct
// interpolation of `contains(NumberField)` / `contains(TextAreaField)`
// field values.
// ---------------------------------------------------------------------------
const CLEAN_FIXTURES: { name: string; covers: string }[] = [
{
name: 'plain-glimmer',
covers: 'field value types via the primitive symbol',
},
{ name: 'runtime-common', covers: 'bare @cardstack/runtime-common import' },
{ name: 'boxel-host-tools', covers: '@cardstack/boxel-host/tools/* import' },
{
name: 'helpers-and-fields',
covers: 'boxel-ui helper call + field interpolation',
},
];

describe('boxel parse (against the installed CLI)', () => {
it(
'type-checks a card importing @cardstack/boxel-host/tools/* clean',
async () => {
// Exercises the host-tools path alias + the bundled `tools/`
// source. Also proves glint ran end-to-end on a real card in the
// installed layout.
let result = await parseFixture('boxel-host-tools');
expect(result.errors).toEqual([]);
expect(result.status).toBe('passed');
expect(result.filesChecked).toBeGreaterThanOrEqual(1);
},
{ timeout: 180_000 },
);
describe.each(CLEAN_FIXTURES)('$name — $covers', ({ name }) => {
it(
'type-checks clean',
async () => {
let result = await parseFixture(name);
// Surface the actual diagnostics on failure, not a bare count.
expect(result.errors).toEqual([]);
expect(result.status).toBe('passed');
expect(result.filesChecked).toBeGreaterThanOrEqual(1);
},
{ timeout: 180_000 },
);
});

it(
'type-checks a .test.gts using assert.dom clean (qunit-dom augmentation)',
Expand Down Expand Up @@ -83,36 +108,22 @@ describe('boxel parse (against the installed CLI)', () => {
});

// ---------------------------------------------------------------------------
// Known typing gaps, deferred to follow-up work. These are card patterns
// that type-check clean in the monorepo (against host's real types) but
// not yet in a published install, because the bundled types / glint
// config don't cover them:
// Known typing gap, deferred to follow-up work:
//
// - runtime-common / field values: card-api resolves field value types
// (`@model.someNumberField` → `number`) through the `primitive`
// unique symbol imported from `@cardstack/runtime-common`. That
// package is a devDependency, which `npm install` of the published
// CLI does not install, so the mapping collapses to the field class
// (`NumberField`). Fix: bundle runtime-common's generated types.
// - decorators: `@tracked` alongside a `<template>` inside a
// - tracked-format-class — `@tracked` alongside a `<template>` inside a
// `static isolated = class … {}` expression trips glint's
// "Decorators are not valid here". A glint/ember-tsc transform
// limitation for decorators in class expressions.
// - helper arg shapes: the bundled `formatDateTime` typing rejects the
// common positional call `(formatDateTime @model.when 'MMM D')`.
// limitation for decorators in class expressions; it reproduces in
// both the monorepo and the published layout, so it isn't a
// bundled-types gap.
//
// Marked `it.fails`: each is an expected failure while the published CLI
// lacks support for the pattern, so it runs without failing CI. An
// unexpected pass makes `it.fails` itself fail — the signal to remove the
// marker and move the case into the block above.
// Marked `it.fails`: an expected failure while the CLI lacks support for
// the pattern, so it runs without failing CI. An unexpected pass makes
// `it.fails` itself fail — the signal to remove the marker and move the
// case into the block above.
// ---------------------------------------------------------------------------
describe('boxel parse — known typing gaps (deferred)', () => {
const DEFERRED = [
'plain-glimmer',
'runtime-common',
'helpers-and-fields',
'tracked-format-class',
];
const DEFERRED = ['tracked-format-class'];

describe.each(DEFERRED)('%s', (name) => {
it.fails(
Expand Down
Loading