From 3900caaaa94443efb01597dac26df1533f8add35 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Fri, 3 Jul 2026 22:56:43 -0700 Subject: [PATCH] =?UTF-8?q?feat(core,cli):=20media-use=20interop=20?= =?UTF-8?q?=E2=80=94=20shared=20index.md=20regen=20+=20description/entity?= =?UTF-8?q?=20on=20figma=20imports=20(#1927)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-release review of media-use ↔ figma coupling (spec §13.1): - figma asset imports now regenerate .media/index.md, the agent-readable inventory media-use maintains — format locked byte-identical via a cross-runner parity test against media-use's own index-gen.mjs - figma asset --description/--entity land in the manifest record, the index table, and ; component rasterize auto-describes with the node name. Named brand marks become visible to media-use's resolve --entity lookups. - spec §13.1 records the review verdict (loose coupling correct) and the follow-up queue (shared media-ledger module, global cache for figma assets, media-use version-keyed idempotency) Co-authored-by: Claude Fable 5 --- packages/cli/src/commands/figma/asset.test.ts | 52 +++++++++ packages/cli/src/commands/figma/asset.ts | 84 +++++++++++--- packages/cli/src/commands/figma/component.ts | 12 +- packages/core/src/figma/index.ts | 3 + packages/core/src/figma/manifest.ts | 42 +++++-- packages/core/src/figma/mediaIndex.test.ts | 107 ++++++++++++++++++ packages/core/src/figma/mediaIndex.ts | 89 +++++++++++++++ packages/core/src/figma/types.ts | 2 + skills-manifest.json | 4 +- skills/figma/SKILL.md | 4 +- skills/media-use/scripts/resolve.mjs | 18 ++- skills/media-use/scripts/resolve.test.mjs | 34 ++++++ 12 files changed, 418 insertions(+), 33 deletions(-) create mode 100644 packages/core/src/figma/mediaIndex.test.ts create mode 100644 packages/core/src/figma/mediaIndex.ts diff --git a/packages/cli/src/commands/figma/asset.test.ts b/packages/cli/src/commands/figma/asset.test.ts index 680866673..c3e25a340 100644 --- a/packages/cli/src/commands/figma/asset.test.ts +++ b/packages/cli/src/commands/figma/asset.test.ts @@ -101,4 +101,56 @@ describe("runAssetImport", () => { /node/i, ); }); + + it("records description + entity and regenerates .media/index.md", async () => { + const dir = scratch(); + const out = await runAssetImport( + "KEY:1-2", + { format: "png", description: "hero illustration", entity: "Acme hero" }, + deps(dir), + ); + expect(out.record.description).toBe("hero illustration"); + expect(out.record.entity).toBe("Acme hero"); + expect(out.snippet.html).toContain('alt="hero illustration"'); + const index = readFileSync(join(dir, ".media", "index.md"), "utf8"); + expect(index).toContain("hero illustration"); + expect(index).toContain(out.record.id); + }); + + it("applies --description/--entity on a reuse hit instead of dropping them", async () => { + const dir = scratch(); + const first = await runAssetImport("KEY:1-2", { format: "png" }, deps(dir)); + expect(first.record.description).toBeUndefined(); + const again = await runAssetImport( + "KEY:1-2", + { format: "png", description: "Acme logo", entity: "Acme logo" }, + deps(dir), + ); + expect(again.reused).toBe(true); + expect(again.record.description).toBe("Acme logo"); + expect(again.snippet.html).toContain('alt="Acme logo"'); + const index = readFileSync(join(dir, ".media", "index.md"), "utf8"); + expect(index).toContain("Acme logo"); + expect(index).not.toContain("image_002"); + }); + + it("reuses against ANY matching tuple, not just the oldest row", async () => { + const dir = scratch(); + await runAssetImport("KEY:1-2", { format: "svg" }, deps(dir)); // image_001 (svg) + const png = await runAssetImport("KEY:1-2", { format: "png" }, deps(dir)); // image_002 + expect(png.reused).toBe(false); + const pngAgain = await runAssetImport("KEY:1-2", { format: "png" }, deps(dir)); + expect(pngAgain.reused).toBe(true); + expect(pngAgain.record.id).toBe(png.record.id); + }); + + it("flattens whitespace in descriptions so index.md rows stay single-line", async () => { + const dir = scratch(); + const out = await runAssetImport( + "KEY:1-2", + { format: "png", description: "line one\nline two" }, + deps(dir), + ); + expect(out.record.description).toBe("line one line two"); + }); }); diff --git a/packages/cli/src/commands/figma/asset.ts b/packages/cli/src/commands/figma/asset.ts index 450868249..79252c030 100644 --- a/packages/cli/src/commands/figma/asset.ts +++ b/packages/cli/src/commands/figma/asset.ts @@ -9,12 +9,14 @@ import { appendRecord, buildAssetSnippet, createFigmaClient, - findByFigmaNode, + findAllByFigmaNode, freezeBytes, nextId, parseFigmaRef, + regenerateIndex, sanitizeSvg, typeDirPath, + updateRecord, type AssetSnippet, type FigmaAssetFormat, type FigmaClient, @@ -28,6 +30,10 @@ import { withFigmaErrors } from "./cliError.js"; export interface AssetImportOptions { format: FigmaAssetFormat; scale?: number; + /** human description — lands in the manifest + index.md + */ + description?: string; + /** media-use interop: entity name for `resolve --entity` cache hits */ + entity?: string; } export interface AssetImportDeps { @@ -55,21 +61,39 @@ export async function runAssetImport( ); const { version } = await deps.client.fileVersion(ref.fileKey); + const description = normalizeMeta(opts.description); + const entity = normalizeMeta(opts.entity); // Cache key per spec §5: fileKey:nodeId:format:scale:version → reuse. - // Unspecified scale is canonically 1 on both sides (figma's default), so - // `--scale 1` and no flag dedupe to the same record. Reuse also requires - // the frozen file to still exist — a deleted file falls through to - // re-import instead of returning a snippet that points at nothing. - const existing = findByFigmaNode(deps.projectDir, ref.fileKey, ref.nodeId); - if ( - existing && - existing.provenance.format === opts.format && - (existing.provenance.scale ?? 1) === (opts.scale ?? 1) && - existing.provenance.version === version && - existsSync(join(deps.projectDir, existing.path)) - ) { - return { record: existing, snippet: buildAssetSnippet(existing), reused: true }; + // Check EVERY row for the node (a node can legitimately have several + // format/scale/version tuples — the oldest-row shortcut minted duplicates + // forever once a second tuple existed). Unspecified scale is canonically 1 + // on both sides (figma's default). Reuse also requires the frozen file to + // still exist — a deleted file falls through to re-import. + const existing = findAllByFigmaNode(deps.projectDir, ref.fileKey, ref.nodeId).find( + (r) => + r.provenance.format === opts.format && + (r.provenance.scale ?? 1) === (opts.scale ?? 1) && + r.provenance.version === version && + existsSync(join(deps.projectDir, r.path)), + ); + if (existing) { + // Metadata supplied on a re-import still lands: upsert the row instead + // of silently discarding the flags. + let record = existing; + if ( + (description !== undefined && description !== existing.description) || + (entity !== undefined && entity !== existing.entity) + ) { + record = { + ...existing, + ...(description !== undefined && { description }), + ...(entity !== undefined && { entity }), + }; + updateRecord(deps.projectDir, record); + } + safeRegenerateIndex(deps.projectDir); + return { record, snippet: buildAssetSnippet(record), reused: true }; } const rendered = await deps.client.renderNode(ref, opts); @@ -92,6 +116,8 @@ export async function runAssetImport( type: "image", path: relative(deps.projectDir, destAbs), source: `figma:${ref.fileKey}/${ref.nodeId}`, + ...(description !== undefined && { description }), + ...(entity !== undefined && { entity }), provenance: { source: "figma", fileKey: ref.fileKey, @@ -102,9 +128,29 @@ export async function runAssetImport( }, }; appendRecord(deps.projectDir, record); + safeRegenerateIndex(deps.projectDir); return { record, snippet: buildAssetSnippet(record), reused: false }; } +/** index.md is a single table row per record — newlines/tabs in a + * description would corrupt the whole table. */ +function normalizeMeta(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const cleaned = value.replace(/\s+/g, " ").trim(); + return cleaned.length > 0 ? cleaned : undefined; +} + +/** Keep the agent-readable inventory in step with the manifest (media-use + * regenerates the same file after its writes). Best-effort: the import is + * already durable, so an index write failure must not fail the command. */ +function safeRegenerateIndex(projectDir: string): void { + try { + regenerateIndex(projectDir); + } catch (err) { + console.warn(`index.md regeneration failed: ${err instanceof Error ? err.message : err}`); + } +} + const FORMATS: readonly FigmaAssetFormat[] = ["png", "svg", "jpg", "pdf"]; function parseFormat(raw: string): FigmaAssetFormat { @@ -122,6 +168,14 @@ export default defineCommand({ }, format: { type: "string", description: "png | svg | jpg | pdf", default: "svg" }, scale: { type: "string", description: "export scale (e.g. 2)" }, + description: { + type: "string", + description: "what this asset is (index.md + ); e.g. the layer's purpose", + }, + entity: { + type: "string", + description: 'entity name for media-use cache lookups (e.g. "Acme logo")', + }, dir: { type: "string", description: "project directory", default: "." }, }, async run({ args }) { @@ -133,6 +187,8 @@ export default defineCommand({ { format: parseFormat(args.format), scale: args.scale !== undefined ? Number(args.scale) : undefined, + description: args.description, + entity: args.entity, }, { projectDir: args.dir, client, download: downloadRender }, ); diff --git a/packages/cli/src/commands/figma/component.ts b/packages/cli/src/commands/figma/component.ts index 5c6f6bf4e..b80a2f9f0 100644 --- a/packages/cli/src/commands/figma/component.ts +++ b/packages/cli/src/commands/figma/component.ts @@ -67,12 +67,14 @@ export async function runComponentImport( // The search key must match the EMITTED (html-escaped) node id, and // replaceAll covers the same node appearing twice in the tree. let html = mapped.html; + const frozenAssets: string[] = []; for (const req of mapped.rasterize) { const asset = await runAssetImport( `${ref.fileKey}:${req.nodeId}`, - { format: "svg" }, + { format: "svg", description: req.name }, { projectDir: deps.projectDir, client: deps.client, download: deps.download }, ); + frozenAssets.push(asset.record.path); // src is a URL — always forward slashes, even when relative() yields // windows separators. const srcRel = relative(componentDir, join(deps.projectDir, asset.record.path)).replaceAll( @@ -99,9 +101,11 @@ export async function runComponentImport( target: `compositions/components/${name}/${name}.html`, type: "hyperframes:snippet", }, - ...mapped.rasterize.map((r) => ({ - path: `${r.slug}.svg`, - target: `.media/images/${r.slug}.svg`, + // The paths the frozen files ACTUALLY landed at (image_NNN.svg), which + // is also what the emitted HTML references — not the slug names. + ...frozenAssets.map((p) => ({ + path: p.split("/").pop() ?? p, + target: p.replaceAll("\\", "/"), type: "hyperframes:asset", })), ], diff --git a/packages/core/src/figma/index.ts b/packages/core/src/figma/index.ts index 369687ab2..9e9a0b552 100644 --- a/packages/core/src/figma/index.ts +++ b/packages/core/src/figma/index.ts @@ -25,12 +25,15 @@ export { mediaDir, manifestPath, typeDirPath, + updateRecord, isFigmaManifestRecord, readManifest, appendRecord, + findAllByFigmaNode, findByFigmaNode, nextId, } from "./manifest"; +export { regenerateIndex } from "./mediaIndex"; export { buildAssetSnippet } from "./assetSnippet"; export { sanitizeSvg } from "./sanitizeSvg"; export { diff --git a/packages/core/src/figma/manifest.ts b/packages/core/src/figma/manifest.ts index 3bce17158..86f0e5dcd 100644 --- a/packages/core/src/figma/manifest.ts +++ b/packages/core/src/figma/manifest.ts @@ -1,4 +1,4 @@ -import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { readJsonlValues } from "./jsonl"; import type { FigmaManifestRecord } from "./types"; @@ -68,15 +68,41 @@ export function findByFigmaNode( fileKey: string, nodeId: string, ): FigmaManifestRecord | null { - for (const r of readManifest(projectDir)) { - if ( + return findAllByFigmaNode(projectDir, fileKey, nodeId)[0] ?? null; +} + +/** EVERY row for a node — reuse gates must check all (format, scale, version) + * tuples, not just the oldest, or a second tuple defeats idempotency forever. */ +export function findAllByFigmaNode( + projectDir: string, + fileKey: string, + nodeId: string, +): FigmaManifestRecord[] { + return readManifest(projectDir).filter( + (r) => r.provenance.source === "figma" && r.provenance.fileKey === fileKey && - r.provenance.nodeId === nodeId - ) - return r; - } - return null; + r.provenance.nodeId === nodeId, + ); +} + +/** Rewrite one row in place by id, preserving every other line (other + * writers' rows included) byte-for-byte. */ +export function updateRecord(projectDir: string, record: FigmaManifestRecord): void { + const p = manifestPath(projectDir); + const lines = readFileSync(p, "utf8").split(/\r?\n/); + const out = lines.map((line) => { + const trimmed = line.trim(); + if (trimmed.length === 0) return line; + try { + const parsed: unknown = JSON.parse(trimmed); + if (isFigmaManifestRecord(parsed) && parsed.id === record.id) return JSON.stringify(record); + } catch { + // non-JSON line — preserve untouched + } + return line; + }); + writeFileSync(p, out.join("\n")); } export function nextId(projectDir: string, type: FigmaManifestRecord["type"]): string { diff --git a/packages/core/src/figma/mediaIndex.test.ts b/packages/core/src/figma/mediaIndex.test.ts new file mode 100644 index 000000000..b2d597f8a --- /dev/null +++ b/packages/core/src/figma/mediaIndex.test.ts @@ -0,0 +1,107 @@ +// @vitest-environment node +import { describe, expect, it, afterEach } from "vitest"; +import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { execFileSync } from "node:child_process"; +import { generateIndexContent, indexPath, regenerateIndex } from "./mediaIndex"; +import { manifestPath } from "./manifest"; + +const dirs: string[] = []; +function project(): string { + const d = mkdtempSync(join(tmpdir(), "hf-media-index-")); + dirs.push(d); + mkdirSync(join(d, ".media"), { recursive: true }); + return d; +} +afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +const MEDIA_USE_ROW = { + id: "bgm_001", + type: "bgm", + path: ".media/audio/bgm/bgm_001.mp3", + source: "search", + description: "upbeat tech launch", + duration: 25, + provenance: { provider: "heygen-audio", prompt: "upbeat tech launch" }, +}; +const IMAGE_ROW = { + id: "image_001", + type: "image", + path: ".media/images/image_001.jpg", + source: "search", + description: "gradient tech background", + width: 1920, + height: 1080, + provenance: { provider: "heygen-asset", prompt: "gradient tech background" }, +}; +const ICON_ROW = { + id: "icon_001", + type: "icon", + path: ".media/images/icon_001.svg", + source: "search", + description: "rocket", + transparent: true, + provenance: { provider: "heygen-asset", prompt: "rocket" }, +}; +const FIGMA_ROW = { + id: "image_002", + type: "image", + path: ".media/images/image_002.svg", + source: "figma:KEY/1:2", + description: "hero illustration", + entity: "Acme hero", + provenance: { source: "figma", fileKey: "KEY", nodeId: "1:2", version: "9", format: "svg" }, +}; +// media-use renders every JSON-parseable row, shape or no shape — selection +// parity matters as much as format parity. +const JUNK_ROW = { note: "not a media record" }; +const ALL_ROWS = [MEDIA_USE_ROW, IMAGE_ROW, ICON_ROW, FIGMA_ROW, JUNK_ROW]; + +describe("regenerateIndex", () => { + it("renders every writer's rows (media-use + figma) into one table", () => { + const p = project(); + for (const row of ALL_ROWS) appendFileSync(manifestPath(p), JSON.stringify(row) + "\n"); + regenerateIndex(p); + const index = readFileSync(indexPath(p), "utf8"); + expect(index).toContain("# .media · 5 assets"); + expect(index).toContain("25s"); + expect(index).toContain("1920×1080"); + expect(index).toContain("hero illustration"); + }); + + it("matches media-use's index-gen output byte-for-byte on the same rows", () => { + // Covers duration, width×height, icon+transparent, no-dims, and junk-row + // selection — the full set of branches both generators format. + const ours = generateIndexContent(ALL_ROWS as Record[]); + // Run the actual media-use generator on identical input. Resolve the + // script relative to THIS file (cwd varies per test runner) and hand it + // over as a file:// URL so the specifier is valid on windows too. + const genUrl = pathToFileURL( + join( + fileURLToPath(new URL(".", import.meta.url)), + "..", + "..", + "..", + "..", + "skills", + "media-use", + "scripts", + "lib", + "index-gen.mjs", + ), + ).href; + const script = ` + import { generateIndexContent } from ${JSON.stringify(genUrl)}; + const rows = ${JSON.stringify(ALL_ROWS)}; + process.stdout.write(generateIndexContent(rows)); + `; + const theirs = execFileSync("node", ["--input-type=module", "-e", script], { + encoding: "utf8", + }); + expect(ours).toBe(theirs); + }); +}); diff --git a/packages/core/src/figma/mediaIndex.ts b/packages/core/src/figma/mediaIndex.ts new file mode 100644 index 000000000..8ec865b35 --- /dev/null +++ b/packages/core/src/figma/mediaIndex.ts @@ -0,0 +1,89 @@ +/** + * Regenerate .media/index.md — the agent-readable inventory table — after a + * figma import, exactly the way media-use does after a resolve. Both writers + * regenerate the SAME file from the full manifest (all writers' rows), so the + * output format AND row selection here must stay byte-identical with + * skills/media-use/scripts/lib/index-gen.mjs — including rendering every + * JSON-parseable row (no shape filtering), or the file would flip-flop + * depending on which writer ran last. + */ + +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { readJsonlValues } from "./jsonl"; +import { manifestPath, mediaDir } from "./manifest"; + +type IndexRow = Record; + +function isRow(value: unknown): value is IndexRow { + return typeof value === "object" && value !== null; +} + +export function indexPath(projectDir: string): string { + return join(mediaDir(projectDir), "index.md"); +} + +function pad(str: unknown, len: number): string { + return String(str ?? "").padEnd(len); +} + +function formatDur(r: IndexRow): string { + if (r.duration == null) return "—"; + return `${String(r.duration)}s`; +} + +function formatDims(r: IndexRow): string { + if (r.width && r.height) return `${String(r.width)}×${String(r.height)}`; + if (r.type === "icon" && r.transparent) return "svg"; + return "—"; +} + +function len(value: unknown): number { + return String(value ?? "").length; +} + +export function generateIndexContent(records: IndexRow[]): string { + const count = records.length; + const header = `# .media · ${count} asset${count === 1 ? "" : "s"}\n`; + if (count === 0) return header; + + const cols = { id: 4, type: 5, dur: 4, dims: 5, path: 5 }; + for (const r of records) { + cols.id = Math.max(cols.id, len(r.id)); + cols.type = Math.max(cols.type, len(r.type)); + cols.dur = Math.max(cols.dur, formatDur(r).length); + cols.dims = Math.max(cols.dims, formatDims(r).length); + cols.path = Math.max(cols.path, len(r.path)); + } + + const heading = + pad("id", cols.id + 2) + + pad("type", cols.type + 2) + + pad("dur", cols.dur + 2) + + pad("dims", cols.dims + 2) + + pad("path", cols.path + 2) + + "description"; + + const lines = [header, heading]; + for (const r of records) { + lines.push( + pad(r.id, cols.id + 2) + + pad(r.type, cols.type + 2) + + pad(formatDur(r), cols.dur + 2) + + pad(formatDims(r), cols.dims + 2) + + pad(r.path, cols.path + 2) + + String(r.description ?? ""), + ); + } + return lines.join("\n") + "\n"; +} + +/** Rebuild index.md from EVERY writer's manifest rows (media-use + figma). */ +export function regenerateIndex(projectDir: string): string { + const records = readJsonlValues(manifestPath(projectDir)).filter(isRow); + const content = generateIndexContent(records); + const p = indexPath(projectDir); + mkdirSync(dirname(p), { recursive: true }); + writeFileSync(p, content); + return content; +} diff --git a/packages/core/src/figma/types.ts b/packages/core/src/figma/types.ts index 769106235..4cbc7b173 100644 --- a/packages/core/src/figma/types.ts +++ b/packages/core/src/figma/types.ts @@ -20,6 +20,8 @@ export interface FigmaManifestRecord { path: string; source: string; description?: string; + /** media-use interop: lets `resolve --entity` find figma-imported assets */ + entity?: string; width?: number; height?: number; provenance: FigmaProvenance; diff --git a/skills-manifest.json b/skills-manifest.json index 7001b1b64..2b1142879 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -10,7 +10,7 @@ "files": 17 }, "figma": { - "hash": "c462519102acc265", + "hash": "0adc2a1e01767db7", "files": 1 }, "general-video": { @@ -50,7 +50,7 @@ "files": 10 }, "media-use": { - "hash": "fba6e0963e431b1e", + "hash": "75dda0086dda18ce", "files": 19 }, "motion-graphics": { diff --git a/skills/figma/SKILL.md b/skills/figma/SKILL.md index 6a2f15356..17e0f3985 100644 --- a/skills/figma/SKILL.md +++ b/skills/figma/SKILL.md @@ -49,10 +49,10 @@ Parse the user's figma link with `parseFigmaRef` (URL, `fileKey:nodeId`, bare `f ## Assets (Phase 1 — CLI) ```bash -hyperframes figma asset '' [--format svg|png|jpg|pdf] [--scale 2] +hyperframes figma asset '' [--format svg|png|jpg|pdf] [--scale 2] [--description "..."] [--entity "..."] ``` -Renders over REST, sanitizes SVG, freezes under `.media/images/`, appends the manifest with provenance, prints an `` snippet. Idempotent per `fileKey:nodeId:format:scale:version`. Prefer SVG for vectors/logos (scalable, animatable), PNG `--scale 2` for raster fidelity. +Renders over REST, sanitizes SVG, freezes under `.media/images/`, appends the manifest with provenance, regenerates `.media/index.md` (the shared media-use inventory), prints an `` snippet. Idempotent per `fileKey:nodeId:format:scale:version`. Prefer SVG for vectors/logos (scalable, animatable), PNG `--scale 2` for raster fidelity. **Always pass `--description ""`** (it becomes the index row + ``); add `--entity ""` for named brand marks so media-use `resolve --entity` finds them later (entity hits match across image/icon). ## Tokens (Phase 2 — CLI) diff --git a/skills/media-use/scripts/resolve.mjs b/skills/media-use/scripts/resolve.mjs index 0de752cdd..242b49406 100644 --- a/skills/media-use/scripts/resolve.mjs +++ b/skills/media-use/scripts/resolve.mjs @@ -74,10 +74,16 @@ async function run() { return result(projectHit, "cached"); } - // 1b. entity match in project + // 1b. entity match in project. icon and image are interchangeable for + // entity hits — both live in images/, and figma-imported brand marks are + // always recorded as type image while agents ask for logos as type icon. if (entity) { const entityHit = findByEntity(projectDir, entity); - if (entityHit && entityHit.type === type && existsSync(join(projectDir, entityHit.path))) { + if ( + entityHit && + typesMatch(entityHit.type, type) && + existsSync(join(projectDir, entityHit.path)) + ) { return result(entityHit, "cached"); } } @@ -115,7 +121,7 @@ async function run() { if (entity) { const entityCacheHit = cacheGetByEntity(entity); - if (entityCacheHit && entityCacheHit.type === type) { + if (entityCacheHit && typesMatch(entityCacheHit.type, type)) { const id = nextId(projectDir, type); const ext = extname(entityCacheHit.cached_path); const localPath = `.media/${typeSubdir(type)}/${id}${ext}`; @@ -197,6 +203,12 @@ async function run() { return result(record, searchResult.source || "search"); } +function typesMatch(a, b) { + if (a === b) return true; + const visual = new Set(["icon", "image"]); + return visual.has(a) && visual.has(b); +} + function result(record, source) { if (args.json) { console.log(JSON.stringify({ ok: true, ...record, _source: source })); diff --git a/skills/media-use/scripts/resolve.test.mjs b/skills/media-use/scripts/resolve.test.mjs index 90462c0be..60899fd92 100644 --- a/skills/media-use/scripts/resolve.test.mjs +++ b/skills/media-use/scripts/resolve.test.mjs @@ -68,6 +68,40 @@ test("project manifest hit skips providers", () => { cleanup(); }); +test("entity hit matches across icon/image (figma-imported brand marks)", () => { + setup(); + const record = makeRecord({ + id: "image_001", + type: "image", + path: ".media/images/image_001.svg", + description: "Acme logo", + entity: "Acme logo", + provenance: { source: "figma", fileKey: "KEY", nodeId: "1:2", version: "1", format: "svg" }, + }); + delete record.duration; + appendRecord(tmp, record); + const filePath = join(tmp, record.path); + mkdirSync(join(filePath, ".."), { recursive: true }); + writeFileSync(filePath, ""); + + const out = runResolve([ + "--type", + "icon", + "--intent", + "acme brand mark", + "--entity", + "Acme logo", + "--project", + tmp, + "--json", + ]); + const parsed = JSON.parse(out.trim()); + assert.equal(parsed.ok, true); + assert.equal(parsed.id, "image_001"); + assert.equal(parsed._source, "cached"); + cleanup(); +}); + // --- global cache hit --- test("global cache hit copies to project and registers", () => {