diff --git a/src/cli.ts b/src/cli.ts index bf6ca98b..41126936 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -210,7 +210,7 @@ program program .command('cards') .description('Project .faf + .fafa onto A2A / MCP / registry / catalog cards — one context block, every door') - .option('--target ', 'Comma list: a2a,mcp,registry,catalog (default: whatever inputs allow)') + .option('--target ', 'Comma list: a2a,mcp,registry,catalog,ard (default: whatever inputs allow)') .option('--faf ', 'project.faf path (default: auto-discover)') .option('--fafa ', 'agent.fafa path (default: auto-discover)') .option('--dir ', 'Working directory (default: cwd)') diff --git a/src/commands/cards.ts b/src/commands/cards.ts index 1d2be18f..cbfdf5cc 100644 --- a/src/commands/cards.ts +++ b/src/commands/cards.ts @@ -168,18 +168,47 @@ export function cardsCommand(options: CardsCommandOptions = {}): void { if (projected.catalog) { // The catalog is shared: faf updates only its own rows (identifier // exactly faf's) and appends the rest, as a text edit — every other row - // and every other byte stays. + // and every other byte stays. The one key it may add is `host`, and only + // to a catalog that names none: the publisher a catalog needs to be + // discoverable rather than merely minimal. A host already there is the + // site's own and is never touched. const out = join(dir, '.well-known', 'ai-catalog.json'); const rows = projected.catalog; run(out, () => { makeDirInside(dir, dirname(out)); const real = resolveInside(dir, out); const text = present(out) ? readUtf8(real) : null; - const next = upsertCatalogText(text, rows); + const next = upsertCatalogText(text, rows, projected.catalogHost); if (next.changed) {safeWriteFile(real, next.text, { root: dir, expect: text });} return next.changed; }, 'its own rows'); } + if (projected.ard) { + // ARD's own conformance CLI (v0.9.1): "A consumer MUST fetch + // /.well-known/ard.json. Consulting the predecessor + // /.well-known/ai-catalog.json is permitted (MAY) but not required." + // The spec prose still names ai-catalog.json (§4.1, §6.1), so a site + // serving both is served by writing both — same rows, ARD's carrying the + // hints its semantic index is built from. + const out = join(dir, '.well-known', 'ard.json'); + const rows = projected.ard; + run(out, () => { + makeDirInside(dir, dirname(out)); + const real = resolveInside(dir, out); + const text = present(out) ? readUtf8(real) : null; + const next = upsertCatalogText(text, rows, projected.catalogHost); + if (next.changed) {safeWriteFile(real, next.text, { root: dir, expect: text });} + return next.changed; + }, 'its own rows'); + // A manifest with no representativeQueries is valid and unfindable: + // registries build their semantic index from that term. The spec asks for + // 2-5 (ARD v0.9 §Entry). Say so rather than write a card nobody can find. + if (!rows.some((r) => r.representativeQueries?.length)) { + console.error( + `${dim('note')} ${out}: no representativeQueries — registries build their search index from these, so the entries are a valid catalog but will not be found. Add 2-5 to the .fafa as metadata.cards.examples.`, + ); + } + } if (projected.registry) { const inPath = join(dir, 'server.json'); if (present(inPath)) { diff --git a/src/interop/cards.ts b/src/interop/cards.ts index 16970f5c..ddaa71c0 100644 --- a/src/interop/cards.ts +++ b/src/interop/cards.ts @@ -5,12 +5,17 @@ import { parse } from 'yaml'; import type { FafData } from '../core/types.js'; import { makeDirInside, readUtf8, resolveInside } from '../core/safe-write.js'; import { writeRendered, type RenderedResult } from '../core/render-hash.js'; -import { upsertJsonRows } from '../core/json-edit.js'; +import { editJsonText, upsertJsonRows } from '../core/json-edit.js'; import { A2A_PROTOCOL_VERSION, FAF_MEDIA_TYPES, a2aDoors, + ardHints, + catalogHost, + fafaDomain, + fafaHandle, projectA2ACard, + type CatalogHost, type FafaDoc, type ProjectedA2A, } from './pack.js'; @@ -38,8 +43,8 @@ export { } from './pack.js'; export type { FafaAgent, FafaCapability, FafaEndpoint, FafaDoc, ProjectedA2A } from './pack.js'; -export type CardTarget = 'a2a' | 'mcp' | 'registry' | 'catalog'; -export const CARD_TARGETS: CardTarget[] = ['a2a', 'mcp', 'registry', 'catalog']; +export type CardTarget = 'a2a' | 'mcp' | 'registry' | 'catalog' | 'ard'; +export const CARD_TARGETS: CardTarget[] = ['a2a', 'mcp', 'registry', 'catalog', 'ard']; export interface ProjectCardsOptions extends ServerCardOptions { /** Public URL of the emitted A2A card (catalog row). */ @@ -55,6 +60,9 @@ export interface CatalogEntry { description?: string; url: string; updatedAt?: string; + /** ARD's search hints — on the ARD manifest's rows only. */ + tags?: string[]; + representativeQueries?: string[]; } export interface AiCatalog { @@ -74,6 +82,11 @@ export interface ProjectedCards { _meta: Record; }; catalog?: CatalogEntry[]; + /** The same rows, carrying ARD's search hints — the ARD manifest. */ + ard?: CatalogEntry[]; + /** Who publishes the catalog — written only into a catalog that names + * nobody yet. An existing `host` is the site's, and is never touched. */ + catalogHost?: CatalogHost; } export function readFafa(path: string): FafaDoc { @@ -124,11 +137,22 @@ function fafaExtensionParams( generated: block.generated, }; if (agent.id) {params.agentId = agent.id;} - const passport = homepageWellKnown(fafa, 'fafa'); + const passport = fafaPassportUrl(fafa); if (passport) {params.passport = passport;} return params; } +/** Where the `.fafa` itself is served — the same door the catalog's `agent` + * row points at, so a card and a catalog never disagree about it. Left off + * the card, rather than guessed, when the `.fafa` names no domain. */ +function fafaPassportUrl(fafa: FafaDoc): string | undefined { + try { + return `https://${fafaDomain(fafa)}/.well-known/fafa`; + } catch { + return undefined; + } +} + /** Build the A2A Agent Card (JSON) from a .fafa + .faf: the core card * ({@link projectA2ACard}) carrying FAF's context extension. */ export function buildA2ACard( @@ -163,41 +187,37 @@ export const generateA2ACard = deprecate( 'FAF0003', ); -function catalogHost(fafa: FafaDoc): string { - const homepage = fafa.agent?.homepage; - if (!homepage) {return 'local';} - try { - return new URL(homepage).hostname.replace(/^www\./, ''); - } catch { - return 'local'; - } -} - +/** Where the A2A card is served: what the caller passed (`--a2a-url`), else + * the domain's own well-known door. */ function catalogA2AUrl(fafa: FafaDoc, opts: ProjectCardsOptions): string { - if (opts.a2aCardUrl) {return opts.a2aCardUrl;} - const homepage = fafa.agent?.homepage; - if (homepage) { - try { - return new URL('/.well-known/agent-card.json', homepage).href; - } catch { /* fall through */ } - } - return '/.well-known/agent-card.json'; + return opts.a2aCardUrl ?? `https://${fafaDomain(fafa)}/.well-known/agent-card.json`; } +/** + * The catalog rows for this agent, keyed exactly as the pack projector keys + * them: `urn:air:{publisher}:{namespace}:{name}`, where the publisher is the + * domain the `.fafa` *declares* (`agent.id`'s urn:air, `metadata.cards.domain`, + * else the homepage host) and the name is the handle — never the display name, + * which is free text and may carry spaces a URN may not. + * + * Throws, rather than inventing either half, when the `.fafa` names no domain: + * an identifier is a catalog's primary key, and `urn:air:local:…` published to + * the world is worse than a refusal a line of YAML fixes. + */ export function catalogEntriesFor( fafa: FafaDoc, faf: FafData, opts: ProjectCardsOptions = {}, ): CatalogEntry[] { - const host = catalogHost(fafa); + const domain = fafaDomain(fafa); + const handle = fafaHandle(fafa); const agent = fafa.agent ?? {}; - const slug = String(agent.name ?? 'agent'); const now = opts.now ?? (faf.generated as string | undefined) ?? new Date().toISOString(); const entries: CatalogEntry[] = []; if (a2aDoors(fafa, opts).length > 0) { entries.push({ - identifier: `urn:air:${host}:a2a:${slug}`, + identifier: `urn:air:${domain}:a2a:${handle}`, displayName: String(agent.displayName ?? agent.name ?? 'A2A Agent Card'), type: 'application/a2a-agent-card+json', description: 'A2A v1.0 Agent Card. Projected from .fafa.', @@ -206,31 +226,18 @@ export function catalogEntriesFor( }); } - const fafaUrl = homepageWellKnown(fafa, 'fafa'); - if (fafaUrl) { - entries.push({ - identifier: `urn:air:${host}:agent:${slug}`, - displayName: String(agent.displayName ?? agent.name ?? '.fafa'), - type: 'application/vnd.fafa+yaml', - description: 'FAF agent passport (.fafa).', - url: fafaUrl, - updatedAt: now, - }); - } + entries.push({ + identifier: `urn:air:${domain}:agent:${handle}`, + displayName: String(agent.displayName ?? agent.name ?? '.fafa'), + type: 'application/vnd.fafa+yaml', + description: 'FAF agent passport (.fafa).', + url: `https://${domain}/.well-known/fafa`, + updatedAt: now, + }); return entries; } -function homepageWellKnown(fafa: FafaDoc, name: string): string | undefined { - const homepage = fafa.agent?.homepage; - if (!homepage) {return undefined;} - try { - return new URL(`/.well-known/${name}`, homepage).href; - } catch { - return undefined; - } -} - /** The row in `entries` that is faf's own row `row`: the one whose * identifier is exactly faf's (`urn:air::a2a:` or * `urn:air::agent:`). Never matched by type or URL — a row of @@ -242,11 +249,23 @@ function catalogMatchIndex(entries: CatalogEntry[], row: CatalogEntry): number { /** Upsert projector entries into an existing catalog. Leaves every other row * alone: a row is faf's only when its identifier is exactly faf's (never by * type or URL). On match, only url / type / updatedAt move — host copy - * (title, tags) stays; any other faf row is appended. */ -export function upsertCatalog(existing: AiCatalog | undefined, incoming: CatalogEntry[]): AiCatalog { - const base: AiCatalog = existing + * (title, tags) stays; any other faf row is appended. `host` names the + * publisher on a catalog that names none — an existing one is the site's own + * and stays as it is. */ +export function upsertCatalog( + existing: AiCatalog | undefined, + incoming: CatalogEntry[], + host?: CatalogHost, +): AiCatalog { + const opened: AiCatalog = existing ? { ...existing, entries: [...(existing.entries ?? [])] } : { specVersion: '1.0', entries: [] }; + // A host the catalog already names is the site's own — never overwritten. + // A missing one is added where the spec shows it: straight after specVersion. + const base: AiCatalog = + host && opened.host === undefined + ? (({ specVersion, ...rest }) => ({ specVersion, host: { ...host }, ...rest }))(opened) + : opened; for (const row of incoming) { const i = catalogMatchIndex(base.entries, row); if (i >= 0) { @@ -271,18 +290,40 @@ const CATALOG_ROW_UPDATES = ['url', 'type', 'updatedAt'] as const; * (identifier exactly faf's) get their url / type / updatedAt values changed * in place, faf's other rows are appended after the last entry, and every * other byte — your rows, their order and layout, other keys — stays. With no - * text (no catalog yet) a new catalog is returned. Throws a JsonEditError, - * changing nothing, when the catalog cannot be edited that way (not a JSON - * object, `entries` not an array, faf's row there twice, …). + * text (no catalog yet) a new catalog is returned. `host` names the publisher + * (AI Catalog Level 2 "discoverable") and is added, after `specVersion`, only + * to a catalog that names none: a `host` already in the file is the site's own + * and is left byte for byte. Throws a JsonEditError, changing nothing, when + * the catalog cannot be edited that way (not a JSON object, `entries` not an + * array, faf's row there twice, …). */ -export function upsertCatalogText(text: string | null, incoming: CatalogEntry[]): { text: string; changed: boolean } { +export function upsertCatalogText( + text: string | null, + incoming: CatalogEntry[], + host?: CatalogHost, +): { text: string; changed: boolean } { if (text === null) { - return { text: `${JSON.stringify({ specVersion: '1.0', entries: incoming }, null, 2)}\n`, changed: true }; + const fresh = { specVersion: '1.0', ...(host ? { host } : {}), entries: incoming }; + return { text: `${JSON.stringify(fresh, null, 2)}\n`, changed: true }; } - return upsertJsonRows(text, 'entries', incoming as unknown as Record[], { + const rows = upsertJsonRows(text, 'entries', incoming as unknown as Record[], { id: 'identifier', update: CATALOG_ROW_UPDATES, }); + if (!host || namesHost(rows.text)) {return rows;} + const named = editJsonText(rows.text, { host }, { '': { host: ['specVersion'] } }); + return { text: named.text, changed: rows.changed || named.changed }; +} + +/** True when the catalog JSON already names a `host` — any value, including + * null or one the spec would refuse. Whatever is there is the site's. */ +function namesHost(text: string): boolean { + try { + const doc = JSON.parse(text) as Record; + return Object.prototype.hasOwnProperty.call(doc, 'host'); + } catch { + return true; // Unparseable: add nothing. + } } export function projectCards(input: { @@ -308,10 +349,10 @@ export function projectCards(input: { }; } - if (wanted.has('a2a') || wanted.has('catalog')) { + if (wanted.has('a2a') || wanted.has('catalog') || wanted.has('ard')) { if (!input.fafa) { - if (input.targets?.includes('a2a') || input.targets?.includes('catalog')) { - throw new Error('A2A/catalog require a .fafa (agent.fafa). Will not invent an agent.'); + if (input.targets?.some((t) => t === 'a2a' || t === 'catalog' || t === 'ard')) { + throw new Error('A2A/catalog/ARD require a .fafa (agent.fafa). Will not invent an agent.'); } } } @@ -328,8 +369,17 @@ export function projectCards(input: { } } - if (wanted.has('catalog') && input.fafa) { - out.catalog = catalogEntriesFor(input.fafa, input.faf, opts); + if ((wanted.has('catalog') || wanted.has('ard')) && input.fafa) { + const rows = catalogEntriesFor(input.fafa, input.faf, opts); + if (wanted.has('catalog')) {out.catalog = rows;} + if (wanted.has('ard')) { + // ARD builds on ai-catalog: the same rows, carrying the hints its + // semantic index is built from. + const hints = ardHints(input.fafa); + out.ard = rows.map((r) => ({ ...r, ...hints })); + } + const host = catalogHost(input.fafa); + if (host) {out.catalogHost = host;} } assertSameBlock(out); diff --git a/src/interop/pack.ts b/src/interop/pack.ts index 0fc4a272..a5e2f281 100644 --- a/src/interop/pack.ts +++ b/src/interop/pack.ts @@ -364,6 +364,13 @@ export function fafaDomain(fafa: FafaDoc): string { } } +/** The stable short name a card is filed under: `agent.name`, lowercased and + * reduced to the characters an identifier may carry. The `{name}` of + * `urn:air:{publisher}:{namespace}:{name}` — never a display string. */ +export function fafaHandle(fafa: FafaDoc): string { + return handleOf(fafa); +} + function handleOf(fafa: FafaDoc): string { const h = clean(fafa.agent?.name).toLowerCase().replace(/[^a-z0-9._-]/g, '-').replace(/^-+|-+$/g, ''); if (!h) {throw new Error('The .fafa needs agent.name (the short name).');} @@ -488,28 +495,70 @@ export function catalogRows(fafa: FafaDoc, cards: PackCard[], opts: { now?: stri return rows; } +/** Who publishes a catalog: AI Catalog's `host` object. */ +export interface CatalogHost { + displayName: string; + identifier?: string; +} + +/** + * The catalog's `host` — who publishes these entries. Naming one is what + * lifts a catalog from Level 1 "minimal" to Level 2 "discoverable", and + * `displayName` is the field that does it: the validator takes an empty one + * as *invalid*, not as minimal. So a `.fafa` that names nobody gets no host + * at all — a minimal catalog that validates beats a discoverable one that + * does not. `identifier` rides along whenever the `.fafa` says where it + * lives, and is left off when it does not. + */ +export function catalogHost(fafa: FafaDoc): CatalogHost | undefined { + const agent = fafa.agent ?? {}; + const displayName = clean(agent.vendor) || clean(agent.displayName) || clean(agent.name); + if (!displayName) {return undefined;} + let identifier: string | undefined; + try { + identifier = fafaDomain(fafa); + } catch { + identifier = undefined; + } + return { displayName, ...(identifier ? { identifier } : {}) }; +} + /** The AI Catalog for the domain: every row above, with the host named. */ export function projectAiCatalog(fafa: FafaDoc, cards: PackCard[], opts: { now?: string; listFafa?: boolean } = {}): Record { - const agent = fafa.agent ?? {}; - const hostName = clean(agent.vendor) || clean(agent.displayName) || clean(agent.name); + const host = catalogHost(fafa); return { specVersion: AI_CATALOG_SPEC_VERSION, - host: { displayName: hostName, identifier: fafaDomain(fafa) }, + ...(host ? { host } : {}), entries: catalogRows(fafa, cards, opts), }; } -/** The ARD manifest: the same rows, plus the search hints ARD reads (keywords, example requests). */ -export function projectArd(fafa: FafaDoc, cards: PackCard[], opts: { now?: string; listFafa?: boolean } = {}): Record { +/** + * The search hints ARD reads, from the `.fafa`: `metadata.cards.keywords` and + * `metadata.cards.examples`. An entry with no `representativeQueries` is, in + * the conformance CLI's own words, "a valid catalog entry but not a + * discoverable ARD entry" — the semantic index is built from that term. + */ +export function ardHints(fafa: FafaDoc): { tags?: string[]; representativeQueries?: string[] } { const extras = fafa.metadata?.cards ?? {}; const tags = list(extras.keywords); const queries = list(extras.examples); return { - entries: catalogRows(fafa, cards, opts).map((r) => ({ - ...r, - ...(tags.length ? { tags } : {}), - ...(queries.length ? { representativeQueries: queries } : {}), - })), + ...(tags.length ? { tags } : {}), + ...(queries.length ? { representativeQueries: queries } : {}), + }; +} + +/** The ARD manifest: the catalog, plus the search hints ARD reads. ARD builds + * on ai-catalog (spec §4), so the document is the same shape — the entries + * carry more. */ +export function projectArd(fafa: FafaDoc, cards: PackCard[], opts: { now?: string; listFafa?: boolean } = {}): Record { + const host = catalogHost(fafa); + const hints = ardHints(fafa); + return { + specVersion: AI_CATALOG_SPEC_VERSION, + ...(host ? { host } : {}), + entries: catalogRows(fafa, cards, opts).map((r) => ({ ...r, ...hints })), }; } diff --git a/tests/core/owner-rule-r4.test.ts b/tests/core/owner-rule-r4.test.ts index b3be232f..20d77749 100644 --- a/tests/core/owner-rule-r4.test.ts +++ b/tests/core/owner-rule-r4.test.ts @@ -277,8 +277,11 @@ describe('BRAKE: faf cards --target catalog updates only a row whose identifier writeFileSync(f, before); expect(run(d, ['cards', '--target', 'catalog']).status).toBe(0); const after = read(f); - const upToPartner = before.slice(0, before.lastIndexOf('}', before.lastIndexOf(']')) + 1); - expect(after.startsWith(upToPartner)).toBe(true); + // The partner's row, byte for byte. The one key faf adds to a catalog it + // does not own is `host` — and only because this one names none. + const partnerText = before.slice(before.indexOf('{', before.indexOf('"entries"')), before.lastIndexOf('}', before.lastIndexOf(']')) + 1); + expect(after).toContain(partnerText); + expect(after.startsWith('{\n "specVersion": "1.0",\n "host": {\n "displayName": "Example",\n "identifier": "example.com"\n },\n')).toBe(true); const cat = JSON.parse(after); expect(cat.entries[0]).toEqual(partner); expect(cat.entries.map((e: { identifier: string }) => e.identifier)).toEqual([partner.identifier, 'urn:air:example.com:a2a:demo-agent', 'urn:air:example.com:agent:demo-agent']); @@ -290,7 +293,9 @@ describe('BRAKE: faf cards --target catalog updates only a row whose identifier const again = read(f); expect(again).toContain('"displayName": "HAND NAME", "tags": ["HAND"]'); expect(again).toContain('"url": "https://example.com/card.json"'); - expect(again.startsWith(upToPartner)).toBe(true); + expect(again).toContain(partnerText); + // The host is written once: the second run finds one and leaves it. + expect(again.match(/"host":/g)).toHaveLength(1); // A catalog faf cannot edit row by row is refused in one line, byte for byte. const odd = '{\n "specVersion": "1.0",\n "entries": {"HAND": "not a list"}\n}\n'; diff --git a/tests/interop/cards.test.ts b/tests/interop/cards.test.ts index b6c7a5df..eb5d36c0 100644 --- a/tests/interop/cards.test.ts +++ b/tests/interop/cards.test.ts @@ -5,10 +5,14 @@ import { assertSameBlock, buildA2ACard, generateA2ACard, + parseTargets, projectCards, upsertCatalog, + upsertCatalogText, + type CatalogEntry, type FafaDoc, } from '../../src/interop/cards.js'; +import { catalogHost, projectAiCatalog } from '../../src/interop/pack.js'; import { fafContextBlock, REGISTRY_PUBLISHER_KEY } from '../../src/interop/servercard.js'; const faf: FafData = { @@ -200,3 +204,147 @@ describe('ENGINE: 🛡️ one projector — faf cards', () => { expect(generateA2ACard(fafa, faf, opts)).toEqual(buildA2ACard(fafa, faf, opts)); }); }); + +// AI Catalog reads a catalog at Level 1 "minimal" until it names who publishes +// it; a `host` with a non-empty displayName is what makes it Level 2 +// "discoverable". `faf cards --target catalog` wrote neither, so the CLI's +// catalog validated a level below the one the pack API's projectAiCatalog +// produced from the same .fafa. One projector, one host. +describe('ENGINE: 🛡️ the catalog names its host — faf cards', () => { + const rows = () => projectCards({ faf, fafa, targets: ['catalog'] }); + + test('projectCards hands the writer a host: displayName from the .fafa, identifier from its domain', () => { + expect(rows().catalogHost).toEqual({ displayName: 'WolfeJAM', identifier: 'faf.one' }); + }); + + test('a catalog faf writes from scratch names the host, straight after specVersion', () => { + const p = rows(); + const { text } = upsertCatalogText(null, p.catalog!, p.catalogHost); + expect(Object.keys(JSON.parse(text))).toEqual(['specVersion', 'host', 'entries']); + expect(JSON.parse(text).host).toEqual({ displayName: 'WolfeJAM', identifier: 'faf.one' }); + }); + + test('an existing catalog that names no host gets one — every other byte untouched', () => { + const before = [ + '{', + ' "specVersion": "1.0",', + ' "entries": [', + ' {', + ' "identifier": "urn:air:faf.one:context:zeph",', + ' "type": "application/vnd.faf+yaml",', + ' "url": "https://example.com/zeph.faf"', + ' }', + ' ]', + '}', + '', + ].join('\n'); + const p = rows(); + const { text, changed } = upsertCatalogText(before, p.catalog!, p.catalogHost); + expect(changed).toBe(true); + expect(text).toContain(' "host": {\n "displayName": "WolfeJAM",\n "identifier": "faf.one"\n },\n'); + expect(Object.keys(JSON.parse(text))).toEqual(['specVersion', 'host', 'entries']); + // Their row, byte for byte, still there. + expect(text).toContain(' "identifier": "urn:air:faf.one:context:zeph",'); + expect(JSON.parse(text).entries[0]).toEqual(JSON.parse(before).entries[0]); + }); + + test("a host the catalog already names is the site's own — never overwritten", () => { + const before = `{ + "specVersion": "1.0", + "host": { "displayName": "Someone Else", "identifier": "elsewhere.example", "tagline": "theirs" }, + "entries": [] +} +`; + const p = rows(); + const { text } = upsertCatalogText(before, p.catalog!, p.catalogHost); + expect(text).toContain('"host": { "displayName": "Someone Else", "identifier": "elsewhere.example", "tagline": "theirs" },'); + expect(JSON.parse(text).host.displayName).toBe('Someone Else'); + }); + + test('a second run over faf\'s own catalog changes nothing', () => { + const p = rows(); + const first = upsertCatalogText(null, p.catalog!, p.catalogHost); + const again = upsertCatalogText(first.text, p.catalog!, p.catalogHost); + expect(again.changed).toBe(false); + expect(again.text).toBe(first.text); + }); + + test('a .fafa that names nobody gets no host — minimal and valid beats discoverable and invalid', () => { + // ai-catalog-cli v0.2.2 on an empty displayName: "host.displayName is + // required and must not be empty" — invalid, not merely minimal. + expect(catalogHost({ agent: { homepage: 'https://faf.one/agent' } } as FafaDoc)).toBeUndefined(); + expect(catalogHost({ agent: {} } as FafaDoc)).toBeUndefined(); + // Named, but saying nowhere it lives: the name alone still earns a host. + expect(catalogHost({ agent: { name: 'solo' } } as FafaDoc)).toEqual({ displayName: 'solo' }); + }); + + // The defect this PR really closes: the CLI keyed its rows off the homepage + // host and the raw display name, while the pack projector keyed them off the + // domain the .fafa declares and the handle. Same .fafa, two primary keys — + // and with the host now named, the file contradicted itself. + test('the CLI keys its rows exactly as the pack projector does — one catalog, one primary key', () => { + const declared: FafaDoc = { + ...fafa, + agent: { + ...fafa.agent, + name: 'Weather Bot', // a display string: spaces a URN may not carry + id: 'urn:air:weather.acme.example:agent:weather-bot', // the declared publisher + homepage: 'https://acme.example/weather', // a different host entirely + }, + }; + const cli = projectCards({ faf, fafa: declared, targets: ['catalog'] }); + const pack = projectAiCatalog(declared, ['a2a'], { listFafa: true }) as { entries: CatalogEntry[] }; + expect(cli.catalog!.map((e) => e.identifier)).toEqual(pack.entries.map((e) => e.identifier)); + expect(cli.catalog!.map((e) => e.url)).toEqual(pack.entries.map((e) => e.url)); + // The publisher in every row is the one the host names. + expect(cli.catalogHost!.identifier).toBe('weather.acme.example'); + for (const row of cli.catalog!) { + expect(row.identifier.startsWith('urn:air:weather.acme.example:')).toBe(true); + expect(row.identifier).not.toContain(' '); + } + }); + + // ARD builds on ai-catalog (ARD v0.9 §4): the same document, entries + // carrying the hints registries build their semantic index from. The + // conformance CLI is blunt about an entry without them — "a valid catalog + // entry but not a discoverable ARD entry". + test('the ard target is the catalog plus ARD\'s search hints, off the .fafa', () => { + const searchable: FafaDoc = { + ...fafa, + metadata: { cards: { keywords: ['faf', 'context'], examples: ['what is a .faf file', 'how do I score a repo'] } }, + }; + const p = projectCards({ faf, fafa: searchable, targets: ['catalog', 'ard'] }); + // Same rows, same primary key — only the hints differ. + expect(p.ard!.map((e) => e.identifier)).toEqual(p.catalog!.map((e) => e.identifier)); + expect(p.catalog!.every((e) => e.representativeQueries === undefined)).toBe(true); + for (const row of p.ard!) { + expect(row.tags).toEqual(['faf', 'context']); + expect(row.representativeQueries).toEqual(['what is a .faf file', 'how do I score a repo']); + } + }); + + test('a .fafa with no examples still writes a valid manifest — one without search hints', () => { + const p = projectCards({ faf, fafa, targets: ['ard'] }); + expect(p.ard!.length).toBeGreaterThan(0); + expect(p.ard!.every((e) => e.representativeQueries === undefined)).toBe(true); + }); + + test('ard is a target of its own, and needs a .fafa like the rest', () => { + expect(parseTargets('a2a,catalog,ard')).toEqual(['a2a', 'catalog', 'ard']); + expect(() => parseTargets('ARD,nope')).toThrow(/unknown card target: nope/); + expect(() => projectCards({ faf, targets: ['ard'] })).toThrow(/require a \.fafa/); + }); + + test('no domain, no invented identifier: faf refuses rather than publish urn:air:local', () => { + const nowhere: FafaDoc = { ...fafa, agent: { name: 'solo' } }; + expect(() => projectCards({ faf, fafa: nowhere, targets: ['catalog'] })).toThrow(/names no domain/); + }); + + test('upsertCatalog (objects) adds a missing host and keeps an existing one', () => { + const p = rows(); + const added = upsertCatalog(undefined, p.catalog!, p.catalogHost); + expect(Object.keys(added)).toEqual(['specVersion', 'host', 'entries']); + const theirs = { specVersion: '1.0', host: { displayName: 'Someone Else' }, entries: [] }; + expect(upsertCatalog(theirs, p.catalog!, p.catalogHost).host).toEqual({ displayName: 'Someone Else' }); + }); +}); diff --git a/tests/interop/whole-file-owner.test.ts b/tests/interop/whole-file-owner.test.ts index d009c49b..aae67646 100644 --- a/tests/interop/whole-file-owner.test.ts +++ b/tests/interop/whole-file-owner.test.ts @@ -196,9 +196,12 @@ describe('BRAKE: the commands refuse in one line and take --force (show, export, writeFileSync(join(d, '.well-known', 'ai-catalog.json'), hand); expect(run(d, ['cards', '--target', 'catalog']).status).toBe(0); const after = readFileSync(join(d, '.well-known', 'ai-catalog.json'), 'utf-8'); - // The hand text up to its last row is kept byte for byte; faf's rows follow it. - const lastRow = hand.indexOf('}', hand.indexOf('"url"')) + 1; - expect(after.startsWith(hand.slice(0, lastRow))).toBe(true); + // The hand row is kept byte for byte and faf's rows follow it. The one key + // faf adds is `host` (this catalog names none) — laid out in the file's own + // four-space indent, not faf's. + const handRow = hand.slice(hand.indexOf('{', hand.indexOf('"entries"')), hand.indexOf('}', hand.indexOf('"url"')) + 1); + expect(after).toContain(handRow); + expect(after).toContain('\n "host": {\n "displayName": "Me",\n "identifier": "example.com"\n },\n'); expect(after.endsWith('\n ]\n}\n')).toBe(true); const cat = JSON.parse(after); expect(cat.entries[0]).toEqual({ identifier: 'urn:x:mine', type: 'text/html', url: 'https://example.com/HAND' }); // the user's row kept