diff --git a/packages/host/app/commands/evaluate-module.ts b/packages/host/app/commands/evaluate-module.ts index e85aa44ba1..d59651cb10 100644 --- a/packages/host/app/commands/evaluate-module.ts +++ b/packages/host/app/commands/evaluate-module.ts @@ -45,6 +45,15 @@ export default class EvaluateModuleCommand extends HostBaseCommand< let commandModule = await this.loadCommandModule(); try { + // Reset the loader to clear cached modules from prior eval runs. + // Without this, the Loader's internal module Map retains the old + // compiled bytecode and `loader.import()` returns the cached + // (stale) result — so edits the factory agent makes between + // validation turns are invisible to the eval step. + this.loaderService.resetLoader({ + clearFetchCache: true, + reason: 'evaluate-module: fresh eval requires uncached loader', + }); let loader = this.loaderService.loader; await loader.import(moduleUrl); diff --git a/packages/host/app/commands/instantiate-card.ts b/packages/host/app/commands/instantiate-card.ts index c6e597da4d..cf36650d30 100644 --- a/packages/host/app/commands/instantiate-card.ts +++ b/packages/host/app/commands/instantiate-card.ts @@ -62,6 +62,16 @@ export default class InstantiateCardCommand extends HostBaseCommand< let commandModule = await this.loadCommandModule(); try { + // Reset the loader to clear cached modules from prior runs. + // Without this, the Loader's internal module Map retains stale + // compiled bytecode — edits the factory agent makes between + // validation turns are invisible to instantiation. + this.loaderService.resetLoader({ + clearFetchCache: true, + reason: + 'instantiate-card: fresh instantiation requires uncached loader', + }); + // Build or parse the card document let doc; if (input.instanceData) { diff --git a/packages/software-factory/.agents/skills/boxel-development/references/dev-core-concept.md b/packages/software-factory/.agents/skills/boxel-development/references/dev-core-concept.md index 2e8318cdb1..a59a82cef0 100644 --- a/packages/software-factory/.agents/skills/boxel-development/references/dev-core-concept.md +++ b/packages/software-factory/.agents/skills/boxel-development/references/dev-core-concept.md @@ -94,45 +94,50 @@ For computed fields, ask: "Am I keeping this simple and unidirectional?" **Every CardDef inherits:** -- `title`, `description`, `thumbnailURL` +- `cardTitle`, `cardDescription`, `cardThumbnailURL` ### Inherited Fields and CardInfo -**IMPORTANT:** Every CardDef automatically inherits these base fields from the CardDef base class: +**IMPORTANT:** Every CardDef automatically inherits these base fields from the CardDef base class (defined in `packages/base/card-api.gts`): -#### Direct Inherited Fields (Read-Only) +#### CardInfoField (FieldDef — user-editable) -- `title` (StringField) - Computed pass-through from `cardInfo.title` -- `description` (StringField) - Computed pass-through from `cardInfo.description` -- `thumbnailURL` (StringField) - Computed pass-through from `cardInfo.thumbnailURL` +`CardInfoField` is a `FieldDef` with these fields: -#### CardInfo Field (User-Editable) +- `cardInfo.name` (StringField) — user-editable card name +- `cardInfo.summary` (StringField) — user-editable card summary +- `cardInfo.cardThumbnail` (linksTo ImageDef) — linked thumbnail image card +- `cardInfo.cardThumbnailURL` (MaybeBase64Field) — thumbnail URL or base64 string +- `cardInfo.theme` (linksTo Theme) — optional theme card link +- `cardInfo.notes` (MarkdownField) — optional internal notes -Every card also inherits a `cardInfo` field which contains the actual user-editable values: +#### Computed pass-through fields on CardDef (read-only) -- `cardInfo.title` (StringField) - User-editable card title -- `cardInfo.description` (StringField) - User-editable card description -- `cardInfo.thumbnailURL` (StringField) - User-editable thumbnail image URL -- `cardInfo.theme` (linksTo ThemeCard) - Optional theme card link -- `cardInfo.notes` (MarkdownField) - Optional internal notes +These are computed fields that read from `cardInfo`: + +- `cardTitle` (StringField) — computed from `cardInfo.name`, falls back to `Untitled {displayName}` +- `cardDescription` (StringField) — computed from `cardInfo.summary` +- `cardThumbnailURL` (MaybeBase64Field) — computed from `cardInfo.cardThumbnailURL` **How It Works:** -The top-level `title`, `description`, and `thumbnailURL` fields are computed properties that automatically pass through the values from `cardInfo.title`, `cardInfo.description`, and `cardInfo.thumbnailURL` respectively. This means: +The top-level `cardTitle`, `cardDescription`, and `cardThumbnailURL` fields are computed properties that pass through values from `cardInfo`. Users edit values through the `cardInfo` field in edit mode. -- When you read `@model.title` in templates, you get the value from `cardInfo.title` -- Users edit values through the `cardInfo` field in edit mode -- Override to add custom logic that respects user input +- `@model.cardTitle` reads `cardInfo.name` (with fallback) +- `@model.cardDescription` reads `cardInfo.summary` +- Override these computed fields to add custom logic -**Best Practice:** Define your own primary field and compute `title` to respect user's `cardInfo.title` choice: +**Best Practice:** Define your own primary field and compute `cardTitle` to respect user's `cardInfo.name` choice: ```gts export class BlogPost extends CardDef { @field headline = contains(StringField); // Your primary field - // Override inherited title - respects user's cardInfo.title if set - @field title = contains(StringField, { - computeVia: function () { - return this.cardInfo?.title ?? this.headline ?? 'Untitled'; + // Override inherited cardTitle - respects user's cardInfo.name if set + @field cardTitle = contains(StringField, { + computeVia: function (this: BlogPost): string { + return this.cardInfo?.name?.trim()?.length + ? this.cardInfo.name + : (this.headline ?? 'Untitled'); }, }); } diff --git a/packages/software-factory/.agents/skills/boxel-development/references/dev-technical-rules.md b/packages/software-factory/.agents/skills/boxel-development/references/dev-technical-rules.md index 15c9b2838d..f2af5b2901 100644 --- a/packages/software-factory/.agents/skills/boxel-development/references/dev-technical-rules.md +++ b/packages/software-factory/.agents/skills/boxel-development/references/dev-technical-rules.md @@ -70,6 +70,86 @@ Before generating ANY code: - [ ] No JavaScript operations in templates - [ ] ALL THREE FORMATS: isolated, embedded, fitted +### Glint (ember-tsc) Type Checking Patterns + +The factory runs `ember-tsc` (glint) on all `.gts` and `.ts` files to catch type errors. These patterns avoid common glint failures: + +#### Decorators inside inline class assignments + +Glint does not support decorators (`@tracked`, etc.) on fields inside an inline class expression assigned to a static property. Declare the component class separately: + +```gts +// ❌ WRONG — "Decorators are not valid here" +export class StickyNote extends CardDef { + static isolated = class Isolated extends Component { + @tracked editMode = false; // glint error! + + }; +} + +// ✅ CORRECT — declare the class outside the assignment +class Isolated extends Component { + @tracked editMode = false; + +} + +export class StickyNote extends CardDef { + static isolated = Isolated; +} +``` + +Note: `@field` decorators on `CardDef`/`FieldDef` classes work fine — this restriction only applies to component classes using `@tracked` or similar decorators. + +#### Typing dynamic imports in test files + +When test files use `loader.import()`, the return type is `{}` by default. Destructuring a named export from it causes "Property does not exist on type '{}'": + +```gts +// ❌ WRONG — "Property 'StickyNote' does not exist on type '{}'" +let { StickyNote } = await loader.import(cardModuleUrl); + +// ✅ CORRECT — cast the import result +let { StickyNote } = (await loader.import(cardModuleUrl)) as Record; +``` + +#### Accessing cardInfo properties in computeVia + +`CardDef.cardInfo` is a `CardInfoField` (FieldDef) with these fields: `name`, `summary`, `cardThumbnailURL`, `cardThumbnail`, `theme`, `notes`. Access them directly — they are properly typed: + +```gts +// ✅ CORRECT — access cardInfo fields directly (they are typed) +@field cardTitle = contains(StringField, { + computeVia: function (this: MyCard): string { + return this.cardInfo?.name?.trim()?.length + ? this.cardInfo.name + : this.headline ?? 'Untitled'; + }, +}); + +// ❌ WRONG — these fields don't exist on CardInfoField +this.cardInfo.title // use .name instead +this.cardInfo.description // use .summary instead +this.cardInfo.thumbnailURL // use .cardThumbnailURL instead +``` + +**Note:** The computed pass-through fields on CardDef are named `cardTitle` (not `title`), `cardDescription` (not `description`), and `cardThumbnailURL`. Override these — not fields named `title`/`description`. + +#### Explicit types for function parameters + +Glint enforces strict mode. Always type function parameters and return values: + +```gts +// ❌ WRONG — implicit any +greet = (name) => `Hello, ${name}!`; + +// ✅ CORRECT +greet = (name: string): string => `Hello, ${name}!`; +``` + +#### Unused imports from Ember shims + +If you import from `@ember/helper`, `@ember/modifier`, or `@glimmer/tracking`, only import what you actually use. Glint enforces `noUnusedLocals` in the factory's type checking configuration. Remove unused imports rather than suppressing the error. + ### Common Mistakes #### Using contains with CardDef diff --git a/packages/software-factory/.agents/skills/boxel-development/references/dev-template-patterns.md b/packages/software-factory/.agents/skills/boxel-development/references/dev-template-patterns.md index 8377e76ce8..9947e2d430 100644 --- a/packages/software-factory/.agents/skills/boxel-development/references/dev-template-patterns.md +++ b/packages/software-factory/.agents/skills/boxel-development/references/dev-template-patterns.md @@ -1,5 +1,34 @@ ### Template Essentials +#### ⚠️ CRITICAL: Strict Mode — Import Every Helper and Modifier + +Boxel `.gts` templates run in **strict mode**. Every helper, modifier, and component used in a ` + }; +} + +export class ParseFileResult extends FieldDef { + static displayName = 'Parse File Result'; + + @field file = contains(StringField); + @field errors = containsMany(ParseError); + + @field errorCount = contains(NumberField, { + computeVia: function (this: ParseFileResult) { + return this.errors?.length ?? 0; + }, + }); + + get passed() { + return (this.errorCount ?? 0) === 0; + } + + get displayFile() { + let f = this.file ?? ''; + if (f && !f.includes('.')) { + return `${f}.json`; + } + return f; + } + + static embedded = class Embedded extends Component { + + }; +} + +export class ParseResult extends CardDef { + static displayName = 'Parse Result'; + + @field sequenceNumber = contains(NumberField); + @field runAt = contains(DateTimeField); + @field completedAt = contains(DateTimeField); + @field project = linksTo(() => Project); + @field issue = linksTo(() => Issue); + @field status = contains(ParseResultStatusField); + @field durationMs = contains(NumberField); + @field fileResults = containsMany(ParseFileResult); + @field errorMessage = contains(StringField); + + @field totalErrors = contains(NumberField, { + computeVia: function (this: ParseResult) { + return (this.fileResults ?? []).reduce( + (sum, fr) => sum + (fr.errorCount ?? 0), + 0, + ); + }, + }); + + @field filesChecked = contains(NumberField, { + computeVia: function (this: ParseResult) { + return this.fileResults?.length ?? 0; + }, + }); + + @field title = contains(StringField, { + computeVia: function (this: ParseResult) { + let seq = this.sequenceNumber ?? '?'; + let status = this.status ?? 'unknown'; + return `ParseResult #${seq} \u2014 ${status}`; + }, + }); + + get filesWithErrors() { + return (this.fileResults ?? []).filter((fr) => !fr.passed).length; + } + + get filesClean() { + return (this.filesChecked ?? 0) - this.filesWithErrors; + } + + static fitted = class Fitted extends Component { + get displayStatus() { + if ( + (this.args.model.filesChecked ?? 0) === 0 && + this.args.model.status === 'passed' + ) { + return 'empty'; + } + return this.args.model.status; + } + + + }; + + static embedded = this.fitted; + + static isolated = class Isolated extends Component { + get displayStatus() { + if ( + (this.args.model.filesChecked ?? 0) === 0 && + this.args.model.status === 'passed' + ) { + return 'empty'; + } + return this.args.model.status; + } + + + }; +} diff --git a/packages/software-factory/scripts/smoke-tests/smoke-test-realm.ts b/packages/software-factory/scripts/smoke-tests/smoke-test-realm.ts index 18ff53d70c..aaf19adc36 100644 --- a/packages/software-factory/scripts/smoke-tests/smoke-test-realm.ts +++ b/packages/software-factory/scripts/smoke-tests/smoke-test-realm.ts @@ -327,6 +327,10 @@ async function main() { 'software-factory/instantiate-result', realmServerUrl, ).href; + let parseResultsModuleUrl = new URL( + 'software-factory/parse-result', + realmServerUrl, + ).href; let pipeline = createDefaultPipeline({ authorization, @@ -342,6 +346,7 @@ async function main() { lintResultsModuleUrl, evalResultsModuleUrl, instantiateResultsModuleUrl, + parseResultsModuleUrl, }); let validationResults = await pipeline.validate(targetRealmUrl); diff --git a/packages/software-factory/src/factory-issue-loop-wiring.ts b/packages/software-factory/src/factory-issue-loop-wiring.ts index be35d757fa..ef4e36ae44 100644 --- a/packages/software-factory/src/factory-issue-loop-wiring.ts +++ b/packages/software-factory/src/factory-issue-loop-wiring.ts @@ -174,6 +174,10 @@ export async function runFactoryIssueLoop( 'software-factory/instantiate-result', realmServerUrl, ).href; + let parseResultsModuleUrl = new URL( + 'software-factory/parse-result', + realmServerUrl, + ).href; let hostAppUrl = config.hostAppUrl ?? realmServerUrl; let toolBuilderConfig: ToolBuilderConfig = { targetRealmUrl, @@ -216,6 +220,7 @@ export async function runFactoryIssueLoop( lintResultsModuleUrl, evalResultsModuleUrl, instantiateResultsModuleUrl, + parseResultsModuleUrl, issueId, fetchFilenames: (realmUrl: string) => fetchRealmFilenames(realmUrl, fetchOptions), diff --git a/packages/software-factory/src/issue-loop.ts b/packages/software-factory/src/issue-loop.ts index ea9272b1ef..9216573635 100644 --- a/packages/software-factory/src/issue-loop.ts +++ b/packages/software-factory/src/issue-loop.ts @@ -39,7 +39,10 @@ let log = logger('issue-loop'); * See ValidationPipeline for the real implementation. */ export interface Validator { - validate(targetRealmUrl: string): Promise; + validate( + targetRealmUrl: string, + iteration: number, + ): Promise; /** Format validation results for LLM context and issue descriptions. */ formatForContext(results: ValidationResults): string; } @@ -105,7 +108,7 @@ export interface IssueLoopConfig { createValidator: (issueId: string) => Validator; targetRealmUrl: string; briefUrl?: string; - /** Maximum inner-loop iterations per issue. Default: 5. */ + /** Maximum inner-loop iterations per issue. Default: 8. */ maxIterationsPerIssue?: number; /** Maximum outer-loop cycles (safety guard). Default: 50. */ maxOuterCycles?: number; @@ -135,7 +138,7 @@ export interface IssueLoopResult { // Defaults // --------------------------------------------------------------------------- -const DEFAULT_MAX_ITERATIONS_PER_ISSUE = 5; +const DEFAULT_MAX_ITERATIONS_PER_ISSUE = 8; const DEFAULT_MAX_OUTER_CYCLES = 50; // --------------------------------------------------------------------------- @@ -300,8 +303,10 @@ export async function runIssueLoop( log.info(` Agent returned ${result.toolCalls.length} tool call(s)`); - // Validation — runs after every agent turn - validationResults = await validator.validate(targetRealmUrl); + // Validation — runs after every agent turn. + // Pass the iteration number so all steps use it as the sequence + // number in artifact filenames (parse_slug-1, lint_slug-1, etc.) + validationResults = await validator.validate(targetRealmUrl, iteration); validationContext = validationResults && !validationResults.passed ? validator.formatForContext(validationResults) diff --git a/packages/software-factory/src/parse-result-cards.ts b/packages/software-factory/src/parse-result-cards.ts new file mode 100644 index 0000000000..147968d342 --- /dev/null +++ b/packages/software-factory/src/parse-result-cards.ts @@ -0,0 +1,182 @@ +import type { LooseSingleCardDocument } from '@cardstack/runtime-common'; + +import { readFile, writeFile } from './realm-operations'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ParseErrorData { + file: string; + line: number; + column: number; + message: string; +} + +export interface ParseFileResultData { + file: string; + errors: ParseErrorData[]; +} + +export interface ParseResultAttributes { + status: 'running' | 'passed' | 'failed' | 'error'; + durationMs?: number; + fileResults?: ParseFileResultData[]; + errorMessage?: string; +} + +export interface ParseResultRealmOptions { + targetRealmUrl: string; + authorization?: string; + fetch?: typeof globalThis.fetch; +} + +export interface CreateParseResultOptions { + sequenceNumber?: number; + issueURL?: string; + projectCardUrl?: string; +} + +// --------------------------------------------------------------------------- +// Card lifecycle +// --------------------------------------------------------------------------- + +/** + * Create a `ParseResult` card with `status: running`. + * Returns the card path as the handle. + */ +export async function createParseResult( + slug: string, + parseResultsModuleUrl: string, + options: ParseResultRealmOptions & CreateParseResultOptions, +): Promise<{ parseResultId: string; created: boolean; error?: string }> { + let seq = options.sequenceNumber ?? 1; + let parseResultId = `Validations/parse_${slug}-${seq}`; + + let document = buildParseResultCardDocument(parseResultsModuleUrl, { + sequenceNumber: seq, + issueURL: options.issueURL, + projectCardUrl: options.projectCardUrl, + }); + + let result = await writeFile( + options.targetRealmUrl, + `${parseResultId}.json`, + JSON.stringify(document, null, 2), + { authorization: options.authorization, fetch: options.fetch }, + ); + + if (!result.ok) { + return { parseResultId, created: false, error: result.error }; + } + + return { parseResultId, created: true }; +} + +/** + * Update an existing `ParseResult` card with parse results and final status. + */ +export async function completeParseResult( + parseResultId: string, + attrs: ParseResultAttributes, + options: ParseResultRealmOptions & { projectCardUrl?: string }, +): Promise<{ updated: boolean; error?: string }> { + let fetchOptions = { + authorization: options.authorization, + fetch: options.fetch, + }; + + let readResult = await readFile( + options.targetRealmUrl, + parseResultId, + fetchOptions, + ); + + if (!readResult.ok || !readResult.document) { + return { + updated: false, + error: `Failed to read ParseResult: ${readResult.error}`, + }; + } + + let completionAttrs: Record = { + status: attrs.status, + completedAt: new Date().toISOString(), + durationMs: attrs.durationMs, + fileResults: attrs.fileResults, + }; + if (attrs.errorMessage) { + completionAttrs.errorMessage = attrs.errorMessage; + } + + readResult.document.data.attributes = { + ...readResult.document.data.attributes, + ...completionAttrs, + }; + + if (options.projectCardUrl) { + let existingRelationships = + (readResult.document.data as Record).relationships ?? {}; + (readResult.document.data as Record).relationships = { + ...(existingRelationships as Record), + project: { links: { self: options.projectCardUrl } }, + }; + } + + let writeResult = await writeFile( + options.targetRealmUrl, + `${parseResultId}.json`, + JSON.stringify(readResult.document, null, 2), + fetchOptions, + ); + + if (!writeResult.ok) { + return { + updated: false, + error: `Failed to update ParseResult: ${writeResult.error}`, + }; + } + + return { updated: true }; +} + +/** + * Build the initial card document for a ParseResult with `status: running`. + */ +export function buildParseResultCardDocument( + parseResultsModuleUrl: string, + options?: CreateParseResultOptions, +): LooseSingleCardDocument { + let attributes: Record = { + sequenceNumber: options?.sequenceNumber ?? 1, + runAt: new Date().toISOString(), + status: 'running', + }; + + let relationships: + | Record + | undefined; + if (options?.projectCardUrl || options?.issueURL) { + relationships = {}; + if (options?.projectCardUrl) { + relationships.project = { links: { self: options.projectCardUrl } }; + } + if (options?.issueURL) { + relationships.issue = { links: { self: options.issueURL } }; + } + } + + return { + data: { + type: 'card', + attributes, + ...(relationships ? { relationships } : {}), + meta: { + adoptsFrom: { + module: parseResultsModuleUrl, + name: 'ParseResult', + }, + }, + }, + } as LooseSingleCardDocument; +} diff --git a/packages/software-factory/src/test-run-execution.ts b/packages/software-factory/src/test-run-execution.ts index 45cd0684df..77cf8e9cb1 100644 --- a/packages/software-factory/src/test-run-execution.ts +++ b/packages/software-factory/src/test-run-execution.ts @@ -61,11 +61,16 @@ export async function resolveTestRun( }; } - let sequenceNumber = await getNextSequenceNumber( - options.slug, - realmOptions, - options.lastSequenceNumber, - ); + let sequenceNumber: number; + if (options.iteration != null) { + sequenceNumber = options.iteration; + } else { + sequenceNumber = await getNextSequenceNumber( + options.slug, + realmOptions, + options.lastSequenceNumber, + ); + } let createResult = await createTestRun(options.slug, options.testNames, { ...realmOptions, diff --git a/packages/software-factory/src/test-run-types.ts b/packages/software-factory/src/test-run-types.ts index 531fe56c4a..0efecf05d7 100644 --- a/packages/software-factory/src/test-run-types.ts +++ b/packages/software-factory/src/test-run-types.ts @@ -150,4 +150,10 @@ export interface ExecuteTestRunOptions { * number here guarantees the new TestRun gets at least lastSequenceNumber + 1. */ lastSequenceNumber?: number; + /** + * When provided, use this value directly as the sequence number instead of + * computing one via getNextValidationSequenceNumber. Used by the validation + * pipeline to ensure all steps in an iteration share the same sequence number. + */ + iteration?: number; } diff --git a/packages/software-factory/src/validators/eval-step.ts b/packages/software-factory/src/validators/eval-step.ts index b234da0269..81cf7fec9a 100644 --- a/packages/software-factory/src/validators/eval-step.ts +++ b/packages/software-factory/src/validators/eval-step.ts @@ -124,7 +124,10 @@ export class EvalValidationStep implements ValidationStepRunner { )); } - async run(targetRealmUrl: string): Promise { + async run( + targetRealmUrl: string, + iteration?: number, + ): Promise { // Step 1: Discover evaluable files let evaluableFiles: string[]; try { @@ -160,14 +163,18 @@ export class EvalValidationStep implements ValidationStepRunner { : undefined; let seq: number; - try { - let realmSeq = await this.getNextSeqFn(slug, targetRealmUrl); - seq = Math.max(realmSeq, this.lastSequenceNumber + 1); - } catch (err) { - log.warn( - `Failed to resolve sequence number, using floor: ${err instanceof Error ? err.message : String(err)}`, - ); - seq = this.lastSequenceNumber + 1; + if (iteration != null) { + seq = iteration; + } else { + try { + let realmSeq = await this.getNextSeqFn(slug, targetRealmUrl); + seq = Math.max(realmSeq, this.lastSequenceNumber + 1); + } catch (err) { + log.warn( + `Failed to resolve sequence number, using floor: ${err instanceof Error ? err.message : String(err)}`, + ); + seq = this.lastSequenceNumber + 1; + } } let evalResultId: string; diff --git a/packages/software-factory/src/validators/instantiate-step.ts b/packages/software-factory/src/validators/instantiate-step.ts index 9eea6bf926..907eb54d52 100644 --- a/packages/software-factory/src/validators/instantiate-step.ts +++ b/packages/software-factory/src/validators/instantiate-step.ts @@ -170,7 +170,10 @@ export class InstantiateValidationStep implements ValidationStepRunner { )); } - async run(targetRealmUrl: string): Promise { + async run( + targetRealmUrl: string, + iteration?: number, + ): Promise { // Step 1: Discover specs in the realm let specInfos: SpecInfo[]; try { @@ -241,14 +244,18 @@ export class InstantiateValidationStep implements ValidationStepRunner { : undefined; let seq: number; - try { - let realmSeq = await this.getNextSeqFn(slug, targetRealmUrl); - seq = Math.max(realmSeq, this.lastSequenceNumber + 1); - } catch (err) { - log.warn( - `Failed to resolve sequence number, using floor: ${err instanceof Error ? err.message : String(err)}`, - ); - seq = this.lastSequenceNumber + 1; + if (iteration != null) { + seq = iteration; + } else { + try { + let realmSeq = await this.getNextSeqFn(slug, targetRealmUrl); + seq = Math.max(realmSeq, this.lastSequenceNumber + 1); + } catch (err) { + log.warn( + `Failed to resolve sequence number, using floor: ${err instanceof Error ? err.message : String(err)}`, + ); + seq = this.lastSequenceNumber + 1; + } } let instantiateResultId: string; @@ -330,8 +337,29 @@ export class InstantiateValidationStep implements ValidationStepRunner { } } - // If no examples were found/read, try instantiating with no field data + // If the spec declared linkedExamples but none could be read, that's a + // validation failure — a typoed path or permissions error should not be + // silently downgraded into "instantiate with no data." Only fall back to + // empty-data instantiation when the spec has no linkedExamples at all. + if (exampleInstances.length === 0 && spec.exampleUrls.length > 0) { + let codeRef = { module: spec.moduleUrl, name: spec.cardName }; + let message = `All ${spec.exampleUrls.length} linkedExample(s) for spec ${spec.specId} failed to read — cannot validate instantiation. Check that example paths are correct.`; + log.warn(message); + allCardResults.push({ + codeRef, + instanceId: '', + error: message, + }); + failedCards.push({ + instanceId: '', + cardName: spec.cardName, + error: message, + }); + continue; + } + if (exampleInstances.length === 0) { + // Spec has no linkedExamples — try instantiating with no field data exampleInstances.push({ url: '', data: '' }); } diff --git a/packages/software-factory/src/validators/lint-step.ts b/packages/software-factory/src/validators/lint-step.ts index 7805660031..202cc7f190 100644 --- a/packages/software-factory/src/validators/lint-step.ts +++ b/packages/software-factory/src/validators/lint-step.ts @@ -127,7 +127,10 @@ export class LintValidationStep implements ValidationStepRunner { )); } - async run(targetRealmUrl: string): Promise { + async run( + targetRealmUrl: string, + iteration?: number, + ): Promise { // Step 1: Discover lintable files let lintableFiles: string[]; try { @@ -163,17 +166,21 @@ export class LintValidationStep implements ValidationStepRunner { : undefined; let seq: number; - try { - let realmSeq = await this.getNextSeqFn(slug, targetRealmUrl); - // Use the higher of realm state vs in-memory floor. The realm index - // may be stale if the prior lint run just completed (lint is fast), - // so the floor prevents sequence reuse / artifact overwrite. - seq = Math.max(realmSeq, this.lastSequenceNumber + 1); - } catch (err) { - log.warn( - `Failed to resolve sequence number, using floor: ${err instanceof Error ? err.message : String(err)}`, - ); - seq = this.lastSequenceNumber + 1; + if (iteration != null) { + seq = iteration; + } else { + try { + let realmSeq = await this.getNextSeqFn(slug, targetRealmUrl); + // Use the higher of realm state vs in-memory floor. The realm index + // may be stale if the prior lint run just completed (lint is fast), + // so the floor prevents sequence reuse / artifact overwrite. + seq = Math.max(realmSeq, this.lastSequenceNumber + 1); + } catch (err) { + log.warn( + `Failed to resolve sequence number, using floor: ${err instanceof Error ? err.message : String(err)}`, + ); + seq = this.lastSequenceNumber + 1; + } } let lintResultId: string; diff --git a/packages/software-factory/src/validators/parse-step.ts b/packages/software-factory/src/validators/parse-step.ts new file mode 100644 index 0000000000..7fd9a969e2 --- /dev/null +++ b/packages/software-factory/src/validators/parse-step.ts @@ -0,0 +1,1028 @@ +/** + * Parse validation step — verifies that `.gts` and `.json` files in the + * target realm are syntactically valid using glint (ember-tsc) for + * template-aware TypeScript type checking. + * + * For `.gts` files: downloads them to a temp directory along with the + * tsconfig.json from the software-factory realm, then runs `ember-tsc + * --noEmit` which performs full glint type checking — catching both + * TypeScript type errors AND template errors (invalid component args, + * missing helpers, bad template expressions, etc.). + * + * For `.json` files: validates JSON syntax via `JSON.parse()` and checks + * card document structure (presence of `data.type` and `data.meta.adoptsFrom`). + * JSON validation runs against spec `linkedExamples` — the same discovery + * mechanism as the instantiate step — so it validates the example instances + * that the factory agent creates alongside card definitions. + */ + +import { execFile } from 'node:child_process'; +import { + mkdtempSync, + writeFileSync, + mkdirSync, + rmSync, + symlinkSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve, dirname } from 'node:path'; + +import { specRef } from '@cardstack/runtime-common/constants'; + +import type { ValidationStepResult } from '../factory-agent'; +import { deriveIssueSlug } from '../factory-agent-types'; + +import { + fetchRealmFilenames, + getNextValidationSequenceNumber, + readFile, + searchRealm, + type RealmFetchOptions, +} from '../realm-operations'; +import { + createParseResult, + completeParseResult, + type ParseFileResultData, + type ParseErrorData, +} from '../parse-result-cards'; +import { logger } from '../logger'; + +import type { ValidationStepRunner } from './validation-pipeline'; + +let log = logger('parse-validation-step'); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ParseValidationStepConfig { + authorization?: string; + fetch?: typeof globalThis.fetch; + realmServerUrl: string; + parseResultsModuleUrl: string; + issueId?: string; + /** Injected for testing — defaults to fetchRealmFilenames. */ + fetchFilenames?: ( + realmUrl: string, + options?: RealmFetchOptions, + ) => Promise<{ filenames: string[]; error?: string }>; + /** Injected for testing — defaults to readFile from realm-operations. */ + readFileFn?: ( + realmUrl: string, + path: string, + options?: RealmFetchOptions, + ) => Promise<{ + ok: boolean; + content?: string; + document?: { data: Record }; + error?: string; + }>; + /** Injected for testing — defaults to searchRealm-based spec discovery. */ + searchSpecsFn?: ( + realmUrl: string, + ) => Promise<{ specs: SpecExampleInfo[]; error?: string }>; + /** Injected for testing — defaults to getNextValidationSequenceNumber. */ + getNextSequenceNumber?: ( + slug: string, + targetRealmUrl: string, + ) => Promise; + /** + * Injected for testing — runs glint (ember-tsc) on .gts files. + * Defaults to downloading files to a temp dir and running ember-tsc. + */ + runGlintCheckFn?: ( + gtsFiles: { path: string; content: string }[], + ) => Promise; +} + +export interface SpecExampleInfo { + specId: string; + exampleUrls: string[]; +} + +/** Flattened POJO for parse validation details — not a card, just data. */ +export interface ParseValidationDetails { + parseResultId: string; + filesChecked: number; + filesWithErrors: number; + totalErrors: number; + errors: { file: string; line: number; message: string }[]; +} + +/** + * Extensions checked by the parse step. `.js` files are excluded because + * lint (ESLint) already validates JavaScript syntax and the factory agent + * does not generate plain `.js` — it produces `.gts` for card definitions + * and `.ts` for utility modules. + */ +const PARSEABLE_EXTENSIONS = ['.gts', '.gjs', '.ts']; + +/** + * Monorepo layout assumptions: the software-factory package lives at + * `packages/software-factory` alongside `packages/base`, `packages/host`, + * and `packages/boxel-ui`. These paths are used to construct the tsconfig + * path mappings for ember-tsc. If the deployment model changes (e.g., + * packages are published independently), these will need to be + * reconfigured — likely via config injection rather than hardcoded paths. + */ +const PACKAGES_PATH = resolve(__dirname, '..', '..', '..'); +const BASE_PKG_PATH = join(PACKAGES_PATH, 'base'); +const HOST_PKG_PATH = join(PACKAGES_PATH, 'host'); + +/** + * Absolute path to the host package's node_modules. We symlink this (not + * software-factory's node_modules) because the host has all the Ember/Glimmer + * type declarations that realm .gts files import from (@ember/helper, + * @ember/modifier, @glimmer/tracking, @cardstack/boxel-ui, etc.). These + * modules are shimmed at runtime by the host app and have type declarations + * resolved through ember-source's stable types in the host's dependency tree. + * + * NOTE: This assumes the software-factory package is co-located with the host + * package in the monorepo. If we move to a different deployment model where + * these packages are separated, the node_modules path and the tsconfig path + * mappings below will need to be reconfigured. + */ +const NODE_MODULES_PATH = join(HOST_PKG_PATH, 'node_modules'); + +/** Cached tsconfig content — doesn't change between runs. */ +let cachedTsconfigContent: string | undefined; + +// --------------------------------------------------------------------------- +// ParseValidationStep +// --------------------------------------------------------------------------- + +export class ParseValidationStep implements ValidationStepRunner { + readonly step = 'parse' as const; + + private config: ParseValidationStepConfig; + private lastSequenceNumber = 0; + + private fetchFilenamesFn: ( + realmUrl: string, + options?: RealmFetchOptions, + ) => Promise<{ filenames: string[]; error?: string }>; + private readFileFn: ( + realmUrl: string, + path: string, + options?: RealmFetchOptions, + ) => Promise<{ + ok: boolean; + content?: string; + document?: { data: Record }; + error?: string; + }>; + private searchSpecsFn: ( + realmUrl: string, + ) => Promise<{ specs: SpecExampleInfo[]; error?: string }>; + private getNextSeqFn: ( + slug: string, + targetRealmUrl: string, + ) => Promise; + private runGlintCheckFn: ( + gtsFiles: { path: string; content: string }[], + ) => Promise; + + constructor(config: ParseValidationStepConfig) { + this.config = config; + this.fetchFilenamesFn = config.fetchFilenames ?? fetchRealmFilenames; + this.readFileFn = config.readFileFn ?? readFile; + this.searchSpecsFn = + config.searchSpecsFn ?? + ((realmUrl: string) => this.defaultSearchSpecs(realmUrl)); + this.getNextSeqFn = + config.getNextSequenceNumber ?? + ((slug: string, targetRealmUrl: string) => + getNextValidationSequenceNumber( + slug, + 'Validations/parse_', + config.parseResultsModuleUrl, + 'ParseResult', + { + targetRealmUrl, + authorization: config.authorization, + fetch: config.fetch, + }, + )); + this.runGlintCheckFn = + config.runGlintCheckFn ?? ((files) => runGlintCheck(files)); + } + + async run( + targetRealmUrl: string, + iteration?: number, + ): Promise { + // Step 1: Discover files to validate + let gtsFiles: string[]; + let jsonExampleUrls: string[]; + try { + let [gts, json] = await Promise.all([ + this.discoverGtsFiles(targetRealmUrl), + this.discoverJsonExampleFiles(targetRealmUrl), + ]); + gtsFiles = gts; + jsonExampleUrls = json; + } catch (err) { + return { + step: 'parse', + passed: false, + errors: [ + { + message: `Failed to discover files: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + }; + } + + if (gtsFiles.length === 0 && jsonExampleUrls.length === 0) { + log.info('No parseable files found — nothing to validate'); + return { step: 'parse', passed: true, files: [], errors: [] }; + } + + log.info( + `Found ${gtsFiles.length} GTS file(s) and ${jsonExampleUrls.length} JSON example(s) to parse`, + ); + + // Step 2: Create the ParseResult card (status: running) + let slug = this.config.issueId + ? deriveIssueSlug(this.config.issueId) + : 'validation'; + + let issueURL = this.config.issueId + ? new URL(this.config.issueId, targetRealmUrl).href + : undefined; + + let seq: number; + if (iteration != null) { + seq = iteration; + } else { + try { + let realmSeq = await this.getNextSeqFn(slug, targetRealmUrl); + seq = Math.max(realmSeq, this.lastSequenceNumber + 1); + } catch (err) { + log.warn( + `Failed to resolve sequence number, using floor: ${err instanceof Error ? err.message : String(err)}`, + ); + seq = this.lastSequenceNumber + 1; + } + } + + let parseResultId: string; + let artifactCreated = false; + try { + let createResult = await createParseResult( + slug, + this.config.parseResultsModuleUrl, + { + targetRealmUrl, + authorization: this.config.authorization, + fetch: this.config.fetch, + sequenceNumber: seq, + issueURL, + }, + ); + parseResultId = createResult.parseResultId; + if (!createResult.created) { + log.warn( + `ParseResult card creation returned created: false: ${createResult.error ?? 'unknown'}`, + ); + } else { + artifactCreated = true; + this.lastSequenceNumber = seq; + } + } catch (err) { + log.warn( + `Failed to create ParseResult card: ${err instanceof Error ? err.message : String(err)}`, + ); + parseResultId = `Validations/parse_${slug}-${seq}`; + } + + // Step 3: Parse each file + let startedAt = Date.now(); + let allFileResults: ParseFileResultData[] = []; + let allErrors: ParseValidationDetails['errors'] = []; + let fetchOpts: RealmFetchOptions = { + authorization: this.config.authorization, + fetch: this.config.fetch, + }; + + // 3a: Run glint (ember-tsc) on GTS files + if (gtsFiles.length > 0) { + let gtsContents: { path: string; content: string }[] = []; + for (let file of gtsFiles) { + try { + let readResult = await this.readFileFn( + targetRealmUrl, + file, + fetchOpts, + ); + if (!readResult.ok) { + let message = `Could not read ${file}: ${readResult.error ?? 'read failed'}`; + log.warn(message); + allFileResults.push({ + file, + errors: [{ file, line: 0, column: 0, message }], + }); + allErrors.push({ file, line: 0, message }); + continue; + } + if (readResult.content == null) { + let message = `Could not read ${file}: no content`; + log.warn(message); + allFileResults.push({ + file, + errors: [{ file, line: 0, column: 0, message }], + }); + allErrors.push({ file, line: 0, message }); + continue; + } + gtsContents.push({ path: file, content: readResult.content }); + } catch (err) { + let message = `Read failed: ${err instanceof Error ? err.message : String(err)}`; + log.warn(`Error reading ${file}: ${message}`); + allFileResults.push({ + file, + errors: [{ file, line: 0, column: 0, message }], + }); + allErrors.push({ file, line: 0, message }); + } + } + + // Run glint on all files at once (one ember-tsc invocation) + if (gtsContents.length > 0) { + try { + let glintErrors = await this.runGlintCheckFn(gtsContents); + + // Group errors by file for the file results + let errorsByFile = new Map(); + for (let err of glintErrors) { + let existing = errorsByFile.get(err.file) ?? []; + existing.push(err); + errorsByFile.set(err.file, existing); + } + + // Build file results for each GTS file (including clean ones) + for (let { path: file } of gtsContents) { + let fileErrors = errorsByFile.get(file) ?? []; + allFileResults.push({ file, errors: fileErrors }); + for (let e of fileErrors) { + allErrors.push({ + file: e.file, + line: e.line, + message: e.message, + }); + } + } + } catch (err) { + let message = `Glint check failed: ${err instanceof Error ? err.message : String(err)}`; + log.warn(message); + // Report as a single error against the first file + allFileResults.push({ + file: gtsContents[0].path, + errors: [ + { + file: gtsContents[0].path, + line: 0, + column: 0, + message, + }, + ], + }); + allErrors.push({ file: gtsContents[0].path, line: 0, message }); + } + } + } + + // 3b: Parse JSON example files in parallel + // readFile returns `.json` files as `document` (parsed object) not `content` + // (raw string), since the realm API parses JSON before returning. When a + // `document` is present, JSON syntax is already validated — we only need to + // check card document structure. When raw `content` is present (e.g., from + // mocks), we parse it ourselves. + if (jsonExampleUrls.length > 0) { + let jsonSettled = await Promise.allSettled( + jsonExampleUrls.map(async (jsonUrl) => { + let readResult = await this.readFileFn( + targetRealmUrl, + jsonUrl, + fetchOpts, + ); + if (!readResult.ok) { + return { + file: jsonUrl, + errors: [ + { + file: jsonUrl, + line: 0, + column: 0, + message: `Could not read ${jsonUrl}: ${readResult.error ?? 'read failed'}`, + }, + ] as ParseErrorData[], + }; + } + + let errors: ParseErrorData[]; + if (readResult.document) { + errors = validateCardDocumentStructure( + jsonUrl, + readResult.document, + ); + } else if (readResult.content != null) { + errors = parseJsonFile(jsonUrl, readResult.content); + } else { + errors = [ + { + file: jsonUrl, + line: 0, + column: 0, + message: `Could not read ${jsonUrl}: no content or document`, + }, + ]; + } + return { file: jsonUrl, errors }; + }), + ); + + for (let i = 0; i < jsonSettled.length; i++) { + let outcome = jsonSettled[i]; + let jsonUrl = jsonExampleUrls[i]; + if (outcome.status === 'fulfilled') { + allFileResults.push(outcome.value); + for (let e of outcome.value.errors) { + allErrors.push({ file: e.file, line: e.line, message: e.message }); + } + } else { + let message = `Parse failed: ${outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason)}`; + log.warn(`Error parsing ${jsonUrl}: ${message}`); + allFileResults.push({ + file: jsonUrl, + errors: [{ file: jsonUrl, line: 0, column: 0, message }], + }); + allErrors.push({ file: jsonUrl, line: 0, message }); + } + } + } + + let durationMs = Date.now() - startedAt; + let passed = allErrors.length === 0; + + // Step 4: Complete the ParseResult card + if (artifactCreated) { + let completeResult = await completeParseResult( + parseResultId, + { + status: passed ? 'passed' : 'failed', + durationMs, + fileResults: allFileResults, + }, + { + targetRealmUrl, + authorization: this.config.authorization, + fetch: this.config.fetch, + }, + ); + if (!completeResult.updated) { + log.warn( + `Failed to complete ParseResult card ${parseResultId}: ${completeResult.error ?? 'unknown'}`, + ); + } + } + + // Step 5: Build result + let details: ParseValidationDetails = { + parseResultId, + filesChecked: allFileResults.length, + filesWithErrors: allFileResults.filter((fr) => fr.errors.length > 0) + .length, + totalErrors: allErrors.length, + errors: allErrors, + }; + + let errors = allErrors.map((e) => ({ + file: e.file, + message: `${e.file}${e.line ? `:${e.line}` : ''} ${e.message}`, + })); + + return { + step: 'parse', + passed, + files: [...gtsFiles, ...jsonExampleUrls], + errors, + details: details as unknown as Record, + }; + } + + formatForContext(result: ValidationStepResult): string { + if (result.passed) { + let details = result.details as unknown as + | ParseValidationDetails + | undefined; + if (details && details.filesChecked > 0) { + return `## Parse Validation: PASSED\n${details.filesChecked} file(s) checked, no parse errors. (ParseResult: ${details.parseResultId})`; + } + return ''; + } + + let details = result.details as unknown as + | ParseValidationDetails + | undefined; + if (!details) { + let errorLines = result.errors.map((e) => `- ${e.message}`).join('\n'); + return `## Parse Validation: FAILED\n${errorLines}`; + } + + let lines: string[] = [ + `## Parse Validation: FAILED`, + `${details.filesChecked} file(s) checked, ${details.totalErrors} error(s) in ${details.filesWithErrors} file(s) (ParseResult: ${details.parseResultId})`, + ]; + + for (let error of details.errors) { + lines.push( + ` ${error.file}${error.line ? `:${error.line}` : ''} ${error.message}`, + ); + } + + return lines.join('\n'); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * Discover .gts, .gjs, and .ts files in the realm (including test files). + */ + private async discoverGtsFiles(targetRealmUrl: string): Promise { + let result = await this.fetchFilenamesFn(targetRealmUrl, { + authorization: this.config.authorization, + fetch: this.config.fetch, + }); + + if (result.error) { + throw new Error(result.error); + } + + return result.filenames + .filter((f) => PARSEABLE_EXTENSIONS.some((ext) => f.endsWith(ext))) + .sort((a, b) => a.localeCompare(b)); + } + + /** + * Discover JSON example files to validate by searching for Spec cards + * and extracting their linkedExamples — same discovery as instantiate step. + */ + private async discoverJsonExampleFiles( + targetRealmUrl: string, + ): Promise { + let result = await this.searchSpecsFn(targetRealmUrl); + if (result.error) { + log.warn(`Failed to discover specs for JSON validation: ${result.error}`); + return []; + } + + let urls: string[] = []; + for (let spec of result.specs) { + for (let url of spec.exampleUrls) { + if (!urls.includes(url)) { + urls.push(url); + } + } + } + return urls.sort((a, b) => a.localeCompare(b)); + } + + /** + * Default spec discovery: search the realm for Spec cards and extract + * linkedExamples URLs. Same pattern as InstantiateValidationStep. + */ + private async defaultSearchSpecs( + realmUrl: string, + ): Promise<{ specs: SpecExampleInfo[]; error?: string }> { + let fetchOptions: RealmFetchOptions = { + authorization: this.config.authorization, + fetch: this.config.fetch, + }; + + let searchResult = await searchRealm( + realmUrl, + { + filter: { + type: specRef, + }, + }, + fetchOptions, + ); + + if (!searchResult.ok) { + return { specs: [], error: searchResult.error }; + } + + let specs: SpecExampleInfo[] = []; + for (let card of searchResult.data ?? []) { + let specId = (card as Record).id as string | undefined; + if (!specId) { + continue; + } + + let attributes = (card as Record).attributes as + | Record + | undefined; + if (!attributes) { + continue; + } + + // Skip field specs + let specType = attributes.specType as string | undefined; + if (specType === 'field') { + continue; + } + + let relationships = (card as Record).relationships as + | Record + | undefined; + let rawExampleUrls = extractLinkedExamples(relationships); + let specCardUrl = new URL(specId, ensureTrailingSlash(realmUrl)).href; + let normalizedRealmUrl = ensureTrailingSlash(realmUrl); + let exampleUrls: string[] = []; + for (let rawUrl of rawExampleUrls) { + let absoluteUrl = new URL(rawUrl, specCardUrl).href; + if (absoluteUrl.startsWith(normalizedRealmUrl)) { + exampleUrls.push(absoluteUrl.slice(normalizedRealmUrl.length)); + } + } + + specs.push({ specId, exampleUrls }); + } + + return { specs }; + } +} + +// --------------------------------------------------------------------------- +// Glint (ember-tsc) type checking +// --------------------------------------------------------------------------- + +/** + * Run `ember-tsc --noEmit` on .gts files to get glint type errors. + * + * 1. Creates a temp directory with the .gts files + * 2. Writes a tsconfig.json with paths mapping `https://cardstack.com/base/*` + * to the monorepo's `packages/base` directory (same as `realm/tsconfig.json`) + * 3. Runs `ember-tsc --noEmit --project ` + * 4. Parses the output for errors originating from the temp directory + * 5. Maps errors back to original realm file paths + */ +async function runGlintCheck( + files: { path: string; content: string }[], +): Promise { + let tempDir = mkdtempSync(join(tmpdir(), 'sf-parse-')); + + try { + // Write files to temp dir preserving directory structure. + // Sanitize paths to prevent directory traversal — realm file paths + // should never contain '..' or be absolute. + for (let file of files) { + let normalized = join(tempDir, file.path); + let resolved = resolve(normalized); + if (!resolved.startsWith(tempDir + '/')) { + log.warn( + `Skipping file with unsafe path: ${file.path} (resolves outside temp dir)`, + ); + continue; + } + mkdirSync(dirname(resolved), { recursive: true }); + writeFileSync(resolved, file.content, 'utf8'); + } + + // Write tsconfig.json — mirrors realm/tsconfig.json but with absolute + // paths to the base package. Relaxes unused-variable checks since the + // factory agent's generated code may have legitimate unused locals during + // incremental development. Cached because it never changes between runs. + if (!cachedTsconfigContent) { + let tsconfig = { + compilerOptions: { + target: 'es2022', + allowJs: true, + // 'bundler' resolution supports both path mappings and import.meta + // (test files use `import.meta.url` for module URL resolution) + moduleResolution: 'bundler', + allowSyntheticDefaultImports: true, + noEmit: true, + baseUrl: '.', + module: 'es2022', + strict: true, + experimentalDecorators: true, + skipLibCheck: true, + noUnusedLocals: false, + noUnusedParameters: false, + // qunit-dom augments QUnit's Assert type with .dom() — loaded + // globally in test setup, so we include it as a type reference + types: ['qunit-dom', '@cardstack/local-types'], + paths: { + 'https://cardstack.com/base/*': [`${BASE_PKG_PATH}/*`], + // Host test helpers — target realm test files import from these + '@cardstack/host/tests/*': [`${HOST_PKG_PATH}/tests/*`], + '@cardstack/host/*': [`${HOST_PKG_PATH}/app/*`], + '@cardstack/boxel-host/commands/*': [ + `${HOST_PKG_PATH}/app/commands/*`, + ], + '@cardstack/boxel-ui/*': [ + `${join(PACKAGES_PATH, 'boxel-ui', 'addon', 'src')}/*`, + ], + // Fallback: host's types/ directory provides type stubs for + // addons that don't ship their own declarations + '*': [`${HOST_PKG_PATH}/types/*`], + }, + }, + include: ['**/*.ts', '**/*.gts', '**/*.gjs'], + exclude: ['node_modules'], + }; + cachedTsconfigContent = JSON.stringify(tsconfig, null, 2); + } + writeFileSync( + join(tempDir, 'tsconfig.json'), + cachedTsconfigContent, + 'utf8', + ); + + // Symlink node_modules so ember-tsc can resolve @glint/ember-tsc/-private/dsl + // and other glint internals needed for template-aware type checking. + symlinkSync(NODE_MODULES_PATH, join(tempDir, 'node_modules')); + + // Run ember-tsc + let emberTscBin = resolve( + __dirname, + '..', + '..', + 'node_modules', + '.bin', + 'ember-tsc', + ); + let { output, exitedWithError } = await new Promise<{ + output: string; + exitedWithError: boolean; + }>((resolvePromise, reject) => { + let child = execFile( + emberTscBin, + ['--noEmit', '--project', join(tempDir, 'tsconfig.json')], + { + cwd: tempDir, + timeout: 120_000, + maxBuffer: 10 * 1024 * 1024, + }, + (error, stdout, stderr) => { + // ember-tsc exits non-zero when there are type errors (expected). + // Distinguish that from real execution failures: ENOENT (binary + // not found), signal kills, and timeouts produce no TS diagnostics + // and should be surfaced as step failures. + if (error && !stdout && !stderr) { + reject(new Error(`ember-tsc execution failed: ${error.message}`)); + return; + } + if (child.killed || error?.killed) { + reject(new Error('ember-tsc was killed (timeout or signal)')); + return; + } + resolvePromise({ + output: stdout + stderr, + exitedWithError: !!error, + }); + }, + ); + }); + + // Parse output: filter to errors from our temp dir files only + // Format: (line,col): error TS: + let errors: ParseErrorData[] = []; + let totalDiagnosticLines = 0; + let lines = output.split('\n'); + + for (let line of lines) { + // Match lines referencing files in our temp dir + // The path may be relative (../../.../tmp/...) or absolute + let match = line.match( + /^(.+?)\((\d+),(\d+)\):\s*error\s+(TS\d+):\s*(.+)/, + ); + if (!match) { + continue; + } + + totalDiagnosticLines++; + + let [, filePath, lineStr, colStr, tsCode, message] = match; + + // Resolve the path and check if it's in our temp dir + let absolutePath = resolve(tempDir, filePath); + if (!absolutePath.startsWith(tempDir)) { + // Error from base package or other dependency — skip + continue; + } + + // Skip known false positives: + // TS2353 for 'scoped' on