mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
* feat(telemetry): measure which lint rules fire, cost, and fail to converge Lint rule changes are currently argued from anecdote. This adds the three measurements needed to argue them from data. `lint_report`, once per `hyperframes lint` or `hyperframes check`: - `code_counts` / `codes` — which rules actually fire, and how often - `rule_group_ms` — milliseconds per rule-source module (core, gsap, media, ...) - `slowest_rule` / `slowest_rule_ms` — slowest single rule as `<group>#<index>` - `rule_count` — how many rules this build ran `lint_rule_streak`, once per finding that survives an edit to its file: - `edits` — how many edits the finding survived - `cleared` — whether it eventually went away The streak event is the one that matters. A lint pass costs about 5ms, so per-rule CPU is not what makes the authoring loop slow; a rule an agent cannot satisfy is, because every failed attempt costs a full edit-and-relint cycle. A single run cannot see that, so `lint_rule_streak` reconstructs it across runs: high `edits` with `cleared: false` is a rule nobody can fix, and the `cleared: true` distribution is the baseline to judge it against. An iteration is counted only when the file's content digest CHANGED and the finding is still there. Re-linting an untouched project is not an attempt, which is what stops `check` (which lints on every invocation) from inflating the numbers. Rule identity is the source module plus an index within it. Naming all 86 rules would make the timings prettier but it is a refactor this measurement does not need: the group locates the file, and the index locates the rule. Version, agent runtime, CI flag, and invocation id are already attached to every event by `trackEvent`, so lint pain can be split by CLI version and by which agent produced it without adding anything here. Privacy: only rule codes, counts, and timings are sent. Streak state lives in ~/.hyperframes/lint-streaks.json alongside config.json (so `rm -rf ~/.hyperframes` is still a full reset) and stores digests only — no file paths, no project names, no composition source. Nothing is written and nothing is emitted when telemetry is off. Entries expire after 14 days and are capped at 500 files. `EventProperties` gains string arrays and numeric maps. `codes` and `code_counts` are inherently a set and a histogram; flattening them into dynamic top-level keys would make them unqueryable. PostHog stores both natively. `trackLintRun` is the single call site shared by `lint` and `check`, and it swallows every error — telemetry must never turn a green lint red. * feat(telemetry): emit per-group rule counts so slowest_rule stays comparable Review catch on #3367: `slowest_rule` is the one positional key in either event. It is `<group>#<index>`, so adding or removing a rule renumbers every later slot in that group and the same string means different rules in two builds. #3366 does exactly that to 34 of 81 surviving slots, and `rule_count` alone says only THAT the ruleset moved, not which groups. `rule_group_counts` carries the per-group sizes alongside it, so a consumer comparing two builds can tell which groups' indices still mean the same thing without anyone having to remember which release dropped rules. `codes`, `code_counts` and `rule_group_ms` are keyed by name and were never affected. Also corrects the rule count in the RULE_GROUPS comment: 86, not ~60, as LINT_RULE_COUNT in the same file computes.
238 lines
8.0 KiB
TypeScript
238 lines
8.0 KiB
TypeScript
import type {
|
|
HyperframeLintFinding,
|
|
HyperframeLintResult,
|
|
HyperframeLinterOptions,
|
|
LintRule,
|
|
LintTimings,
|
|
} from "./types";
|
|
import type { LintContext } from "./context";
|
|
import { buildLintContext } from "./context";
|
|
import { parseHtmlStructure, readAttr, truncateSnippet } from "./utils";
|
|
import { coreRules } from "./rules/core";
|
|
import { mediaRules } from "./rules/media";
|
|
import { gsapRules } from "./rules/gsap";
|
|
import { captionRules } from "./rules/captions";
|
|
import { compositionRules } from "./rules/composition";
|
|
import { adapterRules } from "./rules/adapters";
|
|
import { textureRules } from "./rules/textures";
|
|
import { fontRules } from "./rules/fonts";
|
|
import { slideshowRules } from "./rules/slideshow";
|
|
|
|
// Rules are grouped by source module so a timing can be attributed to
|
|
// something a human can act on. Individual rules stay anonymous: an
|
|
// index within its group ("gsap#7") is enough to locate a pathological
|
|
// rule, and naming all 86 of them is a refactor this measurement does
|
|
// not need in order to point at the right file.
|
|
//
|
|
// The cost of that choice is that the index is POSITIONAL: adding or
|
|
// removing a rule renumbers every later slot in its group, so "gsap#8"
|
|
// can mean different rules in two builds. LINT_RULE_GROUP_COUNTS below
|
|
// is what makes that detectable — a consumer comparing two builds can
|
|
// see which groups changed size and therefore which numbering is no
|
|
// longer comparable, without anyone having to remember what shipped when.
|
|
const RULE_GROUPS: ReadonlyArray<{
|
|
group: string;
|
|
rules: ReadonlyArray<LintRule<LintContext>>;
|
|
}> = [
|
|
{ group: "core", rules: coreRules },
|
|
{ group: "media", rules: mediaRules },
|
|
{ group: "gsap", rules: gsapRules },
|
|
{ group: "captions", rules: captionRules },
|
|
{ group: "composition", rules: compositionRules },
|
|
{ group: "adapters", rules: adapterRules },
|
|
{ group: "textures", rules: textureRules },
|
|
{ group: "fonts", rules: fontRules },
|
|
{ group: "slideshow", rules: slideshowRules },
|
|
];
|
|
|
|
/**
|
|
* How many rules this build runs. `cli_version` already identifies the release,
|
|
* but a rule added or removed inside one version is invisible without this —
|
|
* and comparing findings-per-run across a rule change is the whole point of
|
|
* measuring them.
|
|
*/
|
|
export const LINT_RULE_COUNT = RULE_GROUPS.reduce((n, g) => n + g.rules.length, 0);
|
|
|
|
/**
|
|
* How many rules each group runs. `slowest_rule` is reported as
|
|
* `<group>#<index>`, and that index shifts whenever a rule is added to or
|
|
* removed from its group. Comparing these counts between two builds tells a
|
|
* consumer exactly which groups' indices still mean the same thing.
|
|
*/
|
|
export const LINT_RULE_GROUP_COUNTS: Readonly<Record<string, number>> = Object.fromEntries(
|
|
RULE_GROUPS.map(({ group, rules }) => [group, rules.length]),
|
|
);
|
|
|
|
/** Two rules reporting the same problem on the same element report it once. */
|
|
function dedupeKeyFor(finding: HyperframeLintFinding): string {
|
|
return [
|
|
finding.code,
|
|
finding.severity,
|
|
finding.selector || "",
|
|
finding.elementId || "",
|
|
finding.message,
|
|
].join("|");
|
|
}
|
|
|
|
/**
|
|
* Run every rule against one parsed context, timing each and deduping as it
|
|
* goes. Rules are timed individually but reported per group; the slowest
|
|
* single rule is kept so a pathological one stays locatable.
|
|
*/
|
|
async function runRules(
|
|
ctx: LintContext,
|
|
filePath: string | undefined,
|
|
): Promise<{ findings: HyperframeLintFinding[]; timings: Omit<LintTimings, "totalMs"> }> {
|
|
const findings: HyperframeLintFinding[] = [];
|
|
const seen = new Set<string>();
|
|
const groupMs: Record<string, number> = {};
|
|
let slowestRule = "";
|
|
let slowestRuleMs = 0;
|
|
|
|
for (const { group, rules } of RULE_GROUPS) {
|
|
for (let index = 0; index < rules.length; index++) {
|
|
const ruleStartedAt = performance.now();
|
|
const produced = await Promise.resolve(rules[index]!(ctx));
|
|
const ruleMs = performance.now() - ruleStartedAt;
|
|
|
|
groupMs[group] = (groupMs[group] ?? 0) + ruleMs;
|
|
if (ruleMs > slowestRuleMs) {
|
|
slowestRuleMs = ruleMs;
|
|
slowestRule = `${group}#${index}`;
|
|
}
|
|
|
|
collectFindings(produced, seen, filePath, findings);
|
|
}
|
|
}
|
|
|
|
return { findings, timings: { groupMs, slowestRule, slowestRuleMs } };
|
|
}
|
|
|
|
function collectFindings(
|
|
produced: readonly HyperframeLintFinding[],
|
|
seen: Set<string>,
|
|
filePath: string | undefined,
|
|
into: HyperframeLintFinding[],
|
|
): void {
|
|
for (const finding of produced) {
|
|
const dedupeKey = dedupeKeyFor(finding);
|
|
if (seen.has(dedupeKey)) continue;
|
|
seen.add(dedupeKey);
|
|
into.push(filePath ? { ...finding, file: filePath } : finding);
|
|
}
|
|
}
|
|
|
|
export async function lintHyperframeHtml(
|
|
html: string,
|
|
options: HyperframeLinterOptions = {},
|
|
): Promise<HyperframeLintResult> {
|
|
const startedAt = performance.now();
|
|
const ctx = buildLintContext(html, options);
|
|
const { findings, timings } = await runRules(ctx, options.filePath);
|
|
|
|
const errorCount = findings.filter((f) => f.severity === "error").length;
|
|
const warningCount = findings.filter((f) => f.severity === "warning").length;
|
|
const infoCount = findings.filter((f) => f.severity === "info").length;
|
|
|
|
return {
|
|
ok: errorCount === 0,
|
|
errorCount,
|
|
warningCount,
|
|
infoCount,
|
|
findings,
|
|
timings: { totalMs: performance.now() - startedAt, ...timings },
|
|
};
|
|
}
|
|
|
|
// ── Async media URL accessibility checker ─────────────────────────────────
|
|
|
|
function extractMediaUrls(html: string): Array<{
|
|
url: string;
|
|
tagName: string;
|
|
elementId?: string;
|
|
snippet: string;
|
|
}> {
|
|
const results: Array<{
|
|
url: string;
|
|
tagName: string;
|
|
elementId?: string;
|
|
snippet: string;
|
|
}> = [];
|
|
for (const { name: tagName, raw } of parseHtmlStructure(html).tags) {
|
|
if (!/^(?:video|audio|img|source)$/.test(tagName)) continue;
|
|
const src = readAttr(raw, "src");
|
|
if (!src) continue;
|
|
if (/^https?:\/\//i.test(src)) {
|
|
results.push({
|
|
url: src,
|
|
tagName,
|
|
elementId: readAttr(raw, "id") || undefined,
|
|
snippet: truncateSnippet(raw) ?? "",
|
|
});
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Async lint pass: HEAD-checks every remote media URL in the HTML.
|
|
* Returns findings for URLs that are unreachable (non-2xx status or network error).
|
|
*
|
|
* Call this after `lintHyperframeHtml()` and merge the findings.
|
|
*
|
|
* @param timeoutMs - per-request timeout (default 8000ms)
|
|
*/
|
|
export async function lintMediaUrls(
|
|
html: string,
|
|
options: { timeoutMs?: number } = {},
|
|
): Promise<HyperframeLintFinding[]> {
|
|
const urls = extractMediaUrls(html);
|
|
if (urls.length === 0) return [];
|
|
|
|
const timeout = options.timeoutMs ?? 8000;
|
|
const findings: HyperframeLintFinding[] = [];
|
|
|
|
const seen = new Set<string>();
|
|
const unique = urls.filter((u) => {
|
|
if (seen.has(u.url)) return false;
|
|
seen.add(u.url);
|
|
return true;
|
|
});
|
|
|
|
const checks = unique.map(async ({ url, tagName, elementId, snippet }) => {
|
|
try {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
const resp = await fetch(url, {
|
|
method: "HEAD",
|
|
signal: controller.signal,
|
|
redirect: "follow",
|
|
});
|
|
clearTimeout(timer);
|
|
if (!resp.ok) {
|
|
findings.push({
|
|
code: "inaccessible_media_url",
|
|
severity: "error",
|
|
message: `<${tagName}${elementId ? ` id="${elementId}"` : ""}> references a URL that returned HTTP ${resp.status}: ${url.slice(0, 100)}`,
|
|
elementId,
|
|
fixHint: "This URL is not accessible. Replace with a valid, reachable media URL.",
|
|
snippet,
|
|
});
|
|
}
|
|
} catch (err) {
|
|
const reason = err instanceof Error ? err.name : "unknown";
|
|
findings.push({
|
|
code: "inaccessible_media_url",
|
|
severity: "error",
|
|
message: `<${tagName}${elementId ? ` id="${elementId}"` : ""}> references an unreachable URL (${reason}): ${url.slice(0, 100)}`,
|
|
elementId,
|
|
fixHint: "This URL is not accessible. Replace with a valid, reachable media URL.",
|
|
snippet,
|
|
});
|
|
}
|
|
});
|
|
|
|
await Promise.all(checks);
|
|
return findings;
|
|
}
|