From 3ef06c25b443859e89c4d88a4a7acedd068bef4d Mon Sep 17 00:00:00 2001 From: James Date: Fri, 10 Jul 2026 13:31:02 -0700 Subject: [PATCH] refactor(core): own edit protocol contract --- package.json | 5 +- packages/core/package.json | 10 ++ packages/core/src/editing/draftMarkers.ts | 12 ++ packages/core/src/runtime/captionOverrides.ts | 2 +- packages/core/src/runtime/init.ts | 2 +- .../src/studio-api/helpers/draftMarkers.ts | 4 +- .../studio-server/src/helpers/draftMarkers.ts | 11 +- .../src/components/editor/manualEditsTypes.ts | 2 +- packages/studio/src/player/index.ts | 2 - scripts/check-package-cycles.mjs | 131 ++++++++++++++++++ scripts/check-package-cycles.test.mjs | 49 +++++++ 11 files changed, 213 insertions(+), 17 deletions(-) create mode 100644 packages/core/src/editing/draftMarkers.ts create mode 100644 scripts/check-package-cycles.mjs create mode 100644 scripts/check-package-cycles.test.mjs diff --git a/package.json b/package.json index 83eeed4c20..87b144b2e2 100644 --- a/package.json +++ b/package.json @@ -24,11 +24,12 @@ "changelog:weekly": "tsx scripts/changelog-weekly.ts", "sync-schemas": "tsx scripts/sync-schemas.ts", "sync-schemas:check": "tsx scripts/sync-schemas.ts --check", - "lint": "bun run check:tracked-artifacts && bun run check:workspace-contracts && oxlint . && tsx scripts/lint-skills.ts", + "lint": "bun run check:tracked-artifacts && bun run check:workspace-contracts && bun run check:package-cycles && oxlint . && tsx scripts/lint-skills.ts", "lint:skills": "tsx scripts/lint-skills.ts", "lint:fix": "oxlint --fix .", "check:tracked-artifacts": "node scripts/check-tracked-artifacts.mjs", "check:workspace-contracts": "node scripts/check-workspace-contracts.mjs", + "check:package-cycles": "node scripts/check-package-cycles.mjs", "format": "oxfmt .", "test": "bun run test:unit", "test:unit": "bun run --filter '*' test", @@ -41,7 +42,7 @@ "player:perf": "bun run --filter @hyperframes/player perf", "format:check": "oxfmt --check .", "knip": "knip", - "test:scripts": "node --import tsx --test scripts/check-tracked-artifacts.test.mjs scripts/check-workspace-contracts.test.mjs scripts/validate-release-channel.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs", + "test:scripts": "node --import tsx --test scripts/check-tracked-artifacts.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/validate-release-channel.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs", "test:skills": "node --test 'skills/**/*.test.mjs'", "generate:previews": "tsx scripts/generate-template-previews.ts", "generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts", diff --git a/packages/core/package.json b/packages/core/package.json index c5cda44752..a0cbbd8985 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -52,6 +52,12 @@ "import": "./src/editing/affordances.ts", "types": "./src/editing/affordances.ts" }, + "./editing/draft-markers": { + "bun": "./src/editing/draftMarkers.ts", + "node": "./dist/editing/draftMarkers.js", + "import": "./src/editing/draftMarkers.ts", + "types": "./src/editing/draftMarkers.ts" + }, "./variables": { "bun": "./src/variables.ts", "node": "./dist/variables.js", @@ -278,6 +284,10 @@ "import": "./dist/editing/affordances.js", "types": "./dist/editing/affordances.d.ts" }, + "./editing/draft-markers": { + "import": "./dist/editing/draftMarkers.js", + "types": "./dist/editing/draftMarkers.d.ts" + }, "./figma": { "import": "./dist/figma/index.js", "types": "./dist/figma/index.d.ts" diff --git a/packages/core/src/editing/draftMarkers.ts b/packages/core/src/editing/draftMarkers.ts new file mode 100644 index 0000000000..a0bbe7c647 --- /dev/null +++ b/packages/core/src/editing/draftMarkers.ts @@ -0,0 +1,12 @@ +/** + * Browser-safe wire constants shared by the runtime, Studio, and studio-server. + * + * Keep this module dependency-free. These names are part of the edit protocol, + * not server implementation details, so browser code must be able to import + * them without pulling the studio-server package into its graph. + */ +export const STUDIO_OFFSET_X_PROP = "--hf-studio-offset-x"; +export const STUDIO_OFFSET_Y_PROP = "--hf-studio-offset-y"; +export const STUDIO_WIDTH_PROP = "--hf-studio-width"; +export const STUDIO_HEIGHT_PROP = "--hf-studio-height"; +export const STUDIO_MANUAL_EDIT_GESTURE_ATTR = "data-hf-studio-manual-edit-gesture"; diff --git a/packages/core/src/runtime/captionOverrides.ts b/packages/core/src/runtime/captionOverrides.ts index d90c049227..e89e557a81 100644 --- a/packages/core/src/runtime/captionOverrides.ts +++ b/packages/core/src/runtime/captionOverrides.ts @@ -10,7 +10,7 @@ * 2. `wordIndex` — fallback, DOM traversal order across .caption-group > span */ -export interface CaptionOverride { +interface CaptionOverride { wordId?: string; wordIndex?: number; x?: number; diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 9ae848443a..0f09eb23d4 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -35,7 +35,7 @@ import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorG import { TransportClock } from "./clock"; import { WebAudioTransport } from "./webAudioTransport"; import { quantizeTimeToFrame } from "../inline-scripts/parityContract"; -import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "../studio-api/helpers/draftMarkers"; +import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "../editing/draftMarkers"; import type { RuntimeDeterministicAdapter, RuntimeJson, diff --git a/packages/core/src/studio-api/helpers/draftMarkers.ts b/packages/core/src/studio-api/helpers/draftMarkers.ts index 4484439394..1be9ef9e05 100644 --- a/packages/core/src/studio-api/helpers/draftMarkers.ts +++ b/packages/core/src/studio-api/helpers/draftMarkers.ts @@ -1,2 +1,2 @@ -/** @deprecated Import from @hyperframes/studio-server/draft-markers */ -export * from "@hyperframes/studio-server/draft-markers"; +/** @deprecated Import from @hyperframes/core/editing/draft-markers. */ +export * from "../../editing/draftMarkers.js"; diff --git a/packages/studio-server/src/helpers/draftMarkers.ts b/packages/studio-server/src/helpers/draftMarkers.ts index 38bc61861f..65f03553b0 100644 --- a/packages/studio-server/src/helpers/draftMarkers.ts +++ b/packages/studio-server/src/helpers/draftMarkers.ts @@ -1,10 +1,5 @@ /** - * Draft-marker constants shared between core's PreviewAdapter and Studio's - * manual-edits code. CSS custom properties written during a drag gesture, plus - * the gesture marker attribute. Exported from @hyperframes/core/studio-api/draft-markers. + * Compatibility export for server consumers. The edit-protocol constants are + * owned by core's dependency-free browser contract. */ -export const STUDIO_OFFSET_X_PROP = "--hf-studio-offset-x"; -export const STUDIO_OFFSET_Y_PROP = "--hf-studio-offset-y"; -export const STUDIO_WIDTH_PROP = "--hf-studio-width"; -export const STUDIO_HEIGHT_PROP = "--hf-studio-height"; -export const STUDIO_MANUAL_EDIT_GESTURE_ATTR = "data-hf-studio-manual-edit-gesture"; +export * from "@hyperframes/core/editing/draft-markers"; diff --git a/packages/studio/src/components/editor/manualEditsTypes.ts b/packages/studio/src/components/editor/manualEditsTypes.ts index 40c5b673b3..074a8762d8 100644 --- a/packages/studio/src/components/editor/manualEditsTypes.ts +++ b/packages/studio/src/components/editor/manualEditsTypes.ts @@ -5,7 +5,7 @@ export { STUDIO_WIDTH_PROP, STUDIO_HEIGHT_PROP, STUDIO_MANUAL_EDIT_GESTURE_ATTR, -} from "@hyperframes/studio-server/draft-markers"; +} from "@hyperframes/core/editing/draft-markers"; export const STUDIO_ROTATION_PROP = "--hf-studio-rotation"; /* ── Internal DOM attribute names ─────────────────────────────────── */ diff --git a/packages/studio/src/player/index.ts b/packages/studio/src/player/index.ts index db2bc1f423..89417ac576 100644 --- a/packages/studio/src/player/index.ts +++ b/packages/studio/src/player/index.ts @@ -11,8 +11,6 @@ export { resolveIframe } from "./lib/timelineDOM"; // Store export { usePlayerStore, liveTime } from "./store/playerStore"; -// Public library surface; external consumers are invisible to the workspace analyzer. -// fallow-ignore-next-line unused-exports export type { SelectElementOptions, TimelineElement, ZoomMode } from "./store/playerStore"; // Utils diff --git a/scripts/check-package-cycles.mjs b/scripts/check-package-cycles.mjs new file mode 100644 index 0000000000..81ded5c64f --- /dev/null +++ b/scripts/check-package-cycles.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = join(import.meta.dirname, ".."); +const RUNTIME_DEPENDENCY_FIELDS = ["dependencies", "optionalDependencies", "peerDependencies"]; + +/** + * The deprecated @hyperframes/core/studio-api forwarding surface is public and + * cannot disappear before a breaking release. Keep its one known package SCC + * explicit so every new cycle still fails CI. Delete this exception together + * with the forwarding surface in the next breaking release. + */ +export const ALLOWED_COMPATIBILITY_CYCLES = [ + { + packages: ["@hyperframes/core", "@hyperframes/studio-server"], + reason: "Deprecated core/studio-api forwarding exports; remove at the next breaking release.", + }, +]; + +function canonicalComponent(names) { + return [...names].sort().join(" -> "); +} + +export function listRuntimePackageCycles(packages) { + const byName = new Map(packages.map((pkg) => [pkg.name, pkg])); + const edges = new Map( + // Collecting three dependency classes in one expression keeps the graph + // construction declarative; focused tests cover runtime versus dev edges. + // fallow-ignore-next-line complexity + packages.map((pkg) => { + const targets = new Set(); + for (const field of RUNTIME_DEPENDENCY_FIELDS) { + for (const dependency of Object.keys(pkg[field] ?? {})) { + if (byName.has(dependency)) targets.add(dependency); + } + } + return [pkg.name, [...targets].sort()]; + }), + ); + + let nextIndex = 0; + const indexes = new Map(); + const lowLinks = new Map(); + const stack = []; + const onStack = new Set(); + const components = []; + + // Tarjan's strongly-connected-component walk is intentionally branchy; the + // state transitions mirror the algorithm and are covered by focused tests. + // fallow-ignore-next-line complexity + function visit(name) { + indexes.set(name, nextIndex); + lowLinks.set(name, nextIndex); + nextIndex += 1; + stack.push(name); + onStack.add(name); + + for (const target of edges.get(name) ?? []) { + if (!indexes.has(target)) { + visit(target); + lowLinks.set(name, Math.min(lowLinks.get(name), lowLinks.get(target))); + } else if (onStack.has(target)) { + lowLinks.set(name, Math.min(lowLinks.get(name), indexes.get(target))); + } + } + + if (lowLinks.get(name) !== indexes.get(name)) return; + const component = []; + let member; + do { + member = stack.pop(); + onStack.delete(member); + component.push(member); + } while (member !== name); + + const selfCycle = component.length === 1 && (edges.get(name) ?? []).includes(name); + if (component.length > 1 || selfCycle) components.push(component.sort()); + } + + for (const name of [...byName.keys()].sort()) { + if (!indexes.has(name)) visit(name); + } + return components.sort((a, b) => canonicalComponent(a).localeCompare(canonicalComponent(b))); +} + +export function listPackageCycleIssues(packages, allowed = ALLOWED_COMPATIBILITY_CYCLES) { + const allowedKeys = new Set(allowed.map((entry) => canonicalComponent(entry.packages))); + return listRuntimePackageCycles(packages) + .map(canonicalComponent) + .filter((component) => !allowedKeys.has(component)) + .map((component) => `runtime workspace dependency cycle: ${component}`); +} + +export function readWorkspacePackages(root = ROOT) { + return readdirSync(join(root, "packages")) + .sort() + .map((directory) => join(root, "packages", directory, "package.json")) + .filter(existsSync) + .map((path) => JSON.parse(readFileSync(path, "utf8"))); +} + +function main() { + const packages = readWorkspacePackages(); + const cycles = listRuntimePackageCycles(packages); + const issues = listPackageCycleIssues(packages); + if (issues.length > 0) { + console.error("Package cycle violations:"); + issues.forEach((issue) => console.error(`- ${issue}`)); + process.exitCode = 1; + return; + } + + const allowedKeys = new Set( + ALLOWED_COMPATIBILITY_CYCLES.map((entry) => canonicalComponent(entry.packages)), + ); + const activeCompatibilityCycles = cycles.filter((cycle) => + allowedKeys.has(canonicalComponent(cycle)), + ); + if (activeCompatibilityCycles.length > 0) { + console.log( + `Package graph verified; ${activeCompatibilityCycles.length} explicit compatibility cycle remains until the next breaking release.`, + ); + return; + } + console.log("Package graph verified: runtime workspace dependencies are acyclic."); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) main(); diff --git a/scripts/check-package-cycles.test.mjs b/scripts/check-package-cycles.test.mjs new file mode 100644 index 0000000000..2451f26fa4 --- /dev/null +++ b/scripts/check-package-cycles.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { listPackageCycleIssues, listRuntimePackageCycles } from "./check-package-cycles.mjs"; + +describe("package cycle checker", () => { + it("accepts an acyclic runtime graph and ignores dev-only edges", () => { + const packages = [ + { name: "a", dependencies: { b: "workspace:*" } }, + { name: "b", devDependencies: { a: "workspace:*" } }, + ]; + assert.deepEqual(listRuntimePackageCycles(packages), []); + }); + + it("reports a new strongly connected runtime component", () => { + const packages = [ + { name: "a", dependencies: { b: "workspace:*" } }, + { name: "b", peerDependencies: { c: "workspace:*" } }, + { name: "c", optionalDependencies: { a: "workspace:*" } }, + ]; + assert.deepEqual(listPackageCycleIssues(packages, []), [ + "runtime workspace dependency cycle: a -> b -> c", + ]); + }); + + it("permits only an exact, documented compatibility component", () => { + const allowed = [{ packages: ["a", "b"], reason: "legacy compatibility surface" }]; + assert.deepEqual( + listPackageCycleIssues( + [ + { name: "a", dependencies: { b: "workspace:*" } }, + { name: "b", dependencies: { a: "workspace:*" } }, + ], + allowed, + ), + [], + ); + assert.equal( + listPackageCycleIssues( + [ + { name: "a", dependencies: { b: "workspace:*" } }, + { name: "b", dependencies: { a: "workspace:*", c: "workspace:*" } }, + { name: "c", dependencies: { b: "workspace:*" } }, + ], + allowed, + ).length, + 1, + ); + }); +});