refactor(core): own edit protocol contract

This commit is contained in:
James
2026-07-18 13:24:57 -04:00
parent 177681aff1
commit 562544f68d
11 changed files with 213 additions and 17 deletions
+3 -2
View File
@@ -24,13 +24,14 @@
"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 && bun run check:gcp-cloud-run-dockerfile && oxlint . && tsx scripts/lint-skills.ts && node scripts/check-skill-mirror.mjs",
"lint": "bun run check:tracked-artifacts && bun run check:workspace-contracts && bun run check:gcp-cloud-run-dockerfile && bun run check:package-cycles && oxlint . && tsx scripts/lint-skills.ts && node scripts/check-skill-mirror.mjs",
"check:gcp-cloud-run-dockerfile": "bun run --cwd packages/gcp-cloud-run test:dockerfile-workspaces",
"lint:skills": "tsx scripts/lint-skills.ts",
"check:skill-mirror": "node scripts/check-skill-mirror.mjs",
"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",
@@ -43,7 +44,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 scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.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 scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.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",
+10
View File
@@ -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",
@@ -290,6 +296,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"
+12
View File
@@ -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";
@@ -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;
+1 -1
View File
@@ -42,7 +42,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,
@@ -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";
@@ -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";
@@ -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 ─────────────────────────────────── */
-2
View File
@@ -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
+131
View File
@@ -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();
+49
View File
@@ -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,
);
});
});