Pattern catalog: pinned library fingerprints as hint-only recipe evidence
Ported from PR #15: a SHA256-pinned catalog of 49 generic library and platform fingerprints (carousel/consent/chat/commerce vendors) with an O(1) signature index. Catalog hits strengthen recipe recognition (small confidence deltas, marquee->logo-cloud, carousel false-grid skip, lightbox->gallery, commerce prior) and are recorded in the manifest as pattern evidence with node references - they never override captured geometry, which remains gated by the existing plan-agreement checks. The integrity pin is unconditional: a catalog/lock mismatch always throws (the fork's env-var soft-warning mode is removed). 515 tests pass (10 new), typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
7e8863fc7e
commit
f991f469cf
@@ -0,0 +1,150 @@
|
||||
import { describe, it, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
loadPatternIndex,
|
||||
assertPinnedCatalog,
|
||||
resolvePatternHints,
|
||||
matchCatalogNode,
|
||||
} from "../src/knowledge/patternIndex.js";
|
||||
import { planForFrameUrl } from "../src/capture/graft.js";
|
||||
import type { IR, IRNode, IRChild } from "../src/normalize/ir.js";
|
||||
|
||||
const CW = 1280;
|
||||
const LOCK_PATH = fileURLToPath(new URL("../data/pattern-catalog.lock", import.meta.url));
|
||||
|
||||
/** Element node at the canonical viewport (ids assigned in pre-order by `page`). */
|
||||
function el(
|
||||
tag: string,
|
||||
opts: { srcClass?: string; attrs?: Record<string, string>; children?: IRChild[] } = {},
|
||||
): IRNode {
|
||||
return {
|
||||
id: "",
|
||||
tag,
|
||||
attrs: opts.attrs ?? {},
|
||||
srcClass: opts.srcClass,
|
||||
visibleByVp: { [CW]: true },
|
||||
bboxByVp: { [CW]: { x: 0, y: 0, width: CW, height: 100 } },
|
||||
computedByVp: { [CW]: { display: "block" } },
|
||||
children: opts.children ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Wrap children in a <body> root and assign stable pre-order ids (n0, n1, …). */
|
||||
function page(children: IRNode[]): IR {
|
||||
const root = el("body", { children });
|
||||
let i = 0;
|
||||
const assign = (n: IRNode): void => {
|
||||
n.id = `n${i++}`;
|
||||
for (const c of n.children) if ((c as IRNode).tag) assign(c as IRNode);
|
||||
};
|
||||
assign(root);
|
||||
return {
|
||||
doc: {
|
||||
sourceUrl: "https://example.test/", title: "Fixture", lang: "en", charset: "UTF-8",
|
||||
metaViewport: "width=device-width, initial-scale=1",
|
||||
viewports: [CW], sampleViewports: [CW], canonicalViewport: CW,
|
||||
perViewport: { [CW]: { scrollHeight: 100, scrollWidth: CW, htmlBg: "", bodyBg: "", bodyColor: "", bodyFont: "" } },
|
||||
nodeCount: i, keyframes: [],
|
||||
},
|
||||
root,
|
||||
};
|
||||
}
|
||||
|
||||
describe("pattern catalog: load + pin", () => {
|
||||
it("loads the frozen catalog and pins by sha256 (lock verifies)", () => {
|
||||
const idx = loadPatternIndex();
|
||||
assert.equal(idx.catalog.version, 1);
|
||||
assert.ok(idx.catalog.patterns.length > 0, "catalog has patterns");
|
||||
// The on-disk lock must equal the catalog's live hash — no throw.
|
||||
assert.doesNotThrow(() => assertPinnedCatalog());
|
||||
const pinned = readFileSync(LOCK_PATH, "utf8").trim();
|
||||
assert.equal(pinned, idx.hash, "lock matches catalog hash");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pattern catalog: integrity check throws unconditionally", () => {
|
||||
const original = readFileSync(LOCK_PATH, "utf8");
|
||||
afterEach(() => {
|
||||
// Always restore the real lock so later tests / runs see the pinned hash.
|
||||
writeFileSync(LOCK_PATH, original);
|
||||
});
|
||||
|
||||
it("throws on a lock/catalog hash mismatch (no env-var escape hatch)", () => {
|
||||
writeFileSync(LOCK_PATH, "deadbeef".repeat(8) + "\n");
|
||||
assert.throws(() => assertPinnedCatalog(), /hash mismatch/);
|
||||
// Restoring must make it pass again.
|
||||
writeFileSync(LOCK_PATH, original);
|
||||
assert.doesNotThrow(() => assertPinnedCatalog());
|
||||
});
|
||||
});
|
||||
|
||||
describe("pattern catalog: fingerprint fixtures hit", () => {
|
||||
it("matchCatalogNode identifies a generic carousel fingerprint by class token", () => {
|
||||
const hits = matchCatalogNode({ tag: "div", attrs: {}, srcClass: "swiper swiper-wrapper" });
|
||||
assert.deepEqual(hits.map((h) => h.id), ["carousel_swiper"]);
|
||||
assert.equal(hits[0].kind, "carousel");
|
||||
});
|
||||
|
||||
it("matchCatalogNode identifies a platform fingerprint by class prefix", () => {
|
||||
const hits = matchCatalogNode({ tag: "div", attrs: {}, srcClass: "elementor-widget-container" });
|
||||
assert.ok(hits.some((h) => h.id === "platform_elementor"), "elementor prefix hit");
|
||||
});
|
||||
|
||||
it("matchCatalogNode identifies a scroll-animation fingerprint by attribute presence", () => {
|
||||
const hits = matchCatalogNode({ tag: "div", attrs: { "data-aos": "fade-up" }, srcClass: undefined });
|
||||
assert.ok(hits.some((h) => h.id === "anim_aos"), "data-aos attr hit");
|
||||
});
|
||||
|
||||
it("resolvePatternHints walks the IR deterministically and records id + cid evidence", () => {
|
||||
const ir = page([
|
||||
el("div", { srcClass: "swiper", children: [el("div", { srcClass: "swiper-slide" })] }),
|
||||
el("div", { srcClass: "swiper-slide" }),
|
||||
]);
|
||||
const hints = resolvePatternHints(ir);
|
||||
const swiper = hints.matches.find((m) => m.id === "carousel_swiper");
|
||||
assert.ok(swiper, "swiper detected");
|
||||
// Three nodes carried a swiper token → count 3, cids are pre-order node ids.
|
||||
assert.equal(swiper!.count, 3);
|
||||
assert.deepEqual(swiper!.cids, ["n1", "n2", "n3"]);
|
||||
// matches are sorted by id → deterministic evidence ordering.
|
||||
const ids = hints.matches.map((m) => m.id);
|
||||
assert.deepEqual(ids, [...ids].sort((a, b) => a.localeCompare(b)));
|
||||
// Same catalog + same IR ⇒ byte-identical hints.
|
||||
assert.deepEqual(resolvePatternHints(page([
|
||||
el("div", { srcClass: "swiper", children: [el("div", { srcClass: "swiper-slide" })] }),
|
||||
el("div", { srcClass: "swiper-slide" }),
|
||||
])), hints);
|
||||
});
|
||||
|
||||
it("resolvePatternHints exposes platform flags (stripped) and simpleStatic", () => {
|
||||
const ir = page([el("section", { srcClass: "shopify-section" })]);
|
||||
const hints = resolvePatternHints(ir);
|
||||
assert.ok(hints.platforms.includes("shopify"), "shopify platform surfaced");
|
||||
assert.ok(hints.flags.includes("platform_shopify"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("popup-vendor skip list: FRAME_SKIP semantics preserved (single source of truth)", () => {
|
||||
// The catalog's consent/chat entries are DOM class/id fingerprints; the capture
|
||||
// pipeline's popup-vendor skip list (graft.ts FRAME_SKIP_RE) matches iframe host
|
||||
// URLs. They are orthogonal axes — the catalog carries no URL hosts, so there is
|
||||
// nothing to consolidate; capture's list stays the single source of truth for
|
||||
// frame-graft decisions. Assert its semantics are unchanged.
|
||||
it("skips promo/consent creative iframe hosts", () => {
|
||||
assert.equal(planForFrameUrl("https://widget.privy.com/assets/popup.html"), "skip");
|
||||
assert.equal(planForFrameUrl("https://app.recart.com/creative"), "skip");
|
||||
assert.equal(planForFrameUrl("https://www.googletagmanager.com/ns.html"), "skip");
|
||||
});
|
||||
|
||||
it("still grafts inline form embeds and same-origin blank frames", () => {
|
||||
assert.equal(planForFrameUrl("https://static-forms.klaviyo.com/form"), "graft");
|
||||
assert.equal(planForFrameUrl("about:blank"), "graft");
|
||||
assert.equal(planForFrameUrl(""), "graft");
|
||||
});
|
||||
|
||||
it("still renders media players as stills", () => {
|
||||
assert.equal(planForFrameUrl("https://www.youtube.com/embed/abc"), "still");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user