mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-14 18:01:20 +08:00
0f8eb892e3
* fix(motion-graphics): make the catalog search fire before hand-authoring
The workflow's only reuse instruction pointed at catalog-map.md, a
hand-maintained snapshot of ~60 registry items, and no file in the skill
ever named `hyperframes catalog --query`. An agent asked mid-build for
CRT scanlines and a glitch effect had no instruction to search, so it
hand-authored both while caption-glitch-rgb ("RGB chromatic aberration
with CRT scanline overlay") ranks first for that query on either tier.
The search reads the hosted registry and needs nothing installed, from
any directory with no project, so "the components were not installed"
was never the cause. Say that where the reader is, since the wrong
diagnosis is the intuitive one.
Director Part 2 and the Builder now run the search before naming a
block, and catalog-map.md is labelled a partial snapshot whose misses
prove nothing. Pinned by a content test in coreSkillContent.test.ts.
* fix(skills): search the component catalog before hand-building a look
Authoring workflows never told the agent to search the component library,
so agents rebuilt effects the registry already shipped. A user reported
building an effect from scratch that the registry already contained; the
search that would have found it needs nothing installed, which is why the
usual self-diagnosis ("I forgot to install the components") is wrong.
All ten workflow skills carried zero mentions of `hyperframes catalog`.
The instruction lived only in hyperframes-cli and hyperframes-registry,
both loaded on demand, and the registry skill's own trigger named the
command rather than the symptom - circular, because an agent that never
thought to search could not reach the doc telling it to search.
- Eight workflows now run the search at the point they decide what to
build, before authoring. The two that compile through a closed
authoring vocabulary (embedded-captions, talking-head-recut) document
why they deliberately do not.
- hyperframes-registry triggers on the symptom (a named look, effect,
treatment or transition) instead of the command name; the router table
and the catalog surfaces carry the same framing.
- Fixes hand-maintained lists that had drifted: bar-chart-race was listed
as a hand-author gap in two files while shipping in the registry;
stat-motion was named as an installable block and is not one; the
caption-* family count was one high; the registry discovery tables
claimed to be the block list while covering 97 of 180.
- bun run lint:skills now fails when a doc marked as a registry snapshot
names an item the registry does not have.
* refactor(scripts): reuse native recursive readdir and the shared registry type
Simplify pass on the new registry-snapshot check, behaviour identical:
- collectMarkdownFiles uses readdirSync({ recursive: true }) instead of
hand-rolled recursion, matching scripts/generate-template-previews.ts.
- registryItemNames types registry.json with the exported RegistryManifest
instead of an ad hoc inline shape, matching scripts/catalog/build-local-vectors.ts.
The runtime guard stays: a cast describes the file, it does not validate it.
- One report() helper replaces the duplicated print-and-count block in both
lint passes.
* refactor(scripts): name the registry check's blind spots and stop self-arming
Applies the review findings on the new check, behaviour identical except
where noted:
- lintRegistryItemRefs returns null for an unmarked file instead of an
empty array, so "not a snapshot" and "a clean snapshot" have one owner
and the marker is matched once rather than twice.
- Marker detection ignores fenced blocks, so a doc that documents the
marker syntax in an example no longer arms the check on itself. The id
scan still reads full content, so fenced examples stay covered.
- The header comment and two tests now pin both known false negatives:
identifiers outside backticks, and single-word item names. Measured on
the six marked files, dropping the hyphen requirement would monitor 3
more items and force 46 allow= entries for ordinary prose words, so the
requirement stays and the gap is stated instead of silent.
* chore(skills): regenerate skills manifest after catalog-search edits
183 lines
7.8 KiB
JavaScript
183 lines
7.8 KiB
JavaScript
// Positive / negative fixture tests for the SKILL.md frontmatter drift guard
|
|
// in scripts/lint-skills.ts. Runs the exported `lintFrontmatter` against known
|
|
// inputs and asserts the violation set matches expectation.
|
|
//
|
|
// Kept in .mjs (not .ts) so `node --test` can execute it via the same runner
|
|
// the rest of scripts/*.test.mjs use, without needing tsx. `bun scripts/…`
|
|
// runs .ts directly at lint-time; tests import the compiled export via tsx.
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { lintFrontmatter, lintRegistryItemRefs } from "./lint-skills.ts";
|
|
|
|
const wrap = (frontmatter) => `---\n${frontmatter}\n---\n\n# body\n`;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Positive fixtures — must pass with zero violations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("valid: bare required keys", () => {
|
|
const violations = lintFrontmatter(wrap("name: foo\ndescription: bar"));
|
|
assert.deepEqual(violations, []);
|
|
});
|
|
|
|
test("valid: with license (optional string)", () => {
|
|
const violations = lintFrontmatter(wrap("name: foo\ndescription: bar\nlicense: MIT"));
|
|
assert.deepEqual(violations, []);
|
|
});
|
|
|
|
test("valid: allowed-tools as YAML sequence", () => {
|
|
const violations = lintFrontmatter(
|
|
wrap("name: foo\ndescription: bar\nallowed-tools:\n - Bash\n - Read"),
|
|
);
|
|
assert.deepEqual(violations, []);
|
|
});
|
|
|
|
test("valid: allowed-tools as single string", () => {
|
|
const violations = lintFrontmatter(wrap('name: foo\ndescription: bar\nallowed-tools: "Bash"'));
|
|
assert.deepEqual(violations, []);
|
|
});
|
|
|
|
test("valid: metadata as nested mapping", () => {
|
|
const violations = lintFrontmatter(
|
|
wrap("name: foo\ndescription: bar\nmetadata:\n version: 1\n tags:\n - a\n - b"),
|
|
);
|
|
assert.deepEqual(violations, []);
|
|
});
|
|
|
|
test("valid: multi-line description via block scalar", () => {
|
|
const violations = lintFrontmatter(
|
|
wrap("name: foo\ndescription: |\n Multi\n line\n description"),
|
|
);
|
|
assert.deepEqual(violations, []);
|
|
});
|
|
|
|
test("valid: description with a colon inside a quoted string", () => {
|
|
const violations = lintFrontmatter(wrap('name: foo\ndescription: "read: file, then: write"'));
|
|
assert.deepEqual(violations, []);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Negative fixtures — must produce at least one matching violation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const has = (violations, needle) =>
|
|
violations.some((v) => v.message.toLowerCase().includes(needle.toLowerCase()));
|
|
|
|
test("invalid: missing frontmatter block", () => {
|
|
const violations = lintFrontmatter("# just a body, no dashes\n");
|
|
assert.ok(has(violations, "Missing SKILL.md YAML frontmatter"));
|
|
});
|
|
|
|
test("invalid: missing name", () => {
|
|
const violations = lintFrontmatter(wrap("description: bar"));
|
|
assert.ok(has(violations, `Missing required frontmatter key "name"`));
|
|
});
|
|
|
|
test("invalid: missing description", () => {
|
|
const violations = lintFrontmatter(wrap("name: foo"));
|
|
assert.ok(has(violations, `Missing required frontmatter key "description"`));
|
|
});
|
|
|
|
test("invalid: unsupported key (the 'category' drift case)", () => {
|
|
const violations = lintFrontmatter(wrap("name: foo\ndescription: bar\ncategory: motion"));
|
|
assert.ok(has(violations, `Unsupported frontmatter key "category"`));
|
|
});
|
|
|
|
test("invalid: name as a list (was silently accepted by the pre-YAML version)", () => {
|
|
const violations = lintFrontmatter(wrap("name: [a, b]\ndescription: bar"));
|
|
assert.ok(has(violations, `"name" must be a string`));
|
|
});
|
|
|
|
test("invalid: description as a number", () => {
|
|
const violations = lintFrontmatter(wrap("name: foo\ndescription: 42"));
|
|
assert.ok(has(violations, `"description" must be a string`));
|
|
});
|
|
|
|
test("invalid: empty description string", () => {
|
|
const violations = lintFrontmatter(wrap('name: foo\ndescription: ""'));
|
|
assert.ok(has(violations, `"description" must not be empty`));
|
|
});
|
|
|
|
test("invalid: allowed-tools as a mapping (must be sequence or string)", () => {
|
|
const violations = lintFrontmatter(
|
|
wrap("name: foo\ndescription: bar\nallowed-tools:\n Bash: true"),
|
|
);
|
|
assert.ok(has(violations, `"allowed-tools" must be a string or a list of strings`));
|
|
});
|
|
|
|
test("invalid: metadata as a scalar (must be a mapping)", () => {
|
|
const violations = lintFrontmatter(
|
|
wrap('name: foo\ndescription: bar\nmetadata: "just a string"'),
|
|
);
|
|
assert.ok(has(violations, `"metadata" must be a mapping`));
|
|
});
|
|
|
|
test("invalid: malformed YAML (unmatched brace)", () => {
|
|
const violations = lintFrontmatter(wrap("name: foo\ndescription: {"));
|
|
assert.ok(has(violations, `Malformed YAML frontmatter`));
|
|
});
|
|
|
|
test("invalid: top-level scalar (frontmatter is not a mapping)", () => {
|
|
const violations = lintFrontmatter("---\njust-a-string\n---\n\nbody");
|
|
// Either parse succeeds and the top-level check catches it, or the parser
|
|
// errors — either is an acceptable rejection, but the violation list must
|
|
// be non-empty.
|
|
assert.ok(violations.length > 0);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Registry-snapshot drift guard
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const KNOWN = new Set(["caption-glitch-rgb", "code-diff", "data-chart"]);
|
|
const MARKER = "<!-- registry-items: -->";
|
|
|
|
test("registry refs: unmarked file is never checked", () => {
|
|
// Opt-in is the whole design. Most kebab-case backticks in skill docs are CSS
|
|
// properties, data-* attributes or skill directory names, and a check that
|
|
// flags those gets switched off. null (not []) distinguishes "not a snapshot"
|
|
// from "a snapshot with nothing wrong", which is what the counter reports.
|
|
assert.equal(lintRegistryItemRefs("Use `not-a-real-item` here.\n", KNOWN), null);
|
|
});
|
|
|
|
test("registry refs: a marker inside a fenced block does not arm the check", () => {
|
|
// Otherwise a doc that documents this marker's own syntax arms the check on
|
|
// itself, and every identifier in it starts failing for no stated reason.
|
|
const doc = ["# Doc", "", "```md", MARKER, "```", "", "Use `not-a-real-item`."].join("\n");
|
|
assert.equal(lintRegistryItemRefs(doc, KNOWN), null);
|
|
});
|
|
|
|
test("registry refs: marked file passes when every id is real", () => {
|
|
const doc = `${MARKER}\n\nUse \`caption-glitch-rgb\` or \`code-diff\`.\n`;
|
|
assert.deepEqual(lintRegistryItemRefs(doc, KNOWN), []);
|
|
});
|
|
|
|
test("registry refs: marked file flags an id the registry does not have", () => {
|
|
const doc = `${MARKER}\n\nInstall \`text-wave-distort\` for the wobble.\n`;
|
|
const violations = lintRegistryItemRefs(doc, KNOWN);
|
|
assert.equal(violations.length, 1);
|
|
assert.equal(violations[0].line, 3);
|
|
assert.ok(violations[0].message.includes("text-wave-distort"));
|
|
});
|
|
|
|
test("registry refs: allow= exempts a legitimately non-item identifier", () => {
|
|
const doc = `<!-- registry-items: allow=dark-plus,pin-rollout -->\n\n\`dark-plus\` and \`pin-rollout\`.\n`;
|
|
assert.deepEqual(lintRegistryItemRefs(doc, KNOWN), []);
|
|
});
|
|
|
|
test("registry refs: non-id backticks are ignored", () => {
|
|
const doc = `${MARKER}\n\n\`--json\`, \`Foo-Bar\`, \`a b-c\`, \`UPPER-CASE\`.\n`;
|
|
assert.deepEqual(lintRegistryItemRefs(doc, KNOWN), []);
|
|
});
|
|
|
|
test("registry refs: single-word ids are a KNOWN blind spot, not an accident", () => {
|
|
// The pattern requires a hyphen, so real single-word registry items (glitch,
|
|
// flowchart, typewriter, confetti, separator, vignette, vignelli) are never
|
|
// checked. Pinned here so the tradeoff is visible in code, not just in a
|
|
// comment: dropping the hyphen would cost 46 allow= entries of prose nouns
|
|
// across the marked files to monitor 3 more items. See lint-skills.ts header.
|
|
const doc = `${MARKER}\n\n\`glitch\` was renamed and this doc was not updated.\n`;
|
|
assert.deepEqual(lintRegistryItemRefs(doc, KNOWN), []);
|
|
});
|