From 330f50e40c98292d52268d6f9764b21e887942b9 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 19 Jun 2026 12:42:41 +0200 Subject: [PATCH 1/3] fix: ingest-card from an instance URL copies only that instance's link graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An instance URL meant "this record" but ingest-card swept in every instance of the entry card's type (findEntryInstances searches `type: {module,name}` regardless of entry kind) — so ingesting one WineCellar pulled both cellars + all 8 bottles. A module URL still means "the card type." Branch on entry kind in sync(): for an instance entry, crawl the same-realm link graph from that instance (`linksTo`/`linksToMany` via relationship `links.self`, transitively) — the instance analogue of crawlModules — instead of the type-wide instance sweep. Module entries are unchanged. Verified live: ingesting one catalog WineCellar instance now copies that cellar + its 6 linked bottles (was both cellars + all 8). New unit tests: extractRelationshipLinks parsing + a fake-realm instance ingest that excludes unrelated siblings. CS-11682. Stacks on CS-11652 (per-realm _search) for Spec copying. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/commands/realm/ingest-card.ts | 95 ++++++++++- .../commands/ingest-card-instance.test.ts | 151 ++++++++++++++++++ .../tests/commands/ingest-card.test.ts | 24 +++ 3 files changed, 262 insertions(+), 8 deletions(-) create mode 100644 packages/boxel-cli/tests/commands/ingest-card-instance.test.ts diff --git a/packages/boxel-cli/src/commands/realm/ingest-card.ts b/packages/boxel-cli/src/commands/realm/ingest-card.ts index 50f75aeb13..13c1f6eda6 100644 --- a/packages/boxel-cli/src/commands/realm/ingest-card.ts +++ b/packages/boxel-cli/src/commands/realm/ingest-card.ts @@ -50,6 +50,34 @@ export function extractImportSpecifiers(source: string): string[] { return [...specs]; } +/** + * Non-null `relationships.*.links.self` values from a card instance's JSON — + * the `linksTo` / `linksToMany` targets to follow when crawling an instance's + * link graph. `linksToMany` serializes as `field.0`, `field.1`, … each with its + * own `links.self`; unset links are `null` and skipped. + */ +export function extractRelationshipLinks(source: string): string[] { + let parsed: unknown; + try { + parsed = JSON.parse(source); + } catch { + return []; + } + let rels = (parsed as { data?: { relationships?: Record } }) + ?.data?.relationships; + if (!rels || typeof rels !== 'object') { + return []; + } + let out: string[] = []; + for (let value of Object.values(rels)) { + let self = (value as { links?: { self?: unknown } })?.links?.self; + if (typeof self === 'string' && self) { + out.push(self); + } + } + return out; +} + /** Exported card/field class names declared in a module (for instance lookup). */ export function extractExportedClassNames(source: string): string[] { let names = new Set(); @@ -196,14 +224,20 @@ class RealmCardIngester extends RealmSyncBase { if (test !== f && fileSet.has(test)) toCopy.add(test); } - // 3. The entry card's own instances. - let entryModuleAbs = entry.moduleRels.map((r) => this.relToAbs(r)); - let instanceRels = await this.findEntryInstances( - moduleFiles, - entryModuleAbs, - fileSet, - ); - for (let r of entry.instanceRels) instanceRels.add(r); + // 3. Instances. A module entry means "the card type" → copy every + // instance of it. An instance entry means "this record" → copy just it + // and the records it links to (transitively), not unrelated siblings. + let instanceRels: Set; + if (entry.instanceRels.length > 0) { + instanceRels = await this.crawlInstanceLinks(entry.instanceRels, fileSet); + } else { + let entryModuleAbs = entry.moduleRels.map((r) => this.relToAbs(r)); + instanceRels = await this.findEntryInstances( + moduleFiles, + entryModuleAbs, + fileSet, + ); + } for (let r of instanceRels) toCopy.add(r); // 4. The card's own Catalog Spec(s) — card/app specType only. @@ -313,6 +347,51 @@ class RealmCardIngester extends RealmSyncBase { return out; } + /** + * BFS the same-realm link graph from the seed instances: each instance plus + * every instance it references via `linksTo` / `linksToMany`, transitively. + * The instance analogue of `crawlModules` — for an instance entry we copy + * that record and the records it links to (e.g. a cellar and its bottles), + * but not unrelated siblings of the same type. + */ + private async crawlInstanceLinks( + seeds: string[], + fileSet: Set, + ): Promise> { + let seen = new Set(); + let queue = [...seeds]; + while (queue.length) { + let rel = queue.shift()!; + if (seen.has(rel)) continue; + seen.add(rel); + let source = await this.fetchText(rel); + if (source == null) continue; + for (let self of extractRelationshipLinks(source)) { + let linked = this.resolveLinkedInstanceRel(self, rel, fileSet); + if (linked && !seen.has(linked)) queue.push(linked); + } + } + return seen; + } + + /** + * Resolve a relationship `links.self` (relative `../Foo/x`, alias, or absolute + * https — and without a `.json` extension) to a same-realm instance file that + * exists in `fileSet`, or null for cross-realm / missing links. + */ + private resolveLinkedInstanceRel( + self: string, + fromRel: string, + fileSet: Set, + ): string | null { + let rel = + /^https?:\/\//.test(self) || self.startsWith('@') + ? this.relativize(self) + : this.relativize(new URL(self, this.relToAbs(fromRel)).href); + let candidate = rel.endsWith('.json') ? rel : `${rel}.json`; + return fileSet.has(candidate) ? candidate : null; + } + /** Card/app Spec cards whose `ref` resolves to a seeded module. */ private async findCardSpecs( moduleFiles: Set, diff --git a/packages/boxel-cli/tests/commands/ingest-card-instance.test.ts b/packages/boxel-cli/tests/commands/ingest-card-instance.test.ts new file mode 100644 index 0000000000..03e3bdbeb5 --- /dev/null +++ b/packages/boxel-cli/tests/commands/ingest-card-instance.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { ingestCard } from '../../src/commands/realm/ingest-card.js'; +import type { RealmAuthenticator } from '../../src/lib/realm-authenticator.js'; +import type { ProfileManager } from '../../src/lib/profile-manager.js'; + +// Ingesting a single *instance* URL should copy that record + its module graph +// + the records it links to (transitively) — NOT every instance of its type +// (CS-11682). Fixture: a Garage links to two Tools; a second Garage and a third +// Tool are unrelated siblings that must be left behind. +// +// garage.gts Garage (linksToMany Tool) +// tool.gts Tool +// Garage/g1.json entry — links to Tool/t1, Tool/t2 +// Garage/g2.json sibling Garage — links to Tool/t3 (NOT ingested) +// Tool/t1.json, t2.json linked from g1 (ingested) +// Tool/t3.json linked only from g2 (NOT ingested) + +const ROOT = 'https://realms.example.test/garage/'; + +const REALM_FILES: Record = { + 'garage.gts': ` +import { CardDef, field, linksToMany } from 'https://cardstack.com/base/card-api'; +import { Tool } from './tool'; +export class Garage extends CardDef { + @field tools = linksToMany(() => Tool); +} +`, + 'tool.gts': ` +import StringField from 'https://cardstack.com/base/string'; +import { CardDef, field, contains } from 'https://cardstack.com/base/card-api'; +export class Tool extends CardDef { + @field name = contains(StringField); +} +`, + 'Garage/g1.json': JSON.stringify({ + data: { + type: 'card', + meta: { adoptsFrom: { module: '../garage', name: 'Garage' } }, + relationships: { + 'tools.0': { links: { self: '../Tool/t1' } }, + 'tools.1': { links: { self: '../Tool/t2' } }, + }, + }, + }), + 'Garage/g2.json': JSON.stringify({ + data: { + type: 'card', + meta: { adoptsFrom: { module: '../garage', name: 'Garage' } }, + relationships: { 'tools.0': { links: { self: '../Tool/t3' } } }, + }, + }), + 'Tool/t1.json': JSON.stringify({ + data: { + type: 'card', + attributes: { name: 'Hammer' }, + meta: { adoptsFrom: { module: '../tool', name: 'Tool' } }, + }, + }), + 'Tool/t2.json': JSON.stringify({ + data: { + type: 'card', + attributes: { name: 'Wrench' }, + meta: { adoptsFrom: { module: '../tool', name: 'Tool' } }, + }, + }), + 'Tool/t3.json': JSON.stringify({ + data: { + type: 'card', + attributes: { name: 'Drill' }, + meta: { adoptsFrom: { module: '../tool', name: 'Tool' } }, + }, + }), +}; + +const EXPECTED_INGESTED = [ + 'Garage/g1.json', + 'Tool/t1.json', + 'Tool/t2.json', + 'garage.gts', + 'tool.gts', +]; + +function makeFakeAuthenticator(): RealmAuthenticator { + let mtimes = Object.fromEntries( + Object.keys(REALM_FILES).map((p, i) => [`${ROOT}${p}`, 1000 + i]), + ); + return { + async authedRealmFetch(input: string | URL | Request) { + let url = String(input); + if (url === `${ROOT}_mtimes`) { + return new Response( + JSON.stringify({ data: { attributes: { mtimes } } }), + { status: 200 }, + ); + } + let rel = url.startsWith(ROOT) ? url.slice(ROOT.length) : null; + if (rel != null && REALM_FILES[rel] != null) { + return new Response(REALM_FILES[rel], { status: 200 }); + } + return new Response('not found', { status: 404 }); + }, + } as RealmAuthenticator; +} + +// No Catalog Spec in this fixture — the search returns nothing. +function makeFakeProfileManager(): ProfileManager { + return { + getActiveProfile() { + return { + profile: { realmServerUrl: 'https://realm-server.example.test' }, + }; + }, + async authedRealmServerFetch() { + return new Response(JSON.stringify({ data: [] }), { status: 200 }); + }, + } as unknown as ProfileManager; +} + +describe('ingest-card from an instance URL (CS-11682)', () => { + let localDir: string; + let result: { files: string[]; error?: string }; + + beforeAll(async () => { + localDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ingest-inst-')); + result = await ingestCard(`${ROOT}Garage/g1`, localDir, { + realm: ROOT, + authenticator: makeFakeAuthenticator(), + profileManager: makeFakeProfileManager(), + }); + }); + + afterAll(() => { + fs.rmSync(localDir, { recursive: true, force: true }); + }); + + it('copies the entry instance + its module graph + linked instances only', () => { + expect(result.files).toEqual(EXPECTED_INGESTED); + }); + + it('does NOT copy unrelated siblings of the same type', () => { + for (let rel of ['Garage/g2.json', 'Tool/t3.json']) { + expect( + fs.existsSync(path.join(localDir, rel)), + `${rel} should NOT be ingested`, + ).toBe(false); + } + }); +}); diff --git a/packages/boxel-cli/tests/commands/ingest-card.test.ts b/packages/boxel-cli/tests/commands/ingest-card.test.ts index 0b7932462a..732244464d 100644 --- a/packages/boxel-cli/tests/commands/ingest-card.test.ts +++ b/packages/boxel-cli/tests/commands/ingest-card.test.ts @@ -2,10 +2,34 @@ import { describe, it, expect } from 'vitest'; import { extractImportSpecifiers, extractExportedClassNames, + extractRelationshipLinks, resolveSameRealmFile, } from '../../src/commands/realm/ingest-card.js'; describe('ingest-card helpers', () => { + describe('extractRelationshipLinks', () => { + it('collects linksToMany (field.N) + linksTo self URLs, skipping nulls', () => { + let src = JSON.stringify({ + data: { + relationships: { + 'bottles.0': { links: { self: '../WineBottle/a' } }, + 'bottles.1': { links: { self: '../WineBottle/b' } }, + label: { links: { self: '../Image/x' } }, + 'cardInfo.theme': { links: { self: null } }, + }, + }, + }); + expect(new Set(extractRelationshipLinks(src))).toEqual( + new Set(['../WineBottle/a', '../WineBottle/b', '../Image/x']), + ); + }); + + it('returns [] for no relationships or invalid JSON', () => { + expect(extractRelationshipLinks('{"data":{}}')).toEqual([]); + expect(extractRelationshipLinks('not json')).toEqual([]); + }); + }); + describe('extractImportSpecifiers', () => { it('captures value, type, namespace, re-export, and side-effect imports', () => { let src = ` From df391fbd667c1ef3bf3393ddfe3305444b0f98c6 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 19 Jun 2026 13:29:18 +0200 Subject: [PATCH 2/3] test: drop ticket id from ingest-card instance test Co-Authored-By: Claude Opus 4.8 (1M context) --- .../boxel-cli/tests/commands/ingest-card-instance.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/boxel-cli/tests/commands/ingest-card-instance.test.ts b/packages/boxel-cli/tests/commands/ingest-card-instance.test.ts index 03e3bdbeb5..60d9b6a98b 100644 --- a/packages/boxel-cli/tests/commands/ingest-card-instance.test.ts +++ b/packages/boxel-cli/tests/commands/ingest-card-instance.test.ts @@ -7,9 +7,9 @@ import type { RealmAuthenticator } from '../../src/lib/realm-authenticator.js'; import type { ProfileManager } from '../../src/lib/profile-manager.js'; // Ingesting a single *instance* URL should copy that record + its module graph -// + the records it links to (transitively) — NOT every instance of its type -// (CS-11682). Fixture: a Garage links to two Tools; a second Garage and a third -// Tool are unrelated siblings that must be left behind. +// + the records it links to (transitively) — NOT every instance of its type. +// Fixture: a Garage links to two Tools; a second Garage and a third Tool are +// unrelated siblings that must be left behind. // // garage.gts Garage (linksToMany Tool) // tool.gts Tool @@ -119,7 +119,7 @@ function makeFakeProfileManager(): ProfileManager { } as unknown as ProfileManager; } -describe('ingest-card from an instance URL (CS-11682)', () => { +describe('ingest-card from an instance URL', () => { let localDir: string; let result: { files: string[]; error?: string }; From da9762f0dac49aa3303a754adb5e9bc0f0efd1d2 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 19 Jun 2026 13:35:29 +0200 Subject: [PATCH 3/3] fix: only follow same-realm relationship links when crawling an instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveLinkedInstanceRel passed absolute http(s) links through relativize(), whose path-tail fallback could match another realm whose URL happens to share this realm's tail (e.g. a different host's /garage/), wrongly copying a local file. Require a resolved relative/absolute link to live under this realm's served root (mirrors resolveSameRealmFile); the published-alias (@cardstack/…) form still maps by tail. Adds a cross-realm decoy link to the instance test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/commands/realm/ingest-card.ts | 29 ++++++++++++++----- .../commands/ingest-card-instance.test.ts | 13 +++++++-- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/boxel-cli/src/commands/realm/ingest-card.ts b/packages/boxel-cli/src/commands/realm/ingest-card.ts index 13c1f6eda6..02e57e0772 100644 --- a/packages/boxel-cli/src/commands/realm/ingest-card.ts +++ b/packages/boxel-cli/src/commands/realm/ingest-card.ts @@ -375,19 +375,34 @@ class RealmCardIngester extends RealmSyncBase { } /** - * Resolve a relationship `links.self` (relative `../Foo/x`, alias, or absolute - * https — and without a `.json` extension) to a same-realm instance file that - * exists in `fileSet`, or null for cross-realm / missing links. + * Resolve a relationship `links.self` (relative `../Foo/x`, published alias + * `@cardstack//…`, or absolute https — and without a `.json` extension) + * to a same-realm instance file that exists in `fileSet`, or null for + * cross-realm / missing links. */ private resolveLinkedInstanceRel( self: string, fromRel: string, fileSet: Set, ): string | null { - let rel = - /^https?:\/\//.test(self) || self.startsWith('@') - ? this.relativize(self) - : this.relativize(new URL(self, this.relToAbs(fromRel)).href); + let rel: string; + if (self.startsWith('@')) { + // Published-alias form — map onto this realm by its path tail. A tail + // that isn't ours (another realm's alias) won't resolve to a local file. + rel = this.relativize(self); + } else { + // Relative or absolute https: resolve to an absolute URL and require it + // to live under THIS realm's served root. A link into another realm — + // even one whose URL happens to share our path tail — is left as a + // runtime reference, not copied (mirrors resolveSameRealmFile). + let absUrl = /^https?:\/\//.test(self) + ? self + : new URL(self, this.relToAbs(fromRel)).href; + if (!absUrl.startsWith(this.realmRoot)) { + return null; + } + rel = absUrl.slice(this.realmRoot.length).replace(/^\/+/, ''); + } let candidate = rel.endsWith('.json') ? rel : `${rel}.json`; return fileSet.has(candidate) ? candidate : null; } diff --git a/packages/boxel-cli/tests/commands/ingest-card-instance.test.ts b/packages/boxel-cli/tests/commands/ingest-card-instance.test.ts index 60d9b6a98b..0d28d0c89d 100644 --- a/packages/boxel-cli/tests/commands/ingest-card-instance.test.ts +++ b/packages/boxel-cli/tests/commands/ingest-card-instance.test.ts @@ -9,14 +9,16 @@ import type { ProfileManager } from '../../src/lib/profile-manager.js'; // Ingesting a single *instance* URL should copy that record + its module graph // + the records it links to (transitively) — NOT every instance of its type. // Fixture: a Garage links to two Tools; a second Garage and a third Tool are -// unrelated siblings that must be left behind. +// unrelated siblings that must be left behind. g1 also carries a decoy link to +// a *different realm* whose URL shares this realm's path tail (`/garage/`) and +// points at a path that exists locally — it must NOT be followed (cross-realm). // // garage.gts Garage (linksToMany Tool) // tool.gts Tool -// Garage/g1.json entry — links to Tool/t1, Tool/t2 +// Garage/g1.json entry — links to Tool/t1, Tool/t2 (+ cross-realm decoy → Tool/t3) // Garage/g2.json sibling Garage — links to Tool/t3 (NOT ingested) // Tool/t1.json, t2.json linked from g1 (ingested) -// Tool/t3.json linked only from g2 (NOT ingested) +// Tool/t3.json only via g2 / the decoy (NOT ingested) const ROOT = 'https://realms.example.test/garage/'; @@ -42,6 +44,11 @@ export class Tool extends CardDef { relationships: { 'tools.0': { links: { self: '../Tool/t1' } }, 'tools.1': { links: { self: '../Tool/t2' } }, + // Cross-realm decoy: different host, same `/garage/` path tail, path + // resolves to a file that exists locally. Must be skipped, not copied. + decoy: { + links: { self: 'https://other-host.example.test/garage/Tool/t3' }, + }, }, }, }),