From d08f07ea71a3dbd9aa5bbd46b35b9a788b9daaa3 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 24 Aug 2026 17:00:04 -0400 Subject: [PATCH 1/5] feat(cli): report which catalog items a render actually used `registry_item_added` fires when a block is installed and `render_complete` fires when a video is produced, but nothing joins them: no render property names a catalog item, so "did this video use the catalog?" has no answer, and "was it any better for it?" cannot even be posed. `hyperframes add` now records each installed item in `hyperframes.json` (installed files are plain composition HTML and carry no provenance marker, so this manifest is the only place that knows). The render plan reads it back and walks the entry's `data-composition-src` tree, and `render_complete` reports both halves: the items the project installed, and the blocks the rendered composition actually reaches. The delta is the part no add-time event can express. An item that was installed and then not mounted was tried and dropped, which is a rejection signal rather than an opinion. Counts are emitted at zero so the no-catalog cohort exists to compare against. Names are slug-gated and capped before they reach telemetry, matching the guard already applied to authoring-skill slugs, and the manifest write follows the same in-place patch discipline as `seedProjectAuthoringSkill` so an install never reflows a committed config or drops keys it does not own. --- packages/cli/src/commands/add.ts | 37 ++++- packages/cli/src/commands/render.ts | 9 ++ packages/cli/src/commands/render/execute.ts | 2 + packages/cli/src/commands/render/plan.test.ts | 28 +++- packages/cli/src/commands/render/plan.ts | 9 +- packages/cli/src/telemetry/events.test.ts | 43 ++++++ packages/cli/src/telemetry/events.ts | 31 ++++ packages/cli/src/utils/catalogUsage.test.ts | 141 ++++++++++++++++++ packages/cli/src/utils/catalogUsage.ts | 139 +++++++++++++++++ packages/cli/src/utils/projectConfig.test.ts | 83 +++++++++++ packages/cli/src/utils/projectConfig.ts | 93 ++++++++++++ 11 files changed, 608 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/utils/catalogUsage.test.ts create mode 100644 packages/cli/src/utils/catalogUsage.ts diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index fd6ed2aba8..a0d8574711 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -25,6 +25,7 @@ import { DEFAULT_PROJECT_CONFIG, loadProjectConfig, projectConfigPath, + recordProjectRegistryItems, writeProjectConfig, } from "../utils/projectConfig.js"; import { copyToClipboard } from "../utils/clipboard.js"; @@ -78,6 +79,23 @@ function variableValuesAttribute(values: Record | null): string return ` data-variable-values='${json}'`; } +/** + * The file a consumer points at for this item: the snippet if it has one, else + * its composition, else whatever landed first. Project-relative, empty when the + * item installed no files. + * + * One owner for this choice. The paste snippet's `data-composition-src` and the + * recorded manifest target must name the same file, or a render would look for + * a block at a path the composition never mounts and report it as dropped. + */ +function primaryInstalledTarget(item: RegistryItem): string { + const primary = + item.files.find((f) => f.type === "hyperframes:snippet") ?? + item.files.find((f) => f.type === "hyperframes:composition") ?? + item.files[0]; + return primary?.target ?? ""; +} + export function buildSnippet( item: RegistryItem, relativeTarget: string, @@ -321,13 +339,22 @@ export async function runAdd(opts: RunAddArgs): Promise { }); } + // Persist what came from the registry. Installed files are plain composition + // HTML with no provenance marker, so without this a later render cannot tell + // a catalog block from one the user wrote — and "did the catalog item survive + // into the video?" stays unanswerable. + recordProjectRegistryItems( + projectDir, + installPlan.map((planItem) => ({ + name: planItem.name, + type: planItem.type, + target: primaryInstalledTarget(planItem), + })), + ); + // 6. Build include snippet + clipboard copy for the requested item. const itemForInstall = installPlan[installPlan.length - 1]!; - const primaryFile = - itemForInstall.files.find((f) => f.type === "hyperframes:snippet") ?? - itemForInstall.files.find((f) => f.type === "hyperframes:composition") ?? - itemForInstall.files[0]; - const snippetTargetRel = primaryFile?.target ?? ""; + const snippetTargetRel = primaryInstalledTarget(itemForInstall); const snippet = buildSnippet(item, snippetTargetRel, variableValues); const clipboardCopied = !opts.skipClipboard && snippet ? copyToClipboard(snippet) : false; diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 8170fc75aa..102a45bcc8 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -4,6 +4,7 @@ import type { Example } from "./_examples.js"; import { mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs"; import { createRenderPlan, resolveBrowserGpuForCli, type RenderFormat } from "./render/plan.js"; import { seedProjectAuthoringSkill } from "../utils/projectConfig.js"; +import type { CatalogUsage } from "../utils/catalogUsage.js"; import { presentRenderPlan } from "./render/present.js"; import { executeRenderPlan, renderLintContinuationHint, runRenderLint } from "./render/execute.js"; // Test-only seams retained at the command boundary for render behavior tests. @@ -377,6 +378,12 @@ export interface RenderOptions { quality: "draft" | "standard" | "high"; /** Authoring workflow skill that drove this render (telemetry attribution). */ authoringSkill?: string; + /** + * Catalog items installed in this project and those the rendered composition + * reaches. Resolved once in the render plan; absent on programmatic callers + * that build options by hand, which simply omit the catalog properties. + */ + catalogUsage?: CatalogUsage; format: RenderFormat; gifLoop?: number; workers?: number; @@ -763,6 +770,7 @@ async function renderDocker( docker: true, gpu: options.gpu, authoringSkill: options.authoringSkill, + catalogUsage: options.catalogUsage, ...getMemorySnapshot(), }), ); @@ -1498,6 +1506,7 @@ function trackRenderMetrics( docker, gpu: options.gpu, authoringSkill: options.authoringSkill, + catalogUsage: options.catalogUsage, staticDedupEnabled: perf?.staticDedup?.enabled, staticDedupArmed: perf?.staticDedup?.armed, staticDedupSkipReason: perf?.staticDedup?.skipReason, diff --git a/packages/cli/src/commands/render/execute.ts b/packages/cli/src/commands/render/execute.ts index 163c849f23..301a9894fd 100644 --- a/packages/cli/src/commands/render/execute.ts +++ b/packages/cli/src/commands/render/execute.ts @@ -101,6 +101,7 @@ export async function executeRenderPlan( fps: plan.fps, quality: plan.quality, authoringSkill: plan.authoringSkill, + catalogUsage: plan.catalogUsage, format: plan.format, gifLoop: plan.gifLoop, workers: plan.workers, @@ -254,6 +255,7 @@ async function executeBatchRender( fps: plan.fps, quality: plan.quality, authoringSkill: plan.authoringSkill, + catalogUsage: plan.catalogUsage, format: plan.format, workers: plan.workers, gpu: plan.useGpu, diff --git a/packages/cli/src/commands/render/plan.test.ts b/packages/cli/src/commands/render/plan.test.ts index 03482cb0af..806d6dae33 100644 --- a/packages/cli/src/commands/render/plan.test.ts +++ b/packages/cli/src/commands/render/plan.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { CliUsageError } from "../../utils/commandResult.js"; @@ -37,6 +37,32 @@ describe("createRenderPlan", () => { expect(Object.isFrozen(plan.environment)).toBe(true); }); + // The catalog join reaches the render event through the plan, so a plan that + // silently drops it would leave every render reporting no catalog items. + it("resolves catalog usage from the project manifest and the render entry", () => { + writeFileSync( + join(projectDir, "index.html"), + '
' + + '
', + ); + mkdirSync(join(projectDir, "compositions"), { recursive: true }); + writeFileSync(join(projectDir, "compositions", "kept.html"), ""); + writeFileSync(join(projectDir, "compositions", "dropped.html"), ""); + writeFileSync( + join(projectDir, "hyperframes.json"), + JSON.stringify({ + registry: "https://example.test", + registryItems: [ + { name: "kept", type: "hyperframes:block", target: "compositions/kept.html" }, + { name: "dropped", type: "hyperframes:block", target: "compositions/dropped.html" }, + ], + }), + ); + + const plan = createRenderPlan({ dir: projectDir, output: "result.mp4" }); + expect(plan.catalogUsage).toEqual({ installed: ["dropped", "kept"], usedBlocks: ["kept"] }); + }); + it("preserves an explicit strict-readiness opt-in", () => { const plan = createRenderPlan({ dir: projectDir, "best-effort": false }); expect(plan.bestEffort).toBe(false); diff --git a/packages/cli/src/commands/render/plan.ts b/packages/cli/src/commands/render/plan.ts index 043b08db9c..600a267760 100644 --- a/packages/cli/src/commands/render/plan.ts +++ b/packages/cli/src/commands/render/plan.ts @@ -29,6 +29,7 @@ import { } from "../../utils/renderArgs.js"; import { normalizeSkillSlug } from "../../telemetry/skill.js"; import { loadProjectConfig } from "../../utils/projectConfig.js"; +import { type CatalogUsage, summarizeCatalogUsage } from "../../utils/catalogUsage.js"; const VALID_QUALITY = new Set(["draft", "standard", "high"]); const RENDER_FORMATS = ["mp4", "webm", "mov", "png-sequence", "gif"] as const; @@ -99,6 +100,8 @@ export interface RenderPlan { quality: RenderQuality; authoringSkill?: string; invalidAuthoringSkill?: string; + /** Catalog items installed in this project, and those the entry reaches. */ + catalogUsage: CatalogUsage; format: RenderFormat; gifLoop?: number; gifFpsCapped: boolean; @@ -204,6 +207,10 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren ? args.skill : undefined; + // Resolved here, once, from the same entry the render will use: batch rows + // vary only their variables, so every row shares this composition tree. + const catalogUsage = summarizeCatalogUsage(project.dir, renderTarget); + const formatRaw = args.format ?? "mp4"; const format = parseRenderFormat(formatRaw); if (!format) { @@ -412,6 +419,7 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren quality, authoringSkill, invalidAuthoringSkill, + catalogUsage, format, gifLoop, gifFpsCapped, @@ -461,7 +469,6 @@ export function renderOutputDirectory(plan: RenderPlan): string { /** Resolve browser GPU mode from Docker, CLI, env, then the auto default. */ // Re-exported by render.ts to preserve its tested public seam. -// fallow-ignore-next-line unused-export export function resolveBrowserGpuForCli( useDocker: boolean, browserGpuArg: boolean | undefined, diff --git a/packages/cli/src/telemetry/events.test.ts b/packages/cli/src/telemetry/events.test.ts index 393e643d63..dcbd8de43b 100644 --- a/packages/cli/src/telemetry/events.test.ts +++ b/packages/cli/src/telemetry/events.test.ts @@ -190,6 +190,49 @@ describe("render telemetry events", () => { flush.mockClear(); }); + // The catalog join. Counts must be present at zero: the no-catalog cohort is + // what the with-catalog cohort is compared against, and an absent property is + // indistinguishable from an older CLI that never sent one. + it("reports zero catalog counts for a project with no registry items", () => { + trackRenderComplete({ + durationMs: 1000, + fps: 30, + quality: "draft", + docker: false, + gpu: false, + catalogUsage: { installed: [], usedBlocks: [] }, + }); + const props = trackEvent.mock.calls[0]?.[1] as Record; + expect(props.registry_item_count).toBe(0); + expect(props.registry_blocks_used_count).toBe(0); + expect(props.registry_items).toBeUndefined(); + }); + + it("names the installed items and the subset the render reached", () => { + trackRenderComplete({ + durationMs: 1000, + fps: 30, + quality: "draft", + docker: false, + gpu: false, + catalogUsage: { installed: ["bar-chart-race", "data-chart"], usedBlocks: ["data-chart"] }, + }); + const props = trackEvent.mock.calls[0]?.[1] as Record; + expect(props.registry_items).toBe("bar-chart-race,data-chart"); + expect(props.registry_item_count).toBe(2); + expect(props.registry_blocks_used).toBe("data-chart"); + expect(props.registry_blocks_used_count).toBe(1); + }); + + // A caller that built render options by hand makes no catalog claim, rather + // than claiming zero items. + it("omits the catalog props entirely when usage was never resolved", () => { + trackRenderComplete({ durationMs: 1, fps: 30, quality: "draft", docker: false, gpu: false }); + const props = trackEvent.mock.calls[0]?.[1] as Record; + expect(props.registry_item_count).toBeUndefined(); + expect(props.registry_blocks_used_count).toBeUndefined(); + }); + it("flushes immediately after render_complete and render_error (exit races the lazy flush)", () => { trackRenderComplete({ durationMs: 1000, fps: 30, quality: "draft", docker: false, gpu: false }); expect(flush).toHaveBeenCalledTimes(1); diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts index 02b01701f7..7e83dafe6f 100644 --- a/packages/cli/src/telemetry/events.ts +++ b/packages/cli/src/telemetry/events.ts @@ -1,6 +1,7 @@ import { redactTelemetryString, type OutputResolutionIssueKind } from "@hyperframes/core"; import type { SubTimelineWaitOutcome } from "@hyperframes/engine"; import { FEEDBACK_RATING_SCALE } from "../utils/feedbackRating.js"; +import type { CatalogUsage } from "../utils/catalogUsage.js"; import { flush, shouldTrack, trackEvent } from "./client.js"; import { readConfig } from "./config.js"; import { getPowerState } from "./system.js"; @@ -173,6 +174,28 @@ export function trackCommand(command: string, runId?: string): void { }); } +/** + * Catalog half of `render_complete`. + * + * Counts are emitted even when zero: the no-catalog cohort is exactly what the + * with-catalog cohort gets compared against, and a property that is simply + * absent is indistinguishable from an older CLI that never sent one. Names ride + * as a comma-joined string because event property values are scalars only (same + * shape as `recent_render_ids` on `cli_render_feedback`). + * + * Undefined usage means the caller built render options by hand rather than + * through the render plan, so it makes no catalog claim at all. + */ +function catalogEventProperties(usage: CatalogUsage | undefined): Record { + if (!usage) return {}; + return { + registry_item_count: usage.installed.length, + registry_blocks_used_count: usage.usedBlocks.length, + ...(usage.installed.length > 0 ? { registry_items: usage.installed.join(",") } : {}), + ...(usage.usedBlocks.length > 0 ? { registry_blocks_used: usage.usedBlocks.join(",") } : {}), + }; +} + export function trackRenderComplete( props: { durationMs: number; @@ -180,6 +203,13 @@ export function trackRenderComplete( quality: string; /** Authoring workflow skill that drove this render (e.g. "product-launch-video"). */ authoringSkill?: string; + /** + * Catalog items installed in this project, and those the rendered + * composition reaches. The pair is what joins `registry_item_added` to a + * finished video: an installed item missing from the used set was tried + * and dropped, which no add-time event can express. + */ + catalogUsage?: CatalogUsage; workers?: number; // Worker auto-sizing provenance (RenderPerfSummary.workerSizing). Answers // "why N workers?" fleet-wide, and validates the advisory per-worker heap @@ -292,6 +322,7 @@ export function trackRenderComplete( fps: props.fps, quality: props.quality, authoring_skill: props.authoringSkill, + ...catalogEventProperties(props.catalogUsage), workers: props.workers, workers_bound_by: props.workersBoundBy, workers_cpu_based: props.workersCpuBased, diff --git a/packages/cli/src/utils/catalogUsage.test.ts b/packages/cli/src/utils/catalogUsage.test.ts new file mode 100644 index 0000000000..dc45b98292 --- /dev/null +++ b/packages/cli/src/utils/catalogUsage.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { summarizeCatalogUsage, type CatalogUsage } from "./catalogUsage.js"; +import type { RegistryItemRecord } from "./projectConfig.js"; + +/** + * Materialize a throwaway project, summarize it, and clean up. Every case here + * needs the same fixture, so the shape lives once. + */ +function usageOf( + files: Record, + registryItems?: RegistryItemRecord[], + entry = "index.html", +): CatalogUsage { + const dir = mkdtempSync(join(tmpdir(), "hf-catalog-test-")); + try { + writeFileSync( + join(dir, "hyperframes.json"), + JSON.stringify({ + registry: "https://example.test", + ...(registryItems ? { registryItems } : {}), + }), + ); + for (const [rel, html] of Object.entries(files)) { + const path = join(dir, rel); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, html); + } + return summarizeCatalogUsage(dir, join(dir, entry)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +function mount(src: string): string { + return `
`; +} + +const EMPTY_DOC = ""; + +const BLOCK = (name: string): RegistryItemRecord => ({ + name, + type: "hyperframes:block", + target: `compositions/${name}.html`, +}); + +describe("summarizeCatalogUsage", () => { + it("reports nothing for a project that never added a catalog item", () => { + expect(usageOf({ "index.html": EMPTY_DOC })).toEqual({ installed: [], usedBlocks: [] }); + }); + + // The whole point of the manifest: an item that was installed and then not + // mounted is a rejection, and no add-time event can say so. + it("separates an installed block that the entry mounts from one it dropped", () => { + expect( + usageOf( + { + "index.html": mount("compositions/kept.html"), + "compositions/kept.html": EMPTY_DOC, + "compositions/dropped.html": EMPTY_DOC, + }, + [BLOCK("kept"), BLOCK("dropped")], + ), + ).toEqual({ installed: ["dropped", "kept"], usedBlocks: ["kept"] }); + }); + + it("follows nested mounts, so a block reached through another block counts", () => { + expect( + usageOf( + { + "index.html": mount("compositions/outer.html"), + "compositions/outer.html": mount("inner.html"), + "compositions/inner.html": EMPTY_DOC, + }, + [BLOCK("outer"), BLOCK("inner")], + ).usedBlocks, + ).toEqual(["inner", "outer"]); + }); + + // A cyclic project must not wedge a render that already produced a video. + it("terminates on a mount cycle", () => { + expect( + usageOf( + { + "index.html": mount("compositions/a.html"), + "compositions/a.html": mount("b.html"), + "compositions/b.html": mount("a.html"), + }, + [BLOCK("a"), BLOCK("b")], + ).usedBlocks, + ).toEqual(["a", "b"]); + }); + + // Components are pasted inline, so there is no src to match. Reporting one as + // "used" would be a guess; reporting it as installed is a fact. + it("counts a component as installed but never as used", () => { + expect( + usageOf({ "index.html": EMPTY_DOC }, [ + { + name: "film-grain", + type: "hyperframes:component", + target: "compositions/components/film-grain.html", + }, + ]), + ).toEqual({ installed: ["film-grain"], usedBlocks: [] }); + }); + + it("drops a manifest name that is not a safe slug rather than sending it", () => { + expect( + usageOf({ "index.html": EMPTY_DOC }, [ + { name: "/Users/someone/secret", type: "hyperframes:block", target: "compositions/x.html" }, + BLOCK("fine"), + ]).installed, + ).toEqual(["fine"]); + }); + + it("never matches a manifest target that escapes the project directory", () => { + expect( + usageOf({ "index.html": mount("compositions/kept.html") }, [ + { name: "escaping", type: "hyperframes:block", target: "../outside.html" }, + ]).usedBlocks, + ).toEqual([]); + }); + + it("survives an entry file that does not exist", () => { + expect(usageOf({}, [BLOCK("kept")], "missing.html")).toEqual({ + installed: ["kept"], + usedBlocks: [], + }); + }); + + it("ignores a remote mount rather than resolving it as a local path", () => { + expect( + usageOf({ "index.html": mount("https://example.test/compositions/kept.html") }, [ + BLOCK("kept"), + ]).usedBlocks, + ).toEqual([]); + }); +}); diff --git a/packages/cli/src/utils/catalogUsage.ts b/packages/cli/src/utils/catalogUsage.ts new file mode 100644 index 0000000000..30de781515 --- /dev/null +++ b/packages/cli/src/utils/catalogUsage.ts @@ -0,0 +1,139 @@ +/** + * Which catalog (registry) items a project installed, and which of them the + * composition being rendered actually reaches. + * + * `hyperframes add` is the only place that knows a file came from the registry + * — installed files are plain composition HTML and carry no provenance marker — + * so it records each item in `hyperframes.json`. Render reads that manifest + * back and walks the composition's `data-composition-src` tree, letting the + * render event report both halves: what the project pulled in, and what + * survived into the video. + * + * The delta is the part no add-time event can produce. `registry_item_added` + * says a block was installed; only this says it was then thrown away. + */ + +import { readFileSync } from "node:fs"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; +import { parseHTML } from "linkedom"; +import { type RegistryItemRecord, loadProjectConfig } from "./projectConfig.js"; + +/** Installed catalog items, and the subset the rendered composition reaches. */ +export interface CatalogUsage { + /** Every item name recorded by `hyperframes add`, sorted, deduped. */ + installed: string[]; + /** + * Installed `hyperframes:block` items whose file is reachable from the render + * entry. Components are excluded: they are pasted inline into the user's own + * markup rather than mounted by src, so a component leaves no trace to match. + */ + usedBlocks: string[]; +} + +const EMPTY: CatalogUsage = Object.freeze({ installed: [], usedBlocks: [] }); + +/** + * Cap on files visited while walking the sub-composition tree. A composition + * nests a handful of blocks; anything past this is a pathological or cyclic + * project, and telemetry must not turn into an unbounded filesystem crawl. + */ +const MAX_VISITED_FILES = 250; + +/** Cap on a single file fed to the parser, mirroring the composition census. */ +const MAX_HTML_BYTES = 20 * 1024 * 1024; + +/** + * Cap on names reported per render. Registry names are low-cardinality slugs, + * but a project with a hundred blocks should not push a hundred-name string + * into every event. + */ +const MAX_REPORTED_ITEMS = 40; + +/** + * Item names are slug-gated before they reach the anonymous event stream, the + * same guard `normalizeSkillSlug` applies to authoring skills: a custom or + * hand-edited registry must not be able to push paths, PII, or unbounded + * cardinality into telemetry. The two rules share a shape but not an owner — + * a registry name and a skill slug are free to diverge. + */ +const REGISTRY_ITEM_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/; + +/** Every `data-composition-src` value in one composition file. */ +function subCompositionSrcs(html: string): string[] { + const { document } = parseHTML(html); + const srcs: string[] = []; + for (const el of document.querySelectorAll("[data-composition-src]")) { + const src = el.getAttribute("data-composition-src")?.trim(); + // Remote mounts have no local file to match an installed item against. + if (src && !/^[a-z][a-z0-9+.-]*:/i.test(src)) srcs.push(src); + } + return srcs; +} + +/** + * Absolute paths of every composition file reachable from `entryPath` through + * `data-composition-src`, entry included. Unreadable or unparseable files are + * skipped rather than thrown: this feeds a telemetry property, and a render + * that produced a video must never fail on the way to reporting it. + */ +function reachableCompositions(entryPath: string): Set { + const seen = new Set(); + const queue = [resolve(entryPath)]; + while (queue.length > 0 && seen.size < MAX_VISITED_FILES) { + const current = queue.shift()!; + if (seen.has(current)) continue; + seen.add(current); + let html: string; + try { + html = readFileSync(current, "utf-8"); + } catch { + continue; + } + if (html.length > MAX_HTML_BYTES) continue; + try { + for (const src of subCompositionSrcs(html)) { + queue.push(resolve(dirname(current), src)); + } + } catch { + // Malformed markup: this file contributes no children, the walk goes on. + } + } + return seen; +} + +/** True when `target` (project-relative, per the manifest) is in `reachable`. */ +function isReached(projectDir: string, target: string, reachable: Set): boolean { + // A manifest target is written project-relative. Guard against an absolute + // or escaping one rather than resolving it against the wrong root. + if (isAbsolute(target)) return false; + const abs = resolve(projectDir, target); + if (relative(projectDir, abs).startsWith("..")) return false; + return reachable.has(abs); +} + +function reportable(names: string[]): string[] { + return [...new Set(names.filter((n) => REGISTRY_ITEM_NAME.test(n)))] + .sort() + .slice(0, MAX_REPORTED_ITEMS); +} + +/** + * Read the project's catalog manifest and resolve it against the composition + * being rendered. Returns empty sets for a project that never ran + * `hyperframes add`, which is the honest answer: no catalog items, not unknown. + */ +export function summarizeCatalogUsage(projectDir: string, entryPath: string): CatalogUsage { + const items: RegistryItemRecord[] = loadProjectConfig(projectDir).registryItems ?? []; + if (items.length === 0) return EMPTY; + + const installed = reportable(items.map((i) => i.name)); + if (installed.length === 0) return EMPTY; + + const reachable = reachableCompositions(entryPath); + const usedBlocks = reportable( + items + .filter((i) => i.type === "hyperframes:block" && isReached(projectDir, i.target, reachable)) + .map((i) => i.name), + ); + return { installed, usedBlocks }; +} diff --git a/packages/cli/src/utils/projectConfig.test.ts b/packages/cli/src/utils/projectConfig.test.ts index f783a95406..6e34767577 100644 --- a/packages/cli/src/utils/projectConfig.test.ts +++ b/packages/cli/src/utils/projectConfig.test.ts @@ -9,6 +9,7 @@ import { projectConfigPath, readProjectConfig, resolveAutoProxy, + recordProjectRegistryItems, seedProjectAuthoringSkill, writeProjectConfig, PROJECT_CONFIG_FILENAME, @@ -364,4 +365,86 @@ describe("projectConfig", () => { } }); }); + describe("recordProjectRegistryItems", () => { + const BLOCK = { + name: "data-chart", + type: "hyperframes:block", + target: "compositions/data-chart.html", + }; + + it("appends installed items to an existing config", () => { + const dir = tmp(); + try { + writeProjectConfig(dir); + recordProjectRegistryItems(dir, [BLOCK]); + expect(loadProjectConfig(dir).registryItems).toEqual([BLOCK]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("dedupes by name, so re-adding an item does not grow the manifest", () => { + const dir = tmp(); + try { + writeProjectConfig(dir); + recordProjectRegistryItems(dir, [BLOCK]); + recordProjectRegistryItems(dir, [BLOCK, { ...BLOCK, name: "bar-chart-race" }]); + expect(loadProjectConfig(dir).registryItems?.map((i) => i.name)).toEqual([ + "data-chart", + "bar-chart-race", + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + // The config is normally committed, so an install must not rewrite keys it + // does not own or reflow the file. + it("preserves unknown keys and the file's own indentation", () => { + const dir = tmp(); + try { + writeFileSync( + projectConfigPath(dir), + JSON.stringify({ registry: "https://example.com/r", customKey: 42 }, null, 4), + "utf-8", + ); + recordProjectRegistryItems(dir, [BLOCK]); + const text = readFileSync(projectConfigPath(dir), "utf-8"); + expect(text).toContain('"customKey": 42'); + expect(text).toContain('\n "registry"'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("does not create a config for a project that has none", () => { + const dir = tmp(); + try { + recordProjectRegistryItems(dir, [BLOCK]); + expect(readProjectConfig(dir)).toBeUndefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("leaves a corrupt config untouched", () => { + const dir = tmp(); + try { + writeFileSync(projectConfigPath(dir), "{ not valid json", "utf-8"); + recordProjectRegistryItems(dir, [BLOCK]); + expect(readFileSync(projectConfigPath(dir), "utf-8")).toBe("{ not valid json"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + // normalizeConfig rebuilds from a whitelist; an omission there would read + // as "this project never installed a catalog item". + it("survives a normalizeConfig round-trip", () => { + expect(normalizeConfig({ registryItems: [BLOCK] }).registryItems).toEqual([BLOCK]); + expect( + normalizeConfig({ registryItems: [{ name: "x" }] as never }).registryItems, + ).toBeUndefined(); + }); + }); }); diff --git a/packages/cli/src/utils/projectConfig.ts b/packages/cli/src/utils/projectConfig.ts index c3afc77096..59f4e5f3ed 100644 --- a/packages/cli/src/utils/projectConfig.ts +++ b/packages/cli/src/utils/projectConfig.ts @@ -33,6 +33,23 @@ export interface ProjectConfigMedia { autoProxy?: boolean; } +/** + * One catalog item installed into this project by `hyperframes add`. + * + * Installed files are plain composition HTML with no provenance marker, so + * this manifest is the only record that a file came from the registry. It is + * what lets a render report which catalog items a finished video actually + * used, instead of only which ones were once downloaded. + */ +export interface RegistryItemRecord { + /** Registry item name, e.g. "data-chart". */ + name: string; + /** Registry item type, e.g. "hyperframes:block". */ + type: string; + /** Primary installed file, relative to the project root. */ + target: string; +} + export interface ProjectConfig { $schema?: string; /** Base URL of the registry to pull items from. */ @@ -49,6 +66,13 @@ export interface ProjectConfig { * telemetry without the caller re-passing the flag. */ authoringSkill?: string; + /** + * Catalog items installed by `hyperframes add`, in install order. Append-only + * and deduped by name; removing an item from the project does not prune it, + * so a later render still reports it as installed-but-unused rather than + * silently forgetting it was ever tried. + */ + registryItems?: RegistryItemRecord[]; } export const DEFAULT_PROJECT_CONFIG: ProjectConfig = { @@ -104,9 +128,31 @@ export function normalizeConfig(partial: Partial): ProjectConfig // Slug-gate on read so a hand-edited or corrupt value never reaches the // telemetry stream; an invalid slug simply drops the attribution. authoringSkill: normalizeSkillSlug(partial.authoringSkill), + // Whitelist rebuild - an omission here silently drops the manifest on + // every config round-trip, which would read as "this project never + // installed a catalog item". + registryItems: normalizeRegistryItems(partial.registryItems), }; } +/** + * Keep only well-formed records. A hand-edited or partially-written manifest + * degrades to the entries that still parse rather than failing a command that + * merely wanted to read the registry URL. + */ +function normalizeRegistryItems(raw: unknown): RegistryItemRecord[] | undefined { + if (!Array.isArray(raw)) return undefined; + const items = raw.filter( + (entry): entry is RegistryItemRecord => + isJsonObject(entry) && + typeof entry.name === "string" && + entry.name !== "" && + typeof entry.type === "string" && + typeof entry.target === "string", + ); + return items.length > 0 ? items : undefined; +} + /** Write `hyperframes.json` to a project directory. Overwrites if present. */ export function writeProjectConfig( projectDir: string, @@ -210,3 +256,50 @@ export function seedProjectAuthoringSkill(projectDir: string, rawSkill: unknown) // never a render blocker. } } + +/** + * Append installed catalog items to `hyperframes.json` so a later render can + * report which of them the finished video actually used. + * + * Same in-place patch discipline as {@link seedProjectAuthoringSkill}, and for + * the same reason: this writes an ALREADY EXISTING, normally committed config, + * so it must not round-trip through {@link normalizeConfig} (a whitelist + * rebuild would drop unknown keys and materialize defaults the user never + * wrote). Existing entries are kept and deduped by name, so re-adding an item + * does not grow the file and a manually pruned entry is not resurrected twice. + * + * Best effort throughout: a read-only project, a missing config, or corrupt + * JSON must never fail the `add` it rode in on. + */ +export function recordProjectRegistryItems( + projectDir: string, + items: readonly RegistryItemRecord[], +): void { + if (items.length === 0) return; + const path = projectConfigPath(projectDir); + + let text: string; + try { + text = readFileSync(path, "utf-8"); + } catch { + // No config to patch. `add` outside an initialized project is a valid + // flow; it simply leaves no manifest behind. + return; + } + + try { + const parsed: unknown = JSON.parse(text); + if (!isJsonObject(parsed)) return; + const existing = normalizeRegistryItems(parsed.registryItems) ?? []; + const byName = new Map(existing.map((item) => [item.name, item])); + for (const item of items) byName.set(item.name, item); + if (byName.size === existing.length && existing.every((item) => byName.get(item.name) === item)) + return; + parsed.registryItems = [...byName.values()]; + const indent = /\n([ \t]+)"/.exec(text)?.[1] ?? " "; + writeFileSync(path, JSON.stringify(parsed, null, indent) + "\n", "utf-8"); + } catch { + // Corrupt JSON or a read-only file: the manifest is telemetry provenance, + // never an install blocker. + } +} From 12b49f157b8adcab8de684b41edc11318dbe739c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 24 Aug 2026 18:19:37 -0400 Subject: [PATCH 2/5] fix(cli): resolve catalog reachability the way the renderer does Review found the reachability walk answered the wrong question, in two independent ways, for the exact case the feature exists to measure. A DOM scan of a raw composition file cannot see `