mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 23:29:50 +00:00
Merge pull request #2599 from heygen-com/via/composition-structure-mandate
feat(skills): add COMPOSITION_STRUCTURE to feedback repro packet + soft-warn lint
This commit is contained in:
@@ -35,6 +35,20 @@ describe("hyperframes-core contract docs", () => {
|
||||
expect(renderReference).toContain("OUTCOME:");
|
||||
expect(renderReference).toContain("WORKAROUND:");
|
||||
});
|
||||
|
||||
it("mandates a composition-structure block for visual-defect feedback", () => {
|
||||
const skill = read("skills", "hyperframes-cli", "SKILL.md");
|
||||
const renderReference = read("skills", "hyperframes-cli", "references", "preview-render.md");
|
||||
|
||||
// Skill teaches the mandate at a high level.
|
||||
expect(skill).toContain("COMPOSITION_STRUCTURE:");
|
||||
// Reference carries the fillable block + agent-helper pointer.
|
||||
expect(renderReference).toContain("COMPOSITION_STRUCTURE:");
|
||||
expect(renderReference).toContain("elements: video=");
|
||||
expect(renderReference).toContain("attributes:");
|
||||
expect(renderReference).toContain("timeline:");
|
||||
expect(renderReference).toContain("buildCompositionCensus");
|
||||
});
|
||||
});
|
||||
|
||||
describe("media-use TTS documentation", () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { buildIssueUrl, HYPERFRAMES_REPO_URL } from "../utils/feedbackIssue.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { parseFeedbackRating } from "../utils/feedbackRating.js";
|
||||
import { lintFeedbackComment, type FeedbackLintInput } from "../utils/feedbackLint.js";
|
||||
|
||||
export const examples: Example[] = [
|
||||
["Submit render feedback", 'hyperframes feedback --rating 8 --comment "fast but font missing"'],
|
||||
@@ -78,6 +79,18 @@ async function publishRepro(dir: string): Promise<string | undefined> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Print soft-warn feedback-lint messages to stdout. Extracted so the
|
||||
* command's `run` stays a flat control-flow driver — the warning loop is
|
||||
* incidental to the command logic and its complexity would otherwise push
|
||||
* `run` over the Fallow CRAP threshold.
|
||||
*/
|
||||
function printFeedbackLintWarnings(input: FeedbackLintInput): void {
|
||||
for (const warning of lintFeedbackComment(input)) {
|
||||
console.log(c.warn(`⚠ ${warning.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
async function openAndPrintIssue(url: string): Promise<void> {
|
||||
if (process.stdout.isTTY) {
|
||||
try {
|
||||
@@ -156,6 +169,11 @@ export default defineCommand({
|
||||
const comment = normalizeComment(args.comment);
|
||||
const doctorSummary = await getDoctorSummary();
|
||||
|
||||
// Soft-warn (never blocks) when the comment for a non-clean report is
|
||||
// missing the mandated reproduction-packet markers. Prints before the
|
||||
// submission ack so the reporter sees the nudge while their run is fresh.
|
||||
printFeedbackLintWarnings({ rating, comment });
|
||||
|
||||
// The standalone command runs separately from `render`, so it has no real
|
||||
// elapsed time to report. Omit it rather than recording a fake duration.
|
||||
trackRenderFeedback({ rating, comment, doctorSummary });
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildCompositionCensus, renderCompositionCensusBlock } from "./compositionCensus.js";
|
||||
|
||||
const MINIMAL_HTML = `<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<div data-composition-id="main" data-start="0" data-duration="5"></div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const RICH_HTML = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
.card { filter: blur(4px); mix-blend-mode: multiply; }
|
||||
.fixed-bar { position: fixed; overflow: hidden; }
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div data-composition-id="main" data-start="0" data-duration="10">
|
||||
<video data-has-audio="true"></video>
|
||||
<video></video>
|
||||
<audio></audio>
|
||||
<img />
|
||||
<img />
|
||||
<img />
|
||||
<svg></svg>
|
||||
<canvas></canvas>
|
||||
<div data-composition-src="scenes/intro.html" data-start="0"></div>
|
||||
<div data-composition-src="scenes/outro.html" data-start="5"></div>
|
||||
<div style="clip-path: circle(50%); transform: translateX(10px); z-index: 3"></div>
|
||||
<div style="background-image: url('bg.png')"></div>
|
||||
<div style="mask-image: url('mask.svg')"></div>
|
||||
</div>
|
||||
<script>
|
||||
gsap.timeline().to(".card", { x: 100 });
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
describe("buildCompositionCensus", () => {
|
||||
it("counts zero media on a minimal composition", () => {
|
||||
const c = buildCompositionCensus(MINIMAL_HTML);
|
||||
expect(c.elementCensus).toEqual({
|
||||
video: 0,
|
||||
audio: 0,
|
||||
img: 0,
|
||||
svg: 0,
|
||||
canvas: 0,
|
||||
subCompositionMounts: 0,
|
||||
});
|
||||
expect(c.timelineShape.nested).toBe(false);
|
||||
expect(c.timelineShape.subCompositionCount).toBe(0);
|
||||
expect(c.timelineShape.usesGsap).toBe(false);
|
||||
expect(c.timelineShape.usesDataTimeline).toBe(true);
|
||||
});
|
||||
|
||||
it("counts each element category on a rich composition", () => {
|
||||
const c = buildCompositionCensus(RICH_HTML);
|
||||
expect(c.elementCensus).toEqual({
|
||||
video: 2,
|
||||
audio: 1,
|
||||
img: 3,
|
||||
svg: 1,
|
||||
canvas: 1,
|
||||
subCompositionMounts: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("detects structural attributes from both inline style and <style> rules", () => {
|
||||
const c = buildCompositionCensus(RICH_HTML);
|
||||
// Inline-style probes
|
||||
expect(c.structuralAttributes.clipPath).toBe(true);
|
||||
expect(c.structuralAttributes.transform).toBe(true);
|
||||
expect(c.structuralAttributes.zIndex).toBe(true);
|
||||
expect(c.structuralAttributes.backgroundImage).toBe(true);
|
||||
expect(c.structuralAttributes.maskImage).toBe(true);
|
||||
// <style> tag probes
|
||||
expect(c.structuralAttributes.filter).toBe(true);
|
||||
expect(c.structuralAttributes.mixBlendMode).toBe(true);
|
||||
expect(c.structuralAttributes.positionFixed).toBe(true);
|
||||
expect(c.structuralAttributes.overflowHidden).toBe(true);
|
||||
// data-* attribute probes
|
||||
expect(c.structuralAttributes.dataHasAudio).toBe(true);
|
||||
expect(c.structuralAttributes.dataDuration).toBe(true);
|
||||
expect(c.structuralAttributes.dataStart).toBe(true);
|
||||
expect(c.structuralAttributes.dataCompositionSrc).toBe(true);
|
||||
});
|
||||
|
||||
it("reports absent attributes as false on minimal HTML", () => {
|
||||
const c = buildCompositionCensus(MINIMAL_HTML);
|
||||
expect(c.structuralAttributes.clipPath).toBe(false);
|
||||
expect(c.structuralAttributes.filter).toBe(false);
|
||||
expect(c.structuralAttributes.mixBlendMode).toBe(false);
|
||||
expect(c.structuralAttributes.dataHasAudio).toBe(false);
|
||||
expect(c.structuralAttributes.backgroundImage).toBe(false);
|
||||
expect(c.structuralAttributes.maskImage).toBe(false);
|
||||
});
|
||||
|
||||
it("detects gsap from both script src and inline gsap.* calls", () => {
|
||||
expect(buildCompositionCensus(RICH_HTML).timelineShape.usesGsap).toBe(true);
|
||||
const inlineOnly = `<html><body><div data-composition-id="m"></div><script>gsap.to('.x', {})</script></body></html>`;
|
||||
expect(buildCompositionCensus(inlineOnly).timelineShape.usesGsap).toBe(true);
|
||||
const noGsap = `<html><body><div data-composition-id="m"></div><script>console.log('hi')</script></body></html>`;
|
||||
expect(buildCompositionCensus(noGsap).timelineShape.usesGsap).toBe(false);
|
||||
});
|
||||
|
||||
it("marks timelines as nested when sub-comp mounts exist", () => {
|
||||
const c = buildCompositionCensus(RICH_HTML);
|
||||
expect(c.timelineShape.nested).toBe(true);
|
||||
expect(c.timelineShape.subCompositionCount).toBe(2);
|
||||
});
|
||||
|
||||
describe("value-scoped structural probes", () => {
|
||||
it("positionFixed is false for inline position:absolute (name-vs-value scope)", () => {
|
||||
const html = `<html><body><div data-composition-id="m" style="position: absolute"></div></body></html>`;
|
||||
const c = buildCompositionCensus(html);
|
||||
expect(c.structuralAttributes.positionFixed).toBe(false);
|
||||
});
|
||||
|
||||
it("positionFixed is true for inline position:fixed", () => {
|
||||
const html = `<html><body><div data-composition-id="m" style="position: fixed"></div></body></html>`;
|
||||
const c = buildCompositionCensus(html);
|
||||
expect(c.structuralAttributes.positionFixed).toBe(true);
|
||||
});
|
||||
|
||||
it("overflowHidden catches inline overflow:hidden (symmetric with style-tag path)", () => {
|
||||
const html = `<html><body><div data-composition-id="m" style="overflow: hidden"></div></body></html>`;
|
||||
const c = buildCompositionCensus(html);
|
||||
expect(c.structuralAttributes.overflowHidden).toBe(true);
|
||||
});
|
||||
|
||||
it("overflowHidden is false for inline overflow:visible", () => {
|
||||
const html = `<html><body><div data-composition-id="m" style="overflow: visible"></div></body></html>`;
|
||||
const c = buildCompositionCensus(html);
|
||||
expect(c.structuralAttributes.overflowHidden).toBe(false);
|
||||
});
|
||||
|
||||
it("backgroundImage catches the inline shorthand `background: url(...)`", () => {
|
||||
const html = `<html><body><div data-composition-id="m" style="background: url('bg.png') center"></div></body></html>`;
|
||||
const c = buildCompositionCensus(html);
|
||||
expect(c.structuralAttributes.backgroundImage).toBe(true);
|
||||
});
|
||||
|
||||
it("maskImage catches the inline shorthand `mask: url(...)`", () => {
|
||||
const html = `<html><body><div data-composition-id="m" style="mask: url('mask.svg')"></div></body></html>`;
|
||||
const c = buildCompositionCensus(html);
|
||||
expect(c.structuralAttributes.maskImage).toBe(true);
|
||||
});
|
||||
|
||||
it("does not count `inherit` / `revert` as authored intent", () => {
|
||||
const html = `<html><body><div data-composition-id="m" style="position: inherit; filter: revert"></div></body></html>`;
|
||||
const c = buildCompositionCensus(html);
|
||||
expect(c.structuralAttributes.positionFixed).toBe(false);
|
||||
expect(c.structuralAttributes.filter).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("short-circuits on HTML over the size cap without throwing", () => {
|
||||
const huge = "a".repeat(21 * 1024 * 1024);
|
||||
const c = buildCompositionCensus(huge);
|
||||
// Guard-rail returns a zero census — no OOM, no crash.
|
||||
expect(c.elementCensus.video).toBe(0);
|
||||
expect(c.timelineShape.subCompositionCount).toBe(0);
|
||||
expect(c.structuralAttributes.positionFixed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderCompositionCensusBlock", () => {
|
||||
it("emits a REPRO-packet-compatible block starting with the mandated header", () => {
|
||||
const block = renderCompositionCensusBlock(buildCompositionCensus(RICH_HTML));
|
||||
expect(block.startsWith("COMPOSITION_STRUCTURE:")).toBe(true);
|
||||
expect(block).toContain("elements: video=2 audio=1 img=3 svg=1 canvas=1 subComps=2");
|
||||
expect(block).toContain("attributes:");
|
||||
expect(block).toContain("timeline: nested (2 sub-comps); driver=gsap+data-timeline");
|
||||
// Placeholder slots the parser can't infer.
|
||||
expect(block).toContain("delta:");
|
||||
expect(block).toContain("defect:");
|
||||
});
|
||||
|
||||
it("emits '(none present)' on the attributes line when no structural attrs are found", () => {
|
||||
const empty = `<html><body><div data-composition-id="m"></div></body></html>`;
|
||||
const block = renderCompositionCensusBlock(buildCompositionCensus(empty));
|
||||
expect(block).toContain("attributes: (none present)");
|
||||
expect(block).toContain("timeline: flat; driver=none");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
import { parseHTML } from "linkedom";
|
||||
|
||||
/**
|
||||
* A privacy-preserving structural anatomy of a HyperFrames composition. The
|
||||
* agent fills this before submitting a non-clean `hyperframes feedback` so
|
||||
* maintainers can pattern-match the report against known bug families without
|
||||
* receiving the composition ZIP.
|
||||
*
|
||||
* Counts and presence flags only — no file paths, no src URLs, no user text.
|
||||
*/
|
||||
export interface CompositionCensus {
|
||||
/** Counts of media/graphic elements at the failing HTML pass. */
|
||||
elementCensus: {
|
||||
video: number;
|
||||
audio: number;
|
||||
img: number;
|
||||
svg: number;
|
||||
canvas: number;
|
||||
/** Sub-composition mount points (elements with `data-composition-src`). */
|
||||
subCompositionMounts: number;
|
||||
};
|
||||
/**
|
||||
* Union set of structural attributes present anywhere in the composition.
|
||||
* Each key is HF's capture-routing signal set (see references/preview-render.md).
|
||||
*/
|
||||
structuralAttributes: {
|
||||
clipPath: boolean;
|
||||
filter: boolean;
|
||||
mixBlendMode: boolean;
|
||||
transform: boolean;
|
||||
mask: boolean;
|
||||
positionFixed: boolean;
|
||||
overflowHidden: boolean;
|
||||
zIndex: boolean;
|
||||
dataHasAudio: boolean;
|
||||
dataDuration: boolean;
|
||||
dataStart: boolean;
|
||||
dataCompositionSrc: boolean;
|
||||
backgroundImage: boolean;
|
||||
maskImage: boolean;
|
||||
};
|
||||
/**
|
||||
* Timeline shape summary. Distinguishes flat single-composition renders
|
||||
* from nested sub-composition trees, and GSAP-driven vs data-attribute-driven.
|
||||
*/
|
||||
timelineShape: {
|
||||
/** True if any `data-composition-src` mount exists. */
|
||||
nested: boolean;
|
||||
/** Count of sub-composition mounts (`data-composition-src` count). */
|
||||
subCompositionCount: number;
|
||||
/**
|
||||
* Any GSAP timeline usage detected via `<script>` src or inline
|
||||
* `gsap.<method>(...)` invocation. Does not scan for `data-gsap-*`
|
||||
* attributes on non-script elements — HTML-authored GSAP hooks that live
|
||||
* outside script tags won't surface here.
|
||||
*/
|
||||
usesGsap: boolean;
|
||||
/** Any element carrying `data-start` or `data-duration`. */
|
||||
usesDataTimeline: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const SUB_COMPOSITION_SELECTOR = "[data-composition-src]";
|
||||
const DATA_HAS_AUDIO_SELECTOR = "[data-has-audio]";
|
||||
const DATA_DURATION_SELECTOR = "[data-duration]";
|
||||
const DATA_START_SELECTOR = "[data-start]";
|
||||
|
||||
// Values that should NOT count as a "filter"/"mask"/"transform" attribute — the
|
||||
// browser default or an explicit inherit/revert to the ancestor's value.
|
||||
// Explicit "none" is authored intent to disable, still counts as noteworthy for
|
||||
// the census (author touched the property).
|
||||
const EMPTY_VALUES = new Set(["", "initial", "unset", "inherit", "revert", "revert-layer"]);
|
||||
|
||||
// Hard cap on input HTML size. Above this, we early-exit rather than feed
|
||||
// linkedom a hostile input. The census is a heuristic for maintainer
|
||||
// pattern-matching; a truncated result is preferable to an OOM on a bad file.
|
||||
const MAX_HTML_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
function iterInlineDeclarations(
|
||||
el: Element,
|
||||
visit: (property: string, value: string) => boolean,
|
||||
): boolean {
|
||||
const raw = el.getAttribute("style");
|
||||
if (!raw) return false;
|
||||
const declarations = raw.split(";");
|
||||
for (const decl of declarations) {
|
||||
const colon = decl.indexOf(":");
|
||||
if (colon <= 0) continue;
|
||||
const prop = decl.slice(0, colon).trim().toLowerCase();
|
||||
const value = decl.slice(colon + 1).trim();
|
||||
if (visit(prop, value)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasNonEmptyInlineStyle(el: Element, property: string): boolean {
|
||||
const target = property.toLowerCase();
|
||||
return iterInlineDeclarations(el, (prop, value) => {
|
||||
if (prop !== target) return false;
|
||||
return !EMPTY_VALUES.has(value.toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
function hasInlineStyleValueMatching(el: Element, property: string, valueRe: RegExp): boolean {
|
||||
const target = property.toLowerCase();
|
||||
return iterInlineDeclarations(el, (prop, value) => {
|
||||
if (prop !== target) return false;
|
||||
return valueRe.test(value);
|
||||
});
|
||||
}
|
||||
|
||||
function anyElementHasInlineStyle(doc: Document, property: string): boolean {
|
||||
const all = doc.querySelectorAll("[style]");
|
||||
for (const el of Array.from(all)) {
|
||||
if (hasNonEmptyInlineStyle(el as unknown as Element, property)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function anyElementHasInlineStyleValue(doc: Document, property: string, valueRe: RegExp): boolean {
|
||||
const all = doc.querySelectorAll("[style]");
|
||||
for (const el of Array.from(all)) {
|
||||
if (hasInlineStyleValueMatching(el as unknown as Element, property, valueRe)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasStyleTagReferencing(doc: Document, needle: RegExp): boolean {
|
||||
const styles = doc.querySelectorAll("style");
|
||||
for (const styleEl of Array.from(styles)) {
|
||||
const text = (styleEl as unknown as Element).textContent ?? "";
|
||||
if (needle.test(text)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function detectGsap(doc: Document): boolean {
|
||||
const scripts = doc.querySelectorAll("script");
|
||||
for (const script of Array.from(scripts)) {
|
||||
const el = script as unknown as Element;
|
||||
const src = el.getAttribute("src") ?? "";
|
||||
if (/gsap/i.test(src)) return true;
|
||||
const text = el.textContent ?? "";
|
||||
if (/\bgsap\.(timeline|to|from|fromTo|set)\b/.test(text)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a structural census of a composition HTML document. Counts elements
|
||||
* and probes for structural attributes without reading any user-supplied
|
||||
* strings (paths, src URLs, text content) beyond what's needed to detect
|
||||
* presence.
|
||||
*
|
||||
* Safe to call on partial/malformed HTML — linkedom's parser tolerates the
|
||||
* usual authoring accidents and the census reports zeros for missing sections.
|
||||
* Inputs larger than {@link MAX_HTML_BYTES} short-circuit to a zero census
|
||||
* rather than risk OOMing linkedom on a hostile file.
|
||||
*/
|
||||
export function buildCompositionCensus(html: string): CompositionCensus {
|
||||
if (html.length > MAX_HTML_BYTES) return emptyCensus();
|
||||
const doc = parseHTML(html).document as unknown as Document;
|
||||
|
||||
const count = (selector: string): number => doc.querySelectorAll(selector).length;
|
||||
|
||||
const subCompositionCount = count(SUB_COMPOSITION_SELECTOR);
|
||||
|
||||
// Inline-style presence probes. Class-based rules would require full CSS
|
||||
// parsing — out of scope here. But we also check <style> tag contents as a
|
||||
// best-effort catch for authored stylesheets in the same file.
|
||||
const anyStyle = (property: string, styleRegex: RegExp): boolean =>
|
||||
anyElementHasInlineStyle(doc, property) || hasStyleTagReferencing(doc, styleRegex);
|
||||
|
||||
// Value-scoped probe — inline branch matches on property value (not just
|
||||
// property presence), so `position:fixed` doesn't false-positive on
|
||||
// `position:absolute` and `overflow:hidden` catches inline `overflow:hidden`.
|
||||
const anyStyleValue = (property: string, inlineValueRe: RegExp, styleRegex: RegExp): boolean =>
|
||||
anyElementHasInlineStyleValue(doc, property, inlineValueRe) ||
|
||||
hasStyleTagReferencing(doc, styleRegex);
|
||||
|
||||
return {
|
||||
elementCensus: {
|
||||
video: count("video"),
|
||||
audio: count("audio"),
|
||||
img: count("img"),
|
||||
svg: count("svg"),
|
||||
canvas: count("canvas"),
|
||||
subCompositionMounts: subCompositionCount,
|
||||
},
|
||||
structuralAttributes: {
|
||||
clipPath: anyStyle("clip-path", /clip-path\s*:/i),
|
||||
filter: anyStyle("filter", /(^|\s|\{)filter\s*:/i),
|
||||
mixBlendMode: anyStyle("mix-blend-mode", /mix-blend-mode\s*:/i),
|
||||
transform: anyStyle("transform", /(^|\s|\{)transform\s*:/i),
|
||||
mask: anyStyle("mask", /(^|\s|\{)mask\s*:/i),
|
||||
positionFixed: anyStyleValue("position", /^fixed$/i, /position\s*:\s*fixed/i),
|
||||
overflowHidden: anyStyleValue(
|
||||
"overflow",
|
||||
/(^|\b)hidden(\b|$)/i,
|
||||
/overflow(-[xy])?\s*:\s*hidden/i,
|
||||
),
|
||||
zIndex: anyStyle("z-index", /z-index\s*:/i),
|
||||
dataHasAudio: count(DATA_HAS_AUDIO_SELECTOR) > 0,
|
||||
dataDuration: count(DATA_DURATION_SELECTOR) > 0,
|
||||
dataStart: count(DATA_START_SELECTOR) > 0,
|
||||
dataCompositionSrc: subCompositionCount > 0,
|
||||
// Match longhand (`background-image:`) OR shorthand (`background:`)
|
||||
// pointing at a `url(...)`. Same for `mask` / `mask-image`.
|
||||
backgroundImage:
|
||||
anyElementHasInlineStyle(doc, "background-image") ||
|
||||
anyElementHasInlineStyleValue(doc, "background", /url\(/i) ||
|
||||
hasStyleTagReferencing(doc, /background(-image)?\s*:[^;]*url\(/i),
|
||||
maskImage:
|
||||
anyElementHasInlineStyle(doc, "mask-image") ||
|
||||
anyElementHasInlineStyleValue(doc, "mask", /url\(/i) ||
|
||||
hasStyleTagReferencing(doc, /mask(-image)?\s*:[^;]*url\(/i),
|
||||
},
|
||||
timelineShape: {
|
||||
nested: subCompositionCount > 0,
|
||||
subCompositionCount,
|
||||
usesGsap: detectGsap(doc),
|
||||
usesDataTimeline: count(DATA_START_SELECTOR) + count(DATA_DURATION_SELECTOR) > 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function emptyCensus(): CompositionCensus {
|
||||
return {
|
||||
elementCensus: {
|
||||
video: 0,
|
||||
audio: 0,
|
||||
img: 0,
|
||||
svg: 0,
|
||||
canvas: 0,
|
||||
subCompositionMounts: 0,
|
||||
},
|
||||
structuralAttributes: {
|
||||
clipPath: false,
|
||||
filter: false,
|
||||
mixBlendMode: false,
|
||||
transform: false,
|
||||
mask: false,
|
||||
positionFixed: false,
|
||||
overflowHidden: false,
|
||||
zIndex: false,
|
||||
dataHasAudio: false,
|
||||
dataDuration: false,
|
||||
dataStart: false,
|
||||
dataCompositionSrc: false,
|
||||
backgroundImage: false,
|
||||
maskImage: false,
|
||||
},
|
||||
timelineShape: {
|
||||
nested: false,
|
||||
subCompositionCount: 0,
|
||||
usesGsap: false,
|
||||
usesDataTimeline: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the census as the exact `COMPOSITION_STRUCTURE:` block the skill
|
||||
* mandates for non-10 visual-defect feedback. Reporter-friendly plain text:
|
||||
* element counts, present attributes as a comma-joined list, and a compact
|
||||
* timeline summary. Placeholder markers (`<...>`) are left for the reporter
|
||||
* to fill in for delta/defect location — those slots the parser can't infer
|
||||
* from HTML alone.
|
||||
*/
|
||||
export function renderCompositionCensusBlock(census: CompositionCensus): string {
|
||||
const { elementCensus, structuralAttributes, timelineShape } = census;
|
||||
|
||||
const elementLine = [
|
||||
`video=${elementCensus.video}`,
|
||||
`audio=${elementCensus.audio}`,
|
||||
`img=${elementCensus.img}`,
|
||||
`svg=${elementCensus.svg}`,
|
||||
`canvas=${elementCensus.canvas}`,
|
||||
`subComps=${elementCensus.subCompositionMounts}`,
|
||||
].join(" ");
|
||||
|
||||
const attrPresent: string[] = [];
|
||||
const push = (key: keyof CompositionCensus["structuralAttributes"], label: string): void => {
|
||||
if (structuralAttributes[key]) attrPresent.push(label);
|
||||
};
|
||||
push("clipPath", "clip-path");
|
||||
push("filter", "filter");
|
||||
push("mixBlendMode", "mix-blend-mode");
|
||||
push("transform", "transform");
|
||||
push("mask", "mask");
|
||||
push("positionFixed", "position:fixed");
|
||||
push("overflowHidden", "overflow:hidden");
|
||||
push("zIndex", "z-index");
|
||||
push("dataHasAudio", "data-has-audio");
|
||||
push("dataDuration", "data-duration");
|
||||
push("dataStart", "data-start");
|
||||
push("dataCompositionSrc", "data-composition-src");
|
||||
push("backgroundImage", "background-image:url");
|
||||
push("maskImage", "mask-image:url");
|
||||
|
||||
const attrLine = attrPresent.length > 0 ? attrPresent.join(", ") : "(none present)";
|
||||
|
||||
const shape = timelineShape.nested
|
||||
? `nested (${timelineShape.subCompositionCount} sub-comps)`
|
||||
: "flat";
|
||||
const driver =
|
||||
[
|
||||
timelineShape.usesGsap ? "gsap" : null,
|
||||
timelineShape.usesDataTimeline ? "data-timeline" : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("+") || "none";
|
||||
|
||||
return [
|
||||
"COMPOSITION_STRUCTURE:",
|
||||
` elements: ${elementLine}`,
|
||||
` attributes: ${attrLine}`,
|
||||
` timeline: ${shape}; driver=${driver}`,
|
||||
" delta: <what differs between the working workaround-render and the broken default render>",
|
||||
" defect: <spatial location + frame index range, e.g. top-left / frames 0-30 — omit for non-visual defects>",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
COMPOSITION_STRUCTURE_RATING_CEILING,
|
||||
VISUAL_DEFECT_KEYWORDS,
|
||||
lintFeedbackComment,
|
||||
mentionsVisualDefect,
|
||||
} from "./feedbackLint.js";
|
||||
|
||||
describe("lintFeedbackComment", () => {
|
||||
it("returns no warnings for a perfect rating regardless of comment", () => {
|
||||
expect(lintFeedbackComment({ rating: 10, comment: "black frame at 0.5s, no REPRO" })).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("returns no warnings when the comment is missing or blank", () => {
|
||||
expect(lintFeedbackComment({ rating: 6, comment: undefined })).toEqual([]);
|
||||
expect(lintFeedbackComment({ rating: 6, comment: "" })).toEqual([]);
|
||||
expect(lintFeedbackComment({ rating: 6, comment: " \n " })).toEqual([]);
|
||||
});
|
||||
|
||||
it("warns on non-10 comments missing REPRO COMMAND:", () => {
|
||||
const warnings = lintFeedbackComment({
|
||||
rating: 6,
|
||||
comment: "fast but crashed after a bit",
|
||||
});
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]?.code).toBe("missing-repro-command");
|
||||
expect(warnings[0]?.message).toContain("REPRO COMMAND:");
|
||||
});
|
||||
|
||||
it("stays silent when the reporter already included a REPRO COMMAND: block", () => {
|
||||
const warnings = lintFeedbackComment({
|
||||
rating: 4,
|
||||
comment: [
|
||||
"cloudrun submission kept timing out.",
|
||||
"REPRO COMMAND: cd project && npx hyperframes cloudrun submit",
|
||||
"EXPECTED / ACTUAL: uploads / hangs at seek",
|
||||
].join("\n"),
|
||||
});
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it("warns on rating<=7 visual-defect comments missing COMPOSITION_STRUCTURE:", () => {
|
||||
const warnings = lintFeedbackComment({
|
||||
rating: 5,
|
||||
comment: [
|
||||
"REPRO COMMAND: cd proj && npx hyperframes render",
|
||||
"EXPECTED / ACTUAL: output correct / black frame at 0.5s",
|
||||
].join("\n"),
|
||||
});
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]?.code).toBe("missing-composition-structure");
|
||||
expect(warnings[0]?.message).toContain("COMPOSITION_STRUCTURE:");
|
||||
expect(warnings[0]?.message).toContain("buildCompositionCensus");
|
||||
});
|
||||
|
||||
it("skips the composition-structure warning above the rating ceiling", () => {
|
||||
const warnings = lintFeedbackComment({
|
||||
rating: COMPOSITION_STRUCTURE_RATING_CEILING + 1,
|
||||
comment: [
|
||||
"REPRO COMMAND: cd proj && npx hyperframes render",
|
||||
"minor black bar on the right edge; workaround with --resolution landscape",
|
||||
].join("\n"),
|
||||
});
|
||||
// Missing structure warning is suppressed at rating 8+.
|
||||
expect(warnings.filter((w) => w.code === "missing-composition-structure")).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips the composition-structure warning when no visual-defect keyword is present", () => {
|
||||
const warnings = lintFeedbackComment({
|
||||
rating: 4,
|
||||
comment: [
|
||||
"docker mode always fails on Alpine",
|
||||
"REPRO COMMAND: docker run ... && npx hyperframes doctor --docker",
|
||||
].join("\n"),
|
||||
});
|
||||
expect(warnings.filter((w) => w.code === "missing-composition-structure")).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits both warnings when a low-rating visual comment lacks both markers", () => {
|
||||
const warnings = lintFeedbackComment({
|
||||
rating: 3,
|
||||
comment: "flicker at every scene boundary",
|
||||
});
|
||||
expect(new Set(warnings.map((w) => w.code))).toEqual(
|
||||
new Set(["missing-repro-command", "missing-composition-structure"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mentionsVisualDefect", () => {
|
||||
it.each(VISUAL_DEFECT_KEYWORDS.map((kw) => kw as string))(
|
||||
"matches keyword %j case-insensitively",
|
||||
(kw) => {
|
||||
expect(mentionsVisualDefect(`Reports a ${kw.toUpperCase()} issue`)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("returns false for comments about non-visual friction", () => {
|
||||
expect(mentionsVisualDefect("cli hangs on init prompt in non-TTY shells")).toBe(false);
|
||||
expect(mentionsVisualDefect("cloudrun deploy expired auth token")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not match on partial-word false positives (word boundary)", () => {
|
||||
// The classic false positives Rames flagged in review.
|
||||
expect(mentionsVisualDefect("scribbled on the blackboard")).toBe(false);
|
||||
expect(mentionsVisualDefect("added to blacklist yesterday")).toBe(false);
|
||||
expect(mentionsVisualDefect("brought a blanket to the office")).toBe(false);
|
||||
expect(mentionsVisualDefect("let me visualize the graph")).toBe(false);
|
||||
expect(mentionsVisualDefect("that's a corruptible pointer")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not fire on `render` — CLI's primary command, too noisy", () => {
|
||||
// `hyperframes render` is the CLI's core command; comments like
|
||||
// "render OOMed at 118s" are build/perf issues, not visual defects.
|
||||
expect(mentionsVisualDefect("render OOMed at 118s")).toBe(false);
|
||||
expect(mentionsVisualDefect("render hung on Alpine")).toBe(false);
|
||||
expect(mentionsVisualDefect("preview render command took forever")).toBe(false);
|
||||
});
|
||||
|
||||
it("still matches the real defect words in prose", () => {
|
||||
expect(mentionsVisualDefect("output is a black frame at 0.5s")).toBe(true);
|
||||
expect(mentionsVisualDefect("the whole sequence flickers hard")).toBe(false); // "flickers" (plural) — accepted false-neg
|
||||
expect(mentionsVisualDefect("the whole sequence has a flicker at 2s")).toBe(true);
|
||||
expect(mentionsVisualDefect("wrong frame at t=0.3")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("marker-check case-insensitivity", () => {
|
||||
it("does not warn when the reporter uses lowercase `Repro command:`", () => {
|
||||
// Case asymmetry fix (C5): marker checks match `mentionsVisualDefect`'s
|
||||
// case-normalization, so compliance in any case is honored.
|
||||
const warnings = lintFeedbackComment({
|
||||
rating: 5,
|
||||
comment: [
|
||||
"cloudrun submission kept timing out.",
|
||||
"Repro command: cd project && npx hyperframes cloudrun submit",
|
||||
].join("\n"),
|
||||
});
|
||||
expect(warnings.filter((w) => w.code === "missing-repro-command")).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not double-warn when reporter uses lowercase `composition_structure:`", () => {
|
||||
const warnings = lintFeedbackComment({
|
||||
rating: 4,
|
||||
comment: [
|
||||
"REPRO COMMAND: cd proj && npx hyperframes render",
|
||||
"composition_structure:",
|
||||
" elements: video=1 audio=0 img=0 svg=0 canvas=0 subComps=0",
|
||||
"black frame at 0.5s",
|
||||
].join("\n"),
|
||||
});
|
||||
expect(warnings.filter((w) => w.code === "missing-composition-structure")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { FEEDBACK_RATING_SCALE } from "./feedbackRating.js";
|
||||
|
||||
/**
|
||||
* Keywords that suggest the reporter is describing a visual defect (as opposed
|
||||
* to a build failure, missing feature, or plain workflow friction). When these
|
||||
* appear in a non-10 feedback comment, the reporter should include a
|
||||
* `COMPOSITION_STRUCTURE:` block so maintainers can pattern-match against
|
||||
* known bug families without receiving the composition ZIP. Matched
|
||||
* case-insensitively, word-bounded against the raw comment (so "black" fires
|
||||
* on "black frame" but not "blackboard" / "no black frame at all").
|
||||
*
|
||||
* `"render"` intentionally omitted: `hyperframes render` is the CLI's primary
|
||||
* command, so build/perf/hang reports mention it constantly and would drown
|
||||
* the structure warning in false positives. Rely on the more specific tokens
|
||||
* ("black", "blank", "flicker", "corrupt", "wrong frame") to identify actual
|
||||
* visual defects.
|
||||
*/
|
||||
export const VISUAL_DEFECT_KEYWORDS: readonly string[] = [
|
||||
"black",
|
||||
"flicker",
|
||||
"corrupt",
|
||||
"wrong frame",
|
||||
"blank",
|
||||
"visual",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Ratings that should mandate `COMPOSITION_STRUCTURE:` when the comment
|
||||
* contains a visual-defect keyword. 7 and below covers "clearly broken" —
|
||||
* 8-9 are usually "worked, but noticed a nit" which doesn't need the full
|
||||
* structural anatomy.
|
||||
*/
|
||||
export const COMPOSITION_STRUCTURE_RATING_CEILING = 7;
|
||||
|
||||
const REPRO_MARKER = "REPRO COMMAND:";
|
||||
const STRUCTURE_MARKER = "COMPOSITION_STRUCTURE:";
|
||||
|
||||
export interface FeedbackLintInput {
|
||||
rating: number;
|
||||
comment: string | undefined;
|
||||
}
|
||||
|
||||
export interface FeedbackLintWarning {
|
||||
code: "missing-repro-command" | "missing-composition-structure";
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-warn lint on the `hyperframes feedback` comment body. Never blocks
|
||||
* submission — some legitimate reports (a one-line "cloudrun quota bumped
|
||||
* yesterday, fine now") won't fit the mold. The warning is just a nudge and
|
||||
* a pointer to the auto-census helper.
|
||||
*
|
||||
* Rules:
|
||||
* 1. `rating === 10` — no check. A perfect run doesn't need a repro packet.
|
||||
* 2. Comment missing / empty — no check. `feedback --rating 6` with no
|
||||
* comment is a valid quick vote; the maintainer sees rating drift without
|
||||
* the reporter having to synthesize a fake repro.
|
||||
* 3. Comment present + rating < 10 + no `REPRO COMMAND:` — warn.
|
||||
* 4. Comment present + rating ≤ 7 + visual-defect keyword + no
|
||||
* `COMPOSITION_STRUCTURE:` — warn (in addition to any #3 warning).
|
||||
*/
|
||||
export function lintFeedbackComment(input: FeedbackLintInput): FeedbackLintWarning[] {
|
||||
const { rating, comment } = input;
|
||||
if (rating === FEEDBACK_RATING_SCALE) return [];
|
||||
const trimmed = comment?.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
// Marker checks are case-insensitive to match `mentionsVisualDefect`'s
|
||||
// normalization. A reporter who writes `Repro command:` shouldn't get warned
|
||||
// for compliance just because they lowercased the marker.
|
||||
const upperTrimmed = trimmed.toUpperCase();
|
||||
const warnings: FeedbackLintWarning[] = [];
|
||||
|
||||
if (!upperTrimmed.includes(REPRO_MARKER)) {
|
||||
warnings.push({
|
||||
code: "missing-repro-command",
|
||||
message: [
|
||||
`Comment on a ${rating}/${FEEDBACK_RATING_SCALE} report is missing a "${REPRO_MARKER}" block —`,
|
||||
"maintainers can't rerun the failure from a symptom summary alone.",
|
||||
"See `references/preview-render.md` → feedback for the required packet shape.",
|
||||
].join(" "),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
rating <= COMPOSITION_STRUCTURE_RATING_CEILING &&
|
||||
mentionsVisualDefect(trimmed) &&
|
||||
!upperTrimmed.includes(STRUCTURE_MARKER)
|
||||
) {
|
||||
warnings.push({
|
||||
code: "missing-composition-structure",
|
||||
message: [
|
||||
`Comment describes a visual defect at ${rating}/${FEEDBACK_RATING_SCALE} but omits a`,
|
||||
`"${STRUCTURE_MARKER}" block. Agents can auto-fill this via the composition-census helper`,
|
||||
"(`buildCompositionCensus`/`renderCompositionCensusBlock` in `packages/cli/src/utils/compositionCensus.ts`)",
|
||||
"so maintainers can pattern-match against known bug families without the composition ZIP.",
|
||||
].join(" "),
|
||||
});
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
// Compile once. Word-boundary at both sides prevents "black" matching
|
||||
// "blackboard", "blank" matching "blanket", "visual" matching "visualize".
|
||||
// Plural forms (e.g. "flickers") won't match — accepted tradeoff for a soft
|
||||
// warn: false negatives skip the nudge, false positives waste the reporter's
|
||||
// attention.
|
||||
function escapeRegex(input: string): string {
|
||||
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
const VISUAL_DEFECT_REGEX = new RegExp(
|
||||
`(^|[^A-Za-z0-9_])(?:${VISUAL_DEFECT_KEYWORDS.map(escapeRegex).join("|")})(?![A-Za-z0-9_])`,
|
||||
"i",
|
||||
);
|
||||
|
||||
/**
|
||||
* Case-insensitive word-bounded probe against `VISUAL_DEFECT_KEYWORDS`.
|
||||
* Exposed for tests and reuse. Word boundaries are enforced on both sides so
|
||||
* partial-word false positives ("blackboard", "visualize", "corruptible")
|
||||
* don't trigger the structure-block nudge.
|
||||
*/
|
||||
export function mentionsVisualDefect(comment: string): boolean {
|
||||
return VISUAL_DEFECT_REGEX.test(comment);
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
"files": 102
|
||||
},
|
||||
"hyperframes-cli": {
|
||||
"hash": "79ab5a7856a42d68",
|
||||
"hash": "8bee5964bf77cc24",
|
||||
"files": 11
|
||||
},
|
||||
"hyperframes-core": {
|
||||
|
||||
@@ -104,7 +104,7 @@ After verifying a successful render, send one feedback report unless telemetry i
|
||||
npx hyperframes feedback --rating <0-10> --comment "<specific result or friction>"
|
||||
```
|
||||
|
||||
Keep clean-run feedback concise. For any bug or friction, capture a **reproduction packet** before submitting; do not send only a symptom summary. Include the rerunnable command and working directory, expected versus actual behavior, exact error, whether output completed/fell back/failed, workaround, and repro-project status. If the issue did not reproduce again, say so and still include the last failing command and logs. Use `--file-issue` only with consent: it publishes a minimal reproduction to a public URL. The required packet format and privacy warning live in `references/preview-render.md`.
|
||||
Keep clean-run feedback concise. For any bug or friction, capture a **reproduction packet** before submitting; do not send only a symptom summary. Include the rerunnable command and working directory, expected versus actual behavior, exact error, whether output completed/fell back/failed, workaround, and repro-project status. For a rating ≤ 7 that describes a visual defect (black frame, flicker, corrupt output, wrong frame, blank output, other visual anomaly), also include a `COMPOSITION_STRUCTURE:` block — a privacy-preserving structural anatomy (element census + attribute presence + timeline shape) so maintainers can pattern-match against known bug families without the composition ZIP. Agents auto-fill this via the composition-census helper; the human user does not fill it by hand. If the issue did not reproduce again, say so and still include the last failing command and logs. Use `--file-issue` only with consent: it publishes a minimal reproduction to a public URL. The required packet format and privacy warning live in `references/preview-render.md`.
|
||||
|
||||
## Read the matching reference before running a command
|
||||
|
||||
|
||||
@@ -161,10 +161,20 @@ EXPECTED / ACTUAL: <expected behavior> / <observed behavior and isolated trigger
|
||||
EXACT ERROR: <verbatim error or warning; include frame/timestamp for visual defects>
|
||||
OUTCOME: <output correct | output corrupt | fallback succeeded | hard exit | command hung>
|
||||
WORKAROUND: <exact workaround, or none>
|
||||
COMPOSITION_STRUCTURE:
|
||||
elements: video=<n> audio=<n> img=<n> svg=<n> canvas=<n> subComps=<n>
|
||||
attributes: <comma-joined subset of clip-path, filter, mix-blend-mode, transform, mask, position:fixed, overflow:hidden, z-index, data-has-audio, data-duration, data-start, data-composition-src, background-image:url, mask-image:url — or "(none present)">
|
||||
timeline: <flat | nested (<n> sub-comps)>; driver=<gsap | data-timeline | gsap+data-timeline | none>
|
||||
delta: <what differs between the working workaround-render and the broken default render>
|
||||
defect: <spatial location + frame index range, e.g. top-left / frames 0-30 — omit for non-visual defects>
|
||||
```
|
||||
|
||||
`COMPOSITION_STRUCTURE:` is a privacy-preserving structural anatomy: counts + presence flags only, no file paths, no src URLs, no user text. It lets maintainers pattern-match the report against known bug families (e.g. "sub-comp mount + clip-path", "GSAP timeline + z-index") without receiving the composition ZIP. Required for any rating ≤ 7 that describes a visual defect (black frame, flicker, corrupt output, wrong frame, blank output, other visual anomaly); optional but appreciated on higher ratings. Agents on this skill can auto-fill the block by calling `buildCompositionCensus(html)` and `renderCompositionCensusBlock(census)` from `packages/cli/src/utils/compositionCensus.ts` against the composition HTML they already have access to — the human user does not fill this out by hand.
|
||||
|
||||
Preserve paths containing spaces, flags, and relevant `HF_*` / `PRODUCER_*` variables; redact secrets and credentials. If the failure no longer reproduces, include the last failing command and log excerpt. Share a project link only when one is already available and safe to share.
|
||||
|
||||
The `hyperframes feedback` command soft-warns when a non-10 `--comment` is missing `REPRO COMMAND:`, and when a rating-≤-7 visual-defect comment is missing `COMPOSITION_STRUCTURE:`. The warnings print above the submission ack and do not block — some legitimate reports (a one-line "cloudrun quota bumped yesterday, fine now") won't fit the mold. Fix the packet and rerun to silence them.
|
||||
|
||||
Hit a reproducible bug? Add `--file-issue` (optionally `--dir <project>` and `--yes` for non-interactive shells) to also publish a minimal repro to a public URL and open a pre-filled GitHub `bug` issue draft for a maintainer to file. This publishes the project publicly, so it is opt-in and consent-gated; the issue is never auto-submitted.
|
||||
|
||||
## publish
|
||||
|
||||
Reference in New Issue
Block a user